From a755b715ed5566a6796bc538653362edab8f74d4 Mon Sep 17 00:00:00 2001
From: Victor Berchet <victor@suumit.com>
Date: Fri, 17 Mar 2017 10:09:42 -0700
Subject: [PATCH] feat(router): introduce `ParamMap` to access parameters

The Router use the type `Params` for all of:
- position parameters,
- matrix parameters,
- query parameters.

`Params` is defined as follow `type Params = {[key: string]: any}`

Because parameters can either have single or multiple values, the type should
actually be `type Params = {[key: string]: string | string[]}`.

The client code often assumes that parameters have single values, as in the
following exemple:

```
class MyComponent {
sessionId: Observable<string>;

constructor(private route: ActivatedRoute) {}

ngOnInit() {
    this.sessionId = this.route
      .queryParams
      .map(params => params['session_id'] || 'None');
}
}

```

The problem here is that `params['session_id']` could be `string` or `string[]`
but the error is not caught at build time because of the `any` type.

Fixing the type as describe above would break the build because `sessionId`
would becomes an `Observable<string | string[]>`.

However the client code knows if it expects a single or multiple values. By
using the new `ParamMap` interface the user code can decide when it needs a
single value (calling `ParamMap.get(): string`) or multiple values (calling
`ParamMap.getAll(): string[]`).

The above exemple should be rewritten as:

```
class MyComponent {
sessionId: Observable<string>;

constructor(private route: ActivatedRoute) {}

ngOnInit() {
    this.sessionId = this.route
      .queryParamMap
      .map(paramMap => paramMap.get('session_id') || 'None');
}
}

```

Added APIs:
- `interface ParamMap`,
- `ActivatedRoute.paramMap: ParamMap`,
- `ActivatedRoute.queryParamMap: ParamMap`,
- `ActivatedRouteSnapshot.paramMap: ParamMap`,
- `ActivatedRouteSnapshot.queryParamMap: ParamMap`,
- `UrlSegment.parameterMap: ParamMap`
---
 .../src/routing/app/inbox-detail.ts           |  4 +-
 packages/router/src/index.ts                  |  2 +-
 packages/router/src/router_state.ts           | 41 ++++++++++++-
 packages/router/src/shared.ts                 | 59 +++++++++++++++++++
 packages/router/src/url_tree.ts               | 22 ++++++-
 .../router/test/create_router_state.spec.ts   |  2 +
 packages/router/test/create_url_tree.spec.ts  |  2 +
 packages/router/test/integration.spec.ts      | 16 ++---
 packages/router/test/recognize.spec.ts        |  1 +
 packages/router/test/shared.spec.ts           | 40 +++++++++++++
 packages/router/test/url_serializer.spec.ts   | 13 +++-
 tools/public_api_guard/router/router.d.ts     | 13 ++++
 12 files changed, 200 insertions(+), 15 deletions(-)
 create mode 100644 packages/router/test/shared.spec.ts

