npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@thomasfosterau/effect-jsonapi

v0.14.0

Published

Type-safe, spec-compliant JSON:API v1.1 on Effect's HttpApi.

Readme

@thomasfosterau/effect-jsonapi, a JSON:API package for Effect

Type-safe, spec-compliant JSON:API v1.1 implement built on top of Effect’s HttpApi, Schema, etc.

CI npm version License: MIT

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.

Installation

npm install @thomasfosterau/effect-jsonapi effect

effect is a peer dependency (>=4.0.0-beta.104). Node.js 20 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+json media type, conventional paths, spec status codes (200/201/204), typed include / fields[TYPE] / sort / page[*] / filter[*] query parameters, and content-negotiation rules (406/415).
  • Everything is a plain Effect Schema / HttpApiEndpoint / HttpApiGroup, so it composes with HttpApi, HttpApiBuilder, HttpApiClient, HttpApiTest and OpenApi untouched.
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { Endpoint, Group, Resource } from "@thomasfosterau/effect-jsonapi"

Status: built against effect@>=4.0.0-beta.104 (the v4 beta). The effect/unstable/httpapi surface may shift between betas.

Contents

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/unstable/httpapi"
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))

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 },
  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
  }
})

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 | ✗ | ✗ (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).

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; 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 |

Documents are not limited to one resource type — see Heterogeneous endpoints for polymorphic collections.

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 PersonId

The 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"

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 helpers compose from a single attribute(...) call, so you can define your own sugar:

const computed = <S extends Schema.Top>(s: S) => Resource.attribute(s, { create: false, update: false })
const createOnly = <S extends Schema.Top>(s: S) => Resource.attribute(s, { update: false })
const defaulted = <S extends Schema.Top>(s: S) => Resource.attribute(s, { create: "optional" })
const optional = <S extends Schema.Top>(s: S) => Resource.attribute(s, { resource: "optional", create: "optional" })
const inputOnly = <S extends Schema.Top>(s: S) => Resource.attribute(s, { resource: false, update: false })

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 attributes

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 wire

Article.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 shared Id / relationships / attributes come from Base, so the shared id brand anchors "any member id" and dotted ?include= paths through the family are meaningful. Pair with members defined as extend(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.

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)

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.

// 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:

