From 1dbbe133c19d4575e03657df9a60debcb8280696 Mon Sep 17 00:00:00 2001 From: Vincent Dubois Date: Fri, 10 Jan 2025 13:39:03 +0100 Subject: [PATCH 1/4] Add MultipartHttpClient to handle file upload --- README.md | 36 ++++++++ src/lib/client/MultipartHttpClient.ts | 110 +++++++++++++++++++++++++ src/lib/client/MultipartHttpRequest.ts | 84 +++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/lib/client/MultipartHttpClient.ts create mode 100644 src/lib/client/MultipartHttpRequest.ts diff --git a/README.md b/README.md index 001c185..925b9c8 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,42 @@ export const customFileFetchClient = (httpRequest: HttpRequest): Promis ); ``` +### Upload file + +You can use `createMultipartHttpFetchRequest` to upload a file or a list of files by adding a +`multipartRequest` method to your API client. + +```ts +export default class ApiHttpClient { + // ... + multipartRequest(method: HttpMethod, path: string): MultipartHttpRequest> { + return createMultipartHttpFetchRequest(baseUrl, method, path, multipartHttpFetchClient); + } +} +``` + +Usage example : +```ts +export default class FilesApi { + private readonly BASE_URL: string = '/orders'; + + constructor(private readonly httpClient: ApiHttpClient) { + } + + uploadFiles = (files: File[]): HttpPromise => this + .httpClient + .mulitpartRequest(HttpMethod.POST, this.BASE_URL) + .files(files) + .execute(); + + uploadFile = (files: File): HttpPromise => this + .httpClient + .mulitpartRequest(HttpMethod.POST, this.BASE_URL) + .file(file) + .execute(); +} +``` + Tree shaking ------------ This library supports tree shaking: components from this library that are not used will not end in your final build as long as your bundler supports this feature. diff --git a/src/lib/client/MultipartHttpClient.ts b/src/lib/client/MultipartHttpClient.ts new file mode 100644 index 0000000..2516ece --- /dev/null +++ b/src/lib/client/MultipartHttpClient.ts @@ -0,0 +1,110 @@ +import { HttpMethod } from 'simple-http-request-builder'; +import { HttpPromise, unwrapHttpPromise } from '../promise/HttpPromise'; +import { networkErrorCatcher } from './FetchClient'; +import { + genericError, HttpResponse, networkError, timeoutError, +} from './HttpResponse'; +import { + MultipartHttpClient, + MultipartHttpOptions, + MultipartHttpRequest, +} from './MultipartHttpRequest'; + +/** + * Handle multipart request using {@link XMLHttpRequest} + * @param multipartHttpRequest the request to be executed + */ +export const multipartHttpFetchClientExecutor: MultipartHttpClient> = ( + multipartHttpRequest: MultipartHttpRequest, +): Promise => { + const xhr: XMLHttpRequest = new XMLHttpRequest(); + + // Abort request after configured timeout time + const timeoutHandle: ReturnType = setTimeout( + () => xhr.abort(), + multipartHttpRequest.optionValues.timeoutInMillis, + ); + + // Return a promise that resolves when the request is complete + return new Promise((resolve: (value: unknown) => void) => { + xhr.open(multipartHttpRequest.method, multipartHttpRequest.buildUrl(), true); + + // Set credentials + xhr.withCredentials = multipartHttpRequest.optionValues.withCredentials; + + // Set headers + if (multipartHttpRequest.headersValue) { + for (const [key, value] of Object.entries(multipartHttpRequest.headersValue)) { + xhr.setRequestHeader(key, value); + } + } + + // Handle response + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + return resolve({ response: xhr.response }); + } + return resolve({ error: xhr.response ?? genericError }); + }; + + // Handle network errors + xhr.onerror = () => resolve({ error: networkError }); + + // Handle request timeout + xhr.ontimeout = () => resolve({ error: timeoutError }); + + // Handle progress + xhr.upload.onprogress = (event: ProgressEvent) => { + multipartHttpRequest.optionValues.onProgressCallback(event); + }; + + xhr.upload.onerror = () => resolve({ error: genericError }); + + // Send the request + xhr.send(multipartHttpRequest.formData); + }) + .finally(() => clearTimeout(timeoutHandle)); +}; + +/** + * A {@link MultipartHttpFetchClient} that executes an {@link MultipartHttpRequest} that returns JSON responses. + * It uses {@link multipartHttpFetchClient} to executes the {@link MultipartHttpRequest}. + */ +export const multipartHttpFetchClient = ( + httpRequest: MultipartHttpRequest, +): Promise> => >>multipartHttpFetchClientExecutor(httpRequest) + .catch(networkErrorCatcher); + +export type MultipartHttpFetchClient = ( + multipartHttpRequest: MultipartHttpRequest, +) => Promise>; + +/** + * Factory function to create fetch {@link MultipartHttpRequest}. + * + * @param baseUrl The base URL. It should not contain an ending slash. A valid base URL is: http://hostname/api + * @param method The HTTP method used for the request, see {@link HttpMethod} + * @param path The path of the endpoint to call, it should be composed with a leading slash + * and will be appended to the {@link MultipartHttpRequest#baseUrl}. A valid path is: /users + * @param multipartHttpClient The fetch client that uses {@link MultipartHttpRequest} and returns + * a `Promise>` + * @param options Optional options to configure the request + */ +export function createMultipartHttpFetchRequest( + baseUrl: string, + method: HttpMethod, + path: string, + multipartHttpClient: MultipartHttpFetchClient, + options?: Partial, +): MultipartHttpRequest> { + return new MultipartHttpRequest>( + (multipartHttpRequest: MultipartHttpRequest) => new HttpPromise( + unwrapHttpPromise(multipartHttpClient(multipartHttpRequest)), + multipartHttpRequest, + ), + baseUrl, + method, + path, + options, + ); +} diff --git a/src/lib/client/MultipartHttpRequest.ts b/src/lib/client/MultipartHttpRequest.ts new file mode 100644 index 0000000..8f98e4b --- /dev/null +++ b/src/lib/client/MultipartHttpRequest.ts @@ -0,0 +1,84 @@ +import { HttpMethod } from 'simple-http-request-builder'; + +export type MultipartHttpOptions = { + timeoutInMillis: number, + onProgressCallback: (event: ProgressEvent) => void, + withCredentials: boolean, +}; + +export type MultipartHttpClient = (request: MultipartHttpRequest) => T; + +export class MultipartHttpRequest { + private static readonly DEFAULT_TIMEOUT_IN_MILLIS: number = 60_000; // 1 minute + + readonly multipartHttpClient: MultipartHttpClient; + + readonly baseUrl: URL; + + readonly method: HttpMethod; + + readonly path: string; + + readonly headersValue: HeadersInit; + + readonly formData: FormData; + + readonly optionValues: MultipartHttpOptions; + + constructor( + multipartHttpClient: MultipartHttpClient, + baseUrl: string, + method: HttpMethod, + path: string, + options?: Partial, + ) { + this.multipartHttpClient = multipartHttpClient; + this.baseUrl = new URL(baseUrl); + this.method = method; + this.path = path; + this.headersValue = {}; + this.formData = new FormData(); + this.optionValues = { + timeoutInMillis: options?.timeoutInMillis ?? MultipartHttpRequest.DEFAULT_TIMEOUT_IN_MILLIS, + onProgressCallback: options?.onProgressCallback ?? (() => {}), + withCredentials: options?.withCredentials ?? false, + }; + } + + headers(headers: Record) { + Object.assign(this.headersValue, headers); + return this; + } + + data(multipartHttpData: [string, string | Blob | undefined][]) { + for (const multipartHttpDataEntry of multipartHttpData) { + if (multipartHttpDataEntry[1]) { + this.formData.append(multipartHttpDataEntry[0], multipartHttpDataEntry[1]); + } + } + return this; + } + + file(file: File) { + this.data([['file', file]]); + return this; + } + + files(files: File[]) { + for (const file of files) { + this.file(file); + } + return this; + } + + buildUrl() { + return encodeURI(this.baseUrl.toString() + this.path); + } + + /** + * Execute the request using the {@link multipartHttpClient}. + */ + execute(): T { + return this.multipartHttpClient(this); + } +} From 6911fa82be97c217f18197aa5e0dd1d40ff926ca Mon Sep 17 00:00:00 2001 From: Vincent Dubois Date: Fri, 10 Jan 2025 13:51:35 +0100 Subject: [PATCH 2/4] Parse response to json --- src/lib/client/MultipartHttpClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/client/MultipartHttpClient.ts b/src/lib/client/MultipartHttpClient.ts index 2516ece..a727bb2 100644 --- a/src/lib/client/MultipartHttpClient.ts +++ b/src/lib/client/MultipartHttpClient.ts @@ -42,9 +42,9 @@ export const multipartHttpFetchClientExecutor: MultipartHttpClient { if (xhr.status >= 200 && xhr.status < 300) { - return resolve({ response: xhr.response }); + return resolve({ response: JSON.parse(xhr.response) }); } - return resolve({ error: xhr.response ?? genericError }); + return resolve({ error: JSON.parse(xhr.response) ?? genericError }); }; // Handle network errors From 873d16b1b74ef0345330d35462ce74e1368d9771 Mon Sep 17 00:00:00 2001 From: Vincent Dubois Date: Wed, 15 Jan 2025 15:26:12 +0100 Subject: [PATCH 3/4] Move multipart files to dedicated folder --- src/lib/{client => multipart}/MultipartHttpClient.ts | 4 ++-- src/lib/{client => multipart}/MultipartHttpRequest.ts | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/lib/{client => multipart}/MultipartHttpClient.ts (97%) rename src/lib/{client => multipart}/MultipartHttpRequest.ts (100%) diff --git a/src/lib/client/MultipartHttpClient.ts b/src/lib/multipart/MultipartHttpClient.ts similarity index 97% rename from src/lib/client/MultipartHttpClient.ts rename to src/lib/multipart/MultipartHttpClient.ts index a727bb2..947fbfc 100644 --- a/src/lib/client/MultipartHttpClient.ts +++ b/src/lib/multipart/MultipartHttpClient.ts @@ -1,9 +1,9 @@ import { HttpMethod } from 'simple-http-request-builder'; import { HttpPromise, unwrapHttpPromise } from '../promise/HttpPromise'; -import { networkErrorCatcher } from './FetchClient'; +import { networkErrorCatcher } from '../client/FetchClient'; import { genericError, HttpResponse, networkError, timeoutError, -} from './HttpResponse'; +} from '../client/HttpResponse'; import { MultipartHttpClient, MultipartHttpOptions, diff --git a/src/lib/client/MultipartHttpRequest.ts b/src/lib/multipart/MultipartHttpRequest.ts similarity index 100% rename from src/lib/client/MultipartHttpRequest.ts rename to src/lib/multipart/MultipartHttpRequest.ts From 60da03ce9ccf59073369bd1e5dce0b467718ed71 Mon Sep 17 00:00:00 2001 From: Vincent Dubois Date: Wed, 15 Jan 2025 15:38:56 +0100 Subject: [PATCH 4/4] Add reference to WIP for readable stream in the readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 925b9c8..be87c70 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,9 @@ export default class FilesApi { } ``` +> Note : XHR seems way easier (2025), but maybe in the future we could use Fetch using ReadableStream like in the [WIP branch multipart-readable-stream](https://github.com/Coreoz/simple-http-rest-client/tree/feature/multipart-readable-stream). +> See : https://github.com/Coreoz/simple-http-rest-client/commit/460b7cc8fe056e95a7708bd97c04c35f552148bd for the detail + Tree shaking ------------ This library supports tree shaking: components from this library that are not used will not end in your final build as long as your bundler supports this feature.