Skip to content

ADR 40: Orval Fetch Mutators for Cross-App HTTP Clients

Status: Accepted
Date: 2026-06-10
Last Updated: 2026-06-10

Terms (this ADR)

IDTermMeaning
RnFunctional requirementNumbered obligation (R1–R6).
NRnNon-functional requirementQuality attribute (NR1–NR4).
CnConstraintNon-negotiable boundary (C1–C4).
CCnCross-cutting challengeRisk spanning components (CC1–CC2).
MutatorOrval overrideCustom fetch function Orval injects into every generated client call instead of the default template.
API surfaceOpenAPI + client pairOne spec dump and one generated module — e.g. gateway (libs/api-client/openapi.jsongenerated.ts) or ops-api (libs/ops-api-client/openapi.jsongenerated.ts).
Handler registryApp bootstrap hooksPer-surface callbacks registered at startup (onUnauthorized, redirect guards) so @substratum/api-client stays UI-framework agnostic.

Canonical product vocabulary: Glossary.

Context

ADR 11 defines the utoipa → OpenAPI → Orval pipeline in @substratum/api-client. Browser apps authenticate with cookie sessions (credentials: 'include') — customer substratum_session on the gateway (ADR 33) and staff substratum_ops_session on ops-api (ADR 38).

Today:

  • apps/file-explorer passes { credentials: 'include' } on every generated call via a local withCredentials helper (ADR 20).
  • apps/admin uses Orval opsFetch mutator (@substratum/ops-api-client) so all ops generated calls send cookies and run global 401 handling (clear session + redirect to /login).

Problems without mutators:

  • Easy to forget withCredentials on a new call — silent unauthenticated requests.
  • 401 session expiry duplicated in services, pages, or ad-hoc interceptors — inconsistent redirect and racey error flashes.
  • Two OpenAPI surfaces (gateway + ops) need separate session semantics but shared mutator mechanics.

We need one pattern that scales to future apps (admin, file-explorer, …) without coupling @substratum/api-client to Mithril or app routing.

Requirements

Functional requirements

IDRequirement
R1Each OpenAPI surface that uses browser cookie auth SHALL wire an Orval override.mutator so generated functions do not rely on callers passing credentials manually.
R2Mutators SHALL always send credentials: 'include' on fetch.
R3On HTTP 401, mutators SHALL invoke app-registered handlers (clear in-memory session, optional redirect) before returning the response to the caller.
R4Mutators SHALL not throw on 401; callers receive { data, status, headers } and MAY check status for domain errors (same as file-explorer services today).
R5Each API surface SHALL expose register*FetchHandlers (or equivalent) callable once at app bootstrap; handler implementations live in the consuming app (main.tsx).
R6OpenAPI JSON files SHALL remain generated from Rust (openapi:dump, openapi:dump:ops) — mutator wiring lives in orval.config.ts and hand-maintained *-fetch.ts shims only.

Non-functional requirements

IDRequirement
NR1@substratum/api-client MUST NOT import Mithril, RxJS, or app routes — only generic handler types.
NR2One createApiFetch() factory instance per surface so ops and gateway handler registries do not collide.
NR3Mutator return shape MUST stay compatible with Orval's default fetch client (data, status, headers).
NR4Adding a new surface SHALL require at most: one *-fetch.ts shim, one orval.config.ts entry, one bootstrap register*Handlers call.

Constraints

IDConstraint
C1Do not hand-edit libs/api-client/openapi.json or libs/ops-api-client/openapi.json to satisfy client needs — change Rust utoipa annotations and re-dump.
C2Mutators MUST NOT swallow non-401 errors; services/pages remain responsible for surfacing 4xx/5xx UX (ADR 20).
C3TUS, EventSource, and other non-Orval transports keep their own credential wiring until explicitly migrated.
C4Customer and ops sessions MUST stay on separate cookies and origins — one mutator instance per surface, not one global handler for both APIs.

Cross-cutting challenges

