-
Notifications
You must be signed in to change notification settings - Fork 24.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(http): Introduction of the fetch
Backend for the HttpClient
#50247
Closed
Closed
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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,251 @@ | ||
/** | ||
* @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 {BehaviorSubject, defer, merge, Observable} from 'rxjs'; | ||
import {finalize} from 'rxjs/operators'; | ||
|
||
import {HttpBackend} from './backend'; | ||
import {HttpHeaders} from './headers'; | ||
import {HttpRequest} from './request'; | ||
import {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpResponse, HttpStatusCode} from './response'; | ||
|
||
const XSSI_PREFIX = /^\)\]\}',?\n/; | ||
|
||
const REQUEST_URL_HEADER = `X-Request-URL`; | ||
|
||
/** | ||
* 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 = REQUEST_URL_HEADER.toLocaleLowerCase(); | ||
return response.headers.get(xRequestUrl); | ||
} | ||
|
||
/** | ||
* Uses `fetch` to send requests to a backend server. | ||
* | ||
* This `FetchBackend` requires the support of the | ||
* [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which is available on all | ||
* supported browsers and on Node.js v18 or later. | ||
* | ||
* @see {@link HttpHandler} | ||
* | ||
* @publicApi | ||
* @developerPreview | ||
*/ | ||
@Injectable() | ||
export class FetchBackend implements HttpBackend { | ||
// We need to bind the native fetch to its context or it will throw an "illegal invocation" | ||
private readonly fetchImpl = | ||
inject(FetchFactory, {optional: true})?.fetch ?? fetch.bind(globalThis); | ||
|
||
handle(req: HttpRequest<any>): Observable<HttpEvent<any>> { | ||
// Deferring to allow creating a new AbortController on retry() | ||
return defer(() => { | ||
const abortController = new AbortController(); | ||
|
||
// TODO: use `AsyncGenerators` instead of `BehaviorSubject` when we no longer support RXJS 6.x | ||
// We can't use it now because from() doesn't accept generators on RxJS 6. | ||
// Just replace `reportEvent` calls with `yield`. | ||
const eventStream = new BehaviorSubject<HttpEvent<any>>({type: HttpEventType.Sent}); | ||
|
||
// Everything happens on Observable subscription. | ||
return merge( | ||
defer(() => { | ||
return this | ||
.doRequest(req, abortController.signal, (event) => eventStream.next(event)) | ||
.finally(() => eventStream.complete()); | ||
}), | ||
eventStream, | ||
) | ||
.pipe(finalize(() => { | ||
// Aborting the fetch call when the observable is unsubscribed | ||
abortController.abort(); | ||
})); | ||
}); | ||
} | ||
|
||
private async doRequest( | ||
request: HttpRequest<any>, signal: AbortSignal, | ||
reportEvent: (event: HttpEvent<any>) => void): Promise<HttpResponse<any>> { | ||
const init = this.createRequestInit(request); | ||
let response; | ||
|
||
try { | ||
response = await this.fetchImpl(request.url, {signal, ...init}); | ||
} catch (error: any) { | ||
throw new HttpErrorResponse({ | ||
error, | ||
status: error.status ?? 0, | ||
statusText: error.statusText, | ||
url: request.url, | ||
headers: error.headers, | ||
}); | ||
} | ||
|
||
const headers = new HttpHeaders(response.headers); | ||
const statusText = response.statusText; | ||
const url = getResponseUrl(response) ?? request.url; | ||
|
||
let status = response.status; | ||
let body: string|ArrayBuffer|Blob|object|null = null; | ||
|
||
if (request.reportProgress) { | ||
reportEvent(new HttpHeaderResponse({headers, status, statusText, url})); | ||
} | ||
|
||
if (response.body) { | ||
// Read Progess | ||
const contentLength = response.headers.get('content-length'); | ||
const chunks: Uint8Array[] = []; | ||
const reader = response.body.getReader(); | ||
let receivedLength = 0; | ||
|
||
let decoder: TextDecoder; | ||
let partialText: string|undefined; | ||
|
||
while (true) { | ||
const {done, value} = await reader.read(); | ||
JeanMeche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (done) { | ||
break; | ||
} | ||
|
||
chunks.push(value); | ||
receivedLength += value.length; | ||
|
||
if (request.reportProgress) { | ||
partialText = request.responseType === 'text' ? | ||
(partialText ?? '') + (decoder ??= new TextDecoder).decode(value, {stream: true}) : | ||
undefined; | ||
|
||
reportEvent({ | ||
type: HttpEventType.DownloadProgress, | ||
total: contentLength ? +contentLength : undefined, | ||
loaded: receivedLength, | ||
partialText, | ||
} as HttpDownloadProgressEvent); | ||
} | ||
} | ||
|
||
// Combine all chunks. | ||
const chunksAll = this.concatChunks(chunks, receivedLength); | ||
|
||
body = this.parseBody(request, response, chunksAll); | ||
} | ||
|
||
// Same behavior as the XhrBackend | ||
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, | ||
headers, | ||
status, | ||
statusText, | ||
url, | ||
}); | ||
} | ||
|
||
throw new HttpErrorResponse({ | ||
JeanMeche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
error: body, | ||
headers, | ||
status, | ||
statusText, | ||
url, | ||
}); | ||
} | ||
|
||
private parseBody(request: HttpRequest<any>, response: Response, binContent: Uint8Array): string | ||
|ArrayBuffer|Blob|object|null { | ||
try { | ||
switch (request.responseType) { | ||
case 'json': | ||
// stripping the XSSI when present | ||
const text = new TextDecoder().decode(binContent).replace(XSSI_PREFIX, ''); | ||
return text === '' ? null : JSON.parse(text) as object; | ||
case 'text': | ||
return new TextDecoder().decode(binContent); | ||
case 'blob': | ||
return new Blob([binContent]); | ||
case 'arraybuffer': | ||
return binContent.buffer; | ||
} | ||
} catch (error) { | ||
// body loading or parsing failed | ||
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; | ||
} | ||
} | ||
JeanMeche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return { | ||
body: req.body, | ||
method: req.method, | ||
headers, | ||
credentials, | ||
}; | ||
} | ||
|
||
private concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array { | ||
const chunksAll = new Uint8Array(totalLength); | ||
let position = 0; | ||
for (const chunk of chunks) { | ||
chunksAll.set(chunk, position); | ||
position += chunk.length; | ||
} | ||
|
||
return chunksAll; | ||
} | ||
} | ||
|
||
/** | ||
* Abstract class to provide a mocked implementation of `fetch()` | ||
*/ | ||
export abstract class FetchFactory { | ||
JeanMeche marked this conversation as resolved.
Show resolved
Hide resolved
JeanMeche marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this method guaranteed to be called for each subscription? if not, this whole method body should be wrapped in
defer(() => { /* ... */ })
. a previous implementation did something similar by returningnew Observable()
.I just want to make sure that multiple subscriptions to the same request Observable don't affect each other:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand your wories here but here is the call site:
angular/packages/common/http/src/client.ts
Lines 542 to 543 in 85b4941
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
XhrBackend contains code exactly for this purpose:
angular/packages/common/http/src/xhr.ts
Lines 69 to 70 in 85b4941
based on your comment it seems that is no longer necessary or at least the comment is outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is still necessary.
HttpClient
may lazily callhandle
, but in the middle of the chain any interceptor is also free to create many intermediate requestObservable
s, and may not subscribe to them. Just callinghandle
shouldn't trigger a request.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thx for the insight Alex, @alan-agius4 that leaves us with no other choice than a floating promise right ? (cf my latest commit)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, you can refactor the code to avoid using the observer.
I think something like the below should be enough (I did not test it, just pseudo code)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Floating promises are normal and reasonable. Under the hood,
switchMap
is going to take the promise returned bydoRequest
and do.then(...)
to emit into the observer when it completes (basically, it creates a floating promise).