feat(http): serialize search parameters from request options

- Extends URLSearchParams API to include operations for combining
  different URLSearchParams objects:

  These new methods include:
  setAll(otherParams): performs `this.set(key, values[0])` for each
      key/value-list pair in `otherParams`

  appendAll(otherParams): performs `this.append(key, values)` for
      each key/value-list pair in `otherParams`

  replaceAll(otherParams): for each key/value-list pair in
      `otherParams`, replaces current set of values for `key` with
      a copy of the list of values.

- RequestOptions do not merge search params automatically (because
  there are multiple ways to do this). Instead, they replace any
  existing `search` field if `search` is provided. Explicit merging
  is required if merging is desirable.

- Some extra test coverage added.

Closes #2417
Closes #3020
This commit is contained in:
Caitlin Potter 2015-07-13 14:47:10 -04:00 committed by Tobias Bosch
parent dfa5103b1d
commit 77d3668432
7 changed files with 210 additions and 18 deletions

View File

@ -1,8 +1,9 @@
import {CONST_EXPR, CONST, isPresent} from 'angular2/src/facade/lang';
import {CONST_EXPR, CONST, isPresent, isString} from 'angular2/src/facade/lang';
import {Headers} from './headers';
import {RequestModesOpts, RequestMethods, RequestCacheOpts, RequestCredentialsOpts} from './enums';
import {IRequestOptions} from './interfaces';
import {Injectable} from 'angular2/di';
import {URLSearchParams} from './url_search_params';
/**
* Creates a request options object similar to the `RequestInit` description
@ -33,7 +34,9 @@ export class RequestOptions implements IRequestOptions {
credentials: RequestCredentialsOpts;
cache: RequestCacheOpts;
url: string;
constructor({method, headers, body, mode, credentials, cache, url}: IRequestOptions = {}) {
search: URLSearchParams;
constructor({method, headers, body, mode, credentials, cache, url, search}:
IRequestOptions = {}) {
this.method = isPresent(method) ? method : null;
this.headers = isPresent(headers) ? headers : null;
this.body = isPresent(body) ? body : null;
@ -41,6 +44,9 @@ export class RequestOptions implements IRequestOptions {
this.credentials = isPresent(credentials) ? credentials : null;
this.cache = isPresent(cache) ? cache : null;
this.url = isPresent(url) ? url : null;
this.search = isPresent(search) ? (isString(search) ? new URLSearchParams(<string>(search)) :
<URLSearchParams>(search)) :
null;
}
/**
@ -56,7 +62,11 @@ export class RequestOptions implements IRequestOptions {
credentials: isPresent(options) && isPresent(options.credentials) ? options.credentials :
this.credentials,
cache: isPresent(options) && isPresent(options.cache) ? options.cache : this.cache,
url: isPresent(options) && isPresent(options.url) ? options.url : this.url
url: isPresent(options) && isPresent(options.url) ? options.url : this.url,
search: isPresent(options) && isPresent(options.search) ?
(isString(options.search) ? new URLSearchParams(<string>(options.search)) :
(<URLSearchParams>(options.search)).clone()) :
this.search
});
}
}

View File

@ -17,6 +17,7 @@ function mergeOptions(defaultOpts, providedOpts, method, url): RequestOptions {
newOptions = newOptions.merge(new RequestOptions({
method: providedOpts.method,
url: providedOpts.url,
search: providedOpts.search,
headers: providedOpts.headers,
body: providedOpts.body,
mode: providedOpts.mode,

View File

@ -12,6 +12,10 @@ import {Headers} from './headers';
import {BaseException} from 'angular2/src/facade/lang';
import {EventEmitter} from 'angular2/src/facade/async';
import {Request} from './static_request';
import {URLSearchParamsUnionFixer, URLSearchParams} from './url_search_params';
// Work around Dartanalyzer problem :(
const URLSearchParams_UnionFixer = URLSearchParamsUnionFixer;
/**
* Abstract class from which real backends are derived.
@ -41,6 +45,7 @@ export class Connection {
export interface IRequestOptions {
url?: string;
method?: RequestMethods;
search?: string | URLSearchParams;
headers?: Headers;
// TODO: Support Blob, ArrayBuffer, JSON, URLSearchParams, FormData
body?: string;

View File

@ -6,7 +6,8 @@ import {
RegExpWrapper,
CONST_EXPR,
isPresent,
isJsObject
isJsObject,
StringWrapper
} from 'angular2/src/facade/lang';
// TODO(jeffbcross): properly implement body accessors
@ -39,7 +40,19 @@ export class Request {
cache: RequestCacheOpts;
constructor(requestOptions: RequestOptions) {
// TODO: assert that url is present
let url = requestOptions.url;
this.url = requestOptions.url;
if (isPresent(requestOptions.search)) {
let search = requestOptions.search.toString();
if (search.length > 0) {
let prefix = '?';
if (StringWrapper.contains(this.url, '?')) {
prefix = (this.url[this.url.length - 1] == '&') ? '' : '&';
}
// TODO: just delete search-query-looking string in url?
this.url = url + prefix + search;
}
}
this._body = requestOptions.body;
this.method = requestOptions.method;
// TODO(jeffbcross): implement behavior

View File

@ -1,4 +1,4 @@
import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
import {CONST_EXPR, isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
import {
Map,
MapWrapper,
@ -7,28 +7,42 @@ import {
isListLikeIterable
} from 'angular2/src/facade/collection';
function paramParser(rawParams: string): Map<string, List<string>> {
function paramParser(rawParams: string = ''): Map<string, List<string>> {
var map: Map<string, List<string>> = new Map();
var params: List<string> = StringWrapper.split(rawParams, new RegExp('&'));
ListWrapper.forEach(params, (param: string) => {
var split: List<string> = StringWrapper.split(param, new RegExp('='));
var key = ListWrapper.get(split, 0);
var val = ListWrapper.get(split, 1);
var list = isPresent(map.get(key)) ? map.get(key) : [];
list.push(val);
map.set(key, list);
});
if (rawParams.length > 0) {
var params: List<string> = StringWrapper.split(rawParams, new RegExp('&'));
ListWrapper.forEach(params, (param: string) => {
var split: List<string> = StringWrapper.split(param, new RegExp('='));
var key = ListWrapper.get(split, 0);
var val = ListWrapper.get(split, 1);
var list = isPresent(map.get(key)) ? map.get(key) : [];
list.push(val);
map.set(key, list);
});
}
return map;
}
// TODO(caitp): This really should not be needed. Issue with ts2dart.
export const URLSearchParamsUnionFixer: string = CONST_EXPR("UnionFixer");
/**
* Map-like representation of url search parameters, based on
* [URLSearchParams](https://url.spec.whatwg.org/#urlsearchparams) in the url living standard.
*
* [URLSearchParams](https://url.spec.whatwg.org/#urlsearchparams) in the url living standard,
* with several extensions for merging URLSearchParams objects:
* - setAll()
* - appendAll()
* - replaceAll()
*/
export class URLSearchParams {
paramsMap: Map<string, List<string>>;
constructor(public rawParams: string) { this.paramsMap = paramParser(rawParams); }
constructor(public rawParams: string = '') { this.paramsMap = paramParser(rawParams); }
clone(): URLSearchParams {
var clone = new URLSearchParams();
clone.appendAll(this);
return clone;
}
has(param: string): boolean { return this.paramsMap.has(param); }
@ -46,6 +60,30 @@ export class URLSearchParams {
return isPresent(mapParam) ? mapParam : [];
}
set(param: string, val: string) {
var mapParam = this.paramsMap.get(param);
var list = isPresent(mapParam) ? mapParam : [];
ListWrapper.clear(list);
list.push(val);
this.paramsMap.set(param, list);
}
// A merge operation
// For each name-values pair in `searchParams`, perform `set(name, values[0])`
//
// E.g: "a=[1,2,3], c=[8]" + "a=[4,5,6], b=[7]" = "a=[4], c=[8], b=[7]"
//
// TODO(@caitp): document this better
setAll(searchParams: URLSearchParams) {
MapWrapper.forEach(searchParams.paramsMap, (value, param) => {
var mapParam = this.paramsMap.get(param);
var list = isPresent(mapParam) ? mapParam : [];
ListWrapper.clear(list);
list.push(value[0]);
this.paramsMap.set(param, list);
});
}
append(param: string, val: string): void {
var mapParam = this.paramsMap.get(param);
var list = isPresent(mapParam) ? mapParam : [];
@ -53,6 +91,44 @@ export class URLSearchParams {
this.paramsMap.set(param, list);
}
// A merge operation
// For each name-values pair in `searchParams`, perform `append(name, value)`
// for each value in `values`.
//
// E.g: "a=[1,2], c=[8]" + "a=[3,4], b=[7]" = "a=[1,2,3,4], c=[8], b=[7]"
//
// TODO(@caitp): document this better
appendAll(searchParams: URLSearchParams) {
MapWrapper.forEach(searchParams.paramsMap, (value, param) => {
var mapParam = this.paramsMap.get(param);
var list = isPresent(mapParam) ? mapParam : [];
for (var i = 0; i < value.length; ++i) {
list.push(value[i]);
}
this.paramsMap.set(param, list);
});
}
// A merge operation
// For each name-values pair in `searchParams`, perform `delete(name)`,
// followed by `set(name, values)`
//
// E.g: "a=[1,2,3], c=[8]" + "a=[4,5,6], b=[7]" = "a=[4,5,6], c=[8], b=[7]"
//
// TODO(@caitp): document this better
replaceAll(searchParams: URLSearchParams) {
MapWrapper.forEach(searchParams.paramsMap, (value, param) => {
var mapParam = this.paramsMap.get(param);
var list = isPresent(mapParam) ? mapParam : [];
ListWrapper.clear(list);
for (var i = 0; i < value.length; ++i) {
list.push(value[i]);
}
this.paramsMap.set(param, list);
});
}
toString(): string {
var paramsList = [];
MapWrapper.forEach(this.paramsMap, (values, k) => {

View File

@ -21,6 +21,7 @@ import {ResponseOptions} from 'angular2/src/http/base_response_options';
import {Request} from 'angular2/src/http/static_request';
import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
import {ConnectionBackend} from 'angular2/src/http/interfaces';
import {URLSearchParams} from 'angular2/src/http/url_search_params';
class SpyObserver extends SpyObject {
onNext: Function;
@ -202,6 +203,47 @@ export function main() {
ObservableWrapper.subscribe(http.head(url), res => {});
}));
});
describe('searchParams', () => {
it('should append search params to url', inject([AsyncTestCompleter], async => {
var params = new URLSearchParams();
params.append('q', 'puppies');
ObservableWrapper.subscribe<MockConnection>(backend.connections, c => {
expect(c.request.url).toEqual('https://www.google.com?q=puppies');
backend.resolveAllConnections();
async.done();
});
ObservableWrapper.subscribe(
http.get('https://www.google.com', new RequestOptions({search: params})),
res => {});
}));
it('should append string search params to url', inject([AsyncTestCompleter], async => {
ObservableWrapper.subscribe<MockConnection>(backend.connections, c => {
expect(c.request.url).toEqual('https://www.google.com?q=piggies');
backend.resolveAllConnections();
async.done();
});
ObservableWrapper.subscribe(
http.get('https://www.google.com', new RequestOptions({search: 'q=piggies'})),
res => {});
}));
it('should produce valid url when url already contains a query',
inject([AsyncTestCompleter], async => {
ObservableWrapper.subscribe<MockConnection>(backend.connections, c => {
expect(c.request.url).toEqual('https://www.google.com?q=angular&as_eq=1.x');
backend.resolveAllConnections();
async.done();
});
ObservableWrapper.subscribe(http.get('https://www.google.com?q=angular',
new RequestOptions({search: 'as_eq=1.x'})),
res => {});
}));
});
});
});
}

