feat(http): automatically set request Content-Type header based on body type

Implement the ability to provide objects as request body. The following use cases
are supported:
* raw objects: a JSON payload is created and the content type set to `application/json`
* text: the text is used as it is and no content type header is automatically added
* URLSearchParams: a form payload is created and the content type set to `application/x-www-form-urlencoded`
* FormData: the object is used as it is and no content type header is automatically added
* Blob: the object is used as it is and the content type set with the value of its `type` property if any
* ArrayBuffer: the object is used as it is and no content type header is automatically added

Closes https://github.com/angular/http/issues/69

Closes #7310
This commit is contained in:
Thierry Templier 2016-02-26 12:25:55 +01:00 committed by Misko Hevery
parent e0c83f669e
commit 0f0a8ade7c
9 changed files with 263 additions and 10 deletions

View File

@ -7,3 +7,4 @@ import {ReflectionCapabilities} from './reflection_capabilities';
* about symbols. * about symbols.
*/ */
export var reflector = new Reflector(new ReflectionCapabilities()); export var reflector = new Reflector(new ReflectionCapabilities());

View File

@ -10,6 +10,7 @@ import {isPresent, isString} from '../../src/facade/lang';
import {Observable} from 'rxjs/Observable'; import {Observable} from 'rxjs/Observable';
import {Observer} from 'rxjs/Observer'; import {Observer} from 'rxjs/Observer';
import {isSuccess, getResponseURL} from '../http_utils'; import {isSuccess, getResponseURL} from '../http_utils';
import {ContentType} from '../enums';
const XSSI_PREFIX = ')]}\',\n'; const XSSI_PREFIX = ')]}\',\n';
@ -83,6 +84,8 @@ export class XHRConnection implements Connection {
responseObserver.error(new Response(responseOptions)); responseObserver.error(new Response(responseOptions));
}; };
this.setDetectedContentType(req, _xhr);
if (isPresent(req.headers)) { if (isPresent(req.headers)) {
req.headers.forEach((values, name) => _xhr.setRequestHeader(name, values.join(','))); req.headers.forEach((values, name) => _xhr.setRequestHeader(name, values.join(',')));
} }
@ -90,7 +93,7 @@ export class XHRConnection implements Connection {
_xhr.addEventListener('load', onLoad); _xhr.addEventListener('load', onLoad);
_xhr.addEventListener('error', onError); _xhr.addEventListener('error', onError);
_xhr.send(this.request.text()); _xhr.send(this.request.getBody());
return () => { return () => {
_xhr.removeEventListener('load', onLoad); _xhr.removeEventListener('load', onLoad);
@ -99,6 +102,34 @@ export class XHRConnection implements Connection {
}; };
}); });
} }
setDetectedContentType(req, _xhr) {
// Skip if a custom Content-Type header is provided
if (isPresent(req.headers) && isPresent(req.headers['Content-Type'])) {
return;
}
// Set the detected content type
switch (req.contentType) {
case ContentType.NONE:
break;
case ContentType.JSON:
_xhr.setRequestHeader('Content-Type', 'application/json');
break;
case ContentType.FORM:
_xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
break;
case ContentType.TEXT:
_xhr.setRequestHeader('Content-Type', 'text/plain');
break;
case ContentType.BLOB:
var blob = req.blob();
if (blob.type) {
_xhr.setRequestHeader('Content-Type', blob.type);
}
break;
}
}
} }
/** /**

View File

@ -43,8 +43,7 @@ export class RequestOptions {
/** /**
* Body to be used when creating a {@link Request}. * Body to be used when creating a {@link Request}.
*/ */
// TODO: support FormData, Blob, URLSearchParams body: any;
body: string;
/** /**
* Url with which to perform a {@link Request}. * Url with which to perform a {@link Request}.
*/ */

View File

@ -36,3 +36,16 @@ export enum ResponseType {
Error, Error,
Opaque Opaque
} }
/**
* Supported content type to be automatically associated with a {@link Request}.
*/
export enum ContentType {
NONE,
JSON,
FORM,
FORM_DATA,
TEXT,
BLOB,
ARRAY_BUFFER
}

View File

