TypeScript-friendly utilities for adhering to the JSON:API specification with NestJS.
npm install @clipboard-health/json-api-nestjs
Create Zod schemas for your API's queries:
import { booleanString } from "@clipboard-health/contract-core";
import {
cursorPaginationQuery,
fieldsQuery,
type FilterMap,
filterQuery,
includeQuery,
sortQuery,
} from "@clipboard-health/json-api-nestjs";
import { z } from "zod";
import type {
ArticleAttributeFields,
UserAttributeFields,
UserIncludeFields,
} from "../src/contract";
const articleFields = ["title"] as const satisfies readonly ArticleAttributeFields[];
const userFields = ["age", "dateOfBirth"] as const satisfies readonly UserAttributeFields[];
const userIncludeFields = [
"articles",
"articles.comments",
] as const satisfies readonly UserIncludeFields[];
const userFilterMap = {
age: {
filters: ["eq", "gt"],
schema: z.coerce.number().int().positive().max(125),
},
dateOfBirth: {
filters: ["gte"],
schema: z.coerce.date().min(new Date("1900-01-01")).max(new Date()),
},
isActive: {
filters: ["eq"],
schema: booleanString,
},
} as const satisfies FilterMap<UserAttributeFields>;
/**
* Disclaimer: Just because JSON:API supports robust querying doesn’t mean your service should
* implement them as they may require database indexes, which have a cost. **Implement only access
* patterns required by clients.**
*
* The spec says that if clients provide fields the server doesn’t support, it **MUST** return 400
* Bad Request, hence the `.strict()`.
*/
export const query = z
.object({
...cursorPaginationQuery(),
...fieldsQuery({ article: articleFields, user: userFields }),
...filterQuery(userFilterMap),
...sortQuery(userFields),
...includeQuery(userIncludeFields),
})
.strict();
The query helpers expect req.query to hold nested objects: ?filter[age][gt]=10 must arrive as
{ filter: { age: { gt: "10" } } }. Express 5's default simple query parser leaves
filter[age][gt] as one flat key, so a .strict() query schema rejects the query with a 400 and a
non-strict one silently drops the filter and pagination.
Set a qs query parser on the Nest app. Clipboard services can use createQueryParser() from
@clipboard-health/api-nestjs 17.0.0 or later:
import { createQueryParser } from "@clipboard-health/api-nestjs";
import { NestFactory } from "@nestjs/core";
import { type NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module";
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set("query parser", createQueryParser());
await app.listen(3000);
Other apps can pass a function that calls qs's parse instead,
for example app.set("query parser", (query: string) => qs.parse(query)).
oRPC routes don't need the setting: they don't read req.query. See oRPC procedures.
The query helpers work in oRPC procedures. oRPC parses the query string itself, so
declare the procedure with inputStructure: "detailed" and put the helpers' schema under query:
import { oc } from "@orpc/contract";
import { z } from "zod";
const query = z
.object({
...cursorPaginationQuery(),
...filterQuery(userFilterMap),
...sortQuery(userFields),
})
.strict();
export const list = oc
.route({ inputStructure: "detailed", method: "GET", path: "/users" })
.input(z.object({ query }));
For the same query string, the helpers return the same values and the same validation issues as with
qs and ts-rest, apart from the differences below. Issue paths in an oRPC 400 start with query.
orpcQueryParsing.test.ts runs each helper through both
parsers. @orpc/* isn't a dependency or peer dependency of this package.
oRPC's parser builds each key's value in the order the parameters appear. When a plain value
(filter[age]=10) is followed by a bracketed key on the same key (filter[age][gt]=5,
filter[age][eq]=20, filter[age][]=20), it discards the plain value before the schema sees it,
and qs keeps it. The request succeeds without that value, so a filter can match more than the client
asked for. Helpers can't recover the value. Clients should send one style per key: put the operator
first (filter[age][gt]=5&filter[age]=10 works), write filter[age][eq]=10 explicitly, or separate
values with commas (filter[age]=10,20). The same applies to sort, include, and fields
(sort=age&sort[]=-age keeps only -age).
Smaller differences:
filter[__proto__][x]=1, filter[hasOwnProperty]=1) with a 400
from .strict(). qs with allowPrototypes: false drops them silently.filter[age]]=10) and an empty key (=x) with a 400. qs
accepts the first and ignores the second.filter[age][eq]=10&filter[age][]=20 keeps both values under both parsers, but qs orders them
20, 10 and oRPC 10, 20.qs with parameterLimit: 1000 silently drops parameters after the
first 1,000. The helpers' own array limits still apply.See package.json scripts for a list of commands.