IDChallengeMitigation
CC1Brief error flash on 401 before redirect (service throws on status !== 200)Accept for v1; optional later: skip throw when status === 401 in services or teach mutator to throw a typed sentinel only apps catch.
CC2Gateway migration from withCredentials per callAdd gateway-fetch.ts + Orval mutator on substratum; register handlers in apps/file-explorer/src/main.tsx; remove redundant withCredentials from services incrementally.

Decision

  1. Generic factory in @substratum/api-client: Implement createApiFetch() in libs/api-client/src/fetch-mutator.ts. It returns { fetch, registerHandlers } where fetch is the Orval mutator signature and registerHandlers accepts ApiFetchHandlers:

    • onUnauthorized — e.g. clear session singleton state.
    • shouldRedirectOnUnauthorized — return false to skip redirect (already on login route).
    • redirectOnUnauthorized — e.g. m.route.set('/login').
  2. One shim file per OpenAPI surface: Thin modules call the factory and export stable names for Orval and apps:

    SurfaceShimGenerated clientOrval config key
    Ops-apilibs/ops-api-client/src/ops-fetch.tsopsFetch, registerOpsFetchHandlersgenerated.tssubstratumOps
    Gatewaysrc/gateway-fetch.ts (planned) → gatewayFetch, registerGatewayFetchHandlersgenerated.tssubstratum

    orval.config.ts sets output.override.mutator.path and name to the shim's export.

  3. 401 flow (ops, today): Every ops generated call → opsFetchglobalThis.fetch with credentials → if status === 401, run registered handlers → return response object. apps/admin/src/main.tsx registers handlers before m.route(...).

  4. Error surfacing: Application services check response.status === 200 (or domain-specific success codes) and throw user-visible errors for other statuses. 401 is not re-handled in services — the mutator already cleared session and redirected.

  5. OpenAPI workflow unchanged: pnpm run openapi:dump / openapi:dump:ops then pnpm run api-client:generate. Mutator sources are inputs to the generate target in libs/api-client/project.json.

Rejected alternatives

AlternativeWhy rejected
Per-call { credentials: 'include' } (withCredentials)Forgotten on new calls; no central 401 behavior; duplicated across services.
Axios-style interceptor inside @substratum/api-clientPulls HTTP client dependency and still needs app-specific redirect logic.
Throw from mutator on 401Breaks callers that expect Orval's { data, status } contract; harder for session refresh paths (getOpsMe) to branch on status.
Single global handler for gateway + opsDifferent cookies, origins, and redirect targets (ADR 33 §7).
Hand-written fetch wrappers per appDrifts from generated paths and types; defeats OpenAPI pipeline (ADR 11).

Consequences

Positive

  • Cookie auth and 401 session expiry are consistent for all calls on a surface.
  • @substratum/api-client stays framework-neutral; apps own redirect and session cleanup.
  • New Mithril apps reuse the same factory with their own shim + bootstrap registration.
  • Aligns with ADR 20 status-check pattern without a separate assert helper.

Negative

  • Gateway still uses withCredentials until gateway-fetch mutator lands (CC2).
  • 401 responses still reach callers — services must not treat 401 like a generic failure when redirect is in flight (CC1).
  • Each surface adds a small hand-maintained shim file alongside generated output.

Neutral

  • Bruno collection and non-browser clients are unaffected — mutators apply to Orval fetch client generation only.

Verification

ScenarioExpected
Staff calls getOpsEntitlementLookup with expired ops cookieopsFetch fires handlers → session cleared → route /login if not already there; response { status: 401, … } returned.
Staff on /login gets 401 from getOpsMeonUnauthorized runs; redirect skipped when shouldRedirectOnUnauthorized returns false.
pnpm run api-client:generate after mutator editOrval still resolves ./src/ops-fetch.ts mutator; admin build succeeds.
Future gateway mutator registered in file-explorergetDrives() sends cookies without per-call withCredentials.