@zeroxsolutions/jsonapi 0.12.0 → 0.13.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 +23 -0
- package/README.md +20 -21
- package/dist/lib/negotiation/negotiate.d.ts +12 -14
- package/dist/lib/negotiation/negotiate.d.ts.map +1 -1
- package/dist/lib/negotiation/negotiate.js +24 -38
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 0.13.0 (2026-09-29)
|
|
2
|
+
|
|
3
|
+
### 🩹 Fixes
|
|
4
|
+
|
|
5
|
+
- ⚠️ **jsonapi:** negotiate by the four json:api server rules alone ([67f4529](https://github.com/zeroxsolutions/zeroxsolutions/commit/67f4529))
|
|
6
|
+
|
|
7
|
+
### ⚠️ Breaking Changes
|
|
8
|
+
|
|
9
|
+
- **jsonapi:** negotiate by the four json:api server rules alone ([67f4529](https://github.com/zeroxsolutions/zeroxsolutions/commit/67f4529))
|
|
10
|
+
negotiateJsonApi returns null for a Content-Type that
|
|
11
|
+
is not the JSON:API media type (or absent), and for an Accept that holds
|
|
12
|
+
no JSON:API instance. A route refuses a body it cannot read through its
|
|
13
|
+
own body validation.
|
|
14
|
+
|
|
15
|
+
### ❤️ Thank You
|
|
16
|
+
|
|
17
|
+
- Claude Opus 5.5 (1M context)
|
|
18
|
+
- Lương Văn Tú
|
|
19
|
+
|
|
20
|
+
## 0.12.1 (2026-09-28)
|
|
21
|
+
|
|
22
|
+
This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
|
|
23
|
+
|
|
1
24
|
## 0.12.0 (2026-09-28)
|
|
2
25
|
|
|
3
26
|
### 🩹 Fixes
|
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
|
|
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
|
|
28
|
-
|
|
|
29
|
-
| `@zeroxsolutions/jsonapi`
|
|
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; 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
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- **
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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,21 +1,19 @@
|
|
|
1
1
|
import { JsonApiError } from '@zeroxsolutions/response';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - **406**
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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;
|
|
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
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.13.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.14.
|
|
33
|
+
"@zeroxsolutions/response": "0.14.2"
|
|
34
34
|
}
|
|
35
35
|
}
|