@ -5,6 +5,7 @@ import {RequestOptionsArgs, ConnectionBackend} from './interfaces';
import {Request} from './static_request'; import {Request} from './static_request';
import {Response} from './static_response'; import {Response} from './static_response';
import {BaseRequestOptions, RequestOptions} from './base_request_options'; import {BaseRequestOptions, RequestOptions} from './base_request_options';
import {URLSearchParams} from './url_search_params';
import {RequestMethod} from './enums'; import {RequestMethod} from './enums';
import {Observable} from 'rxjs/Observable'; import {Observable} from 'rxjs/Observable';
@ -126,7 +127,7 @@ export class Http {
/** /**
* Performs a request with `post` http method. * Performs a request with `post` http method.
*/ */
post(url: string, body: string, options?: RequestOptionsArgs): Observable<Response> { post(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
return httpRequest( return httpRequest(
this._backend, this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})), new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),
@ -136,7 +137,7 @@ export class Http {
/** /**
* Performs a request with `put` http method. * Performs a request with `put` http method.
*/ */
put(url: string, body: string, options?: RequestOptionsArgs): Observable<Response> { put(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
return httpRequest( return httpRequest(
this._backend, this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})), new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),
@ -154,7 +155,7 @@ export class Http {
/** /**
* Performs a request with `patch` http method. * Performs a request with `patch` http method.
*/ */
patch(url: string, body: string, options?: RequestOptionsArgs): Observable<Response> { patch(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
return httpRequest( return httpRequest(
this._backend, this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})), new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),

View File

@ -29,8 +29,7 @@ export interface RequestOptionsArgs {
method?: string | RequestMethod; method?: string | RequestMethod;
search?: string | URLSearchParams; search?: string | URLSearchParams;
headers?: Headers; headers?: Headers;
// TODO: Support Blob, ArrayBuffer, JSON, URLSearchParams, FormData body?: any;
body?: string;
} }
/** /**

View File

@ -1,6 +1,8 @@
import {RequestMethod} from './enums'; import {RequestMethod} from './enums';
import {RequestArgs} from './interfaces'; import {RequestArgs} from './interfaces';
import {Headers} from './headers'; import {Headers} from './headers';
import {ContentType} from './enums';
import {URLSearchParams} from './url_search_params';
import {normalizeMethodName} from './http_utils'; import {normalizeMethodName} from './http_utils';
import {isPresent, StringWrapper} from '../src/facade/lang'; import {isPresent, StringWrapper} from '../src/facade/lang';
@ -53,8 +55,10 @@ export class Request {
headers: Headers; headers: Headers;
/** Url of the remote resource */ /** Url of the remote resource */
url: string; url: string;
// TODO: support URLSearchParams | FormData | Blob | ArrayBuffer /** Body of the request **/
private _body: string; private _body: any;
/** Type of the request body **/
private contentType: ContentType;
constructor(requestOptions: RequestArgs) { constructor(requestOptions: RequestArgs) {
// TODO: assert that url is present // TODO: assert that url is present
let url = requestOptions.url; let url = requestOptions.url;
@ -71,6 +75,7 @@ export class Request {
} }
} }
this._body = requestOptions.body; this._body = requestOptions.body;
this.contentType = this.detectContentType();
this.method = normalizeMethodName(requestOptions.method); this.method = normalizeMethodName(requestOptions.method);
// TODO(jeffbcross): implement behavior // TODO(jeffbcross): implement behavior
// Defaults to 'omit', consistent with browser // Defaults to 'omit', consistent with browser
@ -85,4 +90,82 @@ export class Request {
* string. * string.
*/ */
text(): string { return isPresent(this._body) ? this._body.toString() : ''; } text(): string { return isPresent(this._body) ? this._body.toString() : ''; }
/**
* Returns the request's body as JSON string, assuming that body exists. If body is undefined,
* return
* empty
* string.
*/
json(): string { return isPresent(this._body) ? JSON.stringify(this._body) : ''; }
/**
* Returns the request's body as array buffer, assuming that body exists. If body is undefined,
* return
* null.
*/
arrayBuffer(): ArrayBuffer {
if (this._body instanceof ArrayBuffer) return <ArrayBuffer>this._body;
throw "The request body isn't an array buffer";
}
/**
* Returns the request's body as blob, assuming that body exists. If body is undefined, return
* null.
*/
blob(): Blob {
if (this._body instanceof Blob) return <Blob>this._body;
if (this._body instanceof ArrayBuffer) return new Blob([this._body]);
throw "The request body isn't either a blob or an array buffer";
}
/**
* Returns the content type of request's body based on its type.
*/
detectContentType() {
if (this._body == null) {
return ContentType.NONE;
} else if (this._body instanceof URLSearchParams) {
return ContentType.FORM;
} else if (this._body instanceof FormData) {
return ContentType.FORM_DATA;
} else if (this._body instanceof Blob) {
return ContentType.BLOB;
} else if (this._body instanceof ArrayBuffer) {
return ContentType.ARRAY_BUFFER;
} else if (this._body && typeof this._body == 'object') {
return ContentType.JSON;
} else {
return ContentType.TEXT;
}
}
/**
* Returns the request's body according to its type. If body is undefined, return
* null.
*/
getBody(): any {
switch (this.contentType) {
case ContentType.JSON:
return this.json();
case ContentType.FORM:
return this.text();
case ContentType.FORM_DATA:
return this._body;
case ContentType.TEXT:
return this.text();
case ContentType.BLOB:
return this.blob();
case ContentType.ARRAY_BUFFER:
return this.arrayBuffer();
default:
return null;
}
}
} }
const noop = function () {};
const w = typeof window == 'object' ? window : noop;
const FormData = w['FormData'] || noop;
const Blob = w['Blob'] || noop;
const ArrayBuffer = w['ArrayBuffer'] || noop;

