@zeroxsolutions/response
v0.12.1
Published
The response half of the HTTP contract shared across ZeroXSolutions services and the edge gateway - JSON:API resource declarations and their serializers, the three OpenAPI response descriptors (okResponse / listResponse / errorResponse) every route spells
Readme
@zeroxsolutions/response
The response half of the HTTP contract a service and its edge gateway both answer with - the sibling
of @zeroxsolutions/query, which owns the query-string half, and shaped the same way: one entry, one
wire spelling.
import { errorResponse, jsonApiResource, listResponse, okResponse, toOne } from '@zeroxsolutions/response';
export const subjects = jsonApiResource('subjects', subjectAttributesSchema, {
relationships: { org: toOne(() => orgs, (row) => row.org) },
});
responses: {
200: listResponse('Subjects', subjects), // { data: [...], included?, links? }
201: okResponse('Subject created', subjects), // { data: {...}, included?, ... }
401: errorResponse('Authentication required'), // { errors: [...] }
}
return c.json(await subjects.serializeMany(rows, { links }, c.req.valid('query')));Both take the item on a list and wrap it, so no route restates the carrier. They take a resource
DECLARATION rather than a bare schema, because a document carries a type and its relationships, which
a schema alone has nowhere to put.
The declaration is one value, and the schema a route advertises and the document its handler emits both
come off it: the attribute keys ARE the serializer's projection, so a row member the schema does not
declare cannot reach the wire, and the request's sparse fieldset reaches an included resource because
the descriptor builds the related type's serializer from that same request. Two resources may name each
other - a relationship holds a thunk - and one side of such a pair carries an explicit
JsonApiResource<typeof attributes>, which is the only thing tsc cannot infer through the cycle.
declaredDocumentShape reads that same declaration back as the permit-list a query guard needs: every
reachable type with the fields it publishes - its attributes and its relationships, as the standard counts
them - and every relationship path a document can compound.
Deriving it is the point - a list written by hand beside the declaration permits a column the resource
stopped publishing, with both halves compiling and both suites green.
const { fields, include } = declaredDocumentShape(classes); // feeds `@zeroxsolutions/query`'s permitsA relationship another service holds
Where the related rows are not on the row this handler loaded, the relationship declares how to load them, and the serializer calls that loader when a request includes it. Nothing composes in the route.
export const trips = jsonApiResource('trips', tripAttributesSchema, {
relationships: {
place: toOne(
() => places, // the resource the loaded rows are published as
(row) => row.placeId, // beside a loader this reads the ID off the row, not the row
{ load: (ids, context: TripDocumentContext) => context.catalogue.places.byIds(ids) },
),
},
});
// the serialize call carries what the loaders may call, and the compiler asks for it
return c.json(await trips.serializeMany(rows, { links }, query, { catalogue: c.var.catalogue }));The loader is called ONCE per document, with every id the page carries for that relationship,
deduplicated and in no order, so a page of fifty trips costs one call upstream rather than fifty. It is
called only where the request's include names the relationship: a document that includes nothing makes
no call at all, and still publishes the identifier, which is the key its own row carried.
| The loader | What the document carries |
| --- | --- |
| answers a row for an id | that identifier, and the row under included |
| answers no row for an id it was given | that identifier, and nothing under included |
| throws | nothing; the failure comes out of serialize for the route to answer |
An id with no row back is how a row this caller may not see stays out of the document. Dropping the
identifier with it would tell the client the trip has no place, which the row it was read off does not
say, so including a relationship never changes the data a client reads. A loader's failure is left
alone because the loader is the route's own call on another service, and the upstream error map below
is what turns it into an answer.
The context is how a loader reaches a client bound to THIS request. A resource is declared once at
module scope and can close over nothing request-shaped, so the serialize call hands it over, and its
type is inferred from the loaders the declaration reaches: a resource that reaches one does not compile
without it, and a resource that reaches none takes no such argument. One document therefore loads under
one context type, including through a relationship whose own rows came off the row above it
(include=days.place loads every day's place in one call).
Errors
The domain raises a plain Error subclass carrying only domain data; the transport decides what it
means on the wire, from a table keyed on the error's constructor identity.
// entrypoints/http/error-map.ts - the transport's type -> wire table
import { defineErrorMap, errorEntry } from '@zeroxsolutions/response';
export const academicsErrors = defineErrorMap([
errorEntry(ClassNotFound, { status: 404, title: 'Not Found', code: 'academics.class.not_found' }),
errorEntry(ClassFull, {
status: 409,
title: 'Conflict',
code: 'academics.class.full',
meta: (error) => ({ capacity: error.capacity }), // `capacity` is typed off ClassFull
}),
]);Map membership is the discriminator between a known failure and a bug: an error type the map does not
name answers 500 carrying nothing of itself, so a forgotten entry is loud rather than a lenient 4xx. A
subclass of a mapped class is not mapped either - the lookup is identity, never instanceof.
An answer is { status, title, code }, all three required: an error object needs a title, and the
code is this transport's own word for the failure, never the domain's. errorEntry infers the class, so
an answer's optional members read what that class carries:
metarenders the error's own data a client acts on, in the member the standard gives it, rather than adetaila client would have to parse;sourcenames the request slot to blame;linkspoints at a page about the problem.
The rendered error object carries the error's message as detail.
The onError handler itself is not in this package. It is a hono ErrorHandler, and keeping it out is
what leaves this one with no framework peer: createErrorHandler and createValidationHook ship from
@zeroxsolutions/server, and the map reaches the handler through createDomainErrorResolver(map).
// entrypoints/http/app.ts - one central onError per transport
import { createDomainErrorResolver } from '@zeroxsolutions/response';
import { createErrorHandler } from '@zeroxsolutions/server';
app.onError(
createErrorHandler({
namespace: 'academics', // every code THESE handlers mint carries it, so one surface has one vocabulary
category: ['academics', 'http'],
resolve: createDomainErrorResolver(academicsErrors),
}),
);JsonApiError is for a failure whose wire answer is already decided at the throw site - a guard refusing
a caller, or a gateway translating what a fronted service returned. It takes a BASE_ERROR_CODES entry
for its { status, title } and this transport's own code beside it, and may carry detail, source,
links and meta.
throw new JsonApiError({ ...BASE_ERROR_CODES.FORBIDDEN, code: '<context>.forbidden' });
// the spread FIRST: the entry carries no `code` today, and the explicit one has to win the day it doesEvery error document renders through toErrorDocument, which stamps jsonapi: { version: '1.1' } as a
success document carries it. No producer sets an error object's id: the handler stamps the request id,
so a client quotes the id the request's log lines carry.
A fronted service's failure
A gateway never re-emits the body a service below it rendered: that code names a namespace the public
contract never declared, and that message was written for the gateway, which is the service's only caller.
So the gateway owns a second map - keyed by the upstream code, because a rendered document carries no
class and defineErrorMap resolves by constructor identity, which cannot reach one.
It is a plain object, not a Map: the key is a string the source spells literally, so an object literal
already carries the lookup and the literal keys. An entry earns its place by producing an answer no other
entry produces - two keys reaching one { status, code } is the fallback's job.
// entrypoints/http/error-map.ts - the gateway answers in ITS OWN vocabulary, and the status moves with it:
// a lifecycle the public contract never published is no conflict to the caller, it is an absence.
import {
createUpstreamErrorTranslator,
type Meta,
type UpstreamErrorAnswer,
type UpstreamErrorMap,
} from '@zeroxsolutions/response';
// 502, not 500: reaching the fallback means the gateway did not RECOGNISE what its dependency said, which is
// what 502 states - 500 would claim the gateway itself broke, and a client retries the two differently.
export const PLACE_UPSTREAM_FALLBACK = {
status: 502,
title: 'Bad Gateway',
code: 'api.upstream_failed',
} as const satisfies UpstreamErrorAnswer;
export const PLACE_UPSTREAM_ERRORS = {
'place.place.not_published': {
status: 404,
title: 'Not Found',
code: 'api.place.unavailable',
detail: 'This place is not available.',
},
'place.place.full': {
status: 409,
title: 'Conflict',
code: 'api.place.full',
// the return type is spelled: a callback in an object checked by `satisfies` alone is not a typed function
// expression to @typescript-eslint/explicit-function-return-type
meta: (upstream): Meta | undefined => {
const parsed = placeFullMetaSchema.safeParse(upstream); // what the service promises, and nothing else
return parsed.success ? parsed.data : undefined;
},
},
} satisfies UpstreamErrorMap;
export const translatePlaceUpstreamError = createUpstreamErrorTranslator(PLACE_UPSTREAM_ERRORS, PLACE_UPSTREAM_FALLBACK);A route throws what the translator returns, so the one onError renders it like every other failure:
// entrypoints/http/routes/places.ts - the route declares EVERY status it can emit, including its own 500, or
// `ok` has nothing to narrow to. `ok` is typed `U extends SuccessStatusCode ? true : false` per declared
// status ([email protected] dist/types/client/types.d.ts:108), so `!ok` IS the error arm - a `status !== 200` test
// calls a declared 201 or 204 a failure.
import type { ErrorDocument } from '@zeroxsolutions/response';
const upstream = await c.var.place.v1.places[':placeId'].$get({ param });
if (!upstream.ok) {
const body: ErrorDocument = await upstream.json();
throw translatePlaceUpstreamError(body);
}The translator reads the code off the document's FIRST error object, because createErrorHandler renders
exactly one error per failure. Of that error it keeps only what an answer's meta reads: its source
pointers name the SERVICE's request shape, not the one this caller sent, so echoing them would point a
client at members it has no way to correct.
A meta callback returns its parse, never upstream as received - the schema is what keeps a member the
service adds later from crossing. Returning undefined answers the fallback: a mapped code missing the
data its contract promises is an answer the gateway does not recognise.
A request the route's own schema rejects reaches that same onError. @hono/zod-validator (0.9.0)
answers a rejected parse with its own c.json(result, 400) unless a hook answers or throws first, so
createValidationHook is passed to the constructor, and it throws the rejection for createErrorHandler
to render.
// entrypoints/http/app.ts - the same shape for a request the route's own schema rejects
import { createValidationHook } from '@zeroxsolutions/server';
const app = new OpenAPIHono<AppEnvironment>({
defaultHook: createValidationHook<AppEnvironment>({ namespace: 'academics' }),
});It reads the status off the validation target - a json or form target is 422, a
param/query/header/cookie one is 400 - and renders every offending member as its own error object,
not just the first. The target is the validator's own fact rather than a product's choice, so nothing here
is configurable: an option would let two transports answer one failure two ways, which is the uniformity
this package exists to hold.
It locates each issue by the slot it was read from: a body member as source.pointer, a query member as
source.parameter, a header as source.header. A path segment gets meta instead - the spec defines
those three members and none of them means a path segment, so borrowing one would say something false.
Exports
One root, no subpath, and src/index.ts lists every name it publishes:
okResponse/listResponse/errorResponse, theJsonApiResponsethey return (exported so a consumer's declaration emit can name it), anderrorDocumentSchema, whicherrorResponsereturns inside it;- the resource declaration -
jsonApiResource,toOne/toMany,declaredDocumentShape- and the types their signatures name; - the error document's types,
JsonApiError,BASE_ERROR_CODES,toErrorObject/toErrorDocument; defineErrorMap/errorEntry/createDomainErrorResolverandcreateUpstreamErrorTranslator, with the answer, entry and map types they take;JSON_API_MEDIA_TYPE.
The document and resource-object schemas, the link and identifier primitives and the serializer
functions are what jsonApiResource is built from, and the root does not publish them: a resource's own
document, collectionDocument, serialize and serializeMany are the one way to declare and render a
document.
zod is the only peer. hono left with the plain spelling: nothing here needs its
ContentfulStatusCode, because a JSON:API status is carried by the error object.
The content-negotiation DECISION for the JSON:API media type and the client-side document deserializer
are not here - they are request- and client-side, and live in @zeroxsolutions/jsonapi. Everything
that is a Hono handler - the onError, the defaultHook, the negotiation middleware - is
@zeroxsolutions/server.
