@thomasfosterau/effect-jsonapi
v0.21.0
Published
Type-safe, spec-compliant JSON:API v1.1 on Effect's HttpApi.
Maintainers
Readme
@thomasfosterau/effect-jsonapi, a JSON:API package for Effect
Type-safe, spec-compliant JSON:API v1.1 implementation built on top of Effect’s HttpApi, Schema, etc.
Disclaimer: This repository and package have been created using Claude Code. The package’s API and functionality is in flux, so expect churn and breaking changes.
Install
pnpm add @thomasfosterau/effect-jsonapi effecteffect is a peer dependency (^4.0.0). The one runtime dependency, @thomasfosterau/effect-ext, is installed with the package. Only its pure Schema subpaths are used (Bounded, Ids, Issues, TriState), and they peer the same effect. Node.js 22 or newer is required.
Overview
@thomasfosterau/effect-jsonapi makes it trivial to comply with the JSON:API spec, invariantly — compliance is a property of the construction, not of developer discipline:
- Define each resource once; identifiers, create/update payloads, documents, query parameters and endpoints are all derived from that single definition.
- Declare each error once; you get a tagged Effect error whose wire encoding is a spec-compliant JSON:API error document with the right HTTP status.
- Endpoints bake in the conventions: the
application/vnd.api+jsonmedia type, conventional paths, spec status codes (200/201/204), typedinclude/fields[TYPE]/sort/page[*]/filter[*]query parameters, and content-negotiation rules (406/415). - Everything is a plain Effect
Schema/HttpApiEndpoint/HttpApiGroup, so it composes withHttpApi,HttpApiBuilder,HttpApiClient,HttpApiTestandOpenApiuntouched.
Quick start
A complete read API — resource, error, endpoints, handlers, server — in one file. Each piece is expanded in the sections below.
import { Effect, Layer, Schema } from "effect";
import { HttpApi, HttpApiBuilder } from "effect/http-api";
import {
ApiError,
Endpoint,
Group,
Handlers,
Middleware,
Query,
Resource,
} from "@thomasfosterau/effect-jsonapi";
// 1. Define a resource once — identifiers, payloads, documents and query
// parameters are all derived from this single definition.
const Article = Resource.make("articles", {
attributes: { title: Schema.NonEmptyString, body: Schema.String },
});
// 2. Declare an error once — its wire encoding *is* a JSON:API error document.
class ArticleNotFound extends ApiError.make<ArticleNotFound>()("ArticleNotFound", {
status: 404,
fields: { id: Schema.String },
detail: (e) => `Article ${e.id} not found`,
}) {}
// 3. Build endpoints with the JSON:API conventions baked in.
const articles = Group.make(
Article,
Endpoint.get(Article, { include: true, errors: [ArticleNotFound] }),
Endpoint.list(Article, { page: Query.Page.Offset }),
);
const Api = HttpApi.make("blog").add(articles);
// 4. Implement handlers — inputs are typed and validated, documents are checked
// for the compound-document rules. (`loadArticle` / `listArticles` are your
// own data access returning `Effect`s.)
const ArticlesLive = HttpApiBuilder.group(Api, "articles", (handlers) =>
handlers
.handle("get", ({ params }) =>
loadArticle(params.id).pipe(Effect.map((article) => Handlers.data(article))),
)
.handle("list", ({ query }) =>
listArticles(query).pipe(Effect.map((items) => Handlers.collection(items))),
),
);
// 5. Wire it up — the api won't build unless the JSON:API middleware is
// provided, so spec compliance can't be forgotten.
const ApiLive = HttpApiBuilder.layer(Api).pipe(
Layer.provide(ArticlesLive),
Layer.provide(Middleware.layer),
);The schema-only entry point
The package root exports everything and is the import you want by default. It reaches effect/http-api, because the error classes, endpoints, groups, media type combinators and middleware are built on Effect's HttpApi.
Consumers that only want JSON:API's wire types without the server — a client decoding documents, a loader mapping them into something else, a CLI or code generator producing them — can import the same modules from a schema-only subpath that never reaches Effect's HTTP surface. Its dependencies are effect's core and effect-ext's Schema subpaths, which import only effect's core:
import { Document, Resource } from "@thomasfosterau/effect-jsonapi/schema";/schema re-exports Document, Filter, FlatQuery, Include, Relationship, Resource, Sort and MEDIA_TYPE — the modules whose import graph reaches only effect's core and those effect-ext subpaths. Every symbol is the same object under both imports. Which modules are in the tier, and why Query (whose Query.validateIncludePaths fails with HttpApi-bound error classes) and Client are not: see the /schema module doc.
Module subpaths
Every module is also published as a subpath of its own, as effect publishes effect/Schema: @thomasfosterau/effect-jsonapi/Resource, …/Endpoint, …/Group, and so on for each namespace the root exports. A subpath is the same module as the root's namespace, not a copy, so Resource.make and the error classes are identical whichever way they are imported, and the schema-tier modules (Document, Filter, FlatQuery, Include, Relationship, Resource, Sort) keep the same dependencies under their subpaths too: effect's core and effect-ext's Schema subpaths.
import * as Endpoint from "@thomasfosterau/effect-jsonapi/Endpoint";
import * as Resource from "@thomasfosterau/effect-jsonapi/Resource";Each subpath ships its module's own declaration file, which is what lets declaration emit name a type the inferred type of an exported value mentions — see Declaration emit. The root import stays the default; nothing needs to move.
API
Every module is a namespace export of the package root (import { Resource } from "@thomasfosterau/effect-jsonapi"); the sections below walk through them in the order you would build a server.
| Namespace | Purpose |
| --- | --- |
| Resource | Resource definitions (Resource.make) — the single source of truth everything else derives from. |
| Relationship | Relationship constructors (Relationship.one, Relationship.many, …). |
| Document | Document-level schemas: links, meta, error objects, document shapes. |
| ApiError | One-shot error declarations (ApiError.make) and the standard errors every endpoint supports. |
| Endpoint | Endpoint constructors (Endpoint.get, Endpoint.list, …) with the JSON:API conventions baked in. |
| Group | Resource groups (Group.make, Group.flat). |
| Query | Typed query parameters (Query.schema, Query.Page, Query.canonical, …). |
| Filter | The filter query family: operators, per-attribute declaration, the filter AST. |
| Sort | The sort query family's per-attribute declaration (Sort.able). |
| Include | Declared ?include= surfaces (Include.make, Include.nested). |
| FlatQuery | The flat list-input grammar (FlatQuery.pagination, FlatQuery.listInput, …). |
| Handlers | Server-side document builders (Handlers.data, Handlers.collection, …). |
| Assemble | The server-side document assembler over Handlers, with capability hooks. |
| ResourceHandlers | The server handler factory, counterpart of Group.flat. |
| ServerErrors | The server's error seam: the domain error catalogue and ServerErrors.respond. |
| Registry | The handler registry and router (Registry.webHandler). |
| Negotiation | The representation verdict for a host serving JSON:API on its page URLs. |
| Middleware | Protocol middleware (Middleware.layer): content negotiation, schema errors and the create's Location header. |
| MediaType | Media type annotation combinators for hand-rolled endpoints. |
| Atomic | The atomic operations extension. |
| Lid | Local id (lid) resolution (Lid.make). |
| Idempotency | Idempotent writes — the Idempotency-Key profile. |
| Client | Client-side helpers: negotiation, error decoding, include narrowing and hydration, page cursors. |
| Conformance | The conformance kit: an executable specification a server is driven through. |
| MEDIA_TYPE | The JSON:API media type, "application/vnd.api+json". |
Guide: Adopting the 0.14 surfaces in an existing api.
1. Resources — the single source of truth
const Person = Resource.make("people", {
attributes: {
firstName: Schema.NonEmptyString,
lastName: Schema.NonEmptyString,
},
});
const Tag = Resource.make("tags", {
attributes: { name: Schema.NonEmptyString },
});
const Comment = Resource.make("comments", {
attributes: { body: Schema.NonEmptyString, postedAt: Schema.DateFromString },
relationships: {
author: Relationship.one(() => Person), // a reference, not a string — typos don't compile
},
});
const Article = Resource.make("articles", {
attributes: {
title: Schema.NonEmptyString,
body: Schema.String,
createdAt: Schema.DateFromString, // ISO string on the wire, Date in your code
},
relationships: {
author: Relationship.one(() => Person), // required to-one
editor: Relationship.optional(() => Person), // nullable to-one
tags: Relationship.many(() => Tag), // bounded to-many, inlined
comments: Relationship.paginated(() => Comment), // unbounded to-many, linked
},
});A resource's attributes and relationships share one namespace with each other and with type and id (§Fields), so Resource.make and Resource.extend refuse an attribute or relationship named type or id, and a name declared as both an attribute and a relationship (including one an extension introduces against its base): a type error at the offending key, and a throw naming it when the definition is built (#503).
Relationship kinds
Each relationship declares its cardinality and how its data travels — inline as resource identifiers, or behind a link to a paginated endpoint:
| Constructor | Cardinality | Wire shape of the relationship object | In ?include= | In create payload |
| --- | --- | --- | --- | --- |
| Relationship.one | to-one | { data: identifier } — never null | ✓ | required |
| Relationship.optional | to-one | { data: identifier \| null } | ✓ | optional |
| Relationship.many | to-many | { data: identifier[] } | ✓ | optional |
| Relationship.paginated | to-many | { links: { related, self? } } — no data | ✗ (only on its own relationship endpoint, #483) | ✗ (use relationship endpoints) |
one / optional / many carry inline linkage: clients see the related identifiers right inside the parent resource and can pull the full resources into a compound document with ?include=.
paginated is for unbounded collections (an article's comments, a user's repositories): the relationship object carries only a required related link pointing at a paginated collection endpoint (see Relationship & related endpoints).
Paging is only well defined over a stable total order — first / prev / next / last denote a sequence of pages, and a cursor is a position in an order — so a paginated relationship can declare the canonical order its related collection is paged in, in the same { field, direction } terms ?sort= decodes into:
const Article = Resource.make("articles", {
attributes: { title: Schema.NonEmptyString },
relationships: {
comments: Relationship.paginated(() => Comment, {
// newest first, `id` breaking ties so the order — and any cursor into it — is total
order: [
{ field: "postedAt", direction: "desc" },
{ field: "id", direction: "asc" },
],
}),
},
});
Resource.relationships(Article).comments.order; // the declared termsEach term names an attribute of the related resource, or its id; anything else is a compile error, and an empty or repeating order is refused at definition time.
A to-one relationship is stored as a foreign key on the row that declares it, and every derivation from a resource definition into storage (a table, a sync engine's node graph) needs that key's name. one and optional take it as key, so it is declared once, beside the relationship:
const Article = Resource.make("articles", {
attributes: { title: Schema.NonEmptyString },
relationships: {
author: Relationship.one(() => Person, { key: "authorId" }),
editor: Relationship.optional(() => Person, { key: "editorId", filter: ["eq", "isnull"] }),
},
});
Resource.relationships(Article).author.key; // "authorId" (`undefined` when none is declared)The key is a storage fact, not a wire one: the document, the query grammar and the endpoints are unchanged, and on the wire author stays linkage, never an attribute. It must be non-empty, must not be an attribute name of the resource, and must not repeat across its to-one relationships; Resource.make (and Resource.extend) refuse a violation at definition time. The paginated order and the to-one key are the two storage-facing declarations a relationship carries.
Every relationship object may carry a meta member, which defaults to an open record. Every constructor takes a meta schema that types it instead. The schema then flows into the resource schema, its documents, Document.wire and the flat groups, and so into the endpoints' OpenAPI and their typed clients. A common case is a to-many arm whose linkage is capped per parent and so needs its true count:
const Contest = Resource.make("contests", {
attributes: { name: Schema.String },
relationships: {
ballots: Relationship.many(() => Ballot, {
meta: Schema.Struct({ totalCount: Schema.Number }),
}),
},
});
// relationships.ballots.meta: { readonly totalCount: number } | undefinedBecause the declaration lives in the resource, what Assemble and ResourceHandlers build over the resource's own type already fits the documents the endpoints declare. For a family whose every to-many arm carries the same meta, Document.wire(schema, { toManyMeta }) and Group.flat / Group.flatReadOnly({ …, toManyMeta }) narrow it at the call site instead. That covers every many and paginated arm, in the primary data and in included, and leaves to-one arms alone.
Everything below is derived — never assembled by hand:
| Derived | What it is |
| --- | --- |
| Article | the resource object Schema.Struct itself (type/id/attributes/…) |
| Article.Id | branded id schema — Article.Id values can't be mixed with Person.Id |
| Article.identifier | the { type: "articles", id } resource-identifier schema |
| Article.ref("1") | a typed identifier value — handy for relationship linkage |
| Article.localIdentifier | the { type: "articles", lid } schema — identifies a resource being created (no server id yet) |
| Article.lidRef("a1") | a typed local-identifier value — the lid counterpart of ref |
| Article.createPayload | { data: { type, lid?, attributes, relationships } } — no id (a client-generated data.id is rejected at /data/id, which Endpoint.create answers with 403, #450); one relationships required, paginated relationships and non-create attribute projections excluded |
| Article.updatePayload | { data: { type, id, attributes? (partial), relationships? } } — attributes are tri-state; paginated relationships and non-update attribute projections excluded |
| Article.createInput / Article.updateInput | flat "command-style" request schemas — attributes (and, for update, the id) without the JSON:API envelope |
| Article.document() | single-resource document with Article as primary data (non-null); included union derived from the non-paginated relationships |
| Article.collection() | collection document (strict array data) |
| typeof Article.Type | the decoded TypeScript type |
Undeclared body members. A write body's attributes or relationships member rejects every member it doesn't declare, whether it declares fields or none (#292). That covers a member excluded from that write too (create: false, update: false, paginated), and a resource with no attributes or no relationships. It applies to createPayload, updatePayload, the flat createInput / updateInput and the atomic add / update operations. Within a member, each of the first 12 undeclared members is its own 400 error, with detail Expected no excess property and a source.pointer naming the member (/data/attributes/isAdmin, /isAdmin in a flat payload), and an own __proto__ or constructor is rejected like any other name. If there are more, one further error at the member itself says how many (detail 9988 more undeclared members not listed, source.pointer /data/attributes; a flat payload's has no source, since its member is the whole body). So however many keys a request sends, a member gets at most 13 error objects, 12 plus the summary; an atomic operation's 400 also carries one error for each other resource the operation could have matched. That bounds the number of error objects, not the bytes: each pointer echoes a key name the client sent, so a long name makes a longer error (#308). An empty {} is accepted. A value that isn't an object (a string, a number, a boolean, an array, null) is a 400 Expected object at the member itself (/data/attributes). So a member the resource doesn't declare never reaches the handler. The members JSON:API allows around them are untouched: a document's meta / jsonapi / links, a resource object's meta / links, a relationship's meta / links, a resource identifier's meta and an operation's meta.
The check lives in the schemas, so it applies wherever they decode, not only over HTTP: a Standard Schema validator (Schema.toStandardSchemaV1(Article.createInput)), a remote function or RPC procedure that decodes the flat inputs, and Client.decodeInput all fail on an undeclared member. Encoding through these schemas, which is what Client does with a write body, rejects an undeclared member the same way, before any request is sent. What a rejection reports:
- One member per request.
HttpApidecodes a request body witherrors: "first", so it stops at the first member that fails: a body with undeclared members in bothattributesandrelationshipsgets errors for those inattributesonly. Within that member, the undeclared members up to the cap are reported, and the summary for the rest, whatevererrorssays. - The cap is per resource.
Resource.make(type, { …, maxUndeclaredMemberErrors: 50 })sets how many undeclared members each of that resource's write body members reports at their own pointers: a positive integer, orInfinityto report every one with no summary (anything else throws). It defaults to12,Resource.extendinherits it (and takes the same option, validated under its own name), aResource.familyanchored on a base carries the base's, andArticle.maxUndeclaredMemberErrorsreads it back. It is fixed when the definition is made, so it applies to every write body member above, when decoding and when encoding (Client). There is no process-wide setting: to change it everywhere, pass it to eachResource.make. - A failing declared field first. The undeclared members are checked once the member's declared fields have parsed. So when a declared field fails (a wrong type, a missing required attribute), only that field is reported, not the undeclared members beside it, even with
errors: "all". Schema.isandmakedrop it. For a member that declares fields,Schema.is(Article.createInput)answerstruefor a value with an undeclared member, andArticle.createInput.make(...)strips one: both check the type side, where the struct drops the member before the check sees it. (A member that declares none keeps it there, soSchema.isanswersfalseandmakethrows.) Validate untrusted input with a decode.
The schemas are still Schema.Structs: .fields (Object.keys(Article.createInput.fields)) and the OpenAPI request bodies, which list each member's properties with additionalProperties: false, are unchanged. A struct you derive from one with mapFields (Struct.pick, Struct.omit, …) is a plain Schema.Struct again, which drops undeclared members.
Documents are not limited to one resource type — see Heterogeneous endpoints for polymorphic collections.
Mutually recursive resources
Relationship references are thunks, so the graph they describe may contain cycles — articles linking to their comments and each comment linking back to its article is ordinary JSON:API, and the runtime has always resolved it. What such a pair needs is an explicit type annotation: Resource.make's return type is inferred, so without one each definition's type would depend on the other's and TypeScript refuses both (TS7022).
The annotation is the same escape Effect's recursive schemas use, and this is all of it — one interface per resource in the cycle, naming the relationship record. Hoist the attributes to a const so no attribute schema is restated, and give the value the interface of the same name:
import { Schema } from "effect";
import { Relationship, Resource } from "@thomasfosterau/effect-jsonapi";
const articleAttributes = { title: Schema.NonEmptyString };
const commentAttributes = { body: Schema.NonEmptyString };
interface Article extends Resource.Resource<
"articles",
typeof articleAttributes,
{
readonly comments: Relationship.many<Comment>;
}
> {}
interface Comment extends Resource.Resource<
"comments",
typeof commentAttributes,
{
readonly article: Relationship.one<Article>;
}
> {}
const Article: Article = Resource.make("articles", {
attributes: articleAttributes,
relationships: { comments: Relationship.many(() => Comment) },
});
const Comment: Comment = Resource.make("comments", {
attributes: commentAttributes,
relationships: { article: Relationship.one(() => Article) },
});The lowercase Relationship.one / optional / many / paginated types used in the annotation mirror the constructors of the same name, defaults included (one and optional default to not filterable, paginated to no declared order, exactly as the constructors do). A resource definition is invariant in its relationship record, so the annotation has to name the type the constructor produced; these are that type. A relationship that declares a filter or an order names it too, as the const tuple the constructor infers: Relationship.one<Article, readonly ["eq", "in"]>; a to-one storage key is the third parameter: Relationship.one<Article, false, "articleId">.
Self-references work the same way (parent: Relationship.optional<Category> on Category), and all four relationship kinds may take part in a cycle.
Everything derived from the definitions keeps working, and a cycle cannot make any of it infinite:
| Derived | Behaviour in a cycle |
| --- | --- |
| Article.document() | the included union is one hop wide — the targets of Article's own linkable relationships, not the transitive closure |
| ?include= paths | bounded by the derivation depth (2 by default, includePaths(R, n) at runtime), not by the graph |
| createPayload / updatePayload | unchanged on both sides — a required one across the cycle is still required |
| Endpoint.get / list / Group.resource | derive their paths and documents from the resource's own type tag |
Custom id schemas
By default a resource's id is Resource.Id(type) — a string branded by resource type. Pass your own id schema (anything whose encoded side stays a string, so the wire is spec-compliant) to brand ids your way — e.g. an id shared across a hierarchy of types, or one decoded to a richer value:
const PersonId = Schema.String.pipe(Schema.brand("PersonId"));
const Person = Resource.make("people", {
id: PersonId, // default = Resource.Id("people")
attributes: { name: Schema.NonEmptyString },
});
Person.Id; // PersonId
// Person.identifier.Type → { type: "people"; id: PersonId }
// Person.updatePayload.Type → data.id is PersonId
// Document.DataDocument(Person).Type.data.id is PersonIdThe injected id flows through identifier, updatePayload, ref, createInput/updateInput and the document schemas. Omit id and nothing changes — existing definitions keep the auto-branded id. Resource.Identifier(type, id?) accepts a custom id too, for standalone identifier schemas.
Subtype ids via extend. To make a subtype's id a subtype of its base's id — a PersonId that is an AgentId is a NodeId — pass inheritId: true to extend. The child's id brands the base's id schema, so it accumulates the base's brand(s) and is assignable wherever the base id is expected (transitively through a chain); the reverse is rejected. Effect's brands are intersectional, so this is just brand accumulation:
const Account = Resource.make("accounts", { attributes: { email: Schema.NonEmptyString } });
const Manager = Resource.extend(Account, "managers", { inheritId: true });
const managerId = Manager.Id.make("1");
const asAccount: typeof Account.Id.Type = managerId; // ✓ a manager id IS an account id
// @ts-expect-error an account id is not a manager id
const asManager: typeof Manager.Id.Type = Account.Id.make("2");inheritId defaults to false — by default an extended resource gets a fresh, independent brand.
Update payloads: set / unset / leave unchanged
A PATCH must distinguish three intents per attribute. updatePayload models them with Schema.optional (= optionalKey(UndefinedOr(...))), so each attribute is genuinely tri-state:
| Wire / value | Meaning |
| -------------- | ----------------- |
| key absent | leave unchanged |
| key = null† | unset (clear) |
| key = a value | set to that value |
† null requires a nullable attribute (Schema.NullOr(...)). In-process — and over codec transports that preserve it, like RPC / remote functions — an explicit undefined is also accepted as "unset". Over a JSON HTTP body, JSON can't carry undefined, so null is the wire clear signal and an absent key means "leave unchanged".
const Person = Resource.make("people", {
attributes: { name: Schema.NonEmptyString, bio: Schema.NullOr(Schema.String) },
});
// Person.updatePayload accepts, for `bio`: a string (set), null (clear), or omit (leave unchanged).
// Its type is `{ name?: string; bio?: string | null | undefined }` under `data.attributes`.Per-attribute annotations
Stamp metadata onto an attribute with Effect's schema.annotate({ ... }), and read it back per attribute with Resource.attributeAnnotations — handy for carrying, say, a database column name alongside the attribute schema:
const Person = Resource.make("people", {
attributes: { bio: Schema.NullOr(Schema.String).annotate({ dbColumn: "biography" }) },
});
Resource.attributeAnnotations(Person).bio?.dbColumn; // "biography"Resource-level metadata & introspection
A resource definition is also the natural single source of truth for things outside the HTTP layer — database columns, form descriptors, a sync engine's node definitions. Two accessors make deriving from one a supported thing to do.
Metadata rides the resource. A definition is a Schema.Struct with its derived members assigned onto it, so Effect's own resource.annotate({ ... }) rebuilds the schema and returns a plain struct with type, document(), createPayload and the rest gone — no longer a resource, and no longer a valid relationship target. Resource.annotate is the seam that keeps it one:
const TableName = "acme/table"; // namespace your keys
const Person = Resource.annotate(
Resource.make("people", { attributes: { name: Schema.NonEmptyString } }),
{
[TableName]: "people",
},
);
Person.type; // "people" — still a complete resource
Resource.annotations(Person)[TableName]; // "people"
// still a relationship target, resolved lazily and by identity:
const Article = Resource.make("articles", {
attributes: { title: Schema.NonEmptyString },
relationships: { author: Relationship.one(() => Person) },
});Annotations are inherited by Resource.extend (the child's own keys winning), and can be declared up front with Resource.make's annotations option — the better spelling when other definitions already point at the resource, since like Schema.annotate this returns a new definition rather than mutating the one given.
Attribute shape is readable. Resource.attributeDescriptors reports what the declaration already knows about each attribute, in declaration order, as plain data — no reaching into resource.fields.attributes.ast, no widening the definition through unknown, no running a decoder against sentinel values:
const Article = Resource.make("articles", {
attributes: {
title: Schema.NonEmptyString,
summary: Resource.attribute(Schema.NullOr(Schema.String), { create: "optional" }),
createdAt: Resource.readOnlyAttribute(Schema.DateFromString),
draftBody: Resource.attribute(Schema.String, { resource: false, update: false }),
},
});
Resource.attributeDescriptors(Article);
// [{ key: "title", schema, resource: "required", create: "required", update: "optional",
// clearable: false, nullable: false, readOnly: false, annotations },
// { key: "summary", …, create: "optional", nullable: true, clearable: true },
// { key: "createdAt", …, create: false, update: false, readOnly: true },
// { key: "draftBody", …, resource: false }] // input-only: declared, never on the resource objectWhat belongs in an annotation, and what doesn't. Annotations are for vocabulary JSON:API has no opinion about — a storage table or column name, a sync-engine node kind, an admin-UI label — so that foreign vocabulary stays out of this package. Metadata that is JSON:API's business gets a declaration instead, because the package has to act on it: filterability is Filter.able, sortability is Sort.able, and a paginated relationship's canonical order is Relationship.paginated(ref, { order }) — without a stable order the pagination links that relationship advertises are unsound, which is a defect in the document itself. A storage column name, by contrast, has no wire representation at all: two servers that disagree about it serve byte-identical JSON:API documents. That is the test — if changing it changes nothing a client can observe, it is an annotation. The one exception is a to-one relationship's storage key: every derivation into storage needs it and would otherwise re-declare it beside the resource, where the two declarations could drift, so it is a first-class slot on Relationship.one / optional rather than an annotation.
Read-only & per-attribute projections
By default every attribute is read-write: it appears in the resource object, the documents, and all four write projections (createPayload / updatePayload / createInput / updateInput). Some attributes need a different shape — server-set timestamps that are never client input, fields settable only at create time, optional or clearable values. Resource.attribute(schema, options) declares that shape per attribute, and Resource.readOnlyAttribute(schema) is the common shorthand for "server-set" ({ create: false, update: false }).
const Article = Resource.make("articles", {
attributes: {
title: Schema.NonEmptyString, // plain: read-write everywhere
// server-set: on the resource + in documents, never a write input
createdAt: Resource.readOnlyAttribute(Schema.Date),
// settable only at create, never updatable
slug: Resource.attribute(Schema.String, { update: false }),
// optional at create, clearable (nullable) on update
summary: Resource.attribute(Schema.NullOr(Schema.String), { create: "optional" }),
},
});
// Article.Type.attributes → { title, createdAt, slug, summary }
// Article.createPayload … data.attributes → { title, slug, summary? } (no createdAt)
// Article.updatePayload … data.attributes → { title?, summary? } (no createdAt, no slug)An optional create attribute (create: "optional", or a bare Schema.optionalKey(...) attribute) projects as Schema.optional, exactly as every update attribute does: the key may be absent, or present with a value or an explicit undefined. So a caller that builds { summary: input.summary?.trim() || undefined } type-checks against createInput and updateInput alike (also under exactOptionalPropertyTypes). On a JSON wire an explicit undefined and an absent key are the same thing — the attribute is not supplied.
The descriptor options (all optional; the defaults reproduce a plain Schema attribute):
| option | values | controls |
| --- | --- | --- |
| resource | true (default) · "optional" · false | presence in the resource object schema + documents |
| create | "required" (default) · "optional" · false | presence in createPayload / createInput |
| update | "optional" (default) · false | presence in updatePayload / updateInput (tri-state when present) |
| clearable | boolean (default: nullable?) | whether the update projection additionally accepts null to clear |
The descriptor rides on the attribute's schema value, so it flows through attributeKeys, attributeAnnotations, sparse fields and include, is carried through Resource.extend, and is respected by the atomic operations (add mirrors create, update mirrors update) — all consistently with the create/update payloads.
Write-only (input-only) attributes
resource: false declares an attribute that is accepted as input but never on the resource object — an upload's binary, a password, a one-time token. It is absent from the resource Struct, the documents, attributeKeys, sparse fields, filterable and sortable, yet still projected into createPayload / createInput and updatePayload / updateInput per its create / update settings (and into the atomic add / update operations), so a write-only field stays declared in one place:
const FileSchema = Schema.Uint8Array; // or any `Schema.declare`d binary
const Upload = Resource.make("uploads", {
attributes: {
fileName: Schema.NonEmptyString,
contentType: Schema.NonEmptyString,
// create-only binary: in `createInput`, never on the resource object
file: Resource.attribute(FileSchema, { resource: false, update: false }),
},
});
// Upload.Type.attributes → { fileName, contentType } (no file)
// Upload.createInput.Type → { fileName, contentType, file }
// Upload.updateInput.Type → { id, fileName?, contentType? } (no file — update: false)
Resource.attributeKeys(Upload); // ["fileName", "contentType"]An input-only attribute must keep at least one write projection (resource: false with both create: false and update: false declares nothing) and cannot carry a filter / sort declaration (it is not on the resource object to filter or sort by); Resource.make throws on either, naming the attribute. Resource.attributes(R) returns the resource-object map, without input-only attributes; Resource.declaredAttributes(R) returns the map as declared, and that is what Resource.extend inherits — so a subtype of Upload accepts file at create too.
The common shapes are option presets for a single attribute(...) call, so you can name your own (as const keeps the literals attribute reads at the type level):
const computed = { create: false, update: false } as const;
const createOnly = { update: false } as const;
const defaulted = { create: "optional" } as const;
const optional = { resource: "optional", create: "optional" } as const;
const inputOnly = { resource: false, update: false } as const;
const Upload = Resource.make("uploads", {
attributes: {
checksum: Resource.attribute(Schema.String, computed),
fileName: Resource.attribute(Schema.String, createOnly),
},
});Flat (command-style) payloads
Alongside the JSON:API { data: { type, attributes } } payloads, every resource exposes flat request schemas — the attributes alone, no envelope — for transports (RPC, remote functions) that carry a flat shape:
Person.createInput; // { name, bio } — flat create attributes
Person.updateInput; // { id, name?, bio? } — id plus the same tri-state attributesThey are closed like the enveloped payloads' members: decoding one, through any transport, fails on a key the resource doesn't declare for that write (see Undeclared body members under Resources).
These are also what the payload override on Endpoint.create / Endpoint.update takes, when the HTTP write contract itself is a flat command input rather than a JSON:API document — see Overriding the write payload.
Nullable primary data
Document.DataDocument is a pure envelope: its data member is exactly the schema you pass, so nullability is your compositional choice — not something the constructor decides for you. JSON:API only permits data: null for a single-resource request whose URL might correspond to a resource but currently doesn't; fetch-existing / create / update always carry the resource (a missing one is a 404, never 200 { data: null }).
Document.DataDocument(Article); // data: Article
Document.DataDocument(Schema.NullOr(Article)); // data: Article | null
Document.DataDocument(Article.nullable()); // data: Option<Article>, ⇆ null on the wireArticle.nullable() is Schema.OptionFromNullOr(Article) — the spec-clean nullable codec (None ⇆ null). Avoid effect's structural Schema.Option ({ _tag, value }): it serialises a non-conformant body, and DataDocument can't tell the two apart. Article.document() and Endpoint.get / create / update use the non-null form; Endpoint.related for a to-one relationship keeps the nullable form (data: target | null) for the empty-linkage case.
Reusing & extending resources
When several resources share a set of attributes or relationships, define them once and reuse them. Resource.attributes / Resource.relationships extract a resource's field map and descriptor record so you can spread them into another definition:
const Profile = Resource.make("profiles", {
attributes: { ...Resource.attributes(Person), bio: Schema.String },
});Resource.extend does the same wholesale — a subtype that inherits the base's attributes and relationships, adding (or overriding) its own. JSON:API has no native subtyping, so the result is a distinct resource type: its own type tag and branded id, with payloads and documents derived afresh. meta is inherited unless overridden.
const Account = Resource.make("accounts", {
attributes: { email: Schema.NonEmptyString, createdAt: Schema.DateFromString },
relationships: { organisation: Relationship.one(() => Organisation) },
});
// `admins` inherits email, createdAt and organisation, adding `permissions`.
const Admin = Resource.extend(Account, "admins", {
attributes: { permissions: Schema.Array(Schema.String) },
});
Resource.attributeKeys(Admin); // ["email", "createdAt", "permissions"]extend accepts a custom id schema exactly as Resource.make does, for a consumer that keys its subtypes with its own brand catalogue (branding at the row-mapping seam via AdminId.make(row.id)). The subtype's id is then that schema and nothing else — no package brand is added — and it flows through identifier, ref, the payloads and the documents as usual. id and inheritId: true are contradictory, so passing both is a type error and throws at definition time.
const AdminId = Schema.String.pipe(Schema.brand("AdminId"));
const Admin = Resource.extend(Account, "admins", { id: AdminId });
Admin.Id.make("1"); // string & Brand<"AdminId">
Admin.ref("1"); // { type: "admins", id: string & Brand<"AdminId"> }Polymorphic families (heterogeneous supertypes)
Resource.family defines a supertype over a set of member resources — for a heterogeneous "any node" endpoint, a document whose data is the union of subtypes, or a relationship that targets "any member". A family is the discriminated union over its members (decoded by the type tag) and a first-class resource-like value, so it can be used as primary data, as a compound included member, and as a relationship target.
const Node = Resource.make("nodes", { attributes: { name: Schema.NonEmptyString } });
const Person = Resource.extend(Node, "people", {
inheritId: true,
attributes: { firstName: Schema.String },
});
const Organisation = Resource.extend(Node, "organisations", {
inheritId: true,
attributes: { legalName: Schema.String },
});
// Base-anchored family: shared id brand / relationships / attributes come from Node.
const AnyNode = Resource.family(Node, [Person, Organisation]);
AnyNode.document(); // data: Person | Organisation, included spans members' targets
AnyNode.collection(); // data: Array<Person | Organisation>
// As a relationship target — linkage decodes for ANY member (keyed on the member
// `type`, never the family name):
const Edge = Resource.make("edges", {
attributes: { weight: Schema.Number },
relationships: { to: Relationship.one(() => AnyNode) },
});
// Edge.to.data is { type: "people"; id } | { type: "organisations"; id }Endpoints: Endpoint.polymorphic(AnyNode, { include: true }) is the single-resource GET /nodes/:id (returning any member), and Endpoint.collection(AnyNode.members, …) the GET /nodes collection; Group.make(AnyNode, …) hosts them.
Two forms:
- Base-anchored
Resource.family(Base, [A, B])— recommended. The sharedId/relationships/ attributes come fromBase, so the shared id brand anchors "any member id" and dotted?include=paths through the family are meaningful. Pair with members defined asextend(Base, …, { inheritId: true }). - Named
Resource.family("media", [Article, Photo])— no base; the shared id is a union of the members' ids and the shared relationships/attributes are the by-key intersection of the members'. Fully correct for data /included/ linkage; include-through and the shared id brand degrade to the intersection.
A family value is data/target-only — it is never created, so it deliberately has no ref / createPayload (only its concrete members do). Resource.isFamily distinguishes a family from a single resource or a plain union.
Sparse fieldsets key on the member types, never the family name: an endpoint declaring fields whose response can carry a family (Edge's to, above) declares fields[people] and fields[organisations], and its default document makes those members partial (#350). fields[nodes], the family name, is not declared, since no resource object the response carries has that type: like any undeclared type it is a 400 naming the parameter, and query.fields has no nodes key. A family nested in another stands for its own members in turn, and a real resource whose type is the family's name (Node here, when another relationship targets it directly) is still declared as itself.
2. Errors — declared once, spec-compliant forever
class ArticleNotFound extends ApiError.make<ArticleNotFound>()("ArticleNotFound", {
status: 404,
code: "not_found",
title: "Resource not found",
fields: { id: Schema.String }, // typed fields, round-tripped through the wire
detail: (e) => `Article ${e.id} not found`,
}) {}One declaration gives you all of:
a tagged error class:
Effect.fail(new ArticleNotFound({ id })),Effect.catchTag("ArticleNotFound", …)a wire schema (
ArticleNotFound.wire) whose encoded form is a JSON:API error document:{ "errors": [ { "status": "404", "code": "not_found", "title": "Resource not found", "detail": "Article 42 not found", "meta": { "id": "42" } } ] }the HTTP status and OpenAPI documentation for free.
ApiError.BadRequest (400), ApiError.NotAcceptable (406), ApiError.UnsupportedMediaType (415), ApiError.Forbidden (403) and ApiError.Conflict (409) are predefined.
3. Endpoints & groups — conventions baked in
const articles = Group.make(
Article,
// GET /articles/:id?include=author,tags&fields[articles]=title
Endpoint.get(Article, {
include: true,
fields: true,
errors: [ArticleNotFound],
}),
// GET /articles?sort=-createdAt&page[offset]=0&page[limit]=10&filter[author]=9
// (`filter[f]=v` is `eq`, `filter[f]=a,b` is `in`, `filter[f][gt]=v` an operator —
// the filter grammar, docs/filter-grammar.md)
Endpoint.list(Article, {
include: true,
sort: ["createdAt", "title"],
page: Query.Page.Offset,
filter: { author: Schema.optionalKey(Schema.String) },
meta: Schema.Struct({ total: Schema.Int }),
}),
// POST /articles → 201 (client may send a lid; the required author relationship must be present)
Endpoint.create(Article, { errors: [TitleTaken] }),
// PATCH /articles/:id (partial attributes)
Endpoint.update(Article, { errors: [ArticleNotFound] }),
// DELETE /articles/:id → 204
Endpoint.delete(Article, { errors: [ArticleNotFound] }),
// GET /articles/:id/comments — the paginated related collection
Endpoint.related(Article, "comments", {
page: Query.Page.Offset,
errors: [ArticleNotFound],
}),
// PATCH /articles/:id/relationships/author — replace the author
Endpoint.updateRelationship(Article, "author", { errors: [ArticleNotFound] }),
);
const Api = HttpApi.make("blog").add(articles);The create's Location header
A 201 from Endpoint.create carries a Location header naming the created resource (JSON:API v1.1 §Creating Resources → 201 Created, a SHOULD). Nothing in the handler sets it: the Middleware.CreatedLocation middleware the constructor attaches copies it, verbatim, from the document the endpoint encoded — the created resource's data.links.self, or, when the resource has none, the document's top-level links.self (a link object contributes its href). Give the created resource its own self link and the spec's MUST — when the resource carries links.self, it and Location match — holds by construction:
// In the `create` handler, once the new `article` is stored:
const self = `/articles/${article.id}`;
// 201, `Location: /articles/42`, and `data.links.self` the same URL
return Effect.succeed(Handlers.data({ ...article, links: { self } }, { self }));With neither link the header is omitted, and so is a link that isn't a URI-reference (a space, a control character or a non-ASCII character in it), rather than sent malformed. Only a 201 is touched: a create answering another status is left alone, as is a response that already carries Location, and a body that isn't a JSON document (or isn't settled bytes) passes through unchanged rather than failing the request. The middleware sits inside Idempotency.Middleware, so a replayed 201 keeps its Location. Middleware.layer, Middleware.layerWith and Middleware.layerHostNegotiated all provide it, and Registry.layer / Registry.webHandler provide it beneath whatever middleware they are given; any other api that composes its own middleware set adds Middleware.CreatedLocationLive. The header is not declared on the endpoint, so the typed client does not surface it.
Overriding the write payload
Endpoint.create and Endpoint.update bind their request body to the resource's createPayload / updatePayload — the nested JSON:API envelope — which is what a spec-compliant client sends. Some apis deliberately keep a flat write contract instead: a command input carrying foreign-key ids or nested arrays that JSON:API's relationship-linkage model can't express, while still answering with JSON:API documents.
Pass payload to supply that schema. It defaults to today's envelope, so existing endpoints are unchanged; only the request body moves — path, params, success document, errors and middleware stay exactly as they were.
// POST /articles with a flat body: { title, body }
Endpoint.create(Article, { payload: Article.createInput });
// PATCH /articles/:id with a flat body: { id, title?, body? }
// (the payload's `id` stays the validation authority; the path `:id` is the routing key)
Endpoint.update(Article, { payload: Article.updateInput, errors: [ArticleNotFound] });Any schema works, not just the derived projections — reach for your own when the create needs fields the resource doesn't carry:
Endpoint.create(Article, {
payload: Schema.Struct({ title: Schema.NonEmptyString, authorId: Schema.String }),
});The same option is available per-endpoint when generating a whole group, so a Group.resource call site can adopt flat writes without giving up the generated surface:
const articles = Group.resource(Article, {
endpoints: {
create: { payload: Article.createInput },
update: { payload: Article.updateInput },
},
});Overriding the success document
Endpoint.get, create and update bind their response to the resource's document(), and Endpoint.list to its collection() — the resource is the single source of truth for what a handler returns. Some apis need the response to be a wire variant of the resource rather than the resource itself: the assembler stringifies every link before the document leaves the server, so links.self is a plain string on the wire, while the resource's own links.self is Document.Link, which decodes an absolute reference to a URL. A generated client then hands every call site a URL where it wants a string.
Pass success to supply that schema. It defaults to today's document() / collection(), so existing endpoints are unchanged; only the response moves — path, params, query parameters, request payload, errors and middleware stay exactly as they were. Any schema works, exactly as with payload: the document envelope is untouched, so Handlers.offsetPaginationLinks and the rest of the handler helpers still apply.
With include on (true or a Query.IncludeOptions), the default document's included covers every type a legal include path reaches, not only the resource's direct targets (see Constraining the advertised include paths). A hand-written success replaces that union with whatever its own included admits, and Document.DataDocument / CollectionDocument admit nothing in included unless you pass the union. So an override on an endpoint that advertises comments.author must admit Comment and Person itself, or the response for that path fails to encode. Include.included(Article, { include }) builds that union: given the resource and the endpoint's include option, it returns the included union the default document binds when the endpoint does not declare fields (with fields: true the default's members are partial, and the helper's stay strict), so it follows the relationship graph rather than drifting from it. A widened include: boolean gives what either value admits, as get / list / related do. For a related endpoint pass the relationship's target (a Resource.family target is passed as the family itself), and for collection / polymorphic the array of members (family.members). The members form mirrors those endpoints, which also give what either value admits under a widened include: boolean. Under a declared Include.Surface every form returns the surface's own included, which is exactly what each endpoint binds. The Group.resource example below, whose get and list default to include: true, uses it. On an endpoint that declares fields, a hand-written success also replaces the default's partial attributes (see Sparse fieldsets and the default document).
// The wire variant: the resource, with its link members narrowed to plain strings
const WireArticle = Schema.Struct({
...Article.fields,
links: Schema.optionalKey(Schema.Struct({ self: Schema.optionalKey(Schema.String) })),
});
// GET /articles/:id → 200, { data: { …, links: { self: "https://…" } } } — `self` a string
Endpoint.get(Article, { success: Document.DataDocument(WireArticle), errors: [ArticleNotFound] });
// GET /articles → 200, the collection of the same
Endpoint.list(Article, {
success: Document.CollectionDocument(WireArticle),
page: Query.Page.Offset,
});The same option is available per-endpoint when generating a whole group, so a Group.resource call site can adopt the wire variant without giving up the generated surface:
// The `included` union `Endpoint.get(Article, { include: true })` binds by default
const included = Include.included(Article, { include: true });
const articles = Group.resource(Article, {
endpoints: {
// `Group.resource` turns `include: true` on for `get` and `list`: their documents
// admit what `Article`'s include paths reach (`Person` and `Tag`).
get: { success: Document.DataDocument(WireArticle, { included }) },
list: { success: Document.CollectionDocument(WireArticle, { included }) },
create: { success: Document.DataDocument(WireArticle) },
update: { success: Document.DataDocument(WireArticle) },
},
});Overriding the write status
Endpoint.create answers 201 Created and Endpoint.update 200 OK — the spec's recommendation for a creation that returns the created resource, and the status HttpApi gives any success schema carrying a body. Some apis answer their whole write surface with 200, or accept a write for later application and answer 202.
Pass status to say so. It defaults to today's 201 / 200 — including when success is given, which is otherwise re-stamped with the constructor's own — so existing endpoints are unchanged; only the status moves. This is the status Endpoint.delete already carries, on the write endpoints.
// POST /articles → 200, the created document
Endpoint.create(Article, { status: 200 });
// PATCH /articles/:id → 202, an update accepted but not yet applied
Endpoint.update(Article, { status: 202, errors: [ArticleNotFound] });The same option is available per-endpoint when generating a whole group:
const articles = Group.resource(Article, {
endpoints: { create: { status: 200 }, update: { status: 200 } },
});Overriding the payload media type
Every body the package declares is application/vnd.api+json, request bodies included — and the router matches an incoming Content-Type against that registration, answering 415 on a mismatch. That is right for an api that owns its URLs and negotiates §6 itself.
It is wrong for an api whose host negotiates instead. Such a host enforces §6 at its own seam — Middleware.negotiate is exactly that, reused outside the constructors — and then hands the router a request relabelled application/json, because that is what the rest of its URL space speaks. The request the host just admitted would then be 415'd by the router before any handler sees it.
Pass payloadMediaType to register the request body under the label the host actually dispatches. It defaults to application/vnd.api+json, so existing endpoints are unchanged; the payload schema, the response media type, path, params, errors and middleware all stay as they were. Pair it with Middleware.layerHostNegotiated, or the package's own §5 check will reject what the host admitted:
// POST /articles with Content-Type: application/json — the label the host dispatches
Endpoint.create(Article, { payload: Article.createInput, payloadMediaType: "application/json" });
// …the response is still application/vnd.api+jsonThe same option is available per-endpoint when generating a whole group:
const articles = Group.resource(Article, {
endpoints: {
create: { payload: Article.createInput, payloadMediaType: "application/json" },
update: { payload: Article.updateInput, payloadMediaType: "application/json" },
},
});Reach for this only when something upstream genuinely enforces §6; on an api that owns its own URLs, the default is the correct — and spec-compliant — choice.
Overriding the delete response
Endpoint.delete answers 204 No Content, the spec's recommendation for a deletion with nothing further to say. Some apis do have something to say: a soft delete marks the row deleted, re-reads it, and returns the tombstone resource so an admin viewer can render what was removed.
Pass success to supply that response schema — it defaults to the 204, so existing endpoints are unchanged. The schema is served as application/vnd.api+json like every other body in the package, at 200 unless status says otherwise; the :id path param, errors and middleware are untouched.
// DELETE /articles/:id → 200, { data: { type: "articles", ... } }
Endpoint.delete(Article, { success: Article.document(), errors: [ArticleNotFound] });
// …or an asynchronous deletion that has been accepted but not yet performed
Endpoint.delete(Article, { success: Article.document(), status: 202 });The same option is available per-endpoint when generating a whole group:
const articles = Group.resource(Article, {
endpoints: { delete: { success: Article.document() } },
});Overriding the read query
Endpoint.list composes its query schema from the include / fields / sort / page / filter options: a flat, bracket-keyed string record on the wire that decodes to the spec's nested shape (page: { offset, limit }, filter: { … }). That is the spec-compliant contract, and the default.
Some apis want a different one — a flat list input their operations layer consumes directly, with entity foreign keys and flags JSON:API has no query family for. Forcing such a flag through filter would put it on the wire as filter[includeDeleted], which is not what those clients send.
Pass query to supply the whole schema. It defaults to today's composition, so existing endpoints are unchanged; the feature options are simply ignored once query is given, and the success document, path, errors and middleware stay as they were.
// GET /articles?page[offset]=20&page[limit]=10&sort=-createdAt&authorId=9&includeDeleted=true
// …decoded flat: { offset, limit, sort?, authorId?, includeDeleted? }
const ListArticles = Query.bracketPageKeys(
Schema.Struct({
...Query.Page.offset({ maxLimit: 100 }),
sort: Schema.optionalKey(Schema.String),
authorId: Schema.optionalKey(Schema.String),
includeDeleted: Schema.optionalKey(Schema.Literals(["true", "false"])),
}),
);
Endpoint.list(Article, { query: ListArticles });Query.bracketPageKeys is what keeps the page cursor spec-canonical on the wire (page[offset] / page[limit]) while the handler still sees { offset, limit } — but any schema works, bracketed or not.
Endpoint.get takes the same option, over the include / fields parameters it composes — for a single-resource fetch carrying a flag JSON:API has no family for, or an ?include= grammar the api owns rather than derives:
Endpoint.get(Article, {
query: Schema.Struct({
include: Schema.optionalKey(Schema.String),
includeDeleted: Schema.optionalKey(Schema.Literals(["true", "false"])),
}),
});Both are available per-endpoint when generating a whole group:
const articles = Group.resource(Article, {
endpoints: { list: { query: ListArticles }, get: { query: GetArticle } },
});Note that the package's own ?include= needs no escape hatch for the repeated-key spelling: ?include=author&include=comments and ?include=author,comments denote the same set and both decode to it, so a client using either is served identically. Encoding always emits the comma form.
Generating a whole group from a resource
Writing out every endpoint is explicit, but repetitive — a resource definition already knows its attributes, relationships and graph. Group.resource walks that definition and emits the entire group: the CRUD surface plus, for every relationship, the related and linkage endpoints appropriate to its kind, with include / fields / sort derived from the graph.
// CRUD + every relationship endpoint, fully typed — equivalent to spelling out
// get / list / create / update / delete and each relationship endpoint by hand:
const articles = Group.resource(Article, {
errors: [ArticleNotFound],
page: Query.Page.Offset,
// Per-endpoint config overrides the top-level defaults; the keys are the CRUD
// operations, the values a boolean (emit / omit) or that endpoint's options.
endpoints: {
create: { errors: [TitleTaken] },
list: { filter: { author: Schema.optionalKey(Schema.String) } },
},
});
// A read-only resource: just get + list, no relationship endpoints:
const people = Group.resource(Person, {
endpoints: { create: false, update: false, delete: false },
relationships: false,
});
// Per-relationship config: drop one relationship, re-error another:
const issues = Group.resource(Issue, {
relationships: {
comments: false, // omit this relationship's endpoints
assignee: { errors: [UserNotFound] }, // configure that relationship's endpoints
},
// `meta` may be a function, *extending* the resource's base meta rather than
// replacing it:
meta: (base) => Schema.Struct({ ...base.fields, total: Schema.Int }),
});Defaults emit all five CRUD operations and every relationship's endpoints with include / fields / sort enabled; page and filter stay opt-in (their semantics are application-defined), as do compound relationship documents (relationshipInclude) and their sparse fieldsets (relationshipFields), and errors is applied to every generated endpoint. Every default is overridable, globally or per endpoint / relationship — see Endpoint.ResourceOptions.
For finer control — adding a heterogeneous search, dropping or replacing an individual endpoint — Endpoint.resource returns the same endpoints as a plain tuple to spread into Group.make:
const articles = Group.make(
Article,
...Endpoint.resource(Article, { errors: [ArticleNotFound] }),
// …plus anything else this group should serve
Endpoint.list(Article, {
name: "search",
path: "/articles/search",
filter: { q: Schema.String },
}),
);Relationship & related endpoints
The spec defines two URL families per relationship; both are first-class:
| Constructor | Method & path | Payload | Success |
| --- | --- | --- | --- |
| Endpoint.related | GET /<type>/:id/<name> | — | 200 — the related resource(s) themselves: a single-resource document (to-one) or a collection document with full query support (to-many) |
| Endpoint.getRelationship | GET /<type>/:id/relationships/<name> | — | 200 — a linkage document (data is identifiers, never full resources); with include, a compound one |
| Endpoint.updateRelationship | PATCH /<type>/:id/relationships/<name> | replacement linkage | 200 — the updated linkage |
| Endpoint.addRelationship | POST /<type>/:id/relationships/<name> | identifiers to add | 200 — the resulting linkage (to-many only) |
| Endpoint.removeRelationship | DELETE /<type>/:id/relationships/<name> | identifiers to remove | 204 (to-many only) |
Payload and success schemas follow the relationship's kind:
// `author` is Relationship.one(() => Person):
Endpoint.updateRelationship(Arti