const articles = Group.resource(Article, {
  endpoints: {
    get: { success: Document.DataDocument(WireArticle) },
    list: { success: Document.CollectionDocument(WireArticle) },
    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+json

The 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), 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) | | 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(Article, "author")
// PATCH payload: { data: PersonIdentifier }          — null doesn't decode (required relationship)

// `editor` is Relationship.optional(() => Person):
Endpoint.updateRelationship(Article, "editor")
// PATCH payload: { data: PersonIdentifier | null }   — null clears the relationship

// `comments` is Relationship.paginated(() => Comment):
Endpoint.related(Article, "comments", { page: Query.Page.Offset, include: true })
// GET /articles/:id/comments?page[offset]=0&page[limit]=10&include=author
// → a paginated collection document of full Comment resources

Endpoint.addRelationship(Article, "comments")
// POST payload: { data: CommentIdentifier[] }

// to-many constructors only accept to-many relationship names:
Endpoint.addRelationship(Article, "author") // ✗ compile error

Handlers return linkage documents with Handlers.linkage, and build the relationship URLs with Handlers.relationshipLink / Handlers.relatedLink / Handlers.paginatedRelationship:

.handle("commentsRelationship", ({ params, query }) =>
  loadComments(params.id, query.page).pipe(Effect.map((comments) =>
    Handlers.linkage(comments.map((c) => Comment.ref(c.id)), {
      self: Handlers.relationshipLink("articles", params.id, "comments"),
      related: Handlers.relatedLink("articles", params.id, "comments")
    })
  )))

Heterogeneous endpoints (search, feeds)

Endpoint.collection builds collection endpoints whose data mixes several resource types, discriminated by their type tags — the natural fit for search results, feeds and timelines. A polymorphic collection has no single owning resource, so name and path are required:

const search = Group.make(
  "search",
  // GET /search?filter[q]=bikeshed&include=author&page[offset]=0&page[limit]=10
  Endpoint.collection([Article, Person], {
    name: "search",
    path: "/search",
    filter: { q: Schema.String },
    include: true, // include paths span both resources' graphs
    fields: true, // ?fields[articles]= and ?fields[people]=
    page: Query.Page.Offset,
    meta: Schema.Struct({ total: Schema.Int })
  })
)

const Api = HttpApi.make("blog").add(articles).add(search)

// Handlers return mixed collections; clients discriminate on `type`:
for (const result of doc.data) {
  if (result.type === "articles")
    result.attributes.title // Article
  else result.attributes.firstName // Person
}

The included union spans every searched resource's relationship targets, and query features (fields[TYPE], include, sort) are derived across all of the resources in the union.

Atomic operations

Endpoint.operations models the atomic operations extension: one request carrying an ordered list of operations — creating, updating and deleting resources or their relationships — processed all-or-nothing:

const operations = Group.make(
  "operations",
  // POST /operations with an atomic:operations document
  Endpoint.operations([Article, Comment], { errors: [OperationFailed] })
)

const Api = HttpApi.make("blog").add(articles).add(operations)

Like everything else, the operations a resource supports are derived from its definition — Atomic.operationsFor(Article) exposes them as a named record of schemas:

| Derived operation | Wire form | | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | .add | { op: "add", data: { type, lid?, attributes, relationships } }one relationships required, paginated excluded | | .update | { op: "update", data: { type, id \| lid, attributes?, relationships? } }paginated excluded | | .remove | { op: "remove", ref: { type, id \| lid } } | | .relationships.author.update (per one relationship) | { op: "update", ref: { type, id \| lid, relationship }, data: ref } — never null | | .relationships.editor.update (per optional relationship) | { op: "update", ref: { type, id \| lid, relationship }, data: ref \| null } | | .relationships.comments.add / .update / .remove (per many / paginated relationship) | { op, ref: { type, id \| lid, relationship }, data: [refs] } |

paginated relationships — which carry no inline linkage — are managed exactly this way: their membership is changed through relationship operations (or relationship endpoints), never inside a resource's relationships member.

Clients build requests with the typed operation constructors — note the lid refs (Article.lidRef / Comment.lidRef) linking operations within the same request:

const doc =
  yield *
  client.operations.operations({
    payload: Atomic.request(
      // 1. create an article; it has no id yet, so it declares a lid.
      //    `author` is a required (`one`) relationship, so it must be present.
      Atomic.add(Article, {
        lid: "a1",
        attributes: { title: "Atomic bikeshedding", body: "…", createdAt: new Date() },
        relationships: {
          author: { data: Person.ref("9") },
          tags: { data: [Tag.ref("1")] }
        }
      }),
      // 2. create a comment...
      Atomic.add(Comment, {
        lid: "c1",
        attributes: { body: "First!" },
        relationships: { author: { data: Person.ref("9") } }
      }),
      // 3. ...and link it into the new article's paginated comments relationship —
      //    both sides referenced by lid
      Atomic.addToRelationship(Article, { lid: "a1" }, "comments", [Comment.lidRef("c1")]),
      // 4. to-one relationship operations replace linkage (`one`: never null)
      Atomic.updateRelationship(Comment, "5", "author", Person.ref("9"))
    )
  })

doc["atomic:results"] // one result per operation, in order; `data` is typed Article | Comment

Handlers pattern-match over the decoded operation union — the targetsResource / targetsRelationship guards are curried, so they drop straight into Effect's Match module and narrow each case to fully typed data / ref; Lid.make() tracks the server-assigned ids of lid-created resources:

const OperationsLive = HttpApiBuilder.group(Api, "operations", (handlers) =>
  handlers.handle("operations", ({ payload }) =>
    Effect.gen(function*() {
      const lids = Lid.make()
      const entries = []
      for (const operation of payload["atomic:operations"]) {
        entries.push(Match.value(operation).pipe(
          Match.when(Atomic.targetsRelationship(Article, "comments"), (op) => {
            // op.data is ReadonlyArray<comment ref>; op.op is "add" | "update" | "remove"
            return Atomic.emptyResult
          }),
          Match.when(Atomic.targetsResource(Article), (op) =>
            Match.value(op).pipe(
              Match.when({ op: "add" }, (add) => {
                const id = Article.Id.make(newId())
                const resolved = lids.resolveLinkage(Article, add.data.relationships) // lids → real ids
                const article = Article.make({
                  id,
                  attributes: add.data.attributes,
                  relationships: {
                    // `author` is required (`one`), so the operation always carries it
                    author: { data: lids.identifier(Person, add.data.relationships.author.data) },
                    tags: resolved.tags ?? { data: [] },
                    // `comments` is paginated: new articles start with an empty collection
                    comments: Handlers.paginatedRelationship("articles", id, "comments")
                  }
                })
                if (add.data.lid !== undefined) lids.assign(add.data.lid, article.id)
                return { data: article }
              }),
              Match.when({ op: "update" }, (update) => /* … */),
              Match.when({ op: "remove" }, (remove) => /* … */),
              Match.exhaustive
            )),
          // … one case per resource and relationship; Match.exhaustive proves
          //   every operation in the union is handled
          Match.exhaustive
        ))
      }
      return Atomic.results(entries)
    })))

Because the extension uses the JSON:API media type with an ext parameter, provide the middleware with the extension declared:

Layer.provide(Middleware.layerWith({ extensions: [Atomic.EXTENSION_URI] }))

Every endpoint automatically:

  • serves and accepts application/vnd.api+json
  • declares its errors as JSON:API error documents at the right status
  • carries the content-negotiation middleware (415 on parameterised request media types, 406 on unacceptable Accept headers) and the schema-error middleware (malformed params/query/payloads become JSON:API 400 documents) — and because they're real HttpApiMiddleware services, the api won't build until you provide them: forgetting is a compile error, not a runtime surprise
  • documents itself in OpenAPI (OpenApi.fromApi(Api)) with the JSON:API media type, status codes and bracket query parameters

4. Handlers — typed in, validated out

import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"

const ArticlesLive = HttpApiBuilder.group(Api, "articles", (handlers) =>
  handlers
    .handle("get", ({ params, query }) =>
      //                 ^ params.id is a branded Article id
      //                         ^ query.include / query.fields are typed & validated
      loadArticle(params.id).pipe(
        Effect.map((article) =>
          Handlers.data(article, {
            included: resolveIncluded(article, query.include),
            self: `/articles/${article.id}`
          })
        )
      ))
    .handle("list", ({ query }) =>
      // query.sort: [{ field: "createdAt", direction: "desc" }]
      // query.page: { offset?: number, limit?: number }
      listArticles(query).pipe(
        Effect.map(({ items, total }) =>
          Handlers.collection(items, {
            meta: { total },
            links: Handlers.offsetPaginationLinks("/articles", query.page ?? {}, total)
          })
        )
      ))
    .handle("create", ({ payload }) =>
      // payload.data.attributes is fully typed; payload.data.lid is supported
      createArticle(payload.data).pipe(Effect.map((article) => Handlers.data(article))))
    .handle("update", ({ params, payload }) => /* … */)
    .handle("delete", ({ params }) => deleteArticle(params.id))   // void → 204
)

The document builders (Handlers.data / Handlers.collection) enforce the compound-document rules at runtime: included is deduplicated by (type, id) and checked for full linkage (every included resource must be referenced in the document).

Pagination links are built with Handlers.offsetPaginationLinks (for Page.Offset) and Handlers.numberPaginationLinks (for Page.Number), which emit the spec's first / prev / next / last top-level links from the request's page parameters and the total count. Pass the request's other query parameters as { query: Query.canonicalPairs(listQuery)(query) } and every link carries them too (the canonical query string).

To name the value a builder returns — e.g. on a helper that assembles documents outside a handler — use Handlers.DocumentValue<Data, Included?, Meta?> (the runtime shape, with an optional jsonapi member) or the schema-derived Document.Value<R, Included?, Meta?>, instead of hand-rolling the { data, included?, links?, meta?, jsonapi? } envelope.

To serve it (with @effect/platform-node):

import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"

HttpApiBuilder.layer(Api).pipe(
  Layer.provide(ArticlesLive),
  Layer.provide(Middleware.layer),    // content negotiation + JSON:API 400s
  Layer.provide(NodeHttpServer.layer(...)),
  Layer.launch,
  NodeRuntime.runMain
)

If you negotiate content outside HttpApi — say in a framework hook that owns the URL — Middleware.negotiate(headers, options?) runs the §5 rules standalone, returning the offending ApiError (UnsupportedMediaType / NotAcceptable) or undefined, and ApiError.toDocument(error) renders any ApiError to a JSON:API error-document value:

const error = Middleware.negotiate({
  contentType: request.headers.get("content-type") ?? undefined,
  accept: request.headers.get("accept") ?? undefined
})
if (error) {
  const body = ApiError.toDocument(error) // { errors: [{ status, code, title }] }
  // → respond with `Number(body.errors[0].status)` and `body`
}

Middleware.schemaError(part) is the other half: the JSON:API 400 a request-validation failure produces, standalone. It's the same value the SchemaErrors middleware raises inside HttpApi, so a hook that decodes requests with its own schemas answers byte-identically to an Endpoint-built api:

const parsed = Schema.decodeUnknownExit(ListQuery)(params)
if (parsed._tag === "Failure") {
  return json(400, ApiError.toDocument(Middleware.schemaError("Query")))
}

When the host already negotiated

If you do adopt the endpoint constructors but your host negotiates content upstream — a framework hook serving JSON:API and HTML on one URL space — the constructors' ContentNegotiation middleware would run §5 a second time. That's redundant at best, and contradictory when the host deliberately admits something the spec's rules reject (an api that accepts Accept: application/json would see those requests 406'd after the hook let them through).

