feat(common): new HttpClient API
HttpClient is an evolution of the existing Angular HTTP API, which exists alongside of it in a separate package, @angular/common/http. This structure ensures that existing codebases can slowly migrate to the new API. The new API improves significantly on the ergonomics and features of the legacy API. A partial list of new features includes: * Typed, synchronous response body access, including support for JSON body types * JSON is an assumed default and no longer needs to be explicitly parsed * Interceptors allow middleware logic to be inserted into the pipeline * Immutable request/response objects * Progress events for both request upload and response download * Post-request verification & flush based testing framework
This commit is contained in:

committed by
Jason Aden

parent
2a7ebbe982
commit
37797e2b4e
327
packages/common/http/src/xhr.ts
Normal file
327
packages/common/http/src/xhr.ts
Normal file
@ -0,0 +1,327 @@
|
||||
/**
|
||||
* @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 {Injectable} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {Observer} from 'rxjs/Observer';
|
||||
|
||||
import {HttpBackend} from './backend';
|
||||
import {HttpHeaders} from './headers';
|
||||
import {HttpRequest} from './request';
|
||||
import {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpJsonParseError, HttpResponse, HttpUploadProgressEvent} from './response';
|
||||
|
||||
const XSSI_PREFIX = /^\)\]\}',?\n/;
|
||||
|
||||
/**
|
||||
* Determine an appropriate URL for the response, by checking either
|
||||
* XMLHttpRequest.responseURL or the X-Request-URL header.
|
||||
*/
|
||||
function getResponseUrl(xhr: any): string|null {
|
||||
if ('responseURL' in xhr && xhr.responseURL) {
|
||||
return xhr.responseURL;
|
||||
}
|
||||
if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) {
|
||||
return xhr.getResponseHeader('X-Request-URL');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around the `XMLHttpRequest` constructor.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export abstract class XhrFactory { abstract build(): XMLHttpRequest; }
|
||||
|
||||
/**
|
||||
* A factory for @{link HttpXhrBackend} that uses the `XMLHttpRequest` browser API.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
@Injectable()
|
||||
export class BrowserXhr implements XhrFactory {
|
||||
constructor() {}
|
||||
build(): any { return <any>(new XMLHttpRequest()); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a response from the server that does not yet have a body.
|
||||
*/
|
||||
interface PartialResponse {
|
||||
headers: HttpHeaders;
|
||||
status: number;
|
||||
statusText: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `HttpBackend` which uses the XMLHttpRequest API to send
|
||||
* requests to a backend server.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpXhrBackend implements HttpBackend {
|
||||
constructor(private xhrFactory: XhrFactory) {}
|
||||
|
||||
/**
|
||||
* Process a request and return a stream of response events.
|
||||
*/
|
||||
handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
|
||||
// Quick check to give a better error message when a user attempts to use
|
||||
// HttpClient.jsonp() without installing the JsonpClientModule
|
||||
if (req.method === 'JSONP') {
|
||||
throw new Error(`Attempted to construct Jsonp request without JsonpClientModule installed.`);
|
||||
}
|
||||
|
||||
// Everything happens on Observable subscription.
|
||||
return new Observable((observer: Observer<HttpEvent<any>>) => {
|
||||
// Start by setting up the XHR object with request method, URL, and withCredentials flag.
|
||||
const xhr = this.xhrFactory.build();
|
||||
xhr.open(req.method, req.url);
|
||||
if (!!req.withCredentials) {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
|
||||
// Add all the requested headers.
|
||||
req.headers.forEach((name, values) => xhr.setRequestHeader(name, values.join(',')));
|
||||
|
||||
// Add an Accept header if one isn't present already.
|
||||
if (!req.headers.has('Accept')) {
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
}
|
||||
|
||||
// Auto-detect the Content-Type header if one isn't present already.
|
||||
if (!req.headers.has('Content-Type')) {
|
||||
const detectedType = req.detectContentTypeHeader();
|
||||
// Sometimes Content-Type detection fails.
|
||||
if (detectedType !== null) {
|
||||
xhr.setRequestHeader('Content-Type', detectedType);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the responseType if one was requested.
|
||||
if (req.responseType) {
|
||||
xhr.responseType = req.responseType.toLowerCase() as any;
|
||||
}
|
||||
|
||||
// Serialize the request body if one is present. If not, this will be set to null.
|
||||
const reqBody = req.serializeBody();
|
||||
|
||||
// If progress events are enabled, response headers will be delivered
|
||||
// in two events - the HttpHeaderResponse event and the full HttpResponse
|
||||
// event. However, since response headers don't change in between these
|
||||
// two events, it doesn't make sense to parse them twice. So headerResponse
|
||||
// caches the data extracted from the response whenever it's first parsed,
|
||||
// to ensure parsing isn't duplicated.
|
||||
let headerResponse: HttpHeaderResponse|null = null;
|
||||
|
||||
// partialFromXhr extracts the HttpHeaderResponse from the current XMLHttpRequest
|
||||
// state, and memoizes it into headerResponse.
|
||||
const partialFromXhr = (): HttpHeaderResponse => {
|
||||
if (headerResponse !== null) {
|
||||
return headerResponse;
|
||||
}
|
||||
|
||||
// Read status and normalize an IE9 bug (http://bugs.jquery.com/ticket/1450).
|
||||
const status: number = xhr.status === 1223 ? 204 : xhr.status;
|
||||
const statusText = xhr.statusText || 'OK';
|
||||
|
||||
// Parse headers from XMLHttpRequest - this step is lazy.
|
||||
const headers = new HttpHeaders(xhr.getAllResponseHeaders());
|
||||
|
||||
// Read the response URL from the XMLHttpResponse instance and fall back on the
|
||||
// request URL.
|
||||
const url = getResponseUrl(xhr) || req.url;
|
||||
|
||||
// Construct the HttpHeaderResponse and memoize it.
|
||||
headerResponse = new HttpHeaderResponse({headers, status, statusText, url});
|
||||
return headerResponse;
|
||||
};
|
||||
|
||||
// Next, a few closures are defined for the various events which XMLHttpRequest can
|
||||
// emit. This allows them to be unregistered as event listeners later.
|
||||
|
||||
// First up is the load event, which represents a response being fully available.
|
||||
const onLoad = () => {
|
||||
// Read response state from the memoized partial data.
|
||||
let {headers, status, statusText, url} = partialFromXhr();
|
||||
|
||||
// The body will be read out if present.
|
||||
let body: any|null = null;
|
||||
|
||||
if (status !== 204) {
|
||||
// Use XMLHttpRequest.response if set, responseText otherwise.
|
||||
body = (typeof xhr.response === 'undefined') ? xhr.responseText : xhr.response;
|
||||
|
||||
// Strip a common XSSI prefix from string responses.
|
||||
// TODO: determine if this behavior should be optional and moved to an interceptor.
|
||||
if (typeof body === 'string') {
|
||||
body = body.replace(XSSI_PREFIX, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize another potential bug (this one comes from CORS).
|
||||
if (status === 0) {
|
||||
status = !!body ? 200 : 0;
|
||||
}
|
||||
|
||||
// ok determines whether the response will be transmitted on the event or
|
||||
// error channel. Unsuccessful status codes (not 2xx) will always be errors,
|
||||
// but a successful status code can still result in an error if the user
|
||||
// asked for JSON data and the body cannot be parsed as such.
|
||||
let ok = status >= 200 && status < 300;
|
||||
|
||||
// Check whether the body needs to be parsed as JSON (in many cases the browser
|
||||
// will have done that already).
|
||||
if (ok && typeof body === 'string' && req.responseType === 'json') {
|
||||
// Attempt the parse. If it fails, a parse error should be delivered to the user.
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
} catch (error) {
|
||||
// Even though the response status was 2xx, this is still an error.
|
||||
ok = false;
|
||||
// The parse error contains the text of the body that failed to parse.
|
||||
body = { error, text: body } as HttpJsonParseError;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
// A successful response is delivered on the event stream.
|
||||
observer.next(new HttpResponse({
|
||||
body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url: url || undefined,
|
||||
}));
|
||||
// The full body has been received and delivered, no further events
|
||||
// are possible. This request is complete.
|
||||
observer.complete();
|
||||
} else {
|
||||
// An unsuccessful request is delivered on the error channel.
|
||||
observer.error(new HttpErrorResponse({
|
||||
// The error in this case is the response body (error from the server).
|
||||
error: body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url: url || undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// The onError callback is called when something goes wrong at the network level.
|
||||
// Connection timeout, DNS error, offline, etc. These are actual errors, and are
|
||||
// transmitted on the error channel.
|
||||
const onError = (error: ErrorEvent) => {
|
||||
const res = new HttpErrorResponse({
|
||||
error,
|
||||
status: xhr.status || 0,
|
||||
statusText: xhr.statusText || 'Unknown Error',
|
||||
});
|
||||
observer.error(res);
|
||||
};
|
||||
|
||||
// The sentHeaders flag tracks whether the HttpResponseHeaders event
|
||||
// has been sent on the stream. This is necessary to track if progress
|
||||
// is enabled since the event will be sent on only the first download
|
||||
// progerss event.
|
||||
let sentHeaders = false;
|
||||
|
||||
// The download progress event handler, which is only registered if
|
||||
// progress events are enabled.
|
||||
const onDownProgress = (event: ProgressEvent) => {
|
||||
// Send the HttpResponseHeaders event if it hasn't been sent already.
|
||||
if (!sentHeaders) {
|
||||
observer.next(partialFromXhr());
|
||||
sentHeaders = true;
|
||||
}
|
||||
|
||||
// Start building the download progress event to deliver on the response
|
||||
// event stream.
|
||||
let progressEvent: HttpDownloadProgressEvent = {
|
||||
type: HttpEventType.DownloadProgress,
|
||||
loaded: event.loaded,
|
||||
};
|
||||
|
||||
// Set the total number of bytes in the event if it's available.
|
||||
if (event.lengthComputable) {
|
||||
progressEvent.total = event.total;
|
||||
}
|
||||
|
||||
// If the request was for text content and a partial response is
|
||||
// available on XMLHttpRequest, include it in the progress event
|
||||
// to allow for streaming reads.
|
||||
if (req.responseType === 'text' && !!xhr.responseText) {
|
||||
progressEvent.partialText = xhr.responseText;
|
||||
}
|
||||
|
||||
// Finally, fire the event.
|
||||
observer.next(progressEvent);
|
||||
};
|
||||
|
||||
// The upload progress event handler, which is only registered if
|
||||
// progress events are enabled.
|
||||
const onUpProgress =
|
||||
(event: ProgressEvent) => {
|
||||
// Upload progress events are simpler. Begin building the progress
|
||||
// event.
|
||||
let progress: HttpUploadProgressEvent = {
|
||||
type: HttpEventType.UploadProgress,
|
||||
loaded: event.loaded,
|
||||
};
|
||||
|
||||
// If the total number of bytes being uploaded is available, include
|
||||
// it.
|
||||
if (event.lengthComputable) {
|
||||
progress.total = event.total;
|
||||
}
|
||||
|
||||
// Send the event.
|
||||
observer.next(progress);
|
||||
}
|
||||
|
||||
// By default, register for load and error events.
|
||||
xhr.addEventListener('load', onLoad);
|
||||
xhr.addEventListener('error', onError);
|
||||
|
||||
// Progress events are only enabled if requested.
|
||||
if (req.reportProgress) {
|
||||
// Download progress is always enabled if requested.
|
||||
xhr.addEventListener('progress', onDownProgress);
|
||||
|
||||
// Upload progress depends on whether there is a body to upload.
|
||||
if (reqBody !== null && xhr.upload) {
|
||||
xhr.upload.addEventListener('progress', onUpProgress);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire the request, and notify the event stream that it was fired.
|
||||
xhr.send(reqBody);
|
||||
observer.next({type: HttpEventType.Sent});
|
||||
|
||||
// This is the return from the Observable function, which is the
|
||||
// request cancellation handler.
|
||||
return () => {
|
||||
// On a cancellation, remove all registered event listeners.
|
||||
xhr.removeEventListener('error', onError);
|
||||
xhr.removeEventListener('load', onLoad);
|
||||
if (req.reportProgress) {
|
||||
xhr.removeEventListener('progress', onDownProgress);
|
||||
if (reqBody !== null && xhr.upload) {
|
||||
xhr.upload.removeEventListener('progress', onUpProgress);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, abort the in-flight request.
|
||||
xhr.abort();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user