@zeroxsolutions/jsonapi
v0.11.1
Published
The JSON:API halves that are not the response body: v1.1 content negotiation (media type, 415/406/400) and the client-side document deserializer. The response document, its serializers and its error document live in @zeroxsolutions/response.
Readme
@zeroxsolutions/jsonapi
The JSON:API v1.1 halves that are not a response body: content negotiation on the request, and document deserialization on the client.
Everything a server puts in a response - the document schemas, the serializers, the errors[]
document - is @zeroxsolutions/response, and the central onError that renders it is
@zeroxsolutions/server. The query families are @zeroxsolutions/query, which speaks this
standard's spelling (page[limit], a comma-separated sort, the labelled filter grammar,
fields[TYPE] and include).
Three packages for one standard, split by which side of the exchange each serves: what the client asks for, what the server answers, and what the caller may address.
Install
pnpm add @zeroxsolutions/jsonapi @zeroxsolutions/responseNo peer dep, and no framework: every export here is a plain function over strings and documents. The
Hono middleware that mounts the negotiation decision is @zeroxsolutions/server, which is
what keeps hono out of the package an SPA also loads.
Entry points
| Import | Use in | What it holds |
| --- | --- | --- |
| @zeroxsolutions/jsonapi | services, gateway | negotiateJsonApi - the whole decision, from two header strings to the JsonApiError to reject with or null: 415 unless a write's media type is JSON:API carrying at most ext/profile, 406 unless some Accept member names that same type or a wildcard range |
| @zeroxsolutions/jsonapi/client | SPA, external consumers | deserializeDocument (jsona), readResource / readCollection over it - the same flattening, typed off the route's own document - readRelated for what an include resolved, which no document type can name; and ApiError, the non-2xx a caller throws once a response has come back |
A transport mounts the decision rather than calling it: jsonApiContentNegotiation is the Hono adapter
over it, and it lives with the other framework wiring. negotiateJsonApi itself takes the surface's
namespace, because the refusal it returns is an error object the surface publishes.
// one middleware, before the routes - never a per-handler check
import { jsonApiContentNegotiation } from '@zeroxsolutions/server';
app.use('*', jsonApiContentNegotiation({ namespace: 'api' }));A client flattens the envelope ONCE, at the layer that fetches, so nothing downstream reads
data[i].attributes. ResourceOf / CollectionOf derive that flattened shape from the route's own
response type, which is what keeps the model off a hand-written interface:
// the query layer - the only place the document is spelled
type SubjectList = CollectionOf<InferResponseType<(typeof api.v1.orgs)[':orgId']['subjects']['$get'], 200>>;
if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => null));
const { data, meta } = readCollection(await res.json());Only data is flattened. links and meta keep the names the standard gives them, and meta stays
free-form - the standard puts no member in it, so a row count there is one surface's own convention.
Negotiation raises JsonApiError from @zeroxsolutions/response, so the refusal renders
through the same central onError as every other failure rather than a shape of its own. It names the
offending header in source and, in detail, what the surface reads or answers in - never the value the
caller sent, which teaches the caller nothing it did not already have.
Two decisions the headers alone do not settle:
Content-Typeis read only where the request carries a body. RFC 9110 gives a GET payload no defined semantics, so a read route has no representation to describe; reading the header there would turn a client-wide default ofapplication/jsoninto a 415 on every collection.- An empty
Acceptis refused. RFC 9110 12.5.1 gives an absent header any media type, but an empty field-value is a zero-member list, and no member of it names a range this surface can answer. A weight goes unread, so a wildcard weighted to zero - a client stating that nothing at all is acceptable - is let through; every other unsatisfiable header is caught by the range alone.
