HTTP Client¶
A lightweight, framework-agnostic HTTP client factory built on the global fetch, with built-in error handling (see Errors).
import { createApiClient, buildQueryString } from '@msflib/typescript';
import type {
CreateApiClientConfig,
ApiOptions,
QueryValue,
ApiClientDecorator,
} from '@msflib/typescript';
Table of Contents¶
- createApiClient
- apiClient
- apiFormDataClient
- loginClient
- Request decorators
- buildQueryString
- Example: Full Setup
- Gotchas
- Types
createApiClient¶
createApiClient(
config: CreateApiClientConfig,
emitError?: ClientErrorDispatch,
): { apiClient; apiFormDataClient; loginClient };
| Config option | Type | Required | Description |
|---|---|---|---|
baseURL |
string |
yes | Prepended verbatim to every endpoint (${baseURL}${endpoint}) - no slash is added or removed |
getAccessToken |
() => string | null |
no | Called on every apiClient/apiFormDataClient request. A truthy result is sent as Authorization: Bearer <token> |
apiClientDecorator |
ApiClientDecorator |
no | preRequest/postRequest hooks - see Request decorators |
parseErrorBody |
ErrorBodyParser |
no | Replaces the default error-body parser - see Custom error body parser |
The optional second argument emitError is documented under Error events.
import { createApiClient } from '@msflib/typescript';
const { apiClient, apiFormDataClient, loginClient } = createApiClient({
baseURL: 'https://api.example.com',
getAccessToken: () => localStorage.getItem('access_token'),
});
apiClient¶
Generic JSON-based HTTP client.
apiClient<TResponse = unknown, TRequest = unknown>(
method: string, // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' ...
endpoint: string,
data?: TRequest | null, // default null
options?: ApiOptions, // default {}
noAuth?: boolean, // default false - skip the Authorization header
): Promise<TResponse>;
- Sends
Content-Type: application/json, plusAuthorization: Bearer <token>unlessnoAuthistrueor there is no token. dataisJSON.stringify-ed and sent as the body only whenmethodis exactly'POST','PUT'or'PATCH'(uppercase). For any other method - including'DELETE'and lowercase'post'- the body is silently dropped.optionsis spread into thefetchinit, so anyRequestInitfield works (signal,credentials,cache...).options.headers(plain object) is merged over the defaults.options.querybecomes the query string.- Resolves with
response.json().
const users = await apiClient<{ id: number; name: string }[]>('GET', '/users');
const newUser = await apiClient('POST', '/users', { name: 'Alice' });
With Query Parameters¶
await apiClient(
'GET',
'/users',
null,
{
query: { page: 1, role: 'admin' },
}
);
// GET https://api.example.com/users?page=1&role=admin
Without auth¶
await apiClient('GET', '/public/stats', null, {}, true); // no Authorization header
apiFormDataClient¶
For multipart/form-data requests (file uploads).
apiFormDataClient<TResponse = unknown>(
method: string,
endpoint: string,
formData: FormData,
options?: ApiOptions,
noAuth?: boolean,
): Promise<TResponse>;
No Content-Type header is set, so the runtime adds the correct multipart boundary. The body is sent for any method. Pairs well with asFormData.
const formData = new FormData();
formData.append('file', file);
await apiFormDataClient('POST', '/upload', formData);
loginClient¶
Helper for application/x-www-form-urlencoded login requests (e.g. FastAPI's OAuth2PasswordRequestForm).
loginClient<TResponse = unknown>(
endpoint: string,
payload: { username: string; password: string },
): Promise<TResponse>;
Always POST. Never sends an Authorization header and takes no options.
const result = await loginClient<{ access_token: string }>('/auth/login', {
username: 'john',
password: 'secret',
});
localStorage.setItem('access_token', result.access_token);
Request decorators¶
apiClientDecorator lets you hook into every request made by the three clients:
type ApiClientDecorator = {
preRequest?: (ctx: { input: RequestInfo; init: RequestInit }) => void;
postRequest?: (ctx: {
response: Response;
input: RequestInfo;
init: RequestInit;
}) => Response;
};
preRequestruns beforefetch. It receives a mutable context object - reassignctx.init/ctx.input(or properties on them) to change the request.postRequestruns only for successful (2xx) responses, after the error check. WhateverResponseit returns is what gets parsed with.json(). Error responses never reach it.
const { apiClient } = createApiClient({
baseURL: 'https://api.example.com',
apiClientDecorator: {
preRequest: (ctx) => {
ctx.init.headers = { ...(ctx.init.headers as Record<string, string>), 'X-Request-Id': crypto.randomUUID() };
},
postRequest: ({ response, input }) => {
console.debug('OK', input, response.status);
return response;
},
},
});
buildQueryString¶
The helper behind options.query, exported for reuse.
buildQueryString(query?: Record<string, QueryValue>): string;
Returns '' for no/empty query, otherwise ?-prefixed. undefined values are skipped, arrays become repeated keys, everything else is String()-ed.
buildQueryString({ a: 1, tags: ['x', 'y'], skip: undefined, flag: false });
// "?a=1&tags=x&tags=y&flag=false"
Example: Full Setup¶
import { createApiClient, UserError } from '@msflib/typescript';
const { apiClient } = createApiClient({
baseURL: 'https://api.example.com',
getAccessToken: () => localStorage.getItem('access_token'),
});
try {
const data = await apiClient('GET', '/secure-data');
console.log(data);
} catch (error) {
if (error instanceof UserError) {
console.error(error.message);
}
}
See Errors for ApiResponseError (status, field errors) and the full error contract.
Gotchas¶
- Use uppercase methods.
apiClient('post', ...)sends no body. - No body on
DELETEviaapiClient. UseapiFormDataClientor a rawfetchif your API needs one. 204 No Contentrejects with aSyntaxError, because the response is always parsed with.json(). See Errors.options.headersmust be a plain object. AHeadersinstance (or array of tuples) is spread as an object and its entries are silently lost.baseURL+endpointare concatenated as-is - avoid a double or missing/.- Requires a global
fetch(all modern browsers, Node 18+).
Types¶
type QueryValue = string | number | boolean | undefined | Array<string | number>;
type ApiOptions = RequestInit & {
query?: Record<string, QueryValue>;
};
interface CreateApiClientConfig {
baseURL: string;
getAccessToken?: () => string | null;
apiClientDecorator?: ApiClientDecorator;
parseErrorBody?: ErrorBodyParser;
}
See also: Errors · Object utilities · Quick Start