Skip to content

07 Httpc

Roberto Ferranti edited this page Nov 3, 2026 · 6 revisions

Http client

httpc is a fetch wrapper with a composable request builder, client and request interceptors, and a typed failure contract. One client, many requests; every request carries its own uri, headers and options.

Building a client

const client = httpc.HttpClient.builder()
    .withCsrfToken()                        // reads meta[name='_csrf_header'] / meta[name='_csrf'] and sets the header, on requests to the page's own origin only
    .withRedirectOnUnauthorized('/login')   // 401 -> window.location = '/login'
    .withInterceptors({                     // custom interceptors, in order
        intercept: async (url, request, chain) => {
            request.headers.set('X-Trace-Id', crypto.randomUUID());
            return await chain.proceed(url, request);
        },
    })
    .build();

There are no client-level headers or base url: requests are self-contained. build() hands the client a copy of the builder's interceptors, so one builder can build a client, gain more interceptors and build another, the first client keeping its own. The 401 interceptor never settles its promise after navigating, so callers should not await a session that expired.

An expired session behind Spring Security

withRedirectOnUnauthorized acts on a 401, and with its defaults Spring Security answers an expired session with something else, in two ways:

  • A read redirects. An unauthenticated request is sent to the login page with a 302, which fetch follows: the page receives the login form's html with a 200, and nothing redirects. Have the server answer 401 to script requests. Spring tells them apart by a header, so add it to every request with an interceptor and register a 401 entry point for it.
  • A write is refused as forbidden. The CSRF token lives in the session, so once the session has expired the first write fails the CSRF check before authentication is looked at, and Spring answers 403. The page shows "forbidden" for what is an expired login. Answer 401 for a CSRF failure on a request whose session is no longer valid; a valid session with a bad token still gets 403.
const client = httpc.HttpClient.builder()
    .withInterceptors({
        intercept: async (url, request, chain) => {
            request.headers.set('X-Requested-With', 'XMLHttpRequest');
            return await chain.proceed(url, request);
        },
    })
    .withCsrfToken()
    .withRedirectOnUnauthorized('/login')
    .build();