diff --git a/modules/playground/src/routing/app/inbox-detail.ts b/modules/playground/src/routing/app/inbox-detail.ts
index f0d85c1f6a..584ccc9e80 100644
--- a/modules/playground/src/routing/app/inbox-detail.ts
+++ b/modules/playground/src/routing/app/inbox-detail.ts
@@ -17,8 +17,8 @@ export class InboxDetailCmp {
   private ready: boolean = false;
 
   constructor(db: DbService, route: ActivatedRoute) {
-    route.params.forEach(
-        p => { db.email(p['id']).then((data) => { this.record.setData(data); }); });
+    route.paramMap.forEach(
+        p => { db.email(p.get('id')).then((data) => { this.record.setData(data); }); });
   }
 }
 
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index d1bd73dd12..d21e6151d9 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -20,7 +20,7 @@ export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, pr
 export {RouterOutletMap} from './router_outlet_map';
 export {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader';
 export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state';
-export {PRIMARY_OUTLET, Params} from './shared';
+export {PRIMARY_OUTLET, ParamMap, Params} from './shared';
 export {UrlHandlingStrategy} from './url_handling_strategy';
 export {DefaultUrlSerializer, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
 export {VERSION} from './version';
diff --git a/packages/router/src/router_state.ts b/packages/router/src/router_state.ts
index 7fa52a4389..ee5cbf9c42 100644
--- a/packages/router/src/router_state.ts
+++ b/packages/router/src/router_state.ts
@@ -9,13 +9,15 @@
 import {Type} from '@angular/core';
 import {BehaviorSubject} from 'rxjs/BehaviorSubject';
 import {Observable} from 'rxjs/Observable';
+import {map} from 'rxjs/operator/map';
 
 import {Data, ResolveData, Route} from './config';
-import {PRIMARY_OUTLET, Params} from './shared';
+import {PRIMARY_OUTLET, ParamMap, Params, convertToParamMap} from './shared';
 import {UrlSegment, UrlSegmentGroup, UrlTree, equalSegments} from './url_tree';
 import {merge, shallowEqual, shallowEqualArrays} from './utils/collection';
 import {Tree, TreeNode} from './utils/tree';
 
+
 /**
  * @whatItDoes Represents the state of the router.
  *
@@ -110,6 +112,10 @@ export class ActivatedRoute {
   _futureSnapshot: ActivatedRouteSnapshot;
   /** @internal */
   _routerState: RouterState;
+  /** @internal */
+  _paramMap: Observable<ParamMap>;
+  /** @internal */
+  _queryParamMap: Observable<ParamMap>;
 
   /** @internal */
   constructor(
@@ -149,6 +155,21 @@ export class ActivatedRoute {
   /** The path from the root of the router state tree to this route */
   get pathFromRoot(): ActivatedRoute[] { return this._routerState.pathFromRoot(this); }
 
+  get paramMap(): Observable<ParamMap> {
+    if (!this._paramMap) {
+      this._paramMap = map.call(this.params, (p: Params): ParamMap => convertToParamMap(p));
+    }
+    return this._paramMap;
+  }
+
+  get queryParamMap(): Observable<ParamMap> {
+    if (!this._queryParamMap) {
+      this._queryParamMap =
+          map.call(this.queryParams, (p: Params): ParamMap => convertToParamMap(p));
+    }
+    return this._queryParamMap;
+  }
+
   toString(): string {
     return this.snapshot ? this.snapshot.toString() : `Future(${this._futureSnapshot})`;
   }
@@ -225,6 +246,10 @@ export class ActivatedRouteSnapshot {
   _resolvedData: Data;
   /** @internal */
   _routerState: RouterStateSnapshot;
+  /** @internal */
+  _paramMap: ParamMap;
+  /** @internal */
+  _queryParamMap: ParamMap;
 
   /** @internal */
   constructor(
@@ -267,6 +292,20 @@ export class ActivatedRouteSnapshot {
   /** The path from the root of the router state tree to this route */
   get pathFromRoot(): ActivatedRouteSnapshot[] { return this._routerState.pathFromRoot(this); }
 
+  get paramMap(): ParamMap {
+    if (!this._paramMap) {
+      this._paramMap = convertToParamMap(this.params);
+    }
+    return this._paramMap;
+  }
+
+  get queryParamMap(): ParamMap {
+    if (!this._queryParamMap) {
+      this._queryParamMap = convertToParamMap(this.queryParams);
+    }
+    return this._queryParamMap;
+  }
+
   toString(): string {
     const url = this.url.map(segment => segment.toString()).join('/');
     const matched = this._routeConfig ? this._routeConfig.path : '';
diff --git a/packages/router/src/shared.ts b/packages/router/src/shared.ts
index e98940baca..7f2efba140 100644
--- a/packages/router/src/shared.ts
+++ b/packages/router/src/shared.ts
@@ -27,6 +27,65 @@ export type Params = {
   [key: string]: any
 };
 
+/**
+ * Matrix and Query parameters.
+ *
+ * `ParamMap` makes it easier to work with parameters as they could have either a single value or
+ * multiple value. Because this should be know by the user calling `get` or `getAll` returns the
+ * correct type (either `string` or `string[]`).
+ *
+ * The API is inspired by the URLSearchParams interface.
+ * see https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
+ *
+ * @stable
+ */
+export interface ParamMap {
+  has(name: string): boolean;
+  /**
+   * Return a single value for the given parameter name:
+   * - the value when the parameter has a single value,
+   * - the first value if the parameter has multiple values,
+   * - `null` when there is no such parameter.
+   */
+  get(name: string): string|null;
+  /**
+   * Return an array of values for the given parameter name.
+   *
+   * If there is no such parameter, an empty array is returned.
+   */
+  getAll(name: string): string[];
+}
+
+class ParamsAsMap implements ParamMap {
+  private params: Params;
+
+  constructor(params: Params) { this.params = params || {}; }
+
+  has(name: string): boolean { return this.params.hasOwnProperty(name); }
+
+  get(name: string): string|null {
+    if (this.has(name)) {
+      const v = this.params[name];
+      return Array.isArray(v) ? v[0] : v;
+    }
+
+    return null;
+  }
+
+  getAll(name: string): string[] {
+    if (this.has(name)) {
+      const v = this.params[name];
+      return Array.isArray(v) ? v : [v];
+    }
+
+    return [];
+  }
+}
+
+export function convertToParamMap(params: Params): ParamMap {
+  return new ParamsAsMap(params);
+}
+
 const NAVIGATION_CANCELING_ERROR = 'ngNavigationCancelingError';
 
 export function navigationCancelingError(message: string) {
diff --git a/packages/router/src/url_tree.ts b/packages/router/src/url_tree.ts
index ae9e6fbe04..e03cc058f0 100644
--- a/packages/router/src/url_tree.ts
+++ b/packages/router/src/url_tree.ts
@@ -6,7 +6,7 @@
  * found in the LICENSE file at https://angular.io/license
  */
 
-import {PRIMARY_OUTLET} from './shared';
+import {PRIMARY_OUTLET, ParamMap, convertToParamMap} from './shared';
 import {forEach, shallowEqual} from './utils/collection';
 
 export function createEmptyUrlTree() {
@@ -103,6 +103,9 @@ function containsSegmentGroupHelper(
  * @stable
  */
 export class UrlTree {
+  /** @internal */
+  _queryParamMap: ParamMap;
+
   /** @internal */
   constructor(
       /** The root segment group of the URL tree */
@@ -112,6 +115,13 @@ export class UrlTree {
       /** The fragment of the URL */
       public fragment: string) {}
 
+  get queryParamMap() {
+    if (!this._queryParamMap) {
+      this._queryParamMap = convertToParamMap(this.queryParams);
+    }
+    return this._queryParamMap;
+  }
+
   /** @docsNotRequired */
   toString(): string { return new DefaultUrlSerializer().serialize(this); }
 }
@@ -176,6 +186,9 @@ export class UrlSegmentGroup {
  * @stable
  */
 export class UrlSegment {
+  /** @internal */
+  _parameterMap: ParamMap;
+
   constructor(
       /** The path part of a URL segment */
       public path: string,
@@ -183,6 +196,13 @@ export class UrlSegment {
       /** The matrix parameters associated with a segment */
       public parameters: {[name: string]: string}) {}
 
+  get parameterMap() {
+    if (!this._parameterMap) {
+      this._parameterMap = convertToParamMap(this.parameters);
+    }
+    return this._parameterMap;
+  }
+
   /** @docsNotRequired */
   toString(): string { return serializePath(this); }
 }
diff --git a/packages/router/test/create_router_state.spec.ts b/packages/router/test/create_router_state.spec.ts
index e5a79fad5a..b31247b4fc 100644
--- a/packages/router/test/create_router_state.spec.ts
+++ b/packages/router/test/create_router_state.spec.ts
@@ -84,6 +84,8 @@ describe('create router state', () => {
     const currC = state.children(currP);
 
     expect(currP._futureSnapshot.params).toEqual({id: '2', p: '22'});
+    expect(currP._futureSnapshot.paramMap.get('id')).toEqual('2');
+    expect(currP._futureSnapshot.paramMap.get('p')).toEqual('22');
     checkActivatedRoute(currC[0], ComponentA);
     checkActivatedRoute(currC[1], ComponentB, 'right');
   });
diff --git a/packages/router/test/create_url_tree.spec.ts b/packages/router/test/create_url_tree.spec.ts
index 0e40e875e5..bb7a2b3b00 100644
--- a/packages/router/test/create_url_tree.spec.ts
+++ b/packages/router/test/create_url_tree.spec.ts
@@ -31,12 +31,14 @@ describe('createUrlTree', () => {
       const p = serializer.parse('/');
       const t = createRoot(p, [], {a: 'hey'});
       expect(t.queryParams).toEqual({a: 'hey'});
+      expect(t.queryParamMap.get('a')).toEqual('hey');
     });
 
     it('should stringify query params', () => {
       const p = serializer.parse('/');
       const t = createRoot(p, [], <any>{a: 1});
       expect(t.queryParams).toEqual({a: '1'});
+      expect(t.queryParamMap.get('a')).toEqual('1');
     });
   });
 
diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts
index 7c509f878f..d49f5a6ff9 100644
--- a/packages/router/test/integration.spec.ts
+++ b/packages/router/test/integration.spec.ts
@@ -7,16 +7,16 @@
  */
 
 import {CommonModule, Location} from '@angular/common';
-import {Component, Inject, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core';
+import {Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core';
 import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing';
 import {By} from '@angular/platform-browser/src/dom/debug/by';
 import {expect} from '@angular/platform-browser/testing/src/matchers';
 import {Observable} from 'rxjs/Observable';
 import {map} from 'rxjs/operator/map';
 
-import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, Params, PreloadAllModules, PreloadingStrategy, Resolve, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterStateSnapshot, RoutesRecognized, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '../index';
+import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterStateSnapshot, RoutesRecognized, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '../index';
 import {RouterPreloader} from '../src/router_preloader';
-import {forEach, shallowEqual} from '../src/utils/collection';
+import {forEach} from '../src/utils/collection';
 import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing';
 
 describe('Integration', () => {
@@ -1443,7 +1443,7 @@ describe('Integration', () => {
             providers: [{
               provide: 'CanActivate',
               useValue: (a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => {
-                if (a.params['id'] == '22') {
+                if (a.params['id'] === '22') {
                   return Promise.resolve(true);
                 } else {
                   return Promise.resolve(false);
@@ -1995,7 +1995,7 @@ describe('Integration', () => {
           TestBed.configureTestingModule({
             providers: [{
               provide: 'alwaysFalse',
-              useValue: (a: any, b: any) => a.params.id === '22',
+              useValue: (a: any, b: any) => a.paramMap.get('id') === '22',
             }]
           });
         });
@@ -3233,7 +3233,9 @@ class AbsoluteLinkCmp {
 })
 class DummyLinkCmp {
   private exact: boolean;
-  constructor(route: ActivatedRoute) { this.exact = (<any>route.snapshot.params).exact === 'true'; }
+  constructor(route: ActivatedRoute) {
+    this.exact = route.snapshot.paramMap.get('exact') === 'true';
+  }
 }
 
 @Component({selector: 'link-cmp', template: `<a [routerLink]="['../simple']">link</a>`})
@@ -3326,7 +3328,7 @@ class QueryParamsAndFragmentCmp {
   fragment: Observable<string>;
 
   constructor(route: ActivatedRoute) {
-    this.name = map.call(route.queryParams, (p: any) => p['name']);
+    this.name = map.call(route.queryParamMap, (p: ParamMap) => p.get('name'));
     this.fragment = route.fragment;
   }
 }
diff --git a/packages/router/test/recognize.spec.ts b/packages/router/test/recognize.spec.ts
index 7821126961..cf63d18b53 100644
--- a/packages/router/test/recognize.spec.ts
+++ b/packages/router/test/recognize.spec.ts
@@ -665,6 +665,7 @@ describe('recognize', () => {
       const config = [{path: 'a', component: ComponentA}];
       checkRecognize(config, 'a?q=11', (s: RouterStateSnapshot) => {
         expect(s.root.queryParams).toEqual({q: '11'});
+        expect(s.root.queryParamMap.get('q')).toEqual('11');
       });
     });
 
diff --git a/packages/router/test/shared.spec.ts b/packages/router/test/shared.spec.ts
new file mode 100644
index 0000000000..3e2edfed09
--- /dev/null
+++ b/packages/router/test/shared.spec.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright Google Inc. All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {ParamMap, convertToParamMap} from '../src/shared';
+
+describe('ParamsMap', () => {
+  it('should returns whether a parameter is present', () => {
+    const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']});
+    expect(map.has('single')).toEqual(true);
+    expect(map.has('multiple')).toEqual(true);
+    expect(map.has('not here')).toEqual(false);
+  });
+
+  it('should support single valued parameters', () => {
+    const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']});
+    expect(map.get('single')).toEqual('s');
+    expect(map.get('multiple')).toEqual('m1');
+  });
+
+  it('should support multiple valued parameters', () => {
+    const map = convertToParamMap({single: 's', multiple: ['m1', 'm2']});
+    expect(map.getAll('single')).toEqual(['s']);
+    expect(map.getAll('multiple')).toEqual(['m1', 'm2']);
+  });
+
+  it('should return `null` when a single valued element is absent', () => {
+    const map = convertToParamMap({});
+    expect(map.get('name')).toEqual(null);
+  });
+
+  it('should return `[]` when a mulitple valued element is absent', () => {
+    const map = convertToParamMap({});
+    expect(map.getAll('name')).toEqual([]);
+  });
+});
\ No newline at end of file
diff --git a/packages/router/test/url_serializer.spec.ts b/packages/router/test/url_serializer.spec.ts
index 3ea8a18414..3314f9fc69 100644
--- a/packages/router/test/url_serializer.spec.ts
+++ b/packages/router/test/url_serializer.spec.ts
@@ -163,6 +163,8 @@ describe('url serializer', () => {
   it('should handle multiple query params of the same name into an array', () => {
     const tree = url.parse('/one?a=foo&a=bar&a=swaz');
     expect(tree.queryParams).toEqual({a: ['foo', 'bar', 'swaz']});
+    expect(tree.queryParamMap.get('a')).toEqual('foo');
+    expect(tree.queryParamMap.getAll('a')).toEqual(['foo', 'bar', 'swaz']);
     expect(url.serialize(tree)).toEqual('/one?a=foo&a=bar&a=swaz');
   });
 
@@ -199,8 +201,11 @@ describe('url serializer', () => {
     it('should encode/decode "slash" in path segments and parameters', () => {
       const u = `/${encode("one/two")};${encode("p/1")}=${encode("v/1")}/three`;
       const tree = url.parse(u);
-      expect(tree.root.children[PRIMARY_OUTLET].segments[0].path).toEqual('one/two');
-      expect(tree.root.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({['p/1']: 'v/1'});
+      const segment = tree.root.children[PRIMARY_OUTLET].segments[0];
+      expect(segment.path).toEqual('one/two');
+      expect(segment.parameters).toEqual({'p/1': 'v/1'});
+      expect(segment.parameterMap.get('p/1')).toEqual('v/1');
+      expect(segment.parameterMap.getAll('p/1')).toEqual(['v/1']);
       expect(url.serialize(tree)).toEqual(u);
     });
 
@@ -208,7 +213,9 @@ describe('url serializer', () => {
       const u = `/one?${encode("p 1")}=${encode("v 1")}&${encode("p 2")}=${encode("v 2")}`;
       const tree = url.parse(u);
 
-      expect(tree.queryParams).toEqual({['p 1']: 'v 1', ['p 2']: 'v 2'});
+      expect(tree.queryParams).toEqual({'p 1': 'v 1', 'p 2': 'v 2'});
+      expect(tree.queryParamMap.get('p 1')).toEqual('v 1');
+      expect(tree.queryParamMap.get('p 2')).toEqual('v 2');
       expect(url.serialize(tree)).toEqual(u);
     });
 
diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts
index 08bc632212..42a53a6f06 100644
--- a/tools/public_api_guard/router/router.d.ts
+++ b/tools/public_api_guard/router/router.d.ts
@@ -6,9 +6,11 @@ export declare class ActivatedRoute {
     readonly firstChild: ActivatedRoute;
     fragment: Observable<string>;
     outlet: string;
+    readonly paramMap: Observable<ParamMap>;
     params: Observable<Params>;
     readonly parent: ActivatedRoute;
     readonly pathFromRoot: ActivatedRoute[];
+    readonly queryParamMap: Observable<ParamMap>;
     queryParams: Observable<Params>;
     readonly root: ActivatedRoute;
     readonly routeConfig: Route;
@@ -25,9 +27,11 @@ export declare class ActivatedRouteSnapshot {
     readonly firstChild: ActivatedRouteSnapshot;
     fragment: string;
     outlet: string;
+    readonly paramMap: ParamMap;
     params: Params;
     readonly parent: ActivatedRouteSnapshot;
     readonly pathFromRoot: ActivatedRouteSnapshot[];
+    readonly queryParamMap: ParamMap;
     queryParams: Params;
     readonly root: ActivatedRouteSnapshot;
     readonly routeConfig: Route;
@@ -150,6 +154,13 @@ export declare class NoPreloading implements PreloadingStrategy {
     preload(route: Route, fn: () => Observable<any>): Observable<any>;
 }
 
+/** @stable */
+export interface ParamMap {
+    get(name: string): string | null;
+    getAll(name: string): string[];
+    has(name: string): boolean;
+}
+
 /** @stable */
 export declare type Params = {
     [key: string]: any;
@@ -390,6 +401,7 @@ export declare abstract class UrlHandlingStrategy {
 
 /** @stable */
 export declare class UrlSegment {
+    readonly parameterMap: ParamMap;
     parameters: {
         [name: string]: string;
     };
@@ -428,6 +440,7 @@ export declare abstract class UrlSerializer {
 /** @stable */
 export declare class UrlTree {
     fragment: string;
+    readonly queryParamMap: ParamMap;
     queryParams: {
         [key: string]: string;
     };