Skip to content

Commit

Permalink
feat(common): Introduction of the fetch Backend for the HttpClient
Browse files Browse the repository at this point in the history
This commit introduces a new `HttpBackend` implentation which makes requests using the fetch API

This feature is a developer preview and is opt-in.
It is enabled by setting the providers with `provideHttpClient(withNativeFetch())`.

NB: The fetch API is experimental on Node but available without flags from Node 18 onwards.
  • Loading branch information
JeanMeche committed May 11, 2023
1 parent 3112352 commit 44fabc0
Show file tree
Hide file tree
Showing 5 changed files with 510 additions and 4 deletions.
3 changes: 2 additions & 1 deletion packages/common/http/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
export {HttpBackend, HttpHandler} from './src/backend';
export {HttpClient} from './src/client';
export {HttpContext, HttpContextToken} from './src/context';
export {FetchBackend} from './src/fetch';
export {HttpHeaders} from './src/headers';
export {HTTP_INTERCEPTORS, HttpHandlerFn, HttpInterceptor, HttpInterceptorFn, HttpInterceptorHandler as ɵHttpInterceptorHandler, HttpInterceptorHandler as ɵHttpInterceptingHandler} from './src/interceptor';
export {JsonpClientBackend, JsonpInterceptor} from './src/jsonp';
export {HttpClientJsonpModule, HttpClientModule, HttpClientXsrfModule} from './src/module';
export {HttpParameterCodec, HttpParams, HttpParamsOptions, HttpUrlEncodingCodec} from './src/params';
export {HttpFeature, HttpFeatureKind, provideHttpClient, withInterceptors, withInterceptorsFromDi, withJsonpSupport, withNoXsrfProtection, withRequestsMadeViaParent, withXsrfConfiguration} from './src/provider';
export {HttpFeature, HttpFeatureKind, provideHttpClient, withInterceptors, withInterceptorsFromDi, withJsonpSupport, withNativeFetch, withNoXsrfProtection, withRequestsMadeViaParent, withXsrfConfiguration} from './src/provider';
export {HttpRequest} from './src/request';
export {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpProgressEvent, HttpResponse, HttpResponseBase, HttpSentEvent, HttpStatusCode, HttpUploadProgressEvent, HttpUserEvent} from './src/response';
export {withHttpTransferCache as ɵwithHttpTransferCache} from './src/transfer_cache';
Expand Down
197 changes: 197 additions & 0 deletions packages/common/http/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* @license
* Copyright Google LLC 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 {HttpBackend, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaders, HttpRequest, HttpResponse, HttpStatusCode,} from '@angular/common/http';
import {Injectable, Optional} from '@angular/core';
import {EMPTY, from, Observable, Observer} from 'rxjs';
import {catchError} from 'rxjs/operators';

const XSSI_PREFIX = /^\)\]\}',?\n/;

/**
* Determine an appropriate URL for the response, by checking either
* response url or the X-Request-URL header.
*/
function getResponseUrl(response: Response): string|undefined {
if (response.url) {
return response.url;
}

// stored as lowercase in the map
const xRequestUrl = 'X-Request-URL'.toLocaleLowerCase();
if (response.headers.has(xRequestUrl)) {
return response.headers.get(xRequestUrl) ?? undefined;
}
return undefined;
}

/**
* Uses `fetch` to send requests to a backend server.
* This backend requires nodejs >= 18
* @see {@link HttpHandler}
*
* @developerPreview
*/
@Injectable()
export class FetchBackend implements HttpBackend {
private fetchImpl: typeof fetch;

// Allow to pass a custom implementation for `fetch()`, a mocked fetch for example
public constructor(@Optional() fetchFactory?: FetchFactory) {
this.fetchImpl = fetchFactory?.fetchImpl ?? fetch;
}

handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
// Everything happens on Observable subscription.
return new Observable((observer: Observer<HttpEvent<any>>) => {
const abortController = new AbortController();

const fetching = from(this.fetchImpl(req.url, {
signal: abortController.signal,
...this.createRequestInit(req),
})).pipe(catchError((error) => {
observer.error(new HttpErrorResponse({
error,
status: error.status ?? 0,
statusText: error.statusText,
url: req.url,
headers: error.headers,
}));
return EMPTY;
}));

const subscription = fetching.subscribe((response) => {
const headers = new HttpHeaders(response.headers);
let status = response.status;
const statusText = response.statusText;

// Read the response URL from the response instance and fall back on the request URL.
const url = getResponseUrl(response) ?? req.url;

function sendResult(errorOrBody: any) {
// Normalize another potential bug (this one comes from CORS).
if (status === 0) {
status = !!errorOrBody ? HttpStatusCode.Ok : 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.
const ok = status >= 200 && status < 300;
if (ok) {
observer.next(new HttpResponse({
body: errorOrBody,
headers,
status,
statusText,
url,
}));
observer.complete();
} else {
observer.error(new HttpErrorResponse({
error: errorOrBody,
headers,
status,
statusText,
url,
}));
}
}

// The response body is an async stream, the Reponse API provides helpers
if (req.responseType === 'json') {
// Calling `text()` instead of `json()` to allow XSSI stripping
response.text()
.then((text) => {
text = text.replace(XSSI_PREFIX, '');
sendResult(text === '' ? null : JSON.parse(text));
})
.catch((error) => sendResult(error));
} else {
(req.responseType === 'text' ? response.text() : //
req.responseType === 'blob' ? response.blob() : //
response.arrayBuffer())
.then((text) => {
sendResult(text);
})
.catch((error) => {
sendResult(error);
});
}
});

// notify the event stream that it was fired.
observer.next({type: HttpEventType.Sent});

() => {
subscription.unsubscribe();
// Aborting the fetch call when the observable is unsubscribed
abortController.abort();
};
});
}

private createRequestInit(req: HttpRequest<any>): RequestInit {
// We could share some of this logic with the XhrBackend

const headers: Record<string, string> = {};
const credentials: RequestCredentials|undefined = req.withCredentials ? 'include' : undefined;

// Setting all the requested headers.
req.headers.forEach((name, values) => (headers[name] = values.join(',')));

// Add an Accept header if one isn't present already.
if (!headers['Accept']) {
headers['Accept'] = 'application/json, text/plain, */*';
}

// Auto-detect the Content-Type header if one isn't present already.
if (!headers['Content-Type']) {
const detectedType = req.detectContentTypeHeader();
// Sometimes Content-Type detection fails.
if (detectedType !== null) {
headers['Content-Type'] = detectedType;
}
}

return {
body: req.body,
method: req.method,
headers,
credentials,
};
}
}

