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(withFetch())`.

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 c81b3ab
Show file tree
Hide file tree
Showing 6 changed files with 528 additions and 5 deletions.
17 changes: 16 additions & 1 deletion goldens/public-api/common/http/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import { Observable } from 'rxjs';
import { Provider } from '@angular/core';
import { XhrFactory } from '@angular/common';

// @public
export class FetchBackend implements HttpBackend {
// (undocumented)
handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<FetchBackend, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<FetchBackend>;
}

// @public
export const HTTP_INTERCEPTORS: InjectionToken<HttpInterceptor[]>;

Expand Down Expand Up @@ -1741,6 +1751,8 @@ export enum HttpFeatureKind {
// (undocumented)
CustomXsrfConfiguration = 2,
// (undocumented)
Fetch = 6,
// (undocumented)
Interceptors = 0,
// (undocumented)
JsonpSupport = 4,
Expand Down Expand Up @@ -1783,7 +1795,7 @@ export class HttpHeaderResponse extends HttpResponseBase {
export class HttpHeaders {
constructor(headers?: string | {
[name: string]: string | number | (string | number)[];
});
} | Headers);
append(name: string, value: string | string[]): HttpHeaders;
delete(name: string, value?: string | string[]): HttpHeaders;
get(name: string): string | null;
Expand Down Expand Up @@ -2165,6 +2177,9 @@ export class JsonpInterceptor {
// @public
export function provideHttpClient(...features: HttpFeature<HttpFeatureKind>[]): EnvironmentProviders;

// @public
export function withFetch(): HttpFeature<any>;

// @public
export function withInterceptors(interceptorFns: HttpInterceptorFn[]): HttpFeature<HttpFeatureKind.Interceptors>;

Expand Down
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, withFetch, withInterceptors, withInterceptorsFromDi, withJsonpSupport, 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
163 changes: 163 additions & 0 deletions packages/common/http/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* @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 {inject, Injectable} from '@angular/core';
import {from, Observable, throwError} from 'rxjs';
import {catchError, finalize, mergeMap, startWith} from 'rxjs/operators';

import {HttpBackend} from './backend';
import {HttpHeaders} from './headers';
import {HttpRequest} from './request';
import {HttpErrorResponse, HttpEvent, HttpEventType, HttpResponse, HttpSentEvent, HttpStatusCode} from './response';

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|null {
if (response.url) {
return response.url;
}
// stored as lowercase in the map
const xRequestUrl = 'X-Request-URL'.toLocaleLowerCase();
return response.headers.get(xRequestUrl);
}

/**
* 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 readonly fetchImpl = inject(FetchFactory, {optional: true})?.fetch ?? fetch;

handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
const abortController = new AbortController();
const init = this.createRequestInit(req);
return from(this.fetchImpl(req.url, {signal: abortController.signal, ...init}))
.pipe(
catchError((error) => {
// When the request fails
return throwError(new HttpErrorResponse({
error,
status: error.status ?? 0,
statusText: error.statusText,
url: req.url,
headers: error.headers,
}));
}),
mergeMap(async (response) => {
const body = await this.getBody(req, response);

const headers = new HttpHeaders(response.headers);
let status = response.status;
// Normalize another potential bug (this one comes from CORS).
if (status === 0) {
status = body ? HttpStatusCode.Ok : 0;
}

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;

// 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) {
return new HttpResponse({
body: body,
headers,
status,
statusText,
url,
});
}

throw new HttpErrorResponse({
error: body,
headers,
status,
statusText,
url,
});
}),
startWith({type: HttpEventType.Sent} as HttpSentEvent), finalize(() => {
// Aborting the fetch call when the observable is unsubscribed
abortController.abort();
}));
}

private async getBody(request: HttpRequest<any>, response: Response): Promise<any> {
try {
switch (request.responseType) {
case 'json':
// Calling `text()` instead of `json()` to allow XSSI stripping
const text = (await response.text()).replace(XSSI_PREFIX, '');
return text === '' ? null : JSON.parse(text);
case 'text':
return await response.text();
case 'blob':
return await response.blob();
case 'arraybuffer':
return await response.arrayBuffer();
}
} catch (error) {
throw new HttpErrorResponse({
error,
headers: new HttpHeaders(response.headers),
status: response.status,
statusText: response.statusText,
url: getResponseUrl(response) ?? request.url,
});
}
}

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.
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 {
abstract fetch: typeof fetch;
}
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
15 changes: 15 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 All @@ -27,6 +28,7 @@ export enum HttpFeatureKind {
NoXsrfProtection,
JsonpSupport,
RequestsMadeViaParent,
Fetch,
}

/**
Expand Down Expand Up @@ -61,6 +63,7 @@ function makeHttpFeature<KindT extends HttpFeatureKind>(
* @see {@link withNoXsrfProtection}
* @see {@link withJsonpSupport}
* @see {@link withRequestsMadeViaParent}
* @see {@link withFetch}
*/
export function provideHttpClient(...features: HttpFeature<HttpFeatureKind>[]):
EnvironmentProviders {
Expand Down Expand Up @@ -233,3 +236,15 @@ export function withRequestsMadeViaParent(): HttpFeature<HttpFeatureKind.Request
},
]);
}


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

0 comments on commit c81b3ab

Please sign in to comment.