@zeroxsolutions/jsonapi 0.11.3 → 0.12.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 CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.12.0 (2026-09-28)
2
+
3
+ ### 🩹 Fixes
4
+
5
+ - **jsonapi:** read every content-type parameter, q included ([07f81b9](https://github.com/zeroxsolutions/zeroxsolutions/commit/07f81b9))
6
+ - ⚠️ **jsonapi:** negotiate ext, q, wildcards and content-type per spec ([2e2ffad](https://github.com/zeroxsolutions/zeroxsolutions/commit/2e2ffad))
7
+ - **jsonapi:** read q as a weight when parsing a media type ([5403c39](https://github.com/zeroxsolutions/zeroxsolutions/commit/5403c39))
8
+
9
+ ### ⚠️ Breaking Changes
10
+
11
+ - **jsonapi:** negotiate ext, q, wildcards and content-type per spec ([2e2ffad](https://github.com/zeroxsolutions/zeroxsolutions/commit/2e2ffad))
12
+ a Content-Type wildcard or a missing Content-Type is 415; an unsupported ext is 415 or 406; a wildcard no longer hides modified JSON:API Accept members; q=0 refuses a member.
13
+
14
+ ### ❤️ Thank You
15
+
16
+ - Claude Opus 5.5 (1M context)
17
+ - Lương Văn Tú
18
+
19
+ ## 0.11.3 (2026-09-27)
20
+
21
+ This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
22
+
1
23
  ## 0.11.2 (2026-09-27)
2
24
 
3
25
  This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
package/README.md CHANGED
@@ -26,7 +26,7 @@ what keeps `hono` out of the package an SPA also loads.
26
26
 
27
27
  | Import | Use in | What it holds |
28
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 |
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
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
31
 
32
32
  A transport mounts the decision rather than calling it: `jsonApiContentNegotiation` is the Hono adapter
@@ -59,12 +59,19 @@ 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
- Two decisions the headers alone do not settle:
62
+ What the headers alone do not settle:
63
63
 
64
64
  - **`Content-Type` is read only where the request carries a body.** RFC 9110 gives a GET payload no defined
65
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.
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`.
67
76
  - **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.
77
+ field-value is a zero-member list, and no member of it names a range this surface can answer.
@@ -1,10 +1,29 @@
1
- /** A parsed media type: its base `type` (lower-cased) and the parameter names present. */
1
+ /** A parsed media type: its base type (lower-cased), its parameters up to the weight, and the weight. */
2
2
  export interface ParsedMediaType {
3
3
  type: string;
4
- params: string[];
4
+ params: Map<string, string>;
5
+ /** The `q` weight, 1 where none is given. It is not a parameter of the media type (RFC 9110 12.4.2). */
6
+ weight: number;
5
7
  }
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
+ /** Tunes {@link parseMediaType} for which header it reads a media type off. */
9
+ export interface ParseMediaTypeOptions {
10
+ /**
11
+ * Whether `q` ends the media type's own parameters and starts the accept-params RFC 9110 12.4.2 defines -
12
+ * true for an `Accept` member. A `Content-Type` carries no such split: `q` there is a parameter like any
13
+ * other, so it is read as one rather than as a weight.
14
+ */
15
+ readWeight?: boolean;
16
+ }
17
+ /**
18
+ * Parses one media type or Accept member into its type, its parameters, and its weight.
19
+ *
20
+ * `q` is a weight only where {@link ParseMediaTypeOptions.readWeight} says so (the default, for `Accept`);
21
+ * everywhere else it is one parameter among the rest, so `Content-Type` keeps every one it is sent, `q`
22
+ * included, and a wrong one there is still caught as a disallowed parameter rather than dropped unread.
23
+ */
24
+ export declare function parseMediaType(value: string, options?: ParseMediaTypeOptions): ParsedMediaType;
25
+ /** The extension URIs an `ext` parameter names, which is a space-separated list. */
26
+ export declare function extensionsOf(parsed: ParsedMediaType): string[];
8
27
  /** True if the media type is JSON:API but carries a parameter other than `ext`/`profile`. */
9
28
  export declare function isJsonApiWithDisallowedParams(parsed: ParsedMediaType): boolean;
10
29
  //# sourceMappingURL=media-type.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"media-type.d.ts","sourceRoot":"","sources":["../../../src/lib/negotiation/media-type.ts"],"names":[],"mappings":"AAKA,yGAAyG;AACzG,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,wGAAwG;IACxG,MAAM,EAAE,MAAM,CAAC;CAChB;AAID,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,GAAG,eAAe,CAiBlG;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,EAAE,CAE9D;AAED,6FAA6F;AAC7F,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAE9E"}
@@ -1,16 +1,38 @@
1
1
  import { JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
2
2
  /** Media-type parameters JSON:API v1.1 permits on its media type. */
3
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) {
4
+ const unquote = (value) => (value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value);
5
+ /**
6
+ * Parses one media type or Accept member into its type, its parameters, and its weight.
7
+ *
8
+ * `q` is a weight only where {@link ParseMediaTypeOptions.readWeight} says so (the default, for `Accept`);
9
+ * everywhere else it is one parameter among the rest, so `Content-Type` keeps every one it is sent, `q`
10
+ * included, and a wrong one there is still caught as a disallowed parameter rather than dropped unread.
11
+ */
12
+ export function parseMediaType(value, options = {}) {
13
+ const { readWeight = true } = options;
6
14
  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 };
15
+ const params = new Map();
16
+ let weight = 1;
17
+ for (const part of rest) {
18
+ const [rawName = '', ...rawValue] = part.split('=');
19
+ const name = rawName.trim().toLowerCase();
20
+ if (name === '')
21
+ continue;
22
+ const text = unquote(rawValue.join('=').trim());
23
+ if (readWeight && name === 'q') {
24
+ weight = Number(text);
25
+ break;
26
+ }
27
+ params.set(name, text);
28
+ }
29
+ return { type: (rawType ?? '').trim().toLowerCase(), params, weight: Number.isNaN(weight) ? 0 : weight };
30
+ }
31
+ /** The extension URIs an `ext` parameter names, which is a space-separated list. */
32
+ export function extensionsOf(parsed) {
33
+ return (parsed.params.get('ext') ?? '').split(' ').filter(Boolean);
12
34
  }
13
35
  /** True if the media type is JSON:API but carries a parameter other than `ext`/`profile`. */
14
36
  export function isJsonApiWithDisallowedParams(parsed) {
15
- return parsed.type === JSON_API_MEDIA_TYPE && parsed.params.some((param) => !ALLOWED_PARAMS.has(param));
37
+ return parsed.type === JSON_API_MEDIA_TYPE && [...parsed.params.keys()].some((param) => !ALLOWED_PARAMS.has(param));
16
38
  }
@@ -1,18 +1,24 @@
1
1
  import { JsonApiError } from '@zeroxsolutions/response';
2
2
  /**
3
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.
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.
6
11
  *
7
12
  * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
8
13
  * 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.
14
+ * surface can answer.
11
15
  *
12
16
  * @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
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.
14
19
  * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
20
+ * @param supportedExtensions - the extension URIs this surface answers `ext` requests for; empty by default
15
21
  * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
16
22
  */
17
- export declare function negotiateJsonApi(contentType: string | undefined, accept: string | undefined, namespace: string): JsonApiError | null;
23
+ export declare function negotiateJsonApi(contentType: string | undefined, accept: string | undefined, namespace: string, supportedExtensions?: ReadonlySet<string>): JsonApiError | null;
18
24
  //# sourceMappingURL=negotiate.d.ts.map
@@ -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;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"}
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,30 +1,49 @@
1
1
  import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
2
- import { isJsonApiWithDisallowedParams, parseMediaType } from './media-type.js';
2
+ import { extensionsOf, isJsonApiWithDisallowedParams, parseMediaType } from './media-type.js';
3
3
  /** The ranges an `Accept` member may name and still be satisfied by the one type a JSON:API surface answers in. */
4
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);
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)));
11
+ }
12
+ /**
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);
10
23
  }
11
24
  /**
12
25
  * 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.
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.
15
33
  *
16
34
  * An EMPTY `Accept` is refused, deliberately: RFC 9110 12.5.1 gives an ABSENT header any media type, and an
17
35
  * 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.
36
+ * surface can answer.
20
37
  *
21
38
  * @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
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.
23
41
  * @param namespace - the vocabulary this surface's own codes carry, so a refusal reads as one of them
42
+ * @param supportedExtensions - the extension URIs this surface answers `ext` requests for; empty by default
24
43
  * @returns the {@link JsonApiError} to reject with, or `null` when negotiation passes
25
44
  */
26
- export function negotiateJsonApi(contentType, accept, namespace) {
27
- if (contentType !== undefined && !isAcceptable(parseMediaType(contentType))) {
45
+ export function negotiateJsonApi(contentType, accept, namespace, supportedExtensions = new Set()) {
46
+ if (contentType !== undefined && !isServable(parseMediaType(contentType, { readWeight: false }), supportedExtensions)) {
28
47
  return new JsonApiError({
29
48
  // The entry FIRST: it carries no `code` today, and the explicit one has to win the day it does.
30
49
  ...BASE_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
@@ -35,7 +54,7 @@ export function negotiateJsonApi(contentType, accept, namespace) {
35
54
  source: { header: 'Content-Type' },
36
55
  });
37
56
  }
38
- if (accept !== undefined && !accept.split(',').map(parseMediaType).some(isAcceptable)) {
57
+ if (accept !== undefined && !isAcceptable(accept, supportedExtensions)) {
39
58
  return new JsonApiError({
40
59
  ...BASE_ERROR_CODES.NOT_ACCEPTABLE,
41
60
  code: `${namespace}.not_acceptable`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/jsonapi",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
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.13.0"
33
+ "@zeroxsolutions/response": "0.14.0"
34
34
  }
35
35
  }