Skip to content

Commit

Permalink
Add MultipartHttpClient to handle file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
Vincent Dubois committed Jan 10, 2025
1 parent 96e8337 commit 039d7be
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 0 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,42 @@ export const customFileFetchClient = (httpRequest: HttpRequest<unknown>): Promis
);
```
### Upload file
You can use `createMultipartHttpFetchRequest` to upload a file or a liste of files by adding a
`multipartRequest` method to your API client.
```ts
export default class ApiHttpClient {
// ...
multipartRequest<T>(method: HttpMethod, path: string): MultipartHttpRequest<HttpPromise<T>> {
return createMultipartHttpFetchRequest<T>(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<UploadFileResponse> => this
.httpClient
.mulitpartRequest<UploadFileResponse>(HttpMethod.POST, this.BASE_URL)
.files(files)
.execute();

uploadFile = (files: File): HttpPromise<UploadFileResponse> => this
.httpClient
.mulitpartRequest<UploadFileResponse>(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.
Expand Down
110 changes: 110 additions & 0 deletions src/lib/client/MultipartHttpClient.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<unknown>> = (
multipartHttpRequest: MultipartHttpRequest<unknown>,
): Promise<unknown> => {
const xhr: XMLHttpRequest = new XMLHttpRequest();

// Abort request after configured timeout time
const timeoutHandle: ReturnType<typeof setTimeout> = setTimeout(
() => xhr.abort(),
multipartHttpRequest.optionValues.timeoutInMillis,
);

// Return a promise that resolves when the request is complete
return new Promise<unknown>((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 = <T = void>(
httpRequest: MultipartHttpRequest<unknown>,
): Promise<HttpResponse<T>> => <Promise<HttpResponse<T>>>multipartHttpFetchClientExecutor(httpRequest)
.catch(networkErrorCatcher);

export type MultipartHttpFetchClient = <T>(
multipartHttpRequest: MultipartHttpRequest<unknown>,
) => Promise<HttpResponse<T>>;

/**
* 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<HttpResponse<T>>`
* @param options Optional options to configure the request
*/
export function createMultipartHttpFetchRequest<T>(
baseUrl: string,
method: HttpMethod,
path: string,
multipartHttpClient: MultipartHttpFetchClient,
options?: Partial<MultipartHttpOptions>,
): MultipartHttpRequest<HttpPromise<T>> {
return new MultipartHttpRequest<HttpPromise<T>>(
(multipartHttpRequest: MultipartHttpRequest<unknown>) => new HttpPromise<T>(
unwrapHttpPromise<T>(multipartHttpClient(multipartHttpRequest)),
multipartHttpRequest,
),
baseUrl,
method,
path,
options,
);
}
84 changes: 84 additions & 0 deletions src/lib/client/MultipartHttpRequest.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (request: MultipartHttpRequest<unknown>) => T;

export class MultipartHttpRequest<T> {
private static readonly DEFAULT_TIMEOUT_IN_MILLIS: number = 60_000; // 1 minute

readonly multipartHttpClient: MultipartHttpClient<T>;

readonly baseUrl: URL;

readonly method: HttpMethod;

readonly path: string;

readonly headersValue: HeadersInit;

readonly formData: FormData;

readonly optionValues: MultipartHttpOptions;

constructor(
multipartHttpClient: MultipartHttpClient<T>,
baseUrl: string,
method: HttpMethod,
path: string,
options?: Partial<MultipartHttpOptions>,
) {
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<string, string>) {
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);
}
}

0 comments on commit 039d7be

Please sign in to comment.