@jsonapi-serde/server 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ import { z } from "zod/v4";
2
+ import { JsonApiError, ZodValidationError, ZodValidationErrorParams } from "../common/index.js";
3
+ import { ContentTypeParser } from "../http/index.js";
4
+ import { MediaTypeParserError } from "../http/index.js";
5
+ /**
6
+ * Creates a Zod schema that ensures a string equals the provided fixed type
7
+ */
8
+ const fixedTypeSchema = (type) => z.string().check((context) => {
9
+ if (context.value !== type) {
10
+ context.issues.push({
11
+ code: "custom",
12
+ message: "Type mismatch",
13
+ params: new ZodValidationErrorParams("type_mismatch", `Type '${context.value}' does not match '${type}'`, 409),
14
+ input: context.value,
15
+ });
16
+ }
17
+ });
18
+ /**
19
+ * Constructs a Zod schema for a JSON:API resource identifier
20
+ */
21
+ export const resourceIdentifierSchema = (type, idSchema = z.string()) => z.strictObject({
22
+ type: fixedTypeSchema(type),
23
+ id: idSchema,
24
+ });
25
+ /**
26
+ * Constructs a Zod schema for a client resource identifier using `lid`
27
+ */
28
+ export const clientResourceIdentifierSchema = (type) => z.strictObject({
29
+ type: fixedTypeSchema(type),
30
+ lid: z.string(),
31
+ });
32
+ /**
33
+ * Constructs a relationship object schema that wraps a given relationship data schema
34
+ */
35
+ export const relationshipSchema = (schema) => z.strictObject({
36
+ data: schema,
37
+ });
38
+ /**
39
+ * A container class for looking up included resources of a given type by `lid`
40
+ */
41
+ export class IncludedResourceMap {
42
+ type;
43
+ resources;
44
+ constructor(type, resources) {
45
+ this.type = type;
46
+ this.resources = resources;
47
+ }
48
+ /**
49
+ * Returns the resource with the given `lid` or null if not found
50
+ */
51
+ safeGet(lid) {
52
+ return this.resources.get(lid) ?? null;
53
+ }
54
+ /**
55
+ * Returns the resource with the given `lid`, or throws if not included
56
+ *
57
+ * @throws {JsonApiError} if the resource is missing
58
+ */
59
+ get(lid) {
60
+ const resource = this.resources.get(lid);
61
+ if (!resource) {
62
+ throw new JsonApiError({
63
+ status: "422",
64
+ code: "missing_included_resource",
65
+ title: "Missing included resource",
66
+ detail: `A referenced resource of type '${this.type}' and lid '${lid}' is missing in the document`,
67
+ });
68
+ }
69
+ return resource;
70
+ }
71
+ }
72
+ const buildIncludedSchema = (includedTypes) => {
73
+ if (!includedTypes) {
74
+ return z.undefined();
75
+ }
76
+ const includedResourceSchemas = [];
77
+ for (const [type, schemas] of Object.entries(includedTypes)) {
78
+ includedResourceSchemas.push(z.object({
79
+ lid: z.string(),
80
+ type: z.literal(type),
81
+ attributes: schemas.attributesSchema ? schemas.attributesSchema : z.undefined(),
82
+ relationships: schemas.relationshipsSchema
83
+ ? schemas.relationshipsSchema
84
+ : z.undefined(),
85
+ }));
86
+ }
87
+ if (includedResourceSchemas.length === 0) {
88
+ return z.array(z.never()).optional();
89
+ }
90
+ if (includedResourceSchemas.length === 1) {
91
+ return z.array(includedResourceSchemas[0]).optional();
92
+ }
93
+ return z
94
+ .array(z.discriminatedUnion("type", [
95
+ includedResourceSchemas[0],
96
+ ...includedResourceSchemas.slice(1),
97
+ ]))
98
+ .optional();
99
+ };
100
+ /**
101
+ * Parses a JSON:API resource request
102
+ *
103
+ * Validates content type, parses the document, and extracts resource and included data according to the provided
104
+ * options.
105
+ *
106
+ * @throws {JsonApiError} for invalid content type or malformed document
107
+ */
108
+ export const parseResourceRequest = (context, options) => {
109
+ const body = parseBody(context);
110
+ const includedSchema = buildIncludedSchema(options.includedTypeSchemas);
111
+ const parseResult = z
112
+ .strictObject({
113
+ data: z.strictObject({
114
+ id: options.idSchema ?? z.undefined(),
115
+ type: fixedTypeSchema(options.type),
116
+ attributes: options.attributesSchema ?? z.undefined(),
117
+ relationships: options.relationshipsSchema ?? z.undefined(),
118
+ }),
119
+ included: includedSchema,
120
+ })
121
+ .safeParse(body);
122
+ if (!parseResult.success) {
123
+ throw new ZodValidationError(parseResult.error.issues, "body");
124
+ }
125
+ const { data, included } = parseResult.data;
126
+ const result = {
127
+ type: options.type,
128
+ };
129
+ if ("id" in data) {
130
+ result.id = data.id;
131
+ }
132
+ if ("attributes" in data) {
133
+ result.attributes = data.attributes;
134
+ }
135
+ if ("relationships" in data) {
136
+ result.relationships = data.relationships;
137
+ }
138
+ if (options.includedTypeSchemas) {
139
+ const includedTypes = Object.fromEntries(Object.keys(options.includedTypeSchemas).map((type) => [type, new Map()]));
140
+ if (included) {
141
+ for (const resource of included) {
142
+ const map = includedTypes[resource.type];
143
+ map.set(resource.lid, resource);
144
+ }
145
+ }
146
+ result.includedTypes = Object.fromEntries(Object.entries(options.includedTypeSchemas).map(([type]) => [
147
+ type,
148
+ new IncludedResourceMap(type, includedTypes[type]),
149
+ ]));
150
+ }
151
+ return result;
152
+ };
153
+ /**
154
+ * Parses a JSON:API relationship request and returns an ID
155
+ *
156
+ * The `idSchema` can be made `.nullable`.
157
+ *
158
+ * @throws {JsonApiError} for invalid content type or schema errors
159
+ */
160
+ export const parseRelationshipRequest = (context, type, idSchema) => {
161
+ const body = parseBody(context);
162
+ const parseResult = z
163
+ .object({
164
+ data: z.object({
165
+ type: z.literal(type),
166
+ id: (idSchema ?? z.string()),
167
+ }),
168
+ })
169
+ .safeParse(body);
170
+ if (!parseResult.success) {
171
+ throw new ZodValidationError(parseResult.error.issues, "body");
172
+ }
173
+ return parseResult.data.data.id;
174
+ };
175
+ /**
176
+ * Parses a JSON:API relationships request and returns a list of string IDs
177
+ *
178
+ * @throws {JsonApiError} for invalid content type or schema errors
179
+ */
180
+ export const parseRelationshipsRequest = (context, type, idSchema = z.string()) => {
181
+ const body = parseBody(context);
182
+ const parseResult = z
183
+ .object({
184
+ data: z.array(z.object({
185
+ type: z.literal(type),
186
+ id: idSchema,
187
+ })),
188
+ })
189
+ .safeParse(body);
190
+ if (!parseResult.success) {
191
+ throw new ZodValidationError(parseResult.error.issues, "body");
192
+ }
193
+ return parseResult.data.data.map((identifier) => identifier.id);
194
+ };
195
+ /**
196
+ * Parses and validates the body content as JSON
197
+ *
198
+ * @throws {JsonApiError} if the body is not valid JSON
199
+ */
200
+ const parseBody = (context) => {
201
+ validateContentType(context.contentType);
202
+ if (typeof context.body !== "string") {
203
+ return context.body;
204
+ }
205
+ try {
206
+ return JSON.parse(context.body);
207
+ }
208
+ catch (error) {
209
+ throw new JsonApiError({
210
+ status: "400",
211
+ code: "invalid_json_body",
212
+ title: "Invalid JSON body",
213
+ /* node:coverage ignore next */
214
+ detail: error instanceof Error ? error.message : undefined,
215
+ });
216
+ }
217
+ };
218
+ /**
219
+ * Validates that the provided content type is supported for JSON:API
220
+ *
221
+ * @throws {JsonApiError} for unsupported media types
222
+ */
223
+ const validateContentType = (contentType) => {
224
+ if (!contentType) {
225
+ throw new JsonApiError({
226
+ status: "415",
227
+ code: "unsupported_media_type",
228
+ title: "Unsupported Media Type",
229
+ detail: `Media type is missing, use 'application/vnd.api+json'`,
230
+ source: { header: "Content-Type" },
231
+ });
232
+ }
233
+ let parts;
234
+ try {
235
+ parts = ContentTypeParser.parse(contentType);
236
+ }
237
+ catch (error) {
238
+ /* node:coverage disable */
239
+ if (!(error instanceof MediaTypeParserError)) {
240
+ throw error;
241
+ }
242
+ /* node:coverage enable */
243
+ throw new JsonApiError({
244
+ status: "400",
245
+ code: "bad_request",
246
+ title: "Bad Request",
247
+ detail: error.message,
248
+ source: {
249
+ header: "Content-Type",
250
+ },
251
+ });
252
+ }
253
+ if (parts.type !== "application" || parts.subType !== "vnd.api+json") {
254
+ throw new JsonApiError({
255
+ status: "415",
256
+ code: "unsupported_media_type",
257
+ title: "Unsupported Media Type",
258
+ detail: `Unsupported media type '${parts.type}/${parts.subType}', use 'application/vnd.api+json'`,
259
+ source: { header: "Content-Type" },
260
+ });
261
+ }
262
+ const { ext, profile, ...rest } = parts.parameters;
263
+ if (Object.keys(rest).length === 0) {
264
+ return;
265
+ }
266
+ throw new JsonApiError({
267
+ status: "415",
268
+ code: "unsupported_media_type",
269
+ title: "Unsupported Media Type",
270
+ detail: `Unknown media type parameters: ${Object.keys(rest).join(", ")}`,
271
+ source: { header: "Content-Type" },
272
+ });
273
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./body.js";
2
+ export * from "./query.js";
@@ -0,0 +1,2 @@
1
+ export * from "./body.js";
2
+ export * from "./query.js";
@@ -0,0 +1,116 @@
1
+ import { z } from "zod/v4";
2
+ import type { $ZodType } from "zod/v4/core";
3
+ import type { ParentPaths } from "../common/utils.js";
4
+ import { type SearchParamsInput } from "../http/index.js";
5
+ /**
6
+ * Options for the `include` query parameter
7
+ *
8
+ * Defines which related resources are allowed in an `include` request.
9
+ */
10
+ export type ParseQueryIncludeOptions<TInclude extends string> = {
11
+ /**
12
+ * Allowed include-paths (e.g., "author", "comments.author")
13
+ *
14
+ * Allowing "comments" automatically allows "comments.author".
15
+ */
16
+ allowed: TInclude[];
17
+ /** Default include-paths if none are provided in the query */
18
+ default?: string[];
19
+ };
20
+ /**
21
+ * Represents a sortable field and its sort direction
22
+ */
23
+ export type SortField<TSort extends string> = {
24
+ /** Field name to sort by */
25
+ field: TSort;
26
+ /**
27
+ * Sort direction.
28
+ *
29
+ * - `asc` for ascending
30
+ * - `desc` for descending
31
+ */
32
+ order: "desc" | "asc";
33
+ };
34
+ /**
35
+ * Represents a list of sortable fields
36
+ */
37
+ export type Sort<TSort extends string> = SortField<TSort>[];
38
+ /**
39
+ * Options for the `sort` query parameter
40
+ */
41
+ export type ParseQuerySortOptions<TFields extends string> = {
42
+ /** List of field names that are allowed to be sorted */
43
+ allowed: TFields[];
44
+ /** Default sorting order, if none is provided in the query */
45
+ default?: SortField<TFields>[];
46
+ /** Whether multiple sort fields are allowed (comma-separated) */
47
+ multiple?: boolean;
48
+ };
49
+ export type SparseFieldSets = Record<string, string[]>;
50
+ export type PartialSparseFieldSets<TFieldSets extends SparseFieldSets> = {
51
+ [K in keyof TFieldSets]?: Partial<TFieldSets[K]>;
52
+ };
53
+ /**
54
+ * Options for the `fields` (sparse fieldsets) query parameter
55
+ *
56
+ * Allows specifying which fields are allowed per resource type.
57
+ */
58
+ export type ParseQuerySparseFieldsetOptions<TAllowed extends Record<string, string[]>> = {
59
+ /** Allowed field names per resource type (e.g., { articles: ["title", "body"] }) */
60
+ allowed: TAllowed;
61
+ /** Default fieldsets to apply when the query omits some or all `fields` */
62
+ default?: PartialSparseFieldSets<TAllowed>;
63
+ };
64
+ /**
65
+ * Configuration object for creating a JSON:API query parser
66
+ *
67
+ * Omitting a property will disallow it in the query parameters.
68
+ */
69
+ export type ParseQueryOptions<TInclude extends string | undefined, TSortFields extends string | undefined, TSparseFieldSets extends SparseFieldSets | undefined, TFilterSchema extends $ZodType | undefined, TPageSchema extends $ZodType | undefined> = {
70
+ /** Configuration for `include` query param */
71
+ include?: TInclude extends string ? ParseQueryIncludeOptions<TInclude> : undefined;
72
+ /** Configuration for `sort` query param */
73
+ sort?: TSortFields extends string ? ParseQuerySortOptions<TSortFields> : undefined;
74
+ /** Configuration for `fields` query param */
75
+ fields?: TSparseFieldSets extends SparseFieldSets ? ParseQuerySparseFieldsetOptions<TSparseFieldSets> : undefined;
76
+ /** Zod schema for validating and parsing the `filter` query param */
77
+ filter?: TFilterSchema;
78
+ /** Zod schema for validating and parsing the `page` query param */
79
+ page?: TPageSchema;
80
+ };
81
+ /**
82
+ * Structured result returned by the query parser
83
+ */
84
+ export type ParseQueryResult<TInclude extends string | undefined, TSortFields extends string | undefined, TSparseFieldSets extends SparseFieldSets | undefined, TFilterSchema extends $ZodType | undefined, TPageSchema extends $ZodType | undefined> = {
85
+ include: TInclude extends string ? ParentPaths<TInclude>[] : undefined;
86
+ sort: TSortFields extends string ? Sort<TSortFields> : undefined;
87
+ fields: TSparseFieldSets extends SparseFieldSets ? PartialSparseFieldSets<TSparseFieldSets> : undefined;
88
+ filter: TFilterSchema extends $ZodType ? z.output<TFilterSchema> : undefined;
89
+ page: TPageSchema extends $ZodType ? z.output<TPageSchema> : undefined;
90
+ };
91
+ /**
92
+ * A query parser that parses a JSON:API-style query string into a strongly typed object.
93
+ */
94
+ export type QueryParser<TInclude extends string | undefined, TSortFields extends string | undefined, TSparseFieldSets extends SparseFieldSets | undefined, TFilterSchema extends $ZodType | undefined, TPageSchema extends $ZodType | undefined> = (searchParams: SearchParamsInput) => ParseQueryResult<TInclude, TSortFields, TSparseFieldSets, TFilterSchema, TPageSchema>;
95
+ /**
96
+ * Creates a query parser for JSON:API-compliant query strings.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * import { createQueryParser } from "jsonapi-serde/request";
101
+ *
102
+ * const parsePostQuery = createQueryParser({
103
+ * include: { allowed: ["author", "comments.author"] },
104
+ * sort: { allowed: ["title", "createdAt"], multiple: false },
105
+ * fields: {
106
+ * allowed: {
107
+ * articles: ["title", "body"],
108
+ * users: ["name"],
109
+ * },
110
+ * },
111
+ * });
112
+ *
113
+ * const query = parsePostQuery("include=author&sort=-createdAt&fields[articles]=title,body");
114
+ * ```
115
+ */
116
+ export declare const createQueryParser: <TInclude extends string | undefined, TSortFields extends string | undefined, TSparseFieldSets extends SparseFieldSets | undefined, TFilterSchema extends $ZodType | undefined, TPageSchema extends $ZodType | undefined>(options: ParseQueryOptions<TInclude, TSortFields, TSparseFieldSets, TFilterSchema, TPageSchema>) => QueryParser<NoInfer<TInclude>, NoInfer<TSortFields>, NoInfer<TSparseFieldSets>, NoInfer<TFilterSchema>, NoInfer<TPageSchema>>;
@@ -0,0 +1,142 @@
1
+ import { z } from "zod/v4";
2
+ import { ZodValidationError, ZodValidationErrorParams } from "../common/error.js";
3
+ import { parseSearchParams } from "../http/index.js";
4
+ /**
5
+ * Builds a schema to validate and transform the `include` parameter
6
+ */
7
+ const buildIncludeSchema = (options) => z
8
+ .string()
9
+ .transform((paths) => (paths === "" ? [] : paths.split(",")))
10
+ .check((context) => {
11
+ for (const path of context.value) {
12
+ if (!options.allowed.some((value) => value === path || value.startsWith(`${path}.`))) {
13
+ context.issues.push({
14
+ code: "custom",
15
+ message: "Invalid include path",
16
+ input: context.value,
17
+ continue: true,
18
+ params: new ZodValidationErrorParams("invalid_include_path", `Path '${path}' cannot be included`),
19
+ });
20
+ }
21
+ }
22
+ })
23
+ .default(options.default ?? []);
24
+ /**
25
+ * Builds a schema to validate and transform the `sort` parameter
26
+ */
27
+ const buildSortSchema = (options) => z
28
+ .string()
29
+ .transform((fields) => {
30
+ if (fields === "") {
31
+ return [];
32
+ }
33
+ return fields.split(",").map((field) => {
34
+ if (field.startsWith("-")) {
35
+ return { field: field.substring(1), order: "desc" };
36
+ }
37
+ return { field, order: "asc" };
38
+ });
39
+ })
40
+ .check((context) => {
41
+ if (context.value.length > 1 && !options.multiple) {
42
+ context.issues.push({
43
+ code: "custom",
44
+ message: "Too many sort fields",
45
+ input: context.value,
46
+ continue: true,
47
+ params: new ZodValidationErrorParams("too_many_sort_fields", "Only a single sort field is allowed"),
48
+ });
49
+ return;
50
+ }
51
+ for (const field of context.value) {
52
+ if (!options.allowed.includes(field.field)) {
53
+ context.issues.push({
54
+ code: "custom",
55
+ message: "Invalid sort field",
56
+ input: context.value,
57
+ continue: true,
58
+ params: new ZodValidationErrorParams("invalid_sort_field", `Field '${field.field}]' cannot be sorted by`),
59
+ });
60
+ }
61
+ }
62
+ })
63
+ .default(options.default ?? []);
64
+ /**
65
+ * Builds a schema to validate and transform the `fields` parameter
66
+ */
67
+ const buildSparseFieldsetSchema = (options) => z.strictObject(Object.fromEntries(Object.entries(options.allowed).map(([type, allowedFields]) => [
68
+ type,
69
+ z
70
+ .string()
71
+ .transform((fields) => (fields === "" ? [] : fields.split(",")))
72
+ .check((context) => {
73
+ for (const field of context.value) {
74
+ if (!allowedFields.includes(field)) {
75
+ context.issues.push({
76
+ code: "custom",
77
+ message: "Unknown resource field",
78
+ input: context.value,
79
+ continue: true,
80
+ params: new ZodValidationErrorParams("unknown_resource_field", `Resource '${type}' has no field with name '${field}'`),
81
+ });
82
+ }
83
+ }
84
+ })
85
+ .optional(),
86
+ ])))
87
+ .default({})
88
+ .transform((fieldSets) => {
89
+ /* node:coverage disable */
90
+ if (!options.default) {
91
+ return fieldSets;
92
+ }
93
+ /* node:coverage enable */
94
+ return { ...options.default, ...fieldSets };
95
+ });
96
+ /**
97
+ * Creates a query parser for JSON:API-compliant query strings.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import { createQueryParser } from "jsonapi-serde/request";
102
+ *
103
+ * const parsePostQuery = createQueryParser({
104
+ * include: { allowed: ["author", "comments.author"] },
105
+ * sort: { allowed: ["title", "createdAt"], multiple: false },
106
+ * fields: {
107
+ * allowed: {
108
+ * articles: ["title", "body"],
109
+ * users: ["name"],
110
+ * },
111
+ * },
112
+ * });
113
+ *
114
+ * const query = parsePostQuery("include=author&sort=-createdAt&fields[articles]=title,body");
115
+ * ```
116
+ */
117
+ export const createQueryParser = (options) => {
118
+ const schema = z.strictObject({
119
+ include: options.include
120
+ ? buildIncludeSchema(options.include)
121
+ : z.undefined({ error: "'include' parameter is not supported" }),
122
+ sort: options.sort
123
+ ? buildSortSchema(options.sort)
124
+ : z.undefined({ error: "'sort' parameter is not supported" }),
125
+ fields: options.fields
126
+ ? buildSparseFieldsetSchema(options.fields)
127
+ : z.undefined({ error: "'fields' parameter is not supported" }),
128
+ filter: options.filter
129
+ ? options.filter
130
+ : z.undefined({ error: "'filter' parameter is not supported" }),
131
+ page: options.page
132
+ ? options.page
133
+ : z.undefined({ error: "'page' parameter is not supported" }),
134
+ });
135
+ return (input) => {
136
+ const parseResult = schema.safeParse(parseSearchParams(input));
137
+ if (!parseResult.success) {
138
+ throw new ZodValidationError(parseResult.error.issues, "query");
139
+ }
140
+ return parseResult.data;
141
+ };
142
+ };
@@ -0,0 +1 @@
1
+ export * from "./serializer.js";
@@ -0,0 +1 @@
1
+ export * from "./serializer.js";
@@ -0,0 +1,6 @@
1
+ import { JsonApiDocument } from "../common/response.js";
2
+ import type { InferEntity, SerializeMap, SerializeOptions } from "./serializer.js";
3
+ /**
4
+ * Serializes one or more entities into a JSON:API-compliant document
5
+ */
6
+ export declare const serializeDocument: <TMap extends SerializeMap, TType extends keyof TMap & string, TEntity extends InferEntity<TMap[TType]>>(map: TMap, type: TType, entity: TEntity | Iterable<TEntity> | null, options?: SerializeOptions<TMap>) => JsonApiDocument;