@zeroxsolutions/jsonapi 0.12.1 → 0.13.1

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 CHANGED
@@ -1,3 +1,33 @@
1
+ ## 0.13.1 (2026-09-30)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **jsonapi:** build the request document a client sends ([20ca1a6](https://github.com/zeroxsolutions/zeroxsolutions/commit/20ca1a6))
6
+
7
+ ### ❤️ Thank You
8
+
9
+ - Claude Opus 5.5 (1M context)
10
+ - Lương Văn Tú
11
+
12
+ ## 0.13.0 (2026-09-29)
13
+
14
+ ### 🩹 Fixes
15
+
16
+ - ⚠️ **jsonapi:** negotiate by the four json:api server rules alone ([67f4529](https://github.com/zeroxsolutions/zeroxsolutions/commit/67f4529))
17
+
18
+ ### ⚠️ Breaking Changes
19
+
20
+ - **jsonapi:** negotiate by the four json:api server rules alone ([67f4529](https://github.com/zeroxsolutions/zeroxsolutions/commit/67f4529))
21
+ negotiateJsonApi returns null for a Content-Type that
22
+ is not the JSON:API media type (or absent), and for an Accept that holds
23
+ no JSON:API instance. A route refuses a body it cannot read through its
24
+ own body validation.
25
+
26
+ ### ❤️ Thank You
27
+
28
+ - Claude Opus 5.5 (1M context)
29
+ - Lương Văn Tú
30
+
1
31
  ## 0.12.1 (2026-09-28)
2
32
 
3
33
  This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The [JSON:API v1.1](https://jsonapi.org/) halves that are **not** a response body: content negotiation
4
4
  on the request, and document deserialization on the client.
5
5
 
6
- Everything a server puts *in* a response - the document schemas, the serializers, the `errors[]`
6
+ Everything a server puts _in_ a response - the document schemas, the serializers, the `errors[]`
7
7
  document - is `@zeroxsolutions/response`, and the central `onError` that renders it is
8
8
  `@zeroxsolutions/server`. The **query families** are `@zeroxsolutions/query`, which speaks this
9
9
  standard's spelling (`page[limit]`, a comma-separated `sort`, the labelled `filter` grammar,
@@ -24,10 +24,10 @@ what keeps `hono` out of the package an SPA also loads.
24
24
 
25
25
  ## Entry points
26
26
 
27
- | Import | Use in | What it holds |
28
- | --- | --- | --- |
29
- | `@zeroxsolutions/jsonapi` | services, gateway | `negotiateJsonApi` - the whole decision, from `Content-Type`, `Accept` and the `ext` URIs this surface supports (`supportedExtensions`), to the `JsonApiError` to reject with or `null`: 415 on a write unless `Content-Type` is exactly the JSON:API media type, carrying no parameter beyond `ext`/`profile` and no `ext` outside `supportedExtensions` - a body sent with no `Content-Type` header is refused the same way; 406 unless some `Accept` member names that same servable JSON:API media type - once one does, only JSON:API members decide - or, failing that, a wildcard range weighted above zero |
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 |
27
+ | Import | Use in | What it holds |
28
+ | -------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `@zeroxsolutions/jsonapi` | services, gateway | `negotiateJsonApi` - the whole decision, from `Content-Type`, `Accept` and the `ext` URIs this surface supports (`supportedExtensions`), to the `JsonApiError` to reject with or `null`: 415 where `Content-Type` is the JSON:API media type carrying a parameter beyond `ext`/`profile` or an `ext` outside `supportedExtensions`; 406 where `Accept` holds JSON:API instances and every one carries such a parameter or `ext`; nothing else |
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; `toRequestDocument`, the JSON:API request document a write sends, each relationship wrapped in its relationship object; and `ApiError`, the non-2xx a caller throws once a response has come back |
31
31
 
32
32
  A transport mounts the decision rather than calling it: `jsonApiContentNegotiation` is the Hono adapter
33
33
  over it, and it lives with the other framework wiring. `negotiateJsonApi` itself takes the surface's
@@ -59,19 +59,18 @@ through the same central `onError` as every other failure rather than a shape of
59
59
  offending header in `source` and, in `detail`, what the surface reads or answers in - never the value the
60
60
  caller sent, which teaches the caller nothing it did not already have.
61
61
 
62
- What 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. Where the route does read one, a
67
- body sent with no `Content-Type` header is refused the same way an unservable type is.
68
- - **Once `Accept` names the JSON:API media type at all, only those members decide.** The standard answers 406
69
- when every one of them is unacceptable, and a wildcard sitting beside them does not change that. Failing
70
- that, a wildcard range weighted above zero is enough.
71
- - **A `q` of 0 refuses that member**, the same way a parameter the media type does not allow does.
72
- - **An `ext` URI outside `supportedExtensions` makes that JSON:API instance unservable** - a 415 where it
73
- names `Content-Type`, or dropped from the members `Accept` considers, which is a 406 where nothing else in
74
- it serves. `supportedExtensions` is `negotiateJsonApi`'s fourth parameter: the extension URIs this surface
75
- answers `ext` requests for, empty by default, which refuses every `ext`.
76
- - **An empty `Accept` is refused.** RFC 9110 12.5.1 gives an *absent* header any media type, but an empty
77
- field-value is a zero-member list, and no member of it names a range this surface can answer.
62
+ The decision is the four server rules of JSON:API v1.1 and nothing more:
63
+
64
+ - **Only the JSON:API media type is judged.** A `Content-Type` naming another type, or none, and an `Accept`
65
+ naming no JSON:API instance, are cases the standard sets no rule for, so they pass. A body the route
66
+ cannot read is refused by the route's own body validation.
67
+ - **The body is never consulted.** A `Content-Type` with a disallowed parameter is a 415 on a request with no
68
+ body too, because the standard's rule names the header, not the content. A runtime decides for itself
69
+ whether a bodyless request has a `body` (workerd hands every POST a stream), so a decision keyed on it
70
+ answers differently per runtime.
71
+ - **In `Accept`, `q` is a weight, not a parameter.** An instance weighted 0 is still unmodified, so it does
72
+ not make the header a 406.
73
+ - **An `ext` URI outside `supportedExtensions` makes that JSON:API instance unusable** - a 415 where it
74
+ names `Content-Type`, or one fewer usable member in `Accept`, which is a 406 where none is left.
75
+ `supportedExtensions` is `negotiateJsonApi`'s fourth parameter: the extension URIs this surface answers
76
+ `ext` requests for, empty by default, which refuses every `ext`.
@@ -1,7 +1,9 @@
1
1
  /**
2
- * The client half of the standard: the document a response carries, flattened once, and the failure a caller throws.
2
+ * The client half of the standard: the document a response carries, flattened once, the document a write sends, and
3
+ * the failure a caller throws.
3
4
  */
4
5
  export { ApiError } from './api-error.js';
5
6
  export { deserializeDocument } from './deserialize.js';
6
7
  export { readCollection, readRelated, readResource, type Collection, type CollectionOf, type Resource, type ResourceOf, } from './read-document.js';
8
+ export { toRequestDocument, type RelationshipInput, type ToRequestDocument } from './request-document.js';
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;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;AAC5B,OAAO,EAAE,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC"}
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The client half of the standard: the document a response carries, flattened once, and the failure a caller throws.
2
+ * The client half of the standard: the document a response carries, flattened once, the document a write sends, and
3
+ * the failure a caller throws.
3
4
  */
4
5
  export { ApiError } from './api-error.js';
5
6
  export { deserializeDocument } from './deserialize.js';
6
7
  export { readCollection, readRelated, readResource, } from './read-document.js';
8
+ export { toRequestDocument } from './request-document.js';
@@ -0,0 +1,43 @@
1
+ /** One relationship as a client writes it: the related resource's identifier, `null` to clear a to-one, or a list. */
2
+ export type RelationshipInput = {
3
+ readonly type: string;
4
+ readonly id: string;
5
+ } | null | readonly {
6
+ readonly type: string;
7
+ readonly id: string;
8
+ }[];
9
+ /**
10
+ * The request document {@link toRequestDocument} returns: the `type`, then each member it was handed and no other, so
11
+ * a route's declared body checks it member by member.
12
+ */
13
+ export type ToRequestDocument<T extends string, Id, A, L> = {
14
+ data: {
15
+ type: T;
16
+ } & ([Id] extends [string] ? {
17
+ id: Id;
18
+ } : unknown) & ([A] extends [never] ? unknown : {
19
+ attributes: A;
20
+ }) & ([L] extends [never] ? unknown : {
21
+ relationships: {
22
+ [K in keyof L]: {
23
+ data: L[K];
24
+ };
25
+ };
26
+ });
27
+ };
28
+ /**
29
+ * Builds the JSON:API request document a write sends: one resource object of `type`, its attributes, and each
30
+ * relationship wrapped in the relationship object the standard requires, so no caller writes `{ data: ... }` by hand.
31
+ *
32
+ * The result is typed from the call alone, and never from the type it is assigned to, so a route's declared body can
33
+ * refuse a document missing the `id` an update needs.
34
+ *
35
+ * @param init - `id` on an update and never on a create; the attributes the write changes; each relationship as the
36
+ * related resource's identifier, `null` to clear a to-one, or a list for a to-many
37
+ */
38
+ export declare function toRequestDocument<const T extends string, Id extends string | undefined = undefined, A extends object = never, const L extends Readonly<Record<string, RelationshipInput>> = never>(type: T, init: {
39
+ id?: Id;
40
+ attributes?: A;
41
+ relationships?: L;
42
+ }): ToRequestDocument<T, NoInfer<Id>, NoInfer<A>, NoInfer<L>>;
43
+ //# sourceMappingURL=request-document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-document.d.ts","sourceRoot":"","sources":["../../src/client/request-document.ts"],"names":[],"mappings":"AAAA,sHAAsH;AACtH,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC9C,IAAI,GACJ,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC;AAE9D;;;GAGG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,SAAS,MAAM,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI;IAC1D,IAAI,EAAE;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,GAAG,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG;QAAE,EAAE,EAAE,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAChE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,OAAO,GAAG;QAAE,UAAU,EAAE,CAAC,CAAA;KAAE,CAAC,GACnD,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,OAAO,GAAG;QAAE,aAAa,EAAE;aAAG,CAAC,IAAI,MAAM,CAAC,GAAG;gBAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;aAAE;SAAE,CAAA;KAAE,CAAC,CAAC;CAC3F,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,CAAC,CAAC,SAAS,MAAM,EACtB,EAAE,SAAS,MAAM,GAAG,SAAS,GAAG,SAAS,EACzC,CAAC,SAAS,MAAM,GAAG,KAAK,EACxB,KAAK,CAAC,CAAC,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,GAAG,KAAK,EAEnE,IAAI,EAAE,CAAC,EACP,IAAI,EAAE;IAAE,EAAE,CAAC,EAAE,EAAE,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,CAAC;IAAC,aAAa,CAAC,EAAE,CAAC,CAAA;CAAE,GACnD,iBAAiB,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAe3D"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Builds the JSON:API request document a write sends: one resource object of `type`, its attributes, and each
3
+ * relationship wrapped in the relationship object the standard requires, so no caller writes `{ data: ... }` by hand.
4
+ *
5
+ * The result is typed from the call alone, and never from the type it is assigned to, so a route's declared body can
6
+ * refuse a document missing the `id` an update needs.
7
+ *
8
+ * @param init - `id` on an update and never on a create; the attributes the write changes; each relationship as the
9
+ * related resource's identifier, `null` to clear a to-one, or a list for a to-many
10
+ */
11
+ export function toRequestDocument(type, init) {
12
+ const { id, attributes, relationships } = init;
13
+ const linkage = relationships === undefined
14
+ ? undefined
15
+ : Object.fromEntries(Object.entries(relationships).map(([name, data]) => [name, { data }]));
16
+ // The members are the ones handed, which is what the conditional type above spells.
17
+ return {
18
+ data: {
19
+ type,
20
+ ...(id === undefined ? {} : { id }),
21
+ ...(attributes === undefined ? {} : { attributes }),
22
+ ...(linkage === undefined ? {} : { relationships: linkage }),
23
+ },
24
+ };
25
+ }
@@ -1,21 +1,19 @@
1
1
  import { JsonApiError } from '@zeroxsolutions/response';
2
2
  /**
3
- * Framework-neutral JSON:API v1.1 content-negotiation decision computed from request headers:
4
- * - **415** unless `Content-Type` is exactly the JSON:API media type, carrying no parameter other than
5
- * `ext`/`profile` and no `ext` URI outside `supportedExtensions`. An empty `Content-Type` means a body sent
6
- * without one, and is refused the same way: a wildcard never stands in for the one type this surface reads.
7
- * - **406** unless some `Accept` member names that same servable JSON:API media type, or, where none does, a
8
- * wildcard range weighted above zero. An extension outside `supportedExtensions` makes a JSON:API instance
9
- * unservable, which is a 415 in `Content-Type` and drops that instance from `Accept`. A `q` of 0 refuses
10
- * that member the same way a disallowed parameter does.
3
+ * JSON:API v1.1 content negotiation, decided from the request headers and nothing else. The standard's four
4
+ * server rules are the whole of it:
5
+ * - **415** where `Content-Type` is the JSON:API media type and carries a parameter other than `ext`/`profile`,
6
+ * or an `ext` URI outside `supportedExtensions`.
7
+ * - **406** where `Accept` holds JSON:API instances and every one carries a parameter other than
8
+ * `ext`/`profile`, or an `ext` URI outside `supportedExtensions`.
11
9
  *
12
- * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
13
- * empty field-value is a zero-member list rather than an absent header, so nothing in it names a range this
14
- * surface can answer.
10
+ * Nothing else is refused. A `Content-Type` naming another media type, or none, and an `Accept` naming no
11
+ * JSON:API instance, are cases the standard sets no rule for, so a body in another type reaches the route,
12
+ * whose body validation refuses what it cannot read. The rules hold whether or not the request carries a
13
+ * body, so no runtime's idea of an empty body can change the answer.
15
14
  *
16
- * @param contentType - `undefined` where the request carries no content: RFC 9110 gives a GET payload no
17
- * defined semantics, so reading it there would make every 415 on a read route a false one. `''` means a body
18
- * sent with no `Content-Type` header.
15
+ * @param contentType - the `Content-Type` header, `undefined` where the request sent none
16
+ * @param accept - the `Accept` header, `undefined` where the request sent none
19
17
  * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
20
18
  * @param supportedExtensions - the extension URIs this surface answers `ext` requests for; empty by default
21
19
  * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
@@ -1 +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;AA4B/F;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,SAAS,EAAE,MAAM,EACjB,mBAAmB,GAAE,WAAW,CAAC,MAAM,CAAa,GACnD,YAAY,GAAG,IAAI,CAuBrB"}
1
+ {"version":3,"file":"negotiate.d.ts","sourceRoot":"","sources":["../../../src/lib/negotiation/negotiate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAuB,MAAM,0BAA0B,CAAC;AAQ/F;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,SAAS,EAAE,MAAM,EACjB,mBAAmB,GAAE,WAAW,CAAC,MAAM,CAAa,GACnD,YAAY,GAAG,IAAI,CA4BrB"}
@@ -1,64 +1,50 @@
1
1
  import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
2
2
  import { extensionsOf, 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
- /** A JSON:API instance this surface can answer: weighted above zero, no parameter but ext/profile, every ext supported. */
6
- function isServable(parsed, supported) {
7
- return (parsed.type === JSON_API_MEDIA_TYPE &&
8
- parsed.weight > 0 &&
9
- !isJsonApiWithDisallowedParams(parsed) &&
10
- extensionsOf(parsed).every((uri) => supported.has(uri)));
3
+ /** A JSON:API instance this surface can use: no parameter but ext/profile, and every ext supported. */
4
+ function isUsable(parsed, supported) {
5
+ return !isJsonApiWithDisallowedParams(parsed) && extensionsOf(parsed).every((uri) => supported.has(uri));
11
6
  }
12
7
  /**
13
- * Whether an Accept header names something this surface answers in. Where it names the JSON:API media type at
14
- * all, only those instances decide: the standard answers 406 when every one is modified, and a wildcard beside
15
- * them does not change that. Otherwise a wildcard weighted above zero is enough.
16
- */
17
- function isAcceptable(accept, supported) {
18
- const members = accept.split(',').map((member) => parseMediaType(member));
19
- const jsonApi = members.filter((member) => member.type === JSON_API_MEDIA_TYPE);
20
- if (jsonApi.length > 0)
21
- return jsonApi.some((member) => isServable(member, supported));
22
- return members.some((member) => WILDCARD_RANGES.has(member.type) && member.weight > 0);
23
- }
24
- /**
25
- * Framework-neutral JSON:API v1.1 content-negotiation decision computed from request headers:
26
- * - **415** unless `Content-Type` is exactly the JSON:API media type, carrying no parameter other than
27
- * `ext`/`profile` and no `ext` URI outside `supportedExtensions`. An empty `Content-Type` means a body sent
28
- * without one, and is refused the same way: a wildcard never stands in for the one type this surface reads.
29
- * - **406** unless some `Accept` member names that same servable JSON:API media type, or, where none does, a
30
- * wildcard range weighted above zero. An extension outside `supportedExtensions` makes a JSON:API instance
31
- * unservable, which is a 415 in `Content-Type` and drops that instance from `Accept`. A `q` of 0 refuses
32
- * that member the same way a disallowed parameter does.
8
+ * JSON:API v1.1 content negotiation, decided from the request headers and nothing else. The standard's four
9
+ * server rules are the whole of it:
10
+ * - **415** where `Content-Type` is the JSON:API media type and carries a parameter other than `ext`/`profile`,
11
+ * or an `ext` URI outside `supportedExtensions`.
12
+ * - **406** where `Accept` holds JSON:API instances and every one carries a parameter other than
13
+ * `ext`/`profile`, or an `ext` URI outside `supportedExtensions`.
33
14
  *
34
- * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
35
- * empty field-value is a zero-member list rather than an absent header, so nothing in it names a range this
36
- * surface can answer.
15
+ * Nothing else is refused. A `Content-Type` naming another media type, or none, and an `Accept` naming no
16
+ * JSON:API instance, are cases the standard sets no rule for, so a body in another type reaches the route,
17
+ * whose body validation refuses what it cannot read. The rules hold whether or not the request carries a
18
+ * body, so no runtime's idea of an empty body can change the answer.
37
19
  *
38
- * @param contentType - `undefined` where the request carries no content: RFC 9110 gives a GET payload no
39
- * defined semantics, so reading it there would make every 415 on a read route a false one. `''` means a body
40
- * sent with no `Content-Type` header.
20
+ * @param contentType - the `Content-Type` header, `undefined` where the request sent none
21
+ * @param accept - the `Accept` header, `undefined` where the request sent none
41
22
  * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
42
23
  * @param supportedExtensions - the extension URIs this surface answers `ext` requests for; empty by default
43
24
  * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
44
25
  */
45
26
  export function negotiateJsonApi(contentType, accept, namespace, supportedExtensions = new Set()) {
46
- if (contentType !== undefined && !isServable(parseMediaType(contentType, { readWeight: false }), supportedExtensions)) {
27
+ const content = contentType === undefined ? undefined : parseMediaType(contentType, { readWeight: false });
28
+ if (content?.type === JSON_API_MEDIA_TYPE && !isUsable(content, supportedExtensions)) {
47
29
  return new JsonApiError({
48
30
  // The entry FIRST: it carries no `code` today, and the explicit one has to win the day it does.
49
31
  ...BASE_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
50
32
  code: `${namespace}.unsupported_media_type`,
51
33
  // Names what the surface reads rather than what arrived: a failure body is a client-facing projection
52
34
  // too, and echoing the caller's own value teaches it nothing it did not send.
53
- detail: `This surface reads ${JSON_API_MEDIA_TYPE}.`,
35
+ detail: `This surface reads ${JSON_API_MEDIA_TYPE} with no parameter but a supported ext or a profile.`,
54
36
  source: { header: 'Content-Type' },
55
37
  });
56
38
  }
57
- if (accept !== undefined && !isAcceptable(accept, supportedExtensions)) {
39
+ const instances = (accept ?? '')
40
+ .split(',')
41
+ .map((member) => parseMediaType(member))
42
+ .filter((member) => member.type === JSON_API_MEDIA_TYPE);
43
+ if (instances.length > 0 && !instances.some((instance) => isUsable(instance, supportedExtensions))) {
58
44
  return new JsonApiError({
59
45
  ...BASE_ERROR_CODES.NOT_ACCEPTABLE,
60
46
  code: `${namespace}.not_acceptable`,
61
- detail: `This surface answers ${JSON_API_MEDIA_TYPE}.`,
47
+ detail: `This surface answers ${JSON_API_MEDIA_TYPE} with no parameter but a supported ext or a profile.`,
62
48
  source: { header: 'Accept' },
63
49
  });
64
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/jsonapi",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -30,6 +30,6 @@
30
30
  "dependencies": {
31
31
  "jsona": "^1.12.0",
32
32
  "tslib": "^2.3.0",
33
- "@zeroxsolutions/response": "0.14.1"
33
+ "@zeroxsolutions/response": "0.14.3"
34
34
  }
35
35
  }