ADR 40: Orval Fetch Mutators for Cross-App HTTP Clients
Status: Accepted
Date: 2026-06-10
Last Updated: 2026-06-10
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation (R1–R6). |
| NRn | Non-functional requirement | Quality attribute (NR1–NR4). |
| Cn | Constraint | Non-negotiable boundary (C1–C4). |
| CCn | Cross-cutting challenge | Risk spanning components (CC1–CC2). |
| Mutator | Orval override | Custom fetch function Orval injects into every generated client call instead of the default template. |
| API surface | OpenAPI + client pair | One spec dump and one generated module — e.g. gateway (libs/api-client/openapi.json → generated.ts) or ops-api (libs/ops-api-client/openapi.json → generated.ts). |
| Handler registry | App bootstrap hooks | Per-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-explorerpasses{ credentials: 'include' }on every generated call via a localwithCredentialshelper (ADR 20).apps/adminuses OrvalopsFetchmutator (@substratum/ops-api-client) so all opsgeneratedcalls send cookies and run global 401 handling (clear session + redirect to/login).
Problems without mutators:
- Easy to forget
withCredentialson 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
| ID | Requirement |
|---|---|
| R1 | Each OpenAPI surface that uses browser cookie auth SHALL wire an Orval override.mutator so generated functions do not rely on callers passing credentials manually. |
| R2 | Mutators SHALL always send credentials: 'include' on fetch. |
| R3 | On HTTP 401, mutators SHALL invoke app-registered handlers (clear in-memory session, optional redirect) before returning the response to the caller. |
| R4 | Mutators SHALL not throw on 401; callers receive { data, status, headers } and MAY check status for domain errors (same as file-explorer services today). |
| R5 | Each API surface SHALL expose register*FetchHandlers (or equivalent) callable once at app bootstrap; handler implementations live in the consuming app (main.tsx). |
| R6 | OpenAPI 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
| ID | Requirement |
|---|---|
| NR1 | @substratum/api-client MUST NOT import Mithril, RxJS, or app routes — only generic handler types. |
| NR2 | One createApiFetch() factory instance per surface so ops and gateway handler registries do not collide. |
| NR3 | Mutator return shape MUST stay compatible with Orval's default fetch client (data, status, headers). |
| NR4 | Adding a new surface SHALL require at most: one *-fetch.ts shim, one orval.config.ts entry, one bootstrap register*Handlers call. |
Constraints
| ID | Constraint |
|---|---|
| C1 | Do 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. |
| C2 | Mutators MUST NOT swallow non-401 errors; services/pages remain responsible for surfacing 4xx/5xx UX (ADR 20). |
| C3 | TUS, EventSource, and other non-Orval transports keep their own credential wiring until explicitly migrated. |
| C4 | Customer 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
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Brief 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. |
| CC2 | Gateway migration from withCredentials per call | Add gateway-fetch.ts + Orval mutator on substratum; register handlers in apps/file-explorer/src/main.tsx; remove redundant withCredentials from services incrementally. |
Decision
Generic factory in
@substratum/api-client: ImplementcreateApiFetch()inlibs/api-client/src/fetch-mutator.ts. It returns{ fetch, registerHandlers }wherefetchis the Orval mutator signature andregisterHandlersacceptsApiFetchHandlers:onUnauthorized— e.g. clear session singleton state.shouldRedirectOnUnauthorized— returnfalseto skip redirect (already on login route).redirectOnUnauthorized— e.g.m.route.set('/login').
One shim file per OpenAPI surface: Thin modules call the factory and export stable names for Orval and apps:
Surface Shim Generated client Orval config key Ops-api libs/ops-api-client/src/ops-fetch.ts→opsFetch,registerOpsFetchHandlersgenerated.tssubstratumOpsGateway src/gateway-fetch.ts(planned) →gatewayFetch,registerGatewayFetchHandlersgenerated.tssubstratumorval.config.tssetsoutput.override.mutator.pathandnameto the shim's export.401 flow (ops, today): Every ops
generatedcall →opsFetch→globalThis.fetchwith credentials → ifstatus === 401, run registered handlers → return response object.apps/admin/src/main.tsxregisters handlers beforem.route(...).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.OpenAPI workflow unchanged:
pnpm run openapi:dump/openapi:dump:opsthenpnpm run api-client:generate. Mutator sources are inputs to the generate target inlibs/api-client/project.json.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
Per-call { credentials: 'include' } (withCredentials) | Forgotten on new calls; no central 401 behavior; duplicated across services. |
Axios-style interceptor inside @substratum/api-client | Pulls HTTP client dependency and still needs app-specific redirect logic. |
| Throw from mutator on 401 | Breaks callers that expect Orval's { data, status } contract; harder for session refresh paths (getOpsMe) to branch on status. |
| Single global handler for gateway + ops | Different cookies, origins, and redirect targets (ADR 33 §7). |
| Hand-written fetch wrappers per app | Drifts 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-clientstays 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
withCredentialsuntilgateway-fetchmutator 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
| Scenario | Expected |
|---|---|
Staff calls getOpsEntitlementLookup with expired ops cookie | opsFetch fires handlers → session cleared → route /login if not already there; response { status: 401, … } returned. |
Staff on /login gets 401 from getOpsMe | onUnauthorized runs; redirect skipped when shouldRedirectOnUnauthorized returns false. |
pnpm run api-client:generate after mutator edit | Orval still resolves ./src/ops-fetch.ts mutator; admin build succeeds. |
| Future gateway mutator registered in file-explorer | getDrives() sends cookies without per-call withCredentials. |
Related
- Glossary
- ADR 11: Cross-Boundary Strategies — OpenAPI / Orval pipeline
- ADR 20: File Explorer Service Layer — service status checks
- ADR 33: Frontend Modular Monolith — separate app origins and sessions
- ADR 38: Support and Entitlement Admin Tooling — ops-api and
apps/admin - Operational:
libs/api-client/AGENTS.md,apps/admin/AGENTS.md