angular/packages/common/http/src/fetch.ts
Matthieu Riegler cf86ae5c3a fix(http): Use the response content-type to set the blob type. (#52840)
When downloading a PDF with the fetch client, the blob had no content. It couldn't be displayed in an iframe. This commit fixes this.

Relate to: https://stackoverflow.com/questions/77470626/possible-bug-in-httpclient-when-using-the-blob-data-type

PR Close #52840
2023-11-13 16:22:20 +00:00

271 lines
8.7 KiB
TypeScript

/**
* @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, NgZone} from '@angular/core';
import {Observable, Observer} from 'rxjs';
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
*/
@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);
private readonly ngZone = inject(NgZone);
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
return new Observable(observer => {
const aborter = new AbortController();
this.doRequest(request, aborter.signal, observer)
.then(noop, error => observer.error(new HttpErrorResponse({error})));
return () => aborter.abort();
});
}
private async doRequest(
request: HttpRequest<any>, signal: AbortSignal,
observer: Observer<HttpEvent<any>>): Promise<void> {
const init = this.createRequestInit(request);
let response;
try {
const fetchPromise = this.fetchImpl(request.urlWithParams, {signal, ...init});
// Make sure Zone.js doesn't trigger false-positive unhandled promise
// error in case the Promise is rejected synchronously. See function
// description for additional information.
silenceSuperfluousUnhandledPromiseRejection(fetchPromise);
// Send the `Sent` event before awaiting the response.
observer.next({type: HttpEventType.Sent});
response = await fetchPromise;
} catch (error: any) {
observer.error(new HttpErrorResponse({
error,
status: error.status ?? 0,
statusText: error.statusText,
url: request.urlWithParams,
headers: error.headers,
}));
return;
}
const headers = new HttpHeaders(response.headers);
const statusText = response.statusText;
const url = getResponseUrl(response) ?? request.urlWithParams;
let status = response.status;
let body: string|ArrayBuffer|Blob|object|null = null;
if (request.reportProgress) {
observer.next(new HttpHeaderResponse({headers, status, statusText, url}));
}
if (response.body) {
// Read Progress
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;
// We have to check whether the Zone is defined in the global scope because this may be called
// when the zone is nooped.
const reqZone = typeof Zone !== 'undefined' && Zone.current;
// Perform response processing outside of Angular zone to
// ensure no excessive change detection runs are executed
// Here calling the async ReadableStreamDefaultReader.read() is responsible for triggering CD
await this.ngZone.runOutsideAngular(async () => {
while (true) {
const {done, value} = await reader.read();
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;
const reportProgress = () => observer.next({
type: HttpEventType.DownloadProgress,
total: contentLength ? +contentLength : undefined,
loaded: receivedLength,
partialText,
} as HttpDownloadProgressEvent);
reqZone ? reqZone.run(reportProgress) : reportProgress();
}
}
});
// Combine all chunks.
const chunksAll = this.concatChunks(chunks, receivedLength);
try {
const contentType = response.headers.get('Content-Type') ?? '';
body = this.parseBody(request, chunksAll, contentType);
} catch (error) {
// Body loading or parsing failed
observer.error(new HttpErrorResponse({
error,
headers: new HttpHeaders(response.headers),
status: response.status,
statusText: response.statusText,
url: getResponseUrl(response) ?? request.urlWithParams,
}));
return;
}
}
// 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) {
observer.next(new HttpResponse({
body,
headers,
status,
statusText,
url,
}));
// The full body has been received and delivered, no further events
// are possible. This request is complete.
observer.complete();
} else {
observer.error(new HttpErrorResponse({
error: body,
headers,
status,
statusText,
url,
}));
}
}
private parseBody(request: HttpRequest<any>, binContent: Uint8Array, contentType: string): string
|ArrayBuffer|Blob|object|null {
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], {type: contentType});
case 'arraybuffer':
return binContent.buffer;
}
}
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.serializeBody(),
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 {
abstract fetch: typeof fetch;
}
function noop(): void {}
/**
* Zone.js treats a rejected promise that has not yet been awaited
* as an unhandled error. This function adds a noop `.then` to make
* sure that Zone.js doesn't throw an error if the Promise is rejected
* synchronously.
*/
function silenceSuperfluousUnhandledPromiseRejection(promise: Promise<unknown>) {
promise.then(noop, noop);
}