http.exceptionHandling(eh -> {
    eh.defaultAuthenticationEntryPointFor(
        new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
        new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
    final var forbidden = new AccessDeniedHandlerImpl();
    eh.accessDeniedHandler((request, response, e) -> {
        if (e instanceof CsrfException && request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
            response.sendError(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        forbidden.handle(request, response, e);
    });
});

A page configuring ful passes this client as the plugin's httpClient, so forms, selects and tables use it too.

flowchart TD
    B["request builder<br/>(params, headers, body)"] --> I1["client interceptors"]
    I1 --> I2["request interceptors"]
    I2 --> F["fetch"]
    F --> OK{"response.ok?"}
    OK -- yes --> T["fetch() resolves the Response<br/>json / text / blob decode it"]
    OK -- "no (fetch family only)" --> CT{"error Content-Type"}
    CT -- "application/failures+json" --> P1["the body: an array of problems"]
    CT -- "application/problem+json" --> P2["body.problems, or one GENERIC_PROBLEM"]
    CT -- "declared json that does not decode" --> P4["GENERIC_PROBLEM, status kept,<br/>reason says the body did not decode"]
    CT -- anything else --> P3["GENERIC_PROBLEM,<br/>reason embeds status and body text"]
    P1 --> X["throws HttpClientError<br/>(status + problems)"]
    P2 --> X
    P3 --> X
    P4 --> X
Loading

ful builds a client with the csrf and redirect-on-unauthorized interceptors at configure (redirecting 401s to /) and registers it as the registry component http-client (used by forms, selects and tables).

Making requests

const client = httpc.HttpClient.builder().build();

// get with query params and headers
const data = await client.get('/api/users')
    .param('page', 2)                      // params: repeated calls override, multiple values append
    .params('sort=name,asc')               //  a query string works too
    .headers({ 'Accept-Language': 'it', 'X-Old': null })   // nullish values remove the header
    .fetchJson();
// the uri is parsed, not spliced: a '?' inside the query stays,
// and a fragment never eats the parameters ('/a#frag' + .param('p', 1)
// requests '/a?p=1#frag')

// json post
await client.post('/api/users').json({ name: 'ada' }).fetch();

// multipart post
await client.post('/api/files').multipart(form => {
    form.field('label', 'docs');
    form.json('meta', { page: 3 });                          // a json part
    form.blob('file', new Blob(['data']), 'data.txt');       // a file part
    form.blobs('files', [a, b]);                             // repeated file parts
}).fetch();

// any method, raw body, fetch options, abort
const ac = new AbortController();
const response = await client.request('OPTIONS', '/api/users')
    .body('plain text')                     // no Content-Type set: fetch infers it
    .options({ cache: 'no-store' })
    .option('signal', ac.signal)
    .exchange();

get, head, post, put, patch and delete each start a builder for their method, and request(method, uri) any other. A builder sends nothing until one of the terminals below is called: a chain that ends in .json(...) or .param(...) builds a request and drops it.

Builder methods (all chainable): headers(hs), header(k, v), params(ps), param(k, ...vs), body(data), json(value), multipart(cb), options(kvs), option(k, v), interceptor(i), interceptors(is).

Terminals

Method Resolves Rejects
exchange() the raw Response, any status a transport failure → HttpClientError with CONNECTION_PROBLEM; whatever an interceptor throws
fetch() the Response when response.ok non-2xx → HttpClientError
fetchText() / fetchJson() / fetchBlob() / fetchArrayBuffer() decoded body; fetchJson() yields null on a 204 non-2xx → HttpClientError; malformed body → UNMARSHALING_PROBLEM

null/undefined values in headers/params remove the key rather than stringifying, which is how ful omits empty sort and filters parameters. Credentials follow fetch defaults (same-origin).

Failures

Every non-2xx through the fetch* family throws an HttpClientError (a Failure), carrying status and a problems array:

try {
    await client.post('/api/users').json({ name: '' }).fetch();
} catch (e) {
    e instanceof httpc.HttpClientError;   // true
    e instanceof httpc.Failure;           // true
    e.status;                             // 422
    e.problems;                           // [{ type: 'VALIDATION', context: 'name', reason: 'blank', details: null }]
    e.dropping('user.').problems;         // contexts relative to a nested form
}

The Problem shape, { type, context, reason, details }, is the contract ful forms consume: context addresses a field by its dotted name, reason is the human message (see forms).

The response branch is chosen by content type:

Response Content-Type Problems
application/failures+json the body: a json array of problems, verbatim (a body that does not decode, that is not an array or that is an empty array, reports one GENERIC_PROBLEM carrying the status)
application/problem+json body.problems when it is a non-empty array, else one GENERIC_PROBLEM from whichever of title and detail the body has (a body that does not decode to an object reports one GENERIC_PROBLEM too)
anything else one GENERIC_PROBLEM whose reason embeds status and body text (a body that cannot be read says so, keeping the status)

Failures that never reached a status get status: 0 and a type that says where they came from:

type raised when
CONNECTION_PROBLEM the transport itself refused to deliver, meaning fetch rejected. This is the only place it is raised, at the bottom of the chain. It covers every fetch rejection, a caller's abort and an invalid request included; the error's cause is what fetch rejected with, and its name tells them apart: AbortError for an abort, TimeoutError for AbortSignal.timeout, TypeError for a network failure or a request fetch refused (an abort with a reason of your own rejects with that reason)
UNMARSHALING_PROBLEM the response arrived and its body could not be decoded as the type asked for
UNEXPECTED_PROBLEM anything else thrown inside the chain: an interceptor's own bug, a mapper that blew up

The reason the last two are distinct from the first is diagnosis: a CONNECTION_PROBLEM is fetch refusing to deliver, whether for the network, an abort or an invalid request (its cause says which), an UNEXPECTED_PROBLEM is your code, and telling them apart in a report matters. A Failure thrown by an interceptor deliberately, to short-circuit the call, reaches the caller untouched with the type it was given.

Whatever happens, the fetch* family rejects with a Failure, so a caller reading problems never has to check the shape first.

Failure.dropping(prefix) returns a copy with the prefix stripped from problem contexts (original chained as cause), and httpc.Failure.dropProblemsContext(problems, prefix) works on plain arrays.

Interceptors

/** @typedef {{ intercept(url: URL, request: RequestInit, chain: { proceed(url, request): Promise<Response> }): Promise<Response> }} HttpInterceptor */

Continue with chain.proceed(url, request); mutate request.headers on the way in; short-circuit by returning a Response or throwing a Failure. Client interceptors run first (registration order), then request-level ones, then the actual fetch.

Encodings and media types

httpc.Base64 (encode(arrayBuffer, dialect?) / decode(str, dialect?), unpadded output, url-safe alphabet by default, Base64.STANDARD for the classic one) and httpc.Hex (encode(bytes, upper?) / decode(hex), bytes being an ArrayBuffer or any typed array, a signed one included, written as two digits per byte) operate on binary data. Both decodes reject invalid input (characters outside the dialect, misplaced padding, impossible lengths) instead of corrupting silently. httpc.MediaType.parse('application/json; charset=utf-8') yields { type, subtype, normalized }.

Clone this wiki locally