Skip to content
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

Add MultipartHttpClient to handle file upload #7

Merged
merged 4 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,45 @@ export const customFileFetchClient = (httpRequest: HttpRequest<unknown>): 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<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();
}
```

> 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.
Expand Down
110 changes: 110 additions & 0 deletions src/lib/multipart/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 '../client/FetchClient';
import {
genericError, HttpResponse, networkError, timeoutError,
} from '../client/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: JSON.parse(xhr.response) });
}
return resolve({ error: JSON.parse(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/multipart/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);
}
}