Middleware.layerHostNegotiated is the drop-in replacement for Middleware.layer in that case: it satisfies the endpoints' ContentNegotiation requirement without performing any checks, leaving the host the single negotiating authority, and keeps SchemaErrors live so request validation still answers with JSON:API 400s. Nothing else about the endpoints changes.

HttpApiBuilder.layer(Api).pipe(
  Layer.provide(ArticlesLive),
  Layer.provide(Middleware.layerHostNegotiated) // the hook ran `Middleware.negotiate` already
)

Use it only when something upstream genuinely enforces §5 — for an api that owns its own URLs, Middleware.layer remains the correct choice. (Middleware.ContentNegotiationPassthrough is the negotiation half alone, for composing your own set.)

And to call it — the same definitions drive a fully typed client:

import { HttpApiClient } from "effect/unstable/httpapi"

const client = yield* HttpApiClient.make(Api, { baseUrl: "http://localhost:3000" })

const doc = yield* client.articles.get({
  params: { id: Article.Id.make("1") },
  query: { include: ["author"] }     // ← include paths are typed literals; typos don't compile
}).pipe(
  Effect.catchTag("ArticleNotFound", (e) => /* e.id is typed */ …)
)
// doc.data.attributes.createdAt is a Date; doc.included is typed

Narrowing included by the requested include paths

