Skip to content

Commit

Permalink
feat(http): automatically set request Content-Type header based on bo…
Browse files Browse the repository at this point in the history
…dy 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
  • Loading branch information
Thierry Templier authored and mhevery committed May 24, 2016
1 parent e0c83f6 commit 0f0a8ad
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 10 deletions.
1 change: 1 addition & 0 deletions modules/@angular/core/src/reflection/reflection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ import {ReflectionCapabilities} from './reflection_capabilities';
* about symbols.
*/
export var reflector = new Reflector(new ReflectionCapabilities());

33 changes: 32 additions & 1 deletion modules/@angular/http/src/backends/xhr_backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {isPresent, isString} from '../../src/facade/lang';
import {Observable} from 'rxjs/Observable';
import {Observer} from 'rxjs/Observer';
import {isSuccess, getResponseURL} from '../http_utils';
import {ContentType} from '../enums';

const XSSI_PREFIX = ')]}\',\n';

Expand Down Expand Up @@ -83,14 +84,16 @@ export class XHRConnection implements Connection {
responseObserver.error(new Response(responseOptions));
};

this.setDetectedContentType(req, _xhr);

if (isPresent(req.headers)) {
req.headers.forEach((values, name) => _xhr.setRequestHeader(name, values.join(',')));
}

_xhr.addEventListener('load', onLoad);
_xhr.addEventListener('error', onError);

_xhr.send(this.request.text());
_xhr.send(this.request.getBody());

return () => {
_xhr.removeEventListener('load', onLoad);
Expand All @@ -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;
}
}
}

/**
Expand Down
3 changes: 1 addition & 2 deletions modules/@angular/http/src/base_request_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ export class RequestOptions {
/**
* Body to be used when creating a {@link Request}.
*/
// TODO: support FormData, Blob, URLSearchParams
body: string;
body: any;
/**
* Url with which to perform a {@link Request}.
*/
Expand Down
13 changes: 13 additions & 0 deletions modules/@angular/http/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ export enum ResponseType {
Error,
Opaque
}

/**
* Supported content type to be automatically associated with a {@link Request}.
*/
export enum ContentType {
NONE,
JSON,
FORM,
FORM_DATA,
TEXT,
BLOB,
ARRAY_BUFFER
}
7 changes: 4 additions & 3 deletions modules/@angular/http/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {RequestOptionsArgs, ConnectionBackend} from './interfaces';
import {Request} from './static_request';
import {Response} from './static_response';
import {BaseRequestOptions, RequestOptions} from './base_request_options';
import {URLSearchParams} from './url_search_params';
import {RequestMethod} from './enums';
import {Observable} from 'rxjs/Observable';

Expand Down Expand Up @@ -126,7 +127,7 @@ export class Http {
/**
* 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(
this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),
Expand All @@ -136,7 +137,7 @@ export class Http {
/**
* 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(
this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),
Expand All @@ -154,7 +155,7 @@ export class Http {
/**
* 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(
this._backend,
new Request(mergeOptions(this._defaultOptions.merge(new RequestOptions({body: body})),
Expand Down
3 changes: 1 addition & 2 deletions modules/@angular/http/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ export interface RequestOptionsArgs {
method?: string | RequestMethod;
search?: string | URLSearchParams;
headers?: Headers;
// TODO: Support Blob, ArrayBuffer, JSON, URLSearchParams, FormData
body?: string;
body?: any;
}

/**
Expand Down
87 changes: 85 additions & 2 deletions modules/@angular/http/src/static_request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {RequestMethod} from './enums';
import {RequestArgs} from './interfaces';
import {Headers} from './headers';
import {ContentType} from './enums';
import {URLSearchParams} from './url_search_params';
import {normalizeMethodName} from './http_utils';
import {isPresent, StringWrapper} from '../src/facade/lang';

Expand Down Expand Up @@ -53,8 +55,10 @@ export class Request {
headers: Headers;
/** Url of the remote resource */
url: string;
// TODO: support URLSearchParams | FormData | Blob | ArrayBuffer
private _body: string;
/** Body of the request **/
private _body: any;
/** Type of the request body **/
private contentType: ContentType;
constructor(requestOptions: RequestArgs) {
// TODO: assert that url is present
let url = requestOptions.url;
Expand All @@ -71,6 +75,7 @@ export class Request {
}
}
this._body = requestOptions.body;
this.contentType = this.detectContentType();
this.method = normalizeMethodName(requestOptions.method);
// TODO(jeffbcross): implement behavior
// Defaults to 'omit', consistent with browser
Expand All @@ -85,4 +90,82 @@ export class Request {
* string.
*/
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;
124 changes: 124 additions & 0 deletions modules/@angular/http/test/backends/xhr_backend_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {Map} from '../../src/facade/collection';
import {RequestOptions, BaseRequestOptions} from '../../src/base_request_options';
import {BaseResponseOptions, ResponseOptions} from '../../src/base_response_options';
import {ResponseType} from '../../src/enums';
import {URLSearchParams} from '../../src/url_search_params';

var abortSpy: any;
var sendSpy: any;
Expand Down Expand Up @@ -176,6 +177,129 @@ export function main() {
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',
inject([AsyncTestCompleter], (async: AsyncTestCompleter) => {
var statusCode = 418;
Expand Down
2 changes: 2 additions & 0 deletions modules/@angular/platform-browser/testing/browser_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export class BrowserDetection {
}
}

BrowserDetection.setup();

export function dispatchEvent(element, eventType): void {
getDOM().dispatchEvent(element, getDOM().createEvent(eventType));
}
Expand Down

0 comments on commit 0f0a8ad

Please sign in to comment.