View File

@ -30,6 +30,51 @@ export function main() {
expect(searchParams.toString()).toEqual("q=URLUtils.searchParams&topic=api&topic=webdev");
searchParams.delete("topic");
expect(searchParams.toString()).toEqual("q=URLUtils.searchParams");
// Test default constructor
expect(new URLSearchParams().toString()).toBe("");
});
it('should support map-like merging operation via setAll()', () => {
var mapA = new URLSearchParams('a=1&a=2&a=3&c=8');
var mapB = new URLSearchParams('a=4&a=5&a=6&b=7');
mapA.setAll(mapB);
expect(mapA.has('a')).toBe(true);
expect(mapA.has('b')).toBe(true);
expect(mapA.has('c')).toBe(true);
expect(mapA.getAll('a')).toEqual(['4']);
expect(mapA.getAll('b')).toEqual(['7']);
expect(mapA.getAll('c')).toEqual(['8']);
expect(mapA.toString()).toEqual('a=4&c=8&b=7');
});
it('should support multimap-like merging operation via appendAll()', () => {
var mapA = new URLSearchParams('a=1&a=2&a=3&c=8');
var mapB = new URLSearchParams('a=4&a=5&a=6&b=7');
mapA.appendAll(mapB);
expect(mapA.has('a')).toBe(true);
expect(mapA.has('b')).toBe(true);
expect(mapA.has('c')).toBe(true);
expect(mapA.getAll('a')).toEqual(['1', '2', '3', '4', '5', '6']);
expect(mapA.getAll('b')).toEqual(['7']);
expect(mapA.getAll('c')).toEqual(['8']);
expect(mapA.toString()).toEqual('a=1&a=2&a=3&a=4&a=5&a=6&c=8&b=7');
});
it('should support multimap-like merging operation via replaceAll()', () => {
var mapA = new URLSearchParams('a=1&a=2&a=3&c=8');
var mapB = new URLSearchParams('a=4&a=5&a=6&b=7');
mapA.replaceAll(mapB);
expect(mapA.has('a')).toBe(true);
expect(mapA.has('b')).toBe(true);
expect(mapA.has('c')).toBe(true);
expect(mapA.getAll('a')).toEqual(['4', '5', '6']);
expect(mapA.getAll('b')).toEqual(['7']);
expect(mapA.getAll('c')).toEqual(['8']);
expect(mapA.toString()).toEqual('a=4&a=5&a=6&c=8&b=7');
});
});
}