From 73407351e7fa75250be8bdb6c1eb4f7d37f6f947 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 9 Nov 2016 15:25:47 -0800 Subject: [PATCH] feat(router): add support for custom url matchers Closes #12442 Closes #12772 --- .../@angular/router/src/apply_redirects.ts | 42 +++++------------ modules/@angular/router/src/config.ts | 45 ++++++++++++++++++- modules/@angular/router/src/recognize.ts | 40 +++++------------ modules/@angular/router/src/shared.ts | 37 +++++++++++++++ .../router/test/apply_redirects.spec.ts | 20 +++++++++ modules/@angular/router/test/config.spec.ts | 9 +++- .../@angular/router/test/integration.spec.ts | 2 +- .../@angular/router/test/recognize.spec.ts | 24 ++++++++++ tools/public_api_guard/router/index.d.ts | 1 + 9 files changed, 156 insertions(+), 64 deletions(-) diff --git a/modules/@angular/router/src/apply_redirects.ts b/modules/@angular/router/src/apply_redirects.ts index 164a8af1bd..e16b4abf48 100644 --- a/modules/@angular/router/src/apply_redirects.ts +++ b/modules/@angular/router/src/apply_redirects.ts @@ -18,11 +18,11 @@ import {map} from 'rxjs/operator/map'; import {mergeMap} from 'rxjs/operator/mergeMap'; import {EmptyError} from 'rxjs/util/EmptyError'; -import {Route, Routes} from './config'; +import {Route, Routes, UrlMatchResult} from './config'; import {LoadedRouterConfig, RouterConfigLoader} from './router_config_loader'; -import {NavigationCancelingError, PRIMARY_OUTLET} from './shared'; +import {NavigationCancelingError, PRIMARY_OUTLET, defaultUrlMatcher} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree'; -import {andObservables, merge, waitForMap, wrapIntoObservable} from './utils/collection'; +import {andObservables, forEach, merge, waitForMap, wrapIntoObservable} from './utils/collection'; class NoMatch { constructor(public segmentGroup: UrlSegmentGroup = null) {} @@ -316,34 +316,16 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment } } - const path = route.path; - const parts = path.split('/'); - const positionalParamSegments: {[k: string]: UrlSegment} = {}; - const consumedSegments: UrlSegment[] = []; + const matcher = route.matcher || defaultUrlMatcher; + const res = matcher(segments, segmentGroup, route); + if (!res) return noMatch; - let currentIndex = 0; - - for (let i = 0; i < parts.length; ++i) { - if (currentIndex >= segments.length) return noMatch; - const current = segments[currentIndex]; - - const p = parts[i]; - const isPosParam = p.startsWith(':'); - - if (!isPosParam && p !== current.path) return noMatch; - if (isPosParam) { - positionalParamSegments[p.substring(1)] = current; - } - consumedSegments.push(current); - currentIndex++; - } - - if (route.pathMatch === 'full' && - (segmentGroup.hasChildren() || currentIndex < segments.length)) { - return {matched: false, consumedSegments: [], lastChild: 0, positionalParamSegments: {}}; - } - - return {matched: true, consumedSegments, lastChild: currentIndex, positionalParamSegments}; + return { + matched: true, + consumedSegments: res.consumed, + lastChild: res.consumed.length, + positionalParamSegments: res.posParams + }; } function applyRedirectCommands( diff --git a/modules/@angular/router/src/config.ts b/modules/@angular/router/src/config.ts index 619a39119f..06ea315763 100644 --- a/modules/@angular/router/src/config.ts +++ b/modules/@angular/router/src/config.ts @@ -8,7 +8,8 @@ import {Type} from '@angular/core'; import {Observable} from 'rxjs/Observable'; -import {PRIMARY_OUTLET} from './shared'; +import {PRIMARY_OUTLET, Params} from './shared'; +import {UrlSegment, UrlSegmentGroup} from './url_tree'; /** * @whatItDoes Represents router configuration. @@ -259,6 +260,41 @@ import {PRIMARY_OUTLET} from './shared'; */ export type Routes = Route[]; +/** + * @whatItDoes Represents the results of the URL matching. + * + * * `consumed` is an array of the consumed URL segments. + * * `posParams` is a map of positional parameters. + * + * @experimental + */ +export type UrlMatchResult = { + consumed: UrlSegment[]; posParams?: {[name: string]: UrlSegment}; +}; + +/** + * @whatItDoes A function matching URLs + * + * @description + * + * A custom URL matcher can be provided when a combination of `path` and `pathMatch` isn't + * expressive enough. + * + * For instance, the following matcher matches html files. + * + * ``` + * function htmlFiles(url: UrlSegment[]) { + * return url.length === 1 && url[0].path.endsWith('.html') ? ({consumed: url}) : null; + * } + * + * const routes = [{ matcher: htmlFiles, component: HtmlCmp }]; + * ``` + * + * @experimental + */ +export type UrlMatcher = (segments: UrlSegment[], group: UrlSegmentGroup, route: Route) => + UrlMatchResult; + /** * @whatItDoes Represents the static data associated with a particular route. * See {@link Routes} for more details. @@ -269,7 +305,7 @@ export type Data = { }; /** - * @whatItDoes Represents the resolved data associated with a particular route. + * @whatItDoes Represents the resolved data associated with a particular route. * See {@link Routes} for more details. * @stable */ @@ -299,6 +335,7 @@ export type LoadChildren = string | LoadChildrenCallback; export interface Route { path?: string; pathMatch?: string; + matcher?: UrlMatcher; component?: Type; redirectTo?: string; outlet?: string; @@ -340,6 +377,10 @@ function validateNode(route: Route): void { throw new Error( `Invalid configuration of route '${route.path}': redirectTo and component cannot be used together`); } + if (!!route.path && !!route.matcher) { + throw new Error( + `Invalid configuration of route '${route.path}': path and matcher cannot be used together`); + } if (route.redirectTo === undefined && !route.component && !route.children && !route.loadChildren) { throw new Error( diff --git a/modules/@angular/router/src/recognize.ts b/modules/@angular/router/src/recognize.ts index 4fb4fed459..99ab6e806d 100644 --- a/modules/@angular/router/src/recognize.ts +++ b/modules/@angular/router/src/recognize.ts @@ -11,11 +11,11 @@ import {Observable} from 'rxjs/Observable'; import {Observer} from 'rxjs/Observer'; import {of } from 'rxjs/observable/of'; -import {Data, ResolveData, Route, Routes} from './config'; +import {Data, ResolveData, Route, Routes, UrlMatchResult} from './config'; import {ActivatedRouteSnapshot, RouterStateSnapshot, inheritedParamsDataResolve} from './router_state'; -import {PRIMARY_OUTLET, Params} from './shared'; +import {PRIMARY_OUTLET, Params, defaultUrlMatcher} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlTree, mapChildrenIntoArray} from './url_tree'; -import {last, merge} from './utils/collection'; +import {forEach, last, merge} from './utils/collection'; import {TreeNode} from './utils/tree'; class NoMatch {} @@ -174,35 +174,15 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment } } - const path = route.path; - const parts = path.split('/'); - const posParameters: {[key: string]: any} = {}; - const consumedSegments: UrlSegment[] = []; + const matcher = route.matcher || defaultUrlMatcher; + const res = matcher(segments, segmentGroup, route); + if (!res) throw new NoMatch(); - let currentIndex = 0; + const posParams: {[n: string]: string} = {}; + forEach(res.posParams, (v: UrlSegment, k: string) => { posParams[k] = v.path; }); + const parameters = merge(posParams, res.consumed[res.consumed.length - 1].parameters); - for (let i = 0; i < parts.length; ++i) { - if (currentIndex >= segments.length) throw new NoMatch(); - const current = segments[currentIndex]; - - const p = parts[i]; - const isPosParam = p.startsWith(':'); - - if (!isPosParam && p !== current.path) throw new NoMatch(); - if (isPosParam) { - posParameters[p.substring(1)] = current.path; - } - consumedSegments.push(current); - currentIndex++; - } - - if (route.pathMatch === 'full' && - (segmentGroup.hasChildren() || currentIndex < segments.length)) { - throw new NoMatch(); - } - - const parameters = merge(posParameters, consumedSegments[consumedSegments.length - 1].parameters); - return {consumedSegments, lastChild: currentIndex, parameters}; + return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters}; } function checkOutletNameUniqueness(nodes: TreeNode[]): void { diff --git a/modules/@angular/router/src/shared.ts b/modules/@angular/router/src/shared.ts index de5506aa2d..bd2798710e 100644 --- a/modules/@angular/router/src/shared.ts +++ b/modules/@angular/router/src/shared.ts @@ -6,6 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ + +import {Route, UrlMatchResult} from './config'; +import {UrlSegment, UrlSegmentGroup} from './url_tree'; + + /** * @whatItDoes Name of the primary outlet. * @@ -30,3 +35,35 @@ export class NavigationCancelingError extends Error { } toString(): string { return this.message; } } + +export function defaultUrlMatcher( + segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route): UrlMatchResult { + const path = route.path; + const parts = path.split('/'); + const posParams: {[key: string]: UrlSegment} = {}; + const consumed: UrlSegment[] = []; + + let currentIndex = 0; + + for (let i = 0; i < parts.length; ++i) { + if (currentIndex >= segments.length) return null; + const current = segments[currentIndex]; + + const p = parts[i]; + const isPosParam = p.startsWith(':'); + + if (!isPosParam && p !== current.path) return null; + if (isPosParam) { + posParams[p.substring(1)] = current; + } + consumed.push(current); + currentIndex++; + } + + if (route.pathMatch === 'full' && + (segmentGroup.hasChildren() || currentIndex < segments.length)) { + return null; + } else { + return {consumed, posParams}; + } +} diff --git a/modules/@angular/router/test/apply_redirects.spec.ts b/modules/@angular/router/test/apply_redirects.spec.ts index ef96cf017e..f1bcb081aa 100644 --- a/modules/@angular/router/test/apply_redirects.spec.ts +++ b/modules/@angular/router/test/apply_redirects.spec.ts @@ -546,6 +546,26 @@ describe('applyRedirects', () => { e => { expect(e.message).toEqual('Cannot match any routes. URL Segment: \'a/c\''); }); }); }); + + describe('custom path matchers', () => { + it('should use custom path matcher', () => { + const matcher = (s: any, g: any, r: any) => { + if (s[0].path === 'a') { + return {consumed: s.slice(0, 2), posParams: {id: s[1]}}; + } else { + return null; + } + }; + + checkRedirect( + [{ + matcher: matcher, + component: ComponentA, + children: [{path: 'b', component: ComponentB}] + }], + '/a/1/b', (t: UrlTree) => { compareTrees(t, tree('a/1/b')); }); + }); + }); }); function checkRedirect(config: Routes, url: string, callback: any): void { diff --git a/modules/@angular/router/test/config.spec.ts b/modules/@angular/router/test/config.spec.ts index e08db82cbd..c77c50bdc7 100644 --- a/modules/@angular/router/test/config.spec.ts +++ b/modules/@angular/router/test/config.spec.ts @@ -51,7 +51,14 @@ describe('config', () => { `Invalid configuration of route 'a': redirectTo and component cannot be used together`); }); - it('should throw when path is missing', () => { + + it('should throw when path and mathcer are used together', () => { + expect(() => { validateConfig([{path: 'a', matcher: 'someFunc', children: []}]); }) + .toThrowError( + `Invalid configuration of route 'a': path and matcher cannot be used together`); + }); + + it('should throw when path and matcher are missing', () => { expect(() => { validateConfig([{component: null, redirectTo: 'b'}]); }).toThrowError(`Invalid route configuration: routes must have path specified`); diff --git a/modules/@angular/router/test/integration.spec.ts b/modules/@angular/router/test/integration.spec.ts index ad1867be3c..e8e1cfc488 100644 --- a/modules/@angular/router/test/integration.spec.ts +++ b/modules/@angular/router/test/integration.spec.ts @@ -731,7 +731,7 @@ describe('Integration', () => { expect(cmp.activations.length).toEqual(1); expect(cmp.activations[0] instanceof BlankCmp).toBe(true); - router.navigateByUrl('/simple').catch(e => console.log(e)); + router.navigateByUrl('/simple'); advance(fixture); expect(cmp.activations.length).toEqual(2); diff --git a/modules/@angular/router/test/recognize.spec.ts b/modules/@angular/router/test/recognize.spec.ts index e822d13a52..7821126961 100644 --- a/modules/@angular/router/test/recognize.spec.ts +++ b/modules/@angular/router/test/recognize.spec.ts @@ -636,6 +636,30 @@ describe('recognize', () => { }); }); + describe('custom path matchers', () => { + it('should use custom path matcher', () => { + const matcher = (s: any, g: any, r: any) => { + if (s[0].path === 'a') { + return {consumed: s.slice(0, 2), posParams: {id: s[1]}}; + } else { + return null; + } + }; + + checkRecognize( + [{ + matcher: matcher, + component: ComponentA, + children: [{path: 'b', component: ComponentB}] + }], + '/a/1;p=99/b', (s: RouterStateSnapshot) => { + const a = s.root.firstChild; + checkActivatedRoute(a, 'a/1', {id: '1', p: '99'}, ComponentA); + checkActivatedRoute(a.firstChild, 'b', {}, ComponentB); + }); + }); + }); + describe('query parameters', () => { it('should support query params', () => { const config = [{path: 'a', component: ComponentA}]; diff --git a/tools/public_api_guard/router/index.d.ts b/tools/public_api_guard/router/index.d.ts index 19bc418989..359c422f89 100644 --- a/tools/public_api_guard/router/index.d.ts +++ b/tools/public_api_guard/router/index.d.ts @@ -187,6 +187,7 @@ export interface Route { component?: Type; data?: Data; loadChildren?: LoadChildren; + matcher?: UrlMatcher; outlet?: string; path?: string; pathMatch?: string;