/**
* Abstract class to provide a mocked implementation of `fetch()`
*/
export abstract class FetchFactory {
protected resolve!: Function;
protected error!: Function;
public url!: RequestInfo|URL;
public method?: string;
public body?: any;
public headers?: HeadersInit;
public credentials?: RequestCredentials;

private promise = new Promise<Response>((resolve, error) => {
this.resolve = resolve;
this.error = error;
});

fetchImpl = (input: RequestInfo|URL, init?: RequestInit): Promise<Response> => {
this.url = input;
this.method = init?.method;
this.body = init?.body;
this.headers = init?.headers;
this.credentials = init?.credentials;
return this.promise;
}
}
6 changes: 3 additions & 3 deletions packages/common/http/src/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class HttpHeaders {

/** Constructs a new HTTP header object with the given values.*/

constructor(headers?: string|{[name: string]: string | number | (string | number)[]}) {
constructor(headers?: string|{[name: string]: string | number | (string | number)[]}|Headers) {
if (!headers) {
this.headers = new Map<string, string[]>();
} else if (typeof headers === 'string') {
Expand Down Expand Up @@ -80,7 +80,7 @@ export class HttpHeaders {
} else if (typeof values === 'number') {
headerValues = [values.toString()];
} else {
headerValues = values.map((value) => value.toString());
headerValues = values.map((value: any) => value.toString());
}

if (headerValues.length > 0) {
Expand Down Expand Up @@ -273,7 +273,7 @@ export class HttpHeaders {
* must be either strings, numbers or arrays. Throws an error if an invalid
* header value is present.
*/
function assertValidHeaders(headers: Record<string, unknown>):
function assertValidHeaders(headers: Record<string, unknown>|Headers):
asserts headers is Record<string, string|string[]|number|number[]> {
for (const [key, value] of Object.entries(headers)) {
if (!(typeof value === 'string' || typeof value === 'number') && !Array.isArray(value)) {
Expand Down
14 changes: 14 additions & 0 deletions packages/common/http/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {EnvironmentProviders, inject, InjectionToken, makeEnvironmentProviders,

import {HttpBackend, HttpHandler} from './backend';
import {HttpClient} from './client';
import {FetchBackend} from './fetch';
import {HTTP_INTERCEPTOR_FNS, HttpInterceptorFn, HttpInterceptorHandler, legacyInterceptorFnFactory} from './interceptor';
import {jsonpCallbackContext, JsonpCallbackContext, JsonpClientBackend, jsonpInterceptorFn} from './jsonp';
import {HttpXhrBackend} from './xhr';
Expand Down Expand Up @@ -61,6 +62,7 @@ function makeHttpFeature<KindT extends HttpFeatureKind>(
* @see {@link withNoXsrfProtection}
* @see {@link withJsonpSupport}
* @see {@link withRequestsMadeViaParent}
* @see {@link withNativeFetch}
*/
export function provideHttpClient(...features: HttpFeature<HttpFeatureKind>[]):
EnvironmentProviders {
Expand Down Expand Up @@ -233,3 +235,15 @@ export function withRequestsMadeViaParent(): HttpFeature<HttpFeatureKind.Request
},
]);
}


/**
* Configures the current `HttpClient` instance to make requests using the fetch API.
* @developerPreview
*/
export function withNativeFetch(): HttpFeature<any> {
return makeHttpFeature(6 as HttpFeatureKind, [
FetchBackend,
{provide: HttpBackend, useExisting: FetchBackend},
]);
}

0 comments on commit 44fabc0

Please sign in to comment.