-
Notifications
You must be signed in to change notification settings - Fork 24.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(common): Introduction of the
fetch
Backend for the HttpClient
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
Showing
5 changed files
with
511 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
/** | ||
* @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 {Injectable, Optional} from '@angular/core'; | ||
import {EMPTY, from, Observable, Observer} from 'rxjs'; | ||
import {catchError} from 'rxjs/operators'; | ||
|
||
import {HttpBackend} from './backend'; | ||
import {HttpHeaders} from './headers'; | ||
import {HttpRequest} from './request'; | ||
import {HttpErrorResponse, HttpEvent, HttpEventType, HttpResponse, 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|undefined { | ||
if (response.url) { | ||
return response.url; | ||
} | ||
|
||
// stored as lowercase in the map | ||
const xRequestUrl = 'X-Request-URL'.toLocaleLowerCase(); | ||
return response.headers.get(xRequestUrl) ?? 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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.