1 |
|
2 |
|
3 |
|
4 | import AbortController from "abort-controller";
|
5 | import FormData from "form-data";
|
6 |
|
7 | import { HttpClient } from "./httpClient";
|
8 | import { WebResourceLike } from "./webResource";
|
9 | import { HttpOperationResponse } from "./httpOperationResponse";
|
10 | import { HttpHeaders, HttpHeadersLike } from "./httpHeaders";
|
11 | import { RestError } from "./restError";
|
12 | import { Readable, Transform } from "stream";
|
13 |
|
14 | interface FetchError extends Error {
|
15 | code?: string;
|
16 | errno?: string;
|
17 | type?: string;
|
18 | }
|
19 |
|
20 | export type CommonRequestInfo = string;
|
21 |
|
22 | export type CommonRequestInit = Omit<RequestInit, "body" | "headers" | "signal"> & {
|
23 | body?: any;
|
24 | headers?: any;
|
25 | signal?: any;
|
26 | };
|
27 |
|
28 | export type CommonResponse = Omit<Response, "body" | "trailer" | "formData"> & {
|
29 | body: any;
|
30 | trailer: any;
|
31 | formData: any;
|
32 | };
|
33 |
|
34 | export abstract class FetchHttpClient implements HttpClient {
|
35 | async sendRequest(httpRequest: WebResourceLike): Promise<HttpOperationResponse> {
|
36 | if (!httpRequest && typeof httpRequest !== "object") {
|
37 | throw new Error(
|
38 | "'httpRequest' (WebResource) cannot be null or undefined and must be of type object."
|
39 | );
|
40 | }
|
41 |
|
42 | const abortController = new AbortController();
|
43 | let abortListener: ((event: any) => void) | undefined;
|
44 | if (httpRequest.abortSignal) {
|
45 | if (httpRequest.abortSignal.aborted) {
|
46 | throw new RestError(
|
47 | "The request was aborted",
|
48 | RestError.REQUEST_ABORTED_ERROR,
|
49 | undefined,
|
50 | httpRequest
|
51 | );
|
52 | }
|
53 |
|
54 | abortListener = (event: Event) => {
|
55 | if (event.type === "abort") {
|
56 | abortController.abort();
|
57 | }
|
58 | };
|
59 | httpRequest.abortSignal.addEventListener("abort", abortListener);
|
60 | }
|
61 |
|
62 | if (httpRequest.timeout) {
|
63 | setTimeout(() => {
|
64 | abortController.abort();
|
65 | }, httpRequest.timeout);
|
66 | }
|
67 |
|
68 | if (httpRequest.formData) {
|
69 | const formData: any = httpRequest.formData;
|
70 | const requestForm = new FormData();
|
71 | const appendFormValue = (key: string, value: any) => {
|
72 |
|
73 | if (typeof value === "function") {
|
74 | value = value();
|
75 | }
|
76 | if (value && value.hasOwnProperty("value") && value.hasOwnProperty("options")) {
|
77 | requestForm.append(key, value.value, value.options);
|
78 | } else {
|
79 | requestForm.append(key, value);
|
80 | }
|
81 | };
|
82 | for (const formKey of Object.keys(formData)) {
|
83 | const formValue = formData[formKey];
|
84 | if (Array.isArray(formValue)) {
|
85 | for (let j = 0; j < formValue.length; j++) {
|
86 | appendFormValue(formKey, formValue[j]);
|
87 | }
|
88 | } else {
|
89 | appendFormValue(formKey, formValue);
|
90 | }
|
91 | }
|
92 |
|
93 | httpRequest.body = requestForm;
|
94 | httpRequest.formData = undefined;
|
95 | const contentType = httpRequest.headers.get("Content-Type");
|
96 | if (contentType && contentType.indexOf("multipart/form-data") !== -1) {
|
97 | if (typeof requestForm.getBoundary === "function") {
|
98 | httpRequest.headers.set(
|
99 | "Content-Type",
|
100 | `multipart/form-data; boundary=${requestForm.getBoundary()}`
|
101 | );
|
102 | } else {
|
103 |
|
104 | httpRequest.headers.remove("Content-Type");
|
105 | }
|
106 | }
|
107 | }
|
108 |
|
109 | let body = httpRequest.body
|
110 | ? typeof httpRequest.body === "function"
|
111 | ? httpRequest.body()
|
112 | : httpRequest.body
|
113 | : undefined;
|
114 | if (httpRequest.onUploadProgress && httpRequest.body) {
|
115 | let loadedBytes = 0;
|
116 | const uploadReportStream = new Transform({
|
117 | transform: (chunk: string | Buffer, _encoding, callback) => {
|
118 | loadedBytes += chunk.length;
|
119 | httpRequest.onUploadProgress!({ loadedBytes });
|
120 | callback(undefined, chunk);
|
121 | },
|
122 | });
|
123 |
|
124 | if (isReadableStream(body)) {
|
125 | body.pipe(uploadReportStream);
|
126 | } else {
|
127 | uploadReportStream.end(body);
|
128 | }
|
129 |
|
130 | body = uploadReportStream;
|
131 | }
|
132 |
|
133 | const platformSpecificRequestInit: Partial<RequestInit> = await this.prepareRequest(
|
134 | httpRequest
|
135 | );
|
136 |
|
137 | const requestInit: RequestInit = {
|
138 | body: body,
|
139 | headers: httpRequest.headers.rawHeaders(),
|
140 | method: httpRequest.method,
|
141 | signal: abortController.signal,
|
142 | redirect: "manual",
|
143 | ...platformSpecificRequestInit,
|
144 | };
|
145 |
|
146 | let operationResponse: HttpOperationResponse | undefined;
|
147 | try {
|
148 | const response: CommonResponse = await this.fetch(httpRequest.url, requestInit);
|
149 |
|
150 | const headers = parseHeaders(response.headers);
|
151 | operationResponse = {
|
152 | headers: headers,
|
153 | request: httpRequest,
|
154 | status: response.status,
|
155 | readableStreamBody: httpRequest.streamResponseBody
|
156 | ? ((response.body as unknown) as NodeJS.ReadableStream)
|
157 | : undefined,
|
158 | bodyAsText: !httpRequest.streamResponseBody ? await response.text() : undefined,
|
159 | redirected: response.redirected,
|
160 | url: response.url,
|
161 | };
|
162 |
|
163 | const onDownloadProgress = httpRequest.onDownloadProgress;
|
164 | if (onDownloadProgress) {
|
165 | const responseBody: ReadableStream<Uint8Array> | undefined = response.body || undefined;
|
166 |
|
167 | if (isReadableStream(responseBody)) {
|
168 | let loadedBytes = 0;
|
169 | const downloadReportStream = new Transform({
|
170 | transform: (chunk: string | Buffer, _encoding, callback) => {
|
171 | loadedBytes += chunk.length;
|
172 | onDownloadProgress({ loadedBytes });
|
173 | callback(undefined, chunk);
|
174 | },
|
175 | });
|
176 | responseBody.pipe(downloadReportStream);
|
177 | operationResponse.readableStreamBody = downloadReportStream;
|
178 | } else {
|
179 | const length = parseInt(headers.get("Content-Length")!) || undefined;
|
180 | if (length) {
|
181 |
|
182 | onDownloadProgress({ loadedBytes: length });
|
183 | }
|
184 | }
|
185 | }
|
186 |
|
187 | await this.processRequest(operationResponse);
|
188 |
|
189 | return operationResponse;
|
190 | } catch (error) {
|
191 | const fetchError: FetchError = error;
|
192 | if (fetchError.code === "ENOTFOUND") {
|
193 | throw new RestError(
|
194 | fetchError.message,
|
195 | RestError.REQUEST_SEND_ERROR,
|
196 | undefined,
|
197 | httpRequest
|
198 | );
|
199 | } else if (fetchError.type === "aborted") {
|
200 | throw new RestError(
|
201 | "The request was aborted",
|
202 | RestError.REQUEST_ABORTED_ERROR,
|
203 | undefined,
|
204 | httpRequest
|
205 | );
|
206 | }
|
207 |
|
208 | throw fetchError;
|
209 | } finally {
|
210 |
|
211 | if (httpRequest.abortSignal && abortListener) {
|
212 | let uploadStreamDone = Promise.resolve();
|
213 | if (isReadableStream(body)) {
|
214 | uploadStreamDone = isStreamComplete(body);
|
215 | }
|
216 | let downloadStreamDone = Promise.resolve();
|
217 | if (isReadableStream(operationResponse?.readableStreamBody)) {
|
218 | downloadStreamDone = isStreamComplete(operationResponse!.readableStreamBody);
|
219 | }
|
220 |
|
221 | Promise.all([uploadStreamDone, downloadStreamDone])
|
222 | .then(() => {
|
223 | httpRequest.abortSignal?.removeEventListener("abort", abortListener!);
|
224 | return;
|
225 | })
|
226 | .catch((_e) => {});
|
227 | }
|
228 | }
|
229 | }
|
230 |
|
231 | abstract async prepareRequest(httpRequest: WebResourceLike): Promise<Partial<RequestInit>>;
|
232 | abstract async processRequest(operationResponse: HttpOperationResponse): Promise<void>;
|
233 | abstract async fetch(input: CommonRequestInfo, init?: CommonRequestInit): Promise<CommonResponse>;
|
234 | }
|
235 |
|
236 | function isReadableStream(body: any): body is Readable {
|
237 | return body && typeof body.pipe === "function";
|
238 | }
|
239 |
|
240 | function isStreamComplete(stream: Readable): Promise<void> {
|
241 | return new Promise((resolve) => {
|
242 | stream.on("close", resolve);
|
243 | stream.on("end", resolve);
|
244 | stream.on("error", resolve);
|
245 | });
|
246 | }
|
247 |
|
248 | export function parseHeaders(headers: Headers): HttpHeadersLike {
|
249 | const httpHeaders = new HttpHeaders();
|
250 |
|
251 | headers.forEach((value, key) => {
|
252 | httpHeaders.set(key, value);
|
253 | });
|
254 |
|
255 | return httpHeaders;
|
256 | }
|