View File

@ -20,6 +20,7 @@ import {Map} from '../../src/facade/collection';
import {RequestOptions, BaseRequestOptions} from '../../src/base_request_options'; import {RequestOptions, BaseRequestOptions} from '../../src/base_request_options';
import {BaseResponseOptions, ResponseOptions} from '../../src/base_response_options'; import {BaseResponseOptions, ResponseOptions} from '../../src/base_response_options';
import {ResponseType} from '../../src/enums'; import {ResponseType} from '../../src/enums';
import {URLSearchParams} from '../../src/url_search_params';
var abortSpy: any; var abortSpy: any;
var sendSpy: any; var sendSpy: any;
@ -176,6 +177,129 @@ export function main() {
expect(setRequestHeaderSpy).toHaveBeenCalledWith('X-Multi', 'a,b'); expect(setRequestHeaderSpy).toHaveBeenCalledWith('X-Multi', 'a,b');
}); });
it('should use object body and detect content type header to the request', () => {
var body = {test: 'val'};
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(JSON.stringify(body));
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'application/json');
});
it('should use number body and detect content type header to the request', () => {
var body = 23;
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith('23');
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/plain');
});
it('should use string body and detect content type header to the request', () => {
var body = 'some string';
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/plain');
});
it('should use URLSearchParams body and detect content type header to the request', () => {
var body = new URLSearchParams();
body.set('test1', 'val1');
body.set('test2', 'val2');
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith('test1=val1&test2=val2');
expect(setRequestHeaderSpy)
.toHaveBeenCalledWith('Content-Type',
'application/x-www-form-urlencoded;charset=UTF-8');
});
if (global['Blob']) {
it('should use FormData body and detect content type header to the request', () => {
var body = new FormData();
body.append('test1', 'val1');
body.append('test2', 123456);
var blob = new Blob(['body { color: red; }'], {type: 'text/css'});
body.append("userfile", blob);
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).not.toHaveBeenCalledWith();
});
it('should use blob body and detect content type header to the request', () => {
var body = new Blob(['body { color: red; }'], {type: 'text/css'});
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/css');
});
it('should use blob body without type to the request', () => {
var body = new Blob(['body { color: red; }']);
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).not.toHaveBeenCalledWith();
});
it('should use blob body without type with custom content type header to the request', () => {
var headers = new Headers({'Content-Type': 'text/css'});
var body = new Blob(['body { color: red; }']);
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body, headers: headers}))),
new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/css');
});
it('should use array buffer body to the request', () => {
var body = new ArrayBuffer(512);
var longInt8View = new Uint8Array(body);
for (var i = 0; i < longInt8View.length; i++) {
longInt8View[i] = i % 255;
}
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body}))), new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).not.toHaveBeenCalledWith();
});
it('should use array buffer body without type with custom content type header to the request',
() => {
var headers = new Headers({'Content-Type': 'text/css'});
var body = new ArrayBuffer(512);
var longInt8View = new Uint8Array(body);
for (var i = 0; i < longInt8View.length; i++) {
longInt8View[i] = i % 255;
}
var base = new BaseRequestOptions();
var connection = new XHRConnection(
new Request(base.merge(new RequestOptions({body: body, headers: headers}))),
new MockBrowserXHR());
connection.response.subscribe();
expect(sendSpy).toHaveBeenCalledWith(body);
expect(setRequestHeaderSpy).toHaveBeenCalledWith('Content-Type', 'text/css');
});
}
it('should return the correct status code', it('should return the correct status code',
inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { inject([AsyncTestCompleter], (async: AsyncTestCompleter) => {
var statusCode = 418; var statusCode = 418;

View File

@ -50,6 +50,8 @@ export class BrowserDetection {
} }
} }
BrowserDetection.setup();
export function dispatchEvent(element, eventType): void { export function dispatchEvent(element, eventType): void {
getDOM().dispatchEvent(element, getDOM().createEvent(eventType)); getDOM().dispatchEvent(element, getDOM().createEvent(eventType));
} }