@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 +22 -0
- package/README.md +13 -6
- package/dist/lib/negotiation/media-type.d.ts +23 -4
- package/dist/lib/negotiation/media-type.d.ts.map +1 -1
- package/dist/lib/negotiation/media-type.js +30 -8
- package/dist/lib/negotiation/negotiate.d.ts +12 -6
- package/dist/lib/negotiation/negotiate.d.ts.map +1 -1
- package/dist/lib/negotiation/negotiate.js +33 -14
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
7
|
-
export
|
|
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,
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
5
|
-
*
|
|
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.
|
|
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;
|
|
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
|
-
/**
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
14
|
-
*
|
|
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.
|
|
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 && !
|
|
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
|
|
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.
|
|
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.
|
|
33
|
+
"@zeroxsolutions/response": "0.14.0"
|
|
34
34
|
}
|
|
35
35
|
}
|