-
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(withFetch())`. NB: The fetch API is experimental on Node but available without flags from Node 18 onwards.
- Loading branch information
Showing
6 changed files
with
528 additions
and
5 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
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,165 @@ | ||
/** | ||
* @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(); | ||
return from(this.doRequest(req, abortController.signal)) | ||
.pipe(startWith({type: HttpEventType.Sent} as HttpSentEvent), finalize(() => { | ||
// Aborting the fetch call when the observable is unsubscribed | ||
abortController.abort(); | ||
})); | ||
} | ||
|
||
private async doRequest(req: HttpRequest<any>, signal: AbortSignal): Promise<HttpResponse<any>> { | ||
let response: Response|undefined; | ||
try { | ||
const init = this.createRequestInit(req); | ||
response = await this.fetchImpl(req.url, {signal, ...init}); | ||
} catch (error: any) { | ||
throw new HttpErrorResponse({ | ||
error, | ||
status: error.status ?? 0, | ||
statusText: error.statusText, | ||
url: req.url, | ||
headers: error.headers, | ||
}); | ||
} | ||
|
||
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, | ||
}); | ||
} | ||
|
||
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; | ||
} |
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.