@zeroxsolutions/jsonapi 0.11.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,367 @@
1
+ ## 0.11.0 (2026-09-13)
2
+
3
+ ### 💅 Refactors
4
+
5
+ - ⚠️ **jsonapi:** list the names each entry publishes ([3746762](https://github.com/zeroxsolutions/zeroxsolutions/commit/3746762))
6
+
7
+ ### ⚠️ Breaking Changes
8
+
9
+ - **jsonapi:** list the names each entry publishes ([3746762](https://github.com/zeroxsolutions/zeroxsolutions/commit/3746762))
10
+ the root no longer exports `parseMediaType`,
11
+ `isJsonApiWithDisallowedParams` or `ParsedMediaType`; `negotiateJsonApi`
12
+ is the negotiation decision. `./client` publishes the same names as
13
+ before, from `dist/client/index.js`.
14
+
15
+ ### ❤️ Thank You
16
+
17
+ - Claude Sonnet 5
18
+ - Lương Văn Tú
19
+
20
+ ## 0.10.4 (2026-09-13)
21
+
22
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
23
+
24
+ ## 0.10.3 (2026-09-13)
25
+
26
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
27
+
28
+ ## 0.10.2 (2026-09-13)
29
+
30
+ ### 🧱 Updated Dependencies
31
+
32
+ - Updated @zeroxsolutions/response to 0.11.2
33
+
34
+ ## 0.10.1 (2026-09-07)
35
+
36
+ Version bump only, to take the repackaged `@zeroxsolutions/response`.
37
+
38
+ ## 0.10.0 (2026-09-07)
39
+
40
+ ### 🚀 Features
41
+
42
+ - ⚠️ **server,jsonapi:** one surface, one error-code vocabulary ([32b4aed](https://github.com/zeroxsolutions/zeroxsolutions/commit/32b4aed))
43
+
44
+ ### ⚠️ Breaking Changes
45
+
46
+ - **server,jsonapi:** one surface, one error-code vocabulary ([32b4aed](https://github.com/zeroxsolutions/zeroxsolutions/commit/32b4aed))
47
+ createErrorHandler, createValidationHook and
48
+ jsonApiContentNegotiation take a required `namespace`; negotiateJsonApi takes it
49
+ as a third argument. Codes these mint are now `<namespace>.<name>`.
50
+
51
+ ### ❤️ Thank You
52
+
53
+ - Lương Văn Tú
54
+
55
+ ## 0.9.2 (2026-09-07)
56
+
57
+ ### 💅 Refactors
58
+
59
+ - ⚠️ **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
60
+
61
+ ### ⚠️ Breaking Changes
62
+
63
+ - **query,response,server:** one wire spelling, flat exports ([5c57b3a](https://github.com/zeroxsolutions/zeroxsolutions/commit/5c57b3a))
64
+ `@zeroxsolutions/response/http`, `@zeroxsolutions/query/http`
65
+ and `@zeroxsolutions/server/hono/http` are gone, and the surviving `/jsonapi`
66
+ and `/hono` subpaths move to the package root. Every import drops its subpath.
67
+
68
+ ### ❤️ Thank You
69
+
70
+ - Lương Văn Tú
71
+
72
+ ## 0.9.1 (2026-09-07)
73
+
74
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
75
+
76
+ ## 0.9.0 (2026-09-07)
77
+
78
+ ### 💅 Refactors
79
+
80
+ - ⚠️ **jsonapi:** widen negotiation, move its middleware out ([d28eb2b](https://github.com/zeroxsolutions/zeroxsolutions/commit/d28eb2b))
81
+
82
+ ### ⚠️ Breaking Changes
83
+
84
+ - **jsonapi:** widen negotiation, move its middleware out ([d28eb2b](https://github.com/zeroxsolutions/zeroxsolutions/commit/d28eb2b))
85
+ jsonApiContentNegotiation is no longer exported here; it is
86
+ @zeroxsolutions/server. negotiateJsonApi answers 415 for any
87
+ Content-Type that is not the JSON:API media type, and 406 for an Accept naming
88
+ no range this surface answers, an empty one included - headers that previously
89
+ passed. Its errors now carry `detail` and `source`.
90
+
91
+ ### ❤️ Thank You
92
+
93
+ - Claude Opus 5 (1M context)
94
+ - Lương Văn Tú
95
+
96
+ ## 0.8.4 (2026-09-03)
97
+
98
+ ### 🧱 Updated Dependencies
99
+
100
+ - Updated @zeroxsolutions/response to 0.7.3
101
+
102
+ ## 0.8.3 (2026-09-03)
103
+
104
+ ### 🧱 Updated Dependencies
105
+
106
+ - Updated @zeroxsolutions/response to 0.7.2
107
+
108
+ ## 0.8.2 (2026-09-03)
109
+
110
+ ### 🧱 Updated Dependencies
111
+
112
+ - Updated @zeroxsolutions/response to 0.7.1
113
+
114
+ ## 0.8.1 (2026-09-02)
115
+
116
+ ### 🚀 Features
117
+
118
+ - **jsonapi:** read the related model an include resolved ([6faf408](https://github.com/zeroxsolutions/zeroxsolutions/commit/6faf408))
119
+
120
+ ### ❤️ Thank You
121
+
122
+ - Lương Văn Tú
123
+
124
+ ## 0.8.0 (2026-09-02)
125
+
126
+ ### 🚀 Features
127
+
128
+ - **jsonapi:** carry a failed response as ApiError on the client half ([71e9554](https://github.com/zeroxsolutions/zeroxsolutions/commit/71e9554))
129
+
130
+ ### 🩹 Fixes
131
+
132
+ - ⚠️ **jsonapi:** shape the collection model on the document, not the other spelling ([4ae8d26](https://github.com/zeroxsolutions/zeroxsolutions/commit/4ae8d26))
133
+ - **jsonapi:** add the reader source the previous commit left untracked ([7399114](https://github.com/zeroxsolutions/zeroxsolutions/commit/7399114))
134
+
135
+ ### ⚠️ Breaking Changes
136
+
137
+ - **jsonapi:** shape the collection model on the document, not the other spelling ([4ae8d26](https://github.com/zeroxsolutions/zeroxsolutions/commit/4ae8d26))
138
+ `Collection.items` is `Collection.data`, and `Collection.total`
139
+ is gone - read `meta` for it.
140
+
141
+ ### ❤️ Thank You
142
+
143
+ - Lương Văn Tú
144
+
145
+ ## 0.7.9 (2026-09-02)
146
+
147
+ ### 🚀 Features
148
+
149
+ - **jsonapi:** read a document into a model typed off the route ([02d2205](https://github.com/zeroxsolutions/zeroxsolutions/commit/02d2205))
150
+
151
+ ### ❤️ Thank You
152
+
153
+ - Lương Văn Tú
154
+
155
+ ## 0.7.8 (2026-09-02)
156
+
157
+ ### 🧱 Updated Dependencies
158
+
159
+ - Updated @zeroxsolutions/response to 0.7.0
160
+
161
+ ## 0.7.7 (2026-09-02)
162
+
163
+ ### 🧱 Updated Dependencies
164
+
165
+ - Updated @zeroxsolutions/response to 0.6.1
166
+
167
+ ## 0.7.6 (2026-09-02)
168
+
169
+ ### 🧱 Updated Dependencies
170
+
171
+ - Updated @zeroxsolutions/response to 0.6.0
172
+
173
+ ## 0.7.5 (2026-09-02)
174
+
175
+ ### 🧱 Updated Dependencies
176
+
177
+ - Updated @zeroxsolutions/response to 0.5.0
178
+
179
+ ## 0.7.4 (2026-08-31)
180
+
181
+ ### 🧱 Updated Dependencies
182
+
183
+ - Updated @zeroxsolutions/response to 0.4.0
184
+
185
+ ## 0.7.3 (2026-08-31)
186
+
187
+ ### 🧱 Updated Dependencies
188
+
189
+ - Updated @zeroxsolutions/response to 0.3.0
190
+
191
+ ## 0.7.2 (2026-08-29)
192
+
193
+ ### 💅 Refactors
194
+
195
+ - state an explicit return type on every function ([8ee4264](https://github.com/zeroxsolutions/zeroxsolutions/commit/8ee4264))
196
+
197
+ ### 🧱 Updated Dependencies
198
+
199
+ - Updated @zeroxsolutions/response to 0.2.2
200
+
201
+ ### ❤️ Thank You
202
+
203
+ - Lương Văn Tú
204
+
205
+ ## 0.7.1 (2026-08-28)
206
+
207
+ ### 🧱 Updated Dependencies
208
+
209
+ - Updated @zeroxsolutions/response to 0.2.1
210
+
211
+ ## 0.6.0 (2026-08-16)
212
+
213
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
214
+
215
+ ## 0.5.0 (2026-08-15)
216
+
217
+ ### 🚀 Features
218
+
219
+ - **jsonapi:** let the query guard raise a plain typed error ([3d2084d](https://github.com/zeroxsolutions/zeroxsolutions/commit/3d2084d))
220
+
221
+ ### ❤️ Thank You
222
+
223
+ - Claude Opus 5
224
+ - Lương Văn Tú
225
+
226
+ ## 0.4.1 (2026-08-15)
227
+
228
+ ### 🚀 Features
229
+
230
+ - **jsonapi:** compose a serialized document, not just validate one ([2135153](https://github.com/zeroxsolutions/zeroxsolutions/commit/2135153))
231
+
232
+ ### ❤️ Thank You
233
+
234
+ - Claude Opus 5
235
+ - Lương Văn Tú
236
+
237
+ ## 0.4.0 (2026-08-14)
238
+
239
+ ### 🚀 Features
240
+
241
+ - ⚠️ **jsonapi:** bind the permit-list to a row and an operator vocabulary, and pipe the schema through it ([5f824d0](https://github.com/zeroxsolutions/zeroxsolutions/commit/5f824d0))
242
+ - **jsonapi:** declare a list endpoint's query parameters from its permit-list ([bb3c3c8](https://github.com/zeroxsolutions/zeroxsolutions/commit/bb3c3c8))
243
+
244
+ ### ⚠️ Breaking Changes
245
+
246
+ - **jsonapi:** bind the permit-list to a row and an operator vocabulary, and pipe the schema through it ([5f824d0](https://github.com/zeroxsolutions/zeroxsolutions/commit/5f824d0))
247
+ `jsonApiQuerySchema` now returns a piped schema whose output is
248
+ `GuardedJsonApiQuery`, not the raw query map. Unreleased, so no consumer is affected.
249
+ The two type-level checks are pinned by `@ts-expect-error` in a spec, watched failing
250
+ first: unbinding the generics turns both directives into "unused", which is the whole
251
+ failure mode a type test has.
252
+
253
+ ### ❤️ Thank You
254
+
255
+ - Claude Opus 5 (1M context)
256
+ - Lương Văn Tú
257
+
258
+ ## 0.3.1 (2026-08-08)
259
+
260
+ ### 🩹 Fixes
261
+
262
+ - **jsonapi:** state that an included resource needs its own serializer built from the query ([803aa30](https://github.com/zeroxsolutions/zeroxsolutions/commit/803aa30))
263
+ - **db:** declare the query types once, in the package that owns the wire ([1c3498c](https://github.com/zeroxsolutions/zeroxsolutions/commit/1c3498c))
264
+
265
+ ### ❤️ Thank You
266
+
267
+ - Claude Opus 5 (1M context)
268
+ - luongvantuit
269
+
270
+ ## 0.3.0 (2026-08-08)
271
+
272
+ ### 🚀 Features
273
+
274
+ - ⚠️ **jsonapi:** shape the document by the query, and close the escape hatch ([79da0f2](https://github.com/zeroxsolutions/zeroxsolutions/commit/79da0f2))
275
+
276
+ ### ⚠️ Breaking Changes
277
+
278
+ - **jsonapi:** shape the document by the query, and close the escape hatch ([79da0f2](https://github.com/zeroxsolutions/zeroxsolutions/commit/79da0f2))
279
+ `customFamilies` removed from `JsonApiQuerySpec`, its
280
+ argument removed from `unprocessableQueryParams`, and `isCustomFamilyBase` no
281
+ longer exported. No declaration can accept a parameter outside the families
282
+ the wire standard defines.
283
+
284
+ ### ❤️ Thank You
285
+
286
+ - Claude Opus 5 (1M context)
287
+ - luongvantuit
288
+
289
+ ## 0.2.0 (2026-08-06)
290
+
291
+ ### 🚀 Features
292
+
293
+ - ⚠️ **jsonapi:** bind the list query to a permit-list and parse the filter tree ([2c212b0](https://github.com/zeroxsolutions/zeroxsolutions/commit/2c212b0))
294
+ - **jsonapi:** screen query parameters against the standard's grammar ([e7bd26c](https://github.com/zeroxsolutions/zeroxsolutions/commit/e7bd26c))
295
+ - **jsonapi:** refuse a query the endpoint did not permit ([1dd1df6](https://github.com/zeroxsolutions/zeroxsolutions/commit/1dd1df6))
296
+
297
+ ### ⚠️ Breaking Changes
298
+
299
+ - **jsonapi:** bind the list query to a permit-list and parse the filter tree ([2c212b0](https://github.com/zeroxsolutions/zeroxsolutions/commit/2c212b0))
300
+ `parseJsonApiQuery` and `parseFilter` are removed;
301
+ `filterable` entries are `{ operators, type }` rather than a bare operator
302
+ list; `filter` is a bracketed family, not a JSON-encoded array; queries
303
+ that previously passed unnoticed now receive 400.
304
+
305
+ ### ❤️ Thank You
306
+
307
+ - Claude Opus 5 (1M context)
308
+ - luongvantuit
309
+
310
+ ## 0.1.1 (2026-07-15)
311
+
312
+ ### 🚀 Features
313
+
314
+ - **jsonapi:** allow typing the compound-document included array ([f1faa07](https://github.com/zeroxsolutions/zeroxsolutions/commit/f1faa07))
315
+
316
+ ### ❤️ Thank You
317
+
318
+ - Claude Opus 4.8
319
+ - Lương Văn Tú
320
+
321
+ ## 0.1.0 (2026-07-08)
322
+
323
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
324
+
325
+ ## 0.0.4 (2026-07-06)
326
+
327
+ ### 🚀 Features
328
+
329
+ - **jsonapi:** add a server error handler + error response to /server ([18eedc2](https://github.com/zeroxsolutions/zeroxsolutions/commit/18eedc2))
330
+
331
+ ### ❤️ Thank You
332
+
333
+ - Claude Opus 4.8 (1M context)
334
+ - Lương Văn Tú
335
+
336
+ ## 0.0.3 (2026-07-05)
337
+
338
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
339
+
340
+ ## 0.0.2 (2026-07-05)
341
+
342
+ ### 🚀 Features
343
+
344
+ - **jsonapi:** extract framework-neutral negotiateJsonApi() from the Hono middleware ([e88d8ea](https://github.com/zeroxsolutions/zeroxsolutions/commit/e88d8ea))
345
+
346
+ ### ❤️ Thank You
347
+
348
+ - Claude Opus 4.8 (1M context)
349
+ - Lương Văn Tú
350
+
351
+ ## 0.0.1 (2026-07-05)
352
+
353
+ ### 🚀 Features
354
+
355
+ - ⚠️ add JSON:API v1.1 toolkit and routing lib, dissolve common ([53545a6](https://github.com/zeroxsolutions/zeroxsolutions/commit/53545a6))
356
+
357
+ ### ⚠️ Breaking Changes
358
+
359
+ - add JSON:API v1.1 toolkit and routing lib, dissolve common ([53545a6](https://github.com/zeroxsolutions/zeroxsolutions/commit/53545a6))
360
+ @zeroxsolutions/common is removed. Its error/response
361
+ schemas move to @zeroxsolutions/jsonapi; its path/is-match helpers move to
362
+ @zeroxsolutions/routing.
363
+
364
+ ### ❤️ Thank You
365
+
366
+ - Claude Opus 4.8 (1M context)
367
+ - Lương Văn Tú
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @zeroxsolutions/jsonapi
2
+
3
+ The [JSON:API v1.1](https://jsonapi.org/) halves that are **not** a response body: content negotiation
4
+ on the request, and document deserialization on the client.
5
+
6
+ Everything a server puts *in* a response - the document schemas, the serializers, the `errors[]`
7
+ document - is `@zeroxsolutions/response`, and the central `onError` that renders it is
8
+ `@zeroxsolutions/server`. The **query families** are `@zeroxsolutions/query`, which speaks this
9
+ standard's spelling (`page[limit]`, a comma-separated `sort`, the labelled `filter` grammar,
10
+ `fields[TYPE]` and `include`).
11
+
12
+ Three packages for one standard, split by which side of the exchange each serves: what the client
13
+ asks for, what the server answers, and what the caller may address.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pnpm add @zeroxsolutions/jsonapi @zeroxsolutions/response
19
+ ```
20
+
21
+ No peer dep, and no framework: every export here is a plain function over strings and documents. The
22
+ Hono middleware that mounts the negotiation decision is `@zeroxsolutions/server`, which is
23
+ what keeps `hono` out of the package an SPA also loads.
24
+
25
+ ## Entry points
26
+
27
+ | Import | Use in | What it holds |
28
+ | --- | --- | --- |
29
+ | `@zeroxsolutions/jsonapi` | services, gateway | `negotiateJsonApi` - the whole decision, from two header strings to the `JsonApiError` to reject with or `null`: 415 unless a write's media type is JSON:API carrying at most `ext`/`profile`, 406 unless some `Accept` member names that same type or a wildcard range |
30
+ | `@zeroxsolutions/jsonapi/client` | SPA, external consumers | `deserializeDocument` (jsona), `readResource` / `readCollection` over it - the same flattening, typed off the route's own document - `readRelated` for what an `include` resolved, which no document type can name; and `ApiError`, the non-2xx a caller throws once a response has come back |
31
+
32
+ A transport mounts the decision rather than calling it: `jsonApiContentNegotiation` is the Hono adapter
33
+ over it, and it lives with the other framework wiring. `negotiateJsonApi` itself takes the surface's
34
+ namespace, because the refusal it returns is an error object the surface publishes.
35
+
36
+ ```ts
37
+ // one middleware, before the routes - never a per-handler check
38
+ import { jsonApiContentNegotiation } from '@zeroxsolutions/server';
39
+
40
+ app.use('*', jsonApiContentNegotiation({ namespace: 'api' }));
41
+ ```
42
+
43
+ A client flattens the envelope ONCE, at the layer that fetches, so nothing downstream reads
44
+ `data[i].attributes`. `ResourceOf` / `CollectionOf` derive that flattened shape from the route's own
45
+ response type, which is what keeps the model off a hand-written interface:
46
+
47
+ ```ts
48
+ // the query layer - the only place the document is spelled
49
+ type SubjectList = CollectionOf<InferResponseType<(typeof api.v1.orgs)[':orgId']['subjects']['$get'], 200>>;
50
+ if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => null));
51
+ const { data, meta } = readCollection(await res.json());
52
+ ```
53
+
54
+ Only `data` is flattened. `links` and `meta` keep the names the standard gives them, and `meta` stays
55
+ free-form - the standard puts no member in it, so a row count there is one surface's own convention.
56
+
57
+ Negotiation raises `JsonApiError` from `@zeroxsolutions/response`, so the refusal renders
58
+ through the same central `onError` as every other failure rather than a shape of its own. It names the
59
+ offending header in `source` and, in `detail`, what the surface reads or answers in - never the value the
60
+ caller sent, which teaches the caller nothing it did not already have.
61
+
62
+ Two decisions the headers alone do not settle:
63
+
64
+ - **`Content-Type` is read only where the request carries a body.** RFC 9110 gives a GET payload no defined
65
+ semantics, so a read route has no representation to describe; reading the header there would turn a
66
+ client-wide default of `application/json` into a 415 on every collection.
67
+ - **An empty `Accept` is refused.** RFC 9110 12.5.1 gives an *absent* header any media type, but an empty
68
+ field-value is a zero-member list, and no member of it names a range this surface can answer. A weight
69
+ goes unread, so a wildcard weighted to zero - a client stating that nothing at all is acceptable - is let
70
+ through; every other unsatisfiable header is caught by the range alone.
@@ -0,0 +1,14 @@
1
+ import type { ErrorDocument } from '@zeroxsolutions/response';
2
+ /**
3
+ * A non-2xx response, as the client that made the request holds it.
4
+ *
5
+ * The counterpart of `JsonApiError`, which a SERVER raises for its `onError` to render: this one
6
+ * is what a caller throws once a response has already come back. `body` is null wherever the failing
7
+ * tier emitted no document - a proxy, a gateway timeout, or an auth server with a shape of its own.
8
+ */
9
+ export declare class ApiError extends Error {
10
+ readonly status: number;
11
+ readonly body: ErrorDocument | null;
12
+ constructor(status: number, body: ErrorDocument | null);
13
+ }
14
+ //# sourceMappingURL=api-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../../src/client/api-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE9D;;;;;;GAMG;AACH,qBAAa,QAAS,SAAQ,KAAK;IAE/B,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI;gBAD1B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,aAAa,GAAG,IAAI;CAKtC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A non-2xx response, as the client that made the request holds it.
3
+ *
4
+ * The counterpart of `JsonApiError`, which a SERVER raises for its `onError` to render: this one
5
+ * is what a caller throws once a response has already come back. `body` is null wherever the failing
6
+ * tier emitted no document - a proxy, a gateway timeout, or an auth server with a shape of its own.
7
+ */
8
+ export class ApiError extends Error {
9
+ status;
10
+ body;
11
+ constructor(status, body) {
12
+ super(`Request failed with status ${status}`);
13
+ this.status = status;
14
+ this.body = body;
15
+ this.name = 'ApiError';
16
+ }
17
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Flattens a JSON:API document into plain model object(s) - `{ id, ...attributes }` - via jsona.
3
+ * Returns a single model or an array, mirroring the document's `data`.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const student = deserializeDocument<Student>(document); // { id, ...attributes }
8
+ * ```
9
+ */
10
+ export declare function deserializeDocument<T = unknown>(document: unknown): T;
11
+ //# sourceMappingURL=deserialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deserialize.d.ts","sourceRoot":"","sources":["../../src/client/deserialize.ts"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,CAAC,CAErE"}
@@ -0,0 +1,14 @@
1
+ import { Jsona } from 'jsona';
2
+ const formatter = new Jsona();
3
+ /**
4
+ * Flattens a JSON:API document into plain model object(s) - `{ id, ...attributes }` - via jsona.
5
+ * Returns a single model or an array, mirroring the document's `data`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const student = deserializeDocument<Student>(document); // { id, ...attributes }
10
+ * ```
11
+ */
12
+ export function deserializeDocument(document) {
13
+ return formatter.deserialize(document);
14
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The client half of the standard: the document a response carries, flattened once, and the failure a caller throws.
3
+ */
4
+ export { ApiError } from './api-error.js';
5
+ export { deserializeDocument } from './deserialize.js';
6
+ export { readCollection, readRelated, readResource, type Collection, type CollectionOf, type Resource, type ResourceOf, } from './read-document.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EACL,cAAc,EACd,WAAW,EACX,YAAY,EACZ,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,QAAQ,EACb,KAAK,UAAU,GAChB,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The client half of the standard: the document a response carries, flattened once, and the failure a caller throws.
3
+ */
4
+ export { ApiError } from './api-error.js';
5
+ export { deserializeDocument } from './deserialize.js';
6
+ export { readCollection, readRelated, readResource, } from './read-document.js';
@@ -0,0 +1,51 @@
1
+ import type { CollectionDocument, Meta, PaginationLinks, ResourceDocument } from '@zeroxsolutions/response';
2
+ /** One resource as a client holds it - the attributes, with the resource's own `id` and `type` beside them. */
3
+ export type Resource<A> = A & {
4
+ id: string;
5
+ type: string;
6
+ };
7
+ /** A collection document with its `data` flattened; every other member keeps the name the standard gives it. */
8
+ export interface Collection<A> {
9
+ data: Resource<A>[];
10
+ links?: PaginationLinks;
11
+ meta?: Meta;
12
+ }
13
+ /** The client shape of a single-resource route body, derived from the document that route declares. */
14
+ export type ResourceOf<D> = D extends {
15
+ data: {
16
+ attributes: infer A;
17
+ };
18
+ } ? Resource<A> : never;
19
+ /** The client shape of a collection route body, derived from the document that route declares. */
20
+ export type CollectionOf<D> = D extends {
21
+ data: readonly {
22
+ attributes: infer A;
23
+ }[];
24
+ } ? Collection<A> : never;
25
+ /** Reads a single-resource document into the model a client holds. */
26
+ export declare function readResource<A>(document: ResourceDocument<A>): Resource<A>;
27
+ /**
28
+ * Reads a collection document, flattening `data` and carrying `links` and `meta` through.
29
+ *
30
+ * jsona deserializes `data` alone, so the two top-level members are read here rather than from what
31
+ * {@link deserializeDocument} returns. `meta` stays free-form: the standard puts no member in it, and a
32
+ * count there is one surface's own convention, not a shape this package can promise.
33
+ */
34
+ export declare function readCollection<A>(document: CollectionDocument<A>): Collection<A>;
35
+ /**
36
+ * The related model an `include` resolved onto `name`, or null where the document carried none.
37
+ *
38
+ * A resource's declared attributes cannot name it: the standard puts a related resource in `included`
39
+ * and leaves `relationships.<name>` carrying linkage alone, so what an include resolved to is reachable
40
+ * at runtime and never from the type a route publishes. The caller therefore names the `type` it expects
41
+ * and gets null for anything else.
42
+ *
43
+ * A relationship the document declared but did NOT include deserializes to that linkage - `{ type, id }`
44
+ * and nothing more - which is answered null rather than handed back as a model missing every attribute.
45
+ * A related resource that declares no attributes of its own is indistinguishable from it.
46
+ */
47
+ export declare function readRelated<M extends {
48
+ id: string;
49
+ type: string;
50
+ }>(model: unknown, name: string, type: string): M | null;
51
+ //# sourceMappingURL=read-document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-document.d.ts","sourceRoot":"","sources":["../../src/client/read-document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,IAAI,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAG5G,+GAA+G;AAC/G,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D,gHAAgH;AAChH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,IAAI,CAAC,EAAE,IAAI,CAAC;CACb;AAED,uGAAuG;AACvG,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;CAAE,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAE9F,kGAAkG;AAClG,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS;QAAE,UAAU,EAAE,MAAM,CAAC,CAAA;KAAE,EAAE,CAAA;CAAE,GAAG,UAAU,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAE7G,sEAAsE;AACtE,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAE1E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAMhF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAChE,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,CAAC,GAAG,IAAI,CAMV"}
@@ -0,0 +1,40 @@
1
+ import { deserializeDocument } from './deserialize.js';
2
+ /** Reads a single-resource document into the model a client holds. */
3
+ export function readResource(document) {
4
+ return deserializeDocument(document);
5
+ }
6
+ /**
7
+ * Reads a collection document, flattening `data` and carrying `links` and `meta` through.
8
+ *
9
+ * jsona deserializes `data` alone, so the two top-level members are read here rather than from what
10
+ * {@link deserializeDocument} returns. `meta` stays free-form: the standard puts no member in it, and a
11
+ * count there is one surface's own convention, not a shape this package can promise.
12
+ */
13
+ export function readCollection(document) {
14
+ return {
15
+ data: deserializeDocument(document),
16
+ ...(document.links ? { links: document.links } : {}),
17
+ ...(document.meta ? { meta: document.meta } : {}),
18
+ };
19
+ }
20
+ /**
21
+ * The related model an `include` resolved onto `name`, or null where the document carried none.
22
+ *
23
+ * A resource's declared attributes cannot name it: the standard puts a related resource in `included`
24
+ * and leaves `relationships.<name>` carrying linkage alone, so what an include resolved to is reachable
25
+ * at runtime and never from the type a route publishes. The caller therefore names the `type` it expects
26
+ * and gets null for anything else.
27
+ *
28
+ * A relationship the document declared but did NOT include deserializes to that linkage - `{ type, id }`
29
+ * and nothing more - which is answered null rather than handed back as a model missing every attribute.
30
+ * A related resource that declares no attributes of its own is indistinguishable from it.
31
+ */
32
+ export function readRelated(model, name, type) {
33
+ const related = model?.[name];
34
+ if (related === null || typeof related !== 'object')
35
+ return null;
36
+ const candidate = related;
37
+ if (candidate['type'] !== type)
38
+ return null;
39
+ return Object.keys(candidate).some((key) => key !== 'type' && key !== 'id') ? candidate : null;
40
+ }
@@ -0,0 +1,2 @@
1
+ export { negotiateJsonApi } from './lib/negotiation/index.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { negotiateJsonApi } from './lib/negotiation/index.js';
@@ -0,0 +1,3 @@
1
+ export * from './media-type.js';
2
+ export * from './negotiate.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/negotiation/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from './media-type.js';
2
+ export * from './negotiate.js';
@@ -0,0 +1,10 @@
1
+ /** A parsed media type: its base `type` (lower-cased) and the parameter names present. */
2
+ export interface ParsedMediaType {
3
+ type: string;
4
+ params: string[];
5
+ }
6
+ /** Parses one media-type string (e.g. `application/vnd.api+json; ext="..."`) into its type and param names. */
7
+ export declare function parseMediaType(value: string): ParsedMediaType;
8
+ /** True if the media type is JSON:API but carries a parameter other than `ext`/`profile`. */
9
+ export declare function isJsonApiWithDisallowedParams(parsed: ParsedMediaType): boolean;
10
+ //# sourceMappingURL=media-type.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-type.d.ts","sourceRoot":"","sources":["../../../src/lib/negotiation/media-type.ts"],"names":[],"mappings":"AAKA,0FAA0F;AAC1F,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,+GAA+G;AAC/G,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAO7D;AAED,6FAA6F;AAC7F,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAE9E"}
@@ -0,0 +1,16 @@
1
+ import { JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
2
+ /** Media-type parameters JSON:API v1.1 permits on its media type. */
3
+ const ALLOWED_PARAMS = new Set(['ext', 'profile']);
4
+ /** Parses one media-type string (e.g. `application/vnd.api+json; ext="..."`) into its type and param names. */
5
+ export function parseMediaType(value) {
6
+ const [rawType, ...rest] = value.split(';');
7
+ const type = (rawType ?? '').trim().toLowerCase();
8
+ const params = rest
9
+ .map((part) => part.split('=')[0]?.trim().toLowerCase())
10
+ .filter((name) => Boolean(name));
11
+ return { type, params };
12
+ }
13
+ /** True if the media type is JSON:API but carries a parameter other than `ext`/`profile`. */
14
+ export function isJsonApiWithDisallowedParams(parsed) {
15
+ return parsed.type === JSON_API_MEDIA_TYPE && parsed.params.some((param) => !ALLOWED_PARAMS.has(param));
16
+ }
@@ -0,0 +1,18 @@
1
+ import { JsonApiError } from '@zeroxsolutions/response';
2
+ /**
3
+ * Framework-neutral JSON:API v1.1 content-negotiation decision computed from request headers:
4
+ * - **415** unless `Content-Type` is the JSON:API media type carrying no parameter other than `ext`/`profile`;
5
+ * - **406** unless some `Accept` member names a wildcard range or that same clean JSON:API media type.
6
+ *
7
+ * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
8
+ * empty field-value is a zero-member list rather than an absent header, so nothing in it names a range this
9
+ * surface can answer. A weight goes unread, so a wildcard weighted to zero - a client stating that nothing at
10
+ * all is acceptable - is let through; every other unsatisfiable header is caught by the range alone.
11
+ *
12
+ * @param contentType - `undefined` where the request carries no content: RFC 9110 gives a GET payload no
13
+ * defined semantics, so reading it there would make every 415 on a read route a false one
14
+ * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
15
+ * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
16
+ */
17
+ export declare function negotiateJsonApi(contentType: string | undefined, accept: string | undefined, namespace: string): JsonApiError | null;
18
+ //# sourceMappingURL=negotiate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"negotiate.d.ts","sourceRoot":"","sources":["../../../src/lib/negotiation/negotiate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAuB,MAAM,0BAA0B,CAAC;AAY/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,SAAS,EAAE,MAAM,GAChB,YAAY,GAAG,IAAI,CAuBrB"}
@@ -0,0 +1,47 @@
1
+ import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
2
+ import { isJsonApiWithDisallowedParams, parseMediaType } from './media-type.js';
3
+ /** The ranges an `Accept` member may name and still be satisfied by the one type a JSON:API surface answers in. */
4
+ const WILDCARD_RANGES = new Set(['application/*', '*/*']);
5
+ /** True if this `Accept` member names a range the surface can answer: a wildcard, or clean JSON:API. */
6
+ function isAcceptable(parsed) {
7
+ if (WILDCARD_RANGES.has(parsed.type))
8
+ return true;
9
+ return parsed.type === JSON_API_MEDIA_TYPE && !isJsonApiWithDisallowedParams(parsed);
10
+ }
11
+ /**
12
+ * Framework-neutral JSON:API v1.1 content-negotiation decision computed from request headers:
13
+ * - **415** unless `Content-Type` is the JSON:API media type carrying no parameter other than `ext`/`profile`;
14
+ * - **406** unless some `Accept` member names a wildcard range or that same clean JSON:API media type.
15
+ *
16
+ * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
17
+ * empty field-value is a zero-member list rather than an absent header, so nothing in it names a range this
18
+ * surface can answer. A weight goes unread, so a wildcard weighted to zero - a client stating that nothing at
19
+ * all is acceptable - is let through; every other unsatisfiable header is caught by the range alone.
20
+ *
21
+ * @param contentType - `undefined` where the request carries no content: RFC 9110 gives a GET payload no
22
+ * defined semantics, so reading it there would make every 415 on a read route a false one
23
+ * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
24
+ * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
25
+ */
26
+ export function negotiateJsonApi(contentType, accept, namespace) {
27
+ if (contentType !== undefined && !isAcceptable(parseMediaType(contentType))) {
28
+ return new JsonApiError({
29
+ // The entry FIRST: it carries no `code` today, and the explicit one has to win the day it does.
30
+ ...BASE_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
31
+ code: `${namespace}.unsupported_media_type`,
32
+ // Names what the surface reads rather than what arrived: a failure body is a client-facing projection
33
+ // too, and echoing the caller's own value teaches it nothing it did not send.
34
+ detail: `This surface reads ${JSON_API_MEDIA_TYPE}.`,
35
+ source: { header: 'Content-Type' },
36
+ });
37
+ }
38
+ if (accept !== undefined && !accept.split(',').map(parseMediaType).some(isAcceptable)) {
39
+ return new JsonApiError({
40
+ ...BASE_ERROR_CODES.NOT_ACCEPTABLE,
41
+ code: `${namespace}.not_acceptable`,
42
+ detail: `This surface answers ${JSON_API_MEDIA_TYPE}.`,
43
+ source: { header: 'Accept' },
44
+ });
45
+ }
46
+ return null;
47
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@zeroxsolutions/jsonapi",
3
+ "version": "0.11.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "description": "The JSON:API halves that are not the response body: v1.1 content negotiation (media type, 415/406/400) and the client-side document deserializer. The response document, its serializers and its error document live in @zeroxsolutions/response.",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "!**/*.tsbuildinfo"
16
+ ],
17
+ "exports": {
18
+ "./package.json": "./package.json",
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./client": {
25
+ "types": "./dist/client/index.d.ts",
26
+ "import": "./dist/client/index.js",
27
+ "default": "./dist/client/index.js"
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "jsona": "^1.12.0",
32
+ "tslib": "^2.3.0",
33
+ "@zeroxsolutions/response": "0.12.0"
34
+ }
35
+ }