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 1 commit
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
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 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();
}
```

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();
vincentdbs marked this conversation as resolved.
Show resolved Hide resolved

// 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 });
vincentdbs marked this conversation as resolved.
Show resolved Hide resolved
};

// 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> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quand tu dis capitaliser c'est à dire ? réutiliser ou juste plus s'en servir ?

pour moi, faire les deux dans la même classe va complexifier un code se voulant assez simple.

en revanche, peut etre pourrait-on plus l'utiliser avec une API type ReadableStream, mais ca n'est pas le code fait ici

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je suis de l'avis de Lucas sur la séparation. Le code exporté par la lib pouvant être réutilisé l'a été dans le fichier.
Par contre, je pense qu'il faudrait réorganiser les dossiers de la lib car tout est à la racine pour l'instant

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je comprends que ça serait compliqué d'utiliser HttpRequest.ts ici.

Et effectivement, il faudrait déplacer les 2 fichiers dans un dossier dédié multipart. De plus, il faudrait documenter le fonctionnement de ces fichiers (comment utiliser le client multipart ? dans quel cas ?) dans le fichier README.md

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est fait pour le dossier multipart.
Le readme était déjà là ahah

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[]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pourquoi ces ajouts ? pour moi c'est propre à chaque projet

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parce que, la plupart du temps, on veut s'en servir pour uploader des fichiers donc autant avoir les méthodes dispos sur la requête directement. Je peux supprimer si c'est superflu

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);
}
}