The spec guarantees that a server "MUST NOT include unrequested resource objects", so the client knows statically what included can contain — Client.narrowIncluded exposes that:

const include = ["author"] as const

const doc =
  yield *
  client.articles
    .get({
      params: { id: Article.Id.make("1") },
      query: { include }
    })
    .pipe(Client.narrowIncluded(Article, include))

doc.included
// ^ ReadonlyArray<Person> — not Person | Comment | Tag.
//   doc.included[0].attributes.firstName is accessible without narrowing on `type`.

Dotted paths include the intermediate resources (["comments.author"]Comment | Person), and requesting nothing narrows included to never. This is a type-level operation with no runtime cost; the response is still decoded against the endpoint's full schema, so a non-compliant server fails loudly instead of lying.

Query parameters

| Family | Wire form | Decoded form | | -------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | include | ?include=author,comments.author | ReadonlyArray<"author" \| "comments" \| "comments.author"> — literal paths from the relationship graph (constrainable) | | fields[TYPE] | ?fields[articles]=title,body | { articles?: ReadonlyArray<"title" \| "body" \| …> } — closed per-type key sets | | sort | ?sort=-createdAt,title | [{ field: "createdAt", direction: "desc" }, …] | | page[*] | ?page[offset]=0&page[limit]=10 | { offset?: number, limit?: number } (Page.Offset, Page.Number, Page.Cursor, or custom) | | filter[*] | ?filter[status]=open&filter[age][gt]=18 | Filter.Ast — one root node over the declared fields (the filter grammar, filter: true); fails closed on undeclared fields, operators and literals. Or a user-defined schema per key (filter: { q: … }) |

Unknown include paths, unknown sparse-fieldset names, unknown sort fields, and — under the grammar — unknown filter fields, undeclared operators and bad literals fail decoding, which the schema-error middleware turns into a spec-compliant 400 JSON:API error document whose error objects carry source.parameter naming the offending key (filter[age][gt], page[limit]). HttpApi decodes a request with errors: "first", so a query yields one error object — except the filter family, whose codec reports every offending key together.

Decla