Skip to content

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

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, plus Authorization: Bearer <token> unless noAuth is true or there is no token.
  • data is JSON.stringify-ed and sent as the body only when method is exactly 'POST', 'PUT' or 'PATCH' (uppercase). For any other method - including 'DELETE' and lowercase 'post' - the body is silently dropped.
  • options is spread into the fetch init, so any RequestInit field works (signal, credentials, cache...). options.headers (plain object) is merged over the defaults. options.query becomes 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;
};
  • preRequest runs before fetch. It receives a mutable context object - reassign ctx.init / ctx.input (or properties on them) to change the request.
  • postRequest runs only for successful (2xx) responses, after the error check. Whatever Response it 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 DELETE via apiClient. Use apiFormDataClient or a raw fetch if your API needs one.
  • 204 No Content rejects with a SyntaxError, because the response is always parsed with .json(). See Errors.
  • options.headers must be a plain object. A Headers instance (or array of tuples) is spread as an object and its entries are silently lost.
  • baseURL + endpoint are 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