From 5d6b7cb62c92bfac615ca7515bc5202dda24b266 Mon Sep 17 00:00:00 2001 From: Rob Wormald Date: Thu, 19 Nov 2015 17:29:41 -0800 Subject: [PATCH 1/4] fix(http): error on non-200 status codes BREAKING CHANGE: previously http would only error on network errors to match the fetch specification. Now status codes less than 200 and greater than 299 will cause Http's Observable to error. Closes #5130. --- .../angular2/src/http/backends/xhr_backend.ts | 21 ++++--- modules/angular2/src/http/http_utils.ts | 3 + .../test/http/backends/xhr_backend_spec.ts | 56 +++++++++++++++++-- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/modules/angular2/src/http/backends/xhr_backend.ts b/modules/angular2/src/http/backends/xhr_backend.ts index 7de04161a8e4..897a5259c956 100644 --- a/modules/angular2/src/http/backends/xhr_backend.ts +++ b/modules/angular2/src/http/backends/xhr_backend.ts @@ -7,6 +7,7 @@ import {Injectable} from 'angular2/angular2'; import {BrowserXhr} from './browser_xhr'; import {isPresent} from 'angular2/src/facade/lang'; import {Observable} from 'angular2/angular2'; +import {isSuccess} from '../http_utils'; /** * Creates connections using `XMLHttpRequest`. Given a fully-qualified * request, an `XHRConnection` will immediately create an `XMLHttpRequest` object and send the @@ -33,24 +34,30 @@ export class XHRConnection implements Connection { // responseText is the old-school way of retrieving response (supported by IE8 & 9) // response/responseType properties were introduced in XHR Level2 spec (supported by // IE10) - let response = isPresent(_xhr.response) ? _xhr.response : _xhr.responseText; + let xhrResponse = isPresent(_xhr.response) ? _xhr.response : _xhr.responseText; + // normalize IE9 bug (http://bugs.jquery.com/ticket/1450) - let status = _xhr.status === 1223 ? 204 : _xhr.status; + let status: number = _xhr.status === 1223 ? 204 : _xhr.status; // fix status code when it is 0 (0 status is undocumented). // Occurs when accessing file resources or on Android 4.1 stock browser // while retrieving files from application cache. if (status === 0) { - status = response ? 200 : 0; + status = xhrResponse ? 200 : 0; } - var responseOptions = new ResponseOptions({body: response, status: status}); + var responseOptions = new ResponseOptions({body: xhrResponse, status: status}); if (isPresent(baseResponseOptions)) { responseOptions = baseResponseOptions.merge(responseOptions); } - responseObserver.next(new Response(responseOptions)); - // TODO(gdi2290): defer complete if array buffer until done - responseObserver.complete(); + let response = new Response(responseOptions); + if (isSuccess(status)) { + responseObserver.next(response); + // TODO(gdi2290): defer complete if array buffer until done + responseObserver.complete(); + return; + } + responseObserver.error(response); }; // error event handler let onError = (err) => { diff --git a/modules/angular2/src/http/http_utils.ts b/modules/angular2/src/http/http_utils.ts index 77bb16978cbe..451469eaf426 100644 --- a/modules/angular2/src/http/http_utils.ts +++ b/modules/angular2/src/http/http_utils.ts @@ -1,6 +1,7 @@ import {isString} from 'angular2/src/facade/lang'; import {RequestMethods} from './enums'; import {makeTypeError} from 'angular2/src/facade/exceptions'; +import {Response} from './static_response'; export function normalizeMethodName(method): RequestMethods { if (isString(method)) { @@ -14,4 +15,6 @@ export function normalizeMethodName(method): RequestMethods { return method; } +export const isSuccess = (status: number): boolean => (status >= 200 && status < 300); + export {isJsObject} from 'angular2/src/facade/lang'; diff --git a/modules/angular2/test/http/backends/xhr_backend_spec.ts b/modules/angular2/test/http/backends/xhr_backend_spec.ts index f1db60ad847d..2ae020620539 100644 --- a/modules/angular2/test/http/backends/xhr_backend_spec.ts +++ b/modules/angular2/test/http/backends/xhr_backend_spec.ts @@ -99,6 +99,7 @@ export function main() { expect(res.type).toBe(ResponseTypes.Error); async.done(); }); + existingXHRs[0].setStatusCode(200); existingXHRs[0].dispatchEvent('load'); })); @@ -107,7 +108,7 @@ export function main() { new ResponseOptions({type: ResponseTypes.Error})); connection.response.subscribe(res => { expect(res.type).toBe(ResponseTypes.Error); }, null, () => { async.done(); }); - + existingXHRs[0].setStatusCode(200); existingXHRs[0].dispatchEvent('load'); })); @@ -164,15 +165,57 @@ export function main() { var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), new ResponseOptions({status: statusCode})); - connection.response.subscribe(res => { - expect(res.status).toBe(statusCode); - async.done(); - }); + connection.response.subscribe( + res => { + + }, + errRes => { + expect(errRes.status).toBe(statusCode); + async.done(); + }); + + existingXHRs[0].setStatusCode(statusCode); + existingXHRs[0].dispatchEvent('load'); + })); + + it('should call next and complete on 200 codes', inject([AsyncTestCompleter], async => { + var nextCalled = false; + var errorCalled = false; + var statusCode = 200; + var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), + new ResponseOptions({status: statusCode})); + + connection.response.subscribe( + res => { + nextCalled = true; + expect(res.status).toBe(statusCode); + }, + errRes => { errorCalled = true; }, () => { + expect(nextCalled).toBe(true); + expect(errorCalled).toBe(false); + async.done(); + }); existingXHRs[0].setStatusCode(statusCode); existingXHRs[0].dispatchEvent('load'); })); + it('should call error and not complete on 300+ codes', inject([AsyncTestCompleter], async => { + var nextCalled = false; + var errorCalled = false; + var statusCode = 301; + var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), + new ResponseOptions({status: statusCode})); + + connection.response.subscribe(res => { nextCalled = true; }, errRes => { + expect(errRes.status).toBe(statusCode); + expect(nextCalled).toBe(false); + async.done(); + }, () => { throw 'should not be called'; }); + + existingXHRs[0].setStatusCode(statusCode); + existingXHRs[0].dispatchEvent('load'); + })); it('should normalize IE\'s 1223 status code into 204', inject([AsyncTestCompleter], async => { var statusCode = 1223; var normalizedCode = 204; @@ -204,10 +247,11 @@ export function main() { expect(ress.text()).toBe(responseBody); async.done(); }); + existingXHRs[1].setStatusCode(200); existingXHRs[1].setResponse(responseBody); existingXHRs[1].dispatchEvent('load'); }); - + existingXHRs[0].setStatusCode(200); existingXHRs[0].setResponseText(responseBody); existingXHRs[0].dispatchEvent('load'); })); From d459ff3f6ebddc324bf84cf6bbc5d13eacdc8be1 Mon Sep 17 00:00:00 2001 From: Rob Wormald Date: Thu, 19 Nov 2015 17:51:00 -0800 Subject: [PATCH 2/4] fix(http): return Response headers Properly parse and add response Headers to Response. Closes #5237 --- .../angular2/src/http/backends/xhr_backend.ts | 8 +++-- modules/angular2/src/http/headers.ts | 11 +++++++ .../test/http/backends/xhr_backend_spec.ts | 29 +++++++++++++++++++ modules/angular2/test/http/headers_spec.ts | 19 ++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/modules/angular2/src/http/backends/xhr_backend.ts b/modules/angular2/src/http/backends/xhr_backend.ts index 897a5259c956..66bf375390ff 100644 --- a/modules/angular2/src/http/backends/xhr_backend.ts +++ b/modules/angular2/src/http/backends/xhr_backend.ts @@ -2,6 +2,7 @@ import {ConnectionBackend, Connection} from '../interfaces'; import {ReadyStates, RequestMethods, ResponseTypes} from '../enums'; import {Request} from '../static_request'; import {Response} from '../static_response'; +import {Headers} from '../headers'; import {ResponseOptions, BaseResponseOptions} from '../base_response_options'; import {Injectable} from 'angular2/angular2'; import {BrowserXhr} from './browser_xhr'; @@ -34,8 +35,9 @@ export class XHRConnection implements Connection { // responseText is the old-school way of retrieving response (supported by IE8 & 9) // response/responseType properties were introduced in XHR Level2 spec (supported by // IE10) - let xhrResponse = isPresent(_xhr.response) ? _xhr.response : _xhr.responseText; + let body = isPresent(_xhr.response) ? _xhr.response : _xhr.responseText; + let headers = Headers.fromResponseHeaderString(_xhr.getAllResponseHeaders()); // normalize IE9 bug (http://bugs.jquery.com/ticket/1450) let status: number = _xhr.status === 1223 ? 204 : _xhr.status; @@ -44,9 +46,9 @@ export class XHRConnection implements Connection { // Occurs when accessing file resources or on Android 4.1 stock browser // while retrieving files from application cache. if (status === 0) { - status = xhrResponse ? 200 : 0; + status = body ? 200 : 0; } - var responseOptions = new ResponseOptions({body: xhrResponse, status: status}); + var responseOptions = new ResponseOptions({body, status, headers}); if (isPresent(baseResponseOptions)) { responseOptions = baseResponseOptions.merge(responseOptions); } diff --git a/modules/angular2/src/http/headers.ts b/modules/angular2/src/http/headers.ts index 03c45aae6ece..b6740e642b80 100644 --- a/modules/angular2/src/http/headers.ts +++ b/modules/angular2/src/http/headers.ts @@ -54,6 +54,17 @@ export class Headers { headers, (v, k) => { this._headersMap.set(k, isListLikeIterable(v) ? v : [v]); }); } + /** + * Returns a new Headers instance from the given DOMString of Response Headers + */ + static fromResponseHeaderString(headersString: string): Headers { + return headersString.trim() + .split('\n') + .map(val => val.split(':')) + .map(([key, ...parts]) => ([key.trim(), parts.join(':').trim()])) + .reduce((headers, [key, value]) => !headers.set(key, value) && headers, new Headers()); + } + /** * Appends a header to existing list of header values for a given header name. */ diff --git a/modules/angular2/test/http/backends/xhr_backend_spec.ts b/modules/angular2/test/http/backends/xhr_backend_spec.ts index 2ae020620539..eac46518cf34 100644 --- a/modules/angular2/test/http/backends/xhr_backend_spec.ts +++ b/modules/angular2/test/http/backends/xhr_backend_spec.ts @@ -40,6 +40,7 @@ class MockBrowserXHR extends BrowserXhr { setRequestHeader: any; callbacks = new Map(); status: number; + responseHeaders: string; constructor() { super(); var spy = new SpyObject(); @@ -55,6 +56,10 @@ class MockBrowserXHR extends BrowserXhr { setResponseText(value) { this.responseText = value; } + setResponseHeaders(value) { this.responseHeaders = value; } + + getAllResponseHeaders() { return this.responseHeaders || ''; } + addEventListener(type: string, cb: Function) { this.callbacks.set(type, cb); } removeEventListener(type: string, cb: Function) { this.callbacks.delete(type); } @@ -256,6 +261,30 @@ export function main() { existingXHRs[0].dispatchEvent('load'); })); + it('should parse response headers and add them to the response', + inject([AsyncTestCompleter], async => { + var statusCode = 200; + var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), + new ResponseOptions({status: statusCode})); + + let responseHeaderString = + `Date: Fri, 20 Nov 2015 01:45:26 GMT + Content-Type: application/json; charset=utf-8 + Transfer-Encoding: chunked + Connection: keep-alive` + + connection.response.subscribe(res => { + expect(res.headers.get('Date')).toEqual('Fri, 20 Nov 2015 01:45:26 GMT'); + expect(res.headers.get('Content-Type')).toEqual('application/json; charset=utf-8'); + expect(res.headers.get('Transfer-Encoding')).toEqual('chunked'); + expect(res.headers.get('Connection')).toEqual('keep-alive'); + async.done(); + }); + + existingXHRs[0].setResponseHeaders(responseHeaderString); + existingXHRs[0].setStatusCode(statusCode); + existingXHRs[0].dispatchEvent('load'); + })); }); }); } diff --git a/modules/angular2/test/http/headers_spec.ts b/modules/angular2/test/http/headers_spec.ts index 156ab65b3062..0102164f4ac9 100644 --- a/modules/angular2/test/http/headers_spec.ts +++ b/modules/angular2/test/http/headers_spec.ts @@ -63,4 +63,23 @@ export function main() { }); }); }); + + describe('.fromResponseHeaderString()', () => { + + it('should parse a response header string', () => { + + let responseHeaderString = `Date: Fri, 20 Nov 2015 01:45:26 GMT + Content-Type: application/json; charset=utf-8 + Transfer-Encoding: chunked + Connection: keep-alive`; + + let responseHeaders = Headers.fromResponseHeaderString(responseHeaderString); + + expect(responseHeaders.get('Date')).toEqual('Fri, 20 Nov 2015 01:45:26 GMT'); + expect(responseHeaders.get('Content-Type')).toEqual('application/json; charset=utf-8'); + expect(responseHeaders.get('Transfer-Encoding')).toEqual('chunked'); + expect(responseHeaders.get('Connection')).toEqual('keep-alive'); + + }); + }); } From 747251374cafc7eb0566e1afeb58d174c130d896 Mon Sep 17 00:00:00 2001 From: Rob Wormald Date: Thu, 19 Nov 2015 18:47:29 -0800 Subject: [PATCH 3/4] fix(http): return URL in Response Attach reponseURL or X-Request-URL to Response. Closes #5165 --- .../src/http/backends/jsonp_backend.ts | 4 +- .../angular2/src/http/backends/xhr_backend.ts | 6 ++- modules/angular2/src/http/http_utils.ts | 10 +++++ .../test/http/backends/xhr_backend_spec.ts | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/modules/angular2/src/http/backends/jsonp_backend.ts b/modules/angular2/src/http/backends/jsonp_backend.ts index 12bad4c0ebe1..4059ce7c4a4f 100644 --- a/modules/angular2/src/http/backends/jsonp_backend.ts +++ b/modules/angular2/src/http/backends/jsonp_backend.ts @@ -57,7 +57,7 @@ export class JSONPConnection_ extends JSONPConnection { _dom.cleanup(script); if (!this._finished) { let responseOptions = - new ResponseOptions({body: JSONP_ERR_NO_CALLBACK, type: ResponseTypes.Error}); + new ResponseOptions({body: JSONP_ERR_NO_CALLBACK, type: ResponseTypes.Error, url}); if (isPresent(baseResponseOptions)) { responseOptions = baseResponseOptions.merge(responseOptions); } @@ -65,7 +65,7 @@ export class JSONPConnection_ extends JSONPConnection { return; } - let responseOptions = new ResponseOptions({body: this._responseData}); + let responseOptions = new ResponseOptions({body: this._responseData, url}); if (isPresent(this.baseResponseOptions)) { responseOptions = this.baseResponseOptions.merge(responseOptions); } diff --git a/modules/angular2/src/http/backends/xhr_backend.ts b/modules/angular2/src/http/backends/xhr_backend.ts index 66bf375390ff..a234e07ac876 100644 --- a/modules/angular2/src/http/backends/xhr_backend.ts +++ b/modules/angular2/src/http/backends/xhr_backend.ts @@ -8,7 +8,7 @@ import {Injectable} from 'angular2/angular2'; import {BrowserXhr} from './browser_xhr'; import {isPresent} from 'angular2/src/facade/lang'; import {Observable} from 'angular2/angular2'; -import {isSuccess} from '../http_utils'; +import {isSuccess, getResponseURL} from '../http_utils'; /** * Creates connections using `XMLHttpRequest`. Given a fully-qualified * request, an `XHRConnection` will immediately create an `XMLHttpRequest` object and send the @@ -39,6 +39,8 @@ export class XHRConnection implements Connection { let headers = Headers.fromResponseHeaderString(_xhr.getAllResponseHeaders()); + let url = getResponseURL(_xhr); + // normalize IE9 bug (http://bugs.jquery.com/ticket/1450) let status: number = _xhr.status === 1223 ? 204 : _xhr.status; @@ -48,7 +50,7 @@ export class XHRConnection implements Connection { if (status === 0) { status = body ? 200 : 0; } - var responseOptions = new ResponseOptions({body, status, headers}); + var responseOptions = new ResponseOptions({body, status, headers, url}); if (isPresent(baseResponseOptions)) { responseOptions = baseResponseOptions.merge(responseOptions); } diff --git a/modules/angular2/src/http/http_utils.ts b/modules/angular2/src/http/http_utils.ts index 451469eaf426..e4c2051e0bc9 100644 --- a/modules/angular2/src/http/http_utils.ts +++ b/modules/angular2/src/http/http_utils.ts @@ -17,4 +17,14 @@ export function normalizeMethodName(method): RequestMethods { export const isSuccess = (status: number): boolean => (status >= 200 && status < 300); +export function getResponseURL(xhr: any): string { + if ('responseURL' in xhr) { + return xhr.responseURL; + } + if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) { + return xhr.getResponseHeader('X-Request-URL'); + } + return; +} + export {isJsObject} from 'angular2/src/facade/lang'; diff --git a/modules/angular2/test/http/backends/xhr_backend_spec.ts b/modules/angular2/test/http/backends/xhr_backend_spec.ts index eac46518cf34..865eb8e4c1fc 100644 --- a/modules/angular2/test/http/backends/xhr_backend_spec.ts +++ b/modules/angular2/test/http/backends/xhr_backend_spec.ts @@ -41,6 +41,7 @@ class MockBrowserXHR extends BrowserXhr { callbacks = new Map(); status: number; responseHeaders: string; + responseURL: string; constructor() { super(); var spy = new SpyObject(); @@ -56,10 +57,14 @@ class MockBrowserXHR extends BrowserXhr { setResponseText(value) { this.responseText = value; } + setResponseURL(value) { this.responseURL = value; } + setResponseHeaders(value) { this.responseHeaders = value; } getAllResponseHeaders() { return this.responseHeaders || ''; } + getResponseHeader(key) { return Headers.fromResponseHeaderString(this.responseHeaders).get(key); } + addEventListener(type: string, cb: Function) { this.callbacks.set(type, cb); } removeEventListener(type: string, cb: Function) { this.callbacks.delete(type); } @@ -285,6 +290,39 @@ export function main() { existingXHRs[0].setStatusCode(statusCode); existingXHRs[0].dispatchEvent('load'); })); + + it('should add the responseURL to the response', inject([AsyncTestCompleter], async => { + var statusCode = 200; + var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), + new ResponseOptions({status: statusCode})); + + connection.response.subscribe(res => { + expect(res.url).toEqual('http://google.com'); + async.done(); + }); + + existingXHRs[0].setResponseURL('http://google.com'); + existingXHRs[0].setStatusCode(statusCode); + existingXHRs[0].dispatchEvent('load'); + })); + + it('should add use the X-Request-URL in CORS situations', + inject([AsyncTestCompleter], async => { + var statusCode = 200; + var connection = new XHRConnection(sampleRequest, new MockBrowserXHR(), + new ResponseOptions({status: statusCode})); + var responseHeaders = `X-Request-URL: http://somedomain.com + Foo: Bar` + + connection.response.subscribe(res => { + expect(res.url).toEqual('http://somedomain.com'); + async.done(); + }); + + existingXHRs[0].setResponseHeaders(responseHeaders); + existingXHRs[0].setStatusCode(statusCode); + existingXHRs[0].dispatchEvent('load'); + })); }); }); } From c3034ea4e3897cb6d0aa881d39bbad3ea52f1d2b Mon Sep 17 00:00:00 2001 From: Rob Wormald Date: Thu, 19 Nov 2015 19:54:23 -0800 Subject: [PATCH 4/4] fix(http): Fix all requests defaulting to Get Honor method parameter passed to http.request(). Closes #5309 --- modules/angular2/src/http/http.ts | 6 +-- modules/angular2/test/http/http_spec.ts | 53 +++++++++++++++++++------ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/modules/angular2/src/http/http.ts b/modules/angular2/src/http/http.ts index bba1d73e3d85..09f48159997d 100644 --- a/modules/angular2/src/http/http.ts +++ b/modules/angular2/src/http/http.ts @@ -16,9 +16,9 @@ function mergeOptions(defaultOpts, providedOpts, method, url): RequestOptions { var newOptions = defaultOpts; if (isPresent(providedOpts)) { // Hack so Dart can used named parameters - newOptions = newOptions.merge(new RequestOptions({ - method: providedOpts.method, - url: providedOpts.url, + return newOptions.merge(new RequestOptions({ + method: providedOpts.method || method, + url: providedOpts.url || url, search: providedOpts.search, headers: providedOpts.headers, body: providedOpts.body diff --git a/modules/angular2/test/http/http_spec.ts b/modules/angular2/test/http/http_spec.ts index 1c5fd71f5741..f67bc03f09ca 100644 --- a/modules/angular2/test/http/http_spec.ts +++ b/modules/angular2/test/http/http_spec.ts @@ -151,6 +151,19 @@ export function main() { .subscribe((res) => {}); })); + it('should accept a fully-qualified request as its only parameter', + inject([AsyncTestCompleter], (async) => { + backend.connections.subscribe(c => { + expect(c.request.url).toBe('https://google.com'); + expect(c.request.method).toBe(RequestMethods.Post); + c.mockRespond(new Response(new ResponseOptions({body: 'Thank you'}))); + async.done(); + }); + http.request(new Request(new RequestOptions( + {url: 'https://google.com', method: RequestMethods.Post}))) + .subscribe((res) => {}); + })); + it('should perform a get request for given url if only passed a string', inject([AsyncTestCompleter], (async) => { @@ -162,6 +175,34 @@ export function main() { }); })); + it('should perform a post request for given url if options include a method', + inject([AsyncTestCompleter], (async) => { + backend.connections.subscribe(c => { + expect(c.request.method).toEqual(RequestMethods.Post); + c.mockRespond(baseResponse); + }); + let requestOptions = new RequestOptions({method: RequestMethods.Post}); + http.request('http://basic.connection', requestOptions) + .subscribe(res => { + expect(res.text()).toBe('base response'); + async.done(); + }); + })); + + it('should perform a post request for given url if options include a method', + inject([AsyncTestCompleter], (async) => { + backend.connections.subscribe(c => { + expect(c.request.method).toEqual(RequestMethods.Post); + c.mockRespond(baseResponse); + }); + let requestOptions = {method: RequestMethods.Post}; + http.request('http://basic.connection', requestOptions) + .subscribe(res => { + expect(res.text()).toBe('base response'); + async.done(); + }); + })); + it('should perform a get request and complete the response', inject([AsyncTestCompleter], (async) => { backend.connections.subscribe(c => c.mockRespond(baseResponse)); @@ -180,18 +221,6 @@ export function main() { .subscribe(res => { expect(res.text()).toBe('base response'); }, null, () => { async.done(); }); })); - // TODO: make dart not complain about "argument type 'Map' cannot be assigned to the - // parameter type 'IRequestOptions'" - // xit('should perform a get request for given url if passed a dictionary', - // inject([AsyncTestCompleter], async => { - // ObservableWrapper.subscribe(backend.connections, c => c.mockRespond(baseResponse)); - // ObservableWrapper.subscribe(http.request(url, {method: RequestMethods.GET}), res => - // { - // expect(res.text()).toBe('base response'); - // async.done(); - // }); - // })); - it('should throw if url is not a string or Request', () => { var req = {};