-
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
5 changed files
with
522 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,176 @@ | ||
/** | ||
* @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, forkJoin, from, Observable, Observer, of, throwError} from 'rxjs'; | ||
import {catchError, finalize, map, 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|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?.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((response) => { | ||
return forkJoin({body: this.getBody(req, response), response: of(response)}) | ||
.pipe( | ||
catchError((error) => { | ||
// Body parsing error | ||
return throwError(new HttpErrorResponse({ | ||
error, | ||
headers: new HttpHeaders(response.headers), | ||
status: response.status, | ||
statusText: response.statusText, | ||
url: getResponseUrl(response) ?? req.url, | ||
})); | ||
}), | ||
); | ||
}), | ||
map(({body, response}: {body: any, response: 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; | ||
|
||
// Normalize another potential bug (this one comes from CORS). | ||
if (status === 0) { | ||
status = body ? 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) { | ||
return (new HttpResponse({ | ||
body: body, | ||
headers, | ||
status, | ||
statusText, | ||
url, | ||
})); | ||
} else { | ||
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 getBody(request: HttpRequest<any>, response: Response): Promise<any> { | ||
switch (request.responseType) { | ||
case 'json': | ||
// Calling `text()` instead of `json()` to allow XSSI stripping | ||
return response.text().then((text) => { | ||
text = text.replace(XSSI_PREFIX, ''); | ||
return text === '' ? null : JSON.parse(text); | ||
}); | ||
case 'text': | ||
return response.text(); | ||
case 'blob': | ||
return response.blob(); | ||
case 'arraybuffer': | ||
return response.arrayBuffer(); | ||
} | ||
} | ||
|
||
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 { | ||
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.