@zeroxsolutions/jsonapi 0.13.7 → 0.14.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,36 @@
1
+ ## 0.14.0 (2026-10-23)
2
+
3
+ ### 🚀 Features
4
+
5
+ - ⚠️ **jsonapi:** carry a refusal as ResponseError with its Retry-After ([b176c1f](https://github.com/zeroxsolutions/zeroxsolutions/commit/b176c1f))
6
+
7
+ ### ⚠️ Breaking Changes
8
+
9
+ - **jsonapi:** carry a refusal as ResponseError with its Retry-After ([b176c1f](https://github.com/zeroxsolutions/zeroxsolutions/commit/b176c1f))
10
+ ApiError is renamed ResponseError, with no alias left
11
+ behind; its constructor takes retryAfterSeconds as a third argument;
12
+ errorOf takes a clock ({ now(): Date }) as its second argument, and the
13
+ response it reads must carry headers.
14
+ Refs: #76
15
+
16
+ ### ❤️ Thank You
17
+
18
+ - Lương Văn Tú
19
+
20
+ ## 0.13.8 (2026-10-22)
21
+
22
+ ### 🚀 Features
23
+
24
+ - **jsonapi:** read a refused response into an api error ([723fa9a](https://github.com/zeroxsolutions/zeroxsolutions/commit/723fa9a))
25
+
26
+ ### 💅 Refactors
27
+
28
+ - **jsonapi:** name the refused-response read errorOf ([7bd0c02](https://github.com/zeroxsolutions/zeroxsolutions/commit/7bd0c02))
29
+
30
+ ### ❤️ Thank You
31
+
32
+ - Lương Văn Tú
33
+
1
34
  ## 0.13.7 (2026-10-21)
2
35
 
3
36
  This was a version bump only for @zeroxsolutions/jsonapi to align it with other projects, there were no code changes.
package/README.md CHANGED
@@ -27,7 +27,7 @@ what keeps `hono` out of the package an SPA also loads.
27
27
  | Import | Use in | What it holds |
28
28
  | -------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
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. `findOptionsOf` - a guarded query to the options `@zeroxsolutions/db`'s finders take |
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 |
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; `ResponseError`, the non-2xx a caller throws once a response has come back, and `errorOf`, which reads one off that response |
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
@@ -47,10 +47,54 @@ response type, which is what keeps the model off a hand-written interface:
47
47
  ```ts
48
48
  // the query layer - the only place the document is spelled
49
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));
50
+ if (!res.ok) throw await errorOf(res, clock); // clock: { now(): Date }, what a Retry-After date counts from
51
51
  const { data, meta } = readCollection(await res.json());
52
52
  ```
53
53
 
54
+ A refused response is read into a `ResponseError` by `errorOf`, never by reading the body by hand. It reads the body
55
+ once and parses it as an error document, so `body` holds every error object the server sent, in its order, and is
56
+ `null` where the failing tier sent no error document: a proxy's HTML page, an empty body, or JSON of another shape.
57
+ Parsed rather than trusted, a body with no `errors` cannot make the throw itself throw.
58
+
59
+ It reads `Retry-After` too, on every status, into `retryAfterSeconds`: delay-seconds as sent, an HTTP-date as the whole
60
+ seconds from the clock's `now()` until it, rounded up and never below 0, and `null` where the header is missing or in
61
+ neither form. The clock is the caller's, so a spec hands it a fixed one:
62
+
63
+ ```ts
64
+ import { ResponseError, errorOf } from '@zeroxsolutions/jsonapi/client';
65
+
66
+ const clock = { now: (): Date => new Date() };
67
+
68
+ async function read(res: Response): Promise<unknown> {
69
+ if (!res.ok) throw await errorOf(res, clock);
70
+ return res.json();
71
+ }
72
+
73
+ const refusal = {
74
+ errors: [{ code: 'api.bad_request', source: { pointer: '/data/attributes/name' } }, { code: 'api.conflict' }],
75
+ };
76
+
77
+ try {
78
+ await read(new Response(JSON.stringify(refusal), { status: 422 }));
79
+ } catch (error) {
80
+ if (error instanceof ResponseError) {
81
+ error.status; // 422
82
+ error.body?.errors.map((object) => object.code); // ['api.bad_request', 'api.conflict']
83
+ error.retryAfterSeconds; // null
84
+ }
85
+ }
86
+
87
+ (await errorOf(new Response('<html>502 Bad Gateway</html>', { status: 502 }), clock)).body; // null
88
+ (await errorOf(new Response(null, { status: 429, headers: { 'Retry-After': '10' } }), clock)).retryAfterSeconds; // 10
89
+
90
+ const fixed = { now: (): Date => new Date('2026-10-21T07:28:00Z') };
91
+ const unavailable = new Response(null, { status: 503, headers: { 'Retry-After': 'Wed, 21 Oct 2026 07:30:00 GMT' } });
92
+ (await errorOf(unavailable, fixed)).retryAfterSeconds; // 120
93
+ ```
94
+
95
+ The body is read once, so a caller that reads it too hands `errorOf` a `clone()`. What a screen draws for each
96
+ code, and which field a pointer names, is the screen's, so neither is here.
97
+
54
98
  Only `data` is flattened. `links` and `meta` keep the names the standard gives them, and `meta` stays
55
99
  free-form - the standard puts no member in it, so a row count there is one surface's own convention.
56
100
 
@@ -1,8 +1,8 @@
1
1
  /**
2
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
+ * the failure a caller throws, read off the response that refused it.
4
4
  */
5
- export { ApiError } from './api-error.js';
5
+ export { ResponseError, errorOf } from './response-error.js';
6
6
  export { deserializeDocument } from './deserialize.js';
7
7
  export { readCollection, readRelated, readResource, type Collection, type CollectionOf, type Resource, type ResourceOf, } from './read-document.js';
8
8
  export { toRequestDocument, type RelationshipInput, type ToRequestDocument } from './request-document.js';
@@ -1 +1 @@
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
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC7D,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,8 +1,8 @@
1
1
  /**
2
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
+ * the failure a caller throws, read off the response that refused it.
4
4
  */
5
- export { ApiError } from './api-error.js';
5
+ export { ResponseError, errorOf } from './response-error.js';
6
6
  export { deserializeDocument } from './deserialize.js';
7
7
  export { readCollection, readRelated, readResource, } from './read-document.js';
8
8
  export { toRequestDocument } from './request-document.js';
@@ -0,0 +1,39 @@
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
+ * `retryAfterSeconds` is how long the response's `Retry-After` asked the caller to wait before asking
9
+ * again, and null where it named no wait or one in neither of the header's forms.
10
+ */
11
+ export declare class ResponseError extends Error {
12
+ readonly status: number;
13
+ readonly body: ErrorDocument | null;
14
+ readonly retryAfterSeconds: number | null;
15
+ constructor(status: number, body: ErrorDocument | null, retryAfterSeconds: number | null);
16
+ }
17
+ /**
18
+ * The {@link ResponseError} a refused response yields: its status, the error document its body holds, every error
19
+ * object kept in the order the server sent them, and the wait its `Retry-After` names.
20
+ *
21
+ * Reads the body once, so the response must not have been read before; a caller that needs the body for anything
22
+ * else hands this a clone. `body` is null where the body is no JSON, such as a proxy's HTML page or an empty body,
23
+ * and where it is JSON but no error document. It never rejects.
24
+ *
25
+ * @param clock - what a `Retry-After` HTTP-date is counted from; `retryAfterSeconds` is the whole seconds from
26
+ * `clock.now()` until that date, rounded up and never below 0. Delay-seconds are taken as sent.
27
+ * @example
28
+ * if (!response.ok) throw await errorOf(response, { now: () => new Date() });
29
+ */
30
+ export declare function errorOf(response: {
31
+ readonly status: number;
32
+ readonly headers: {
33
+ get(name: string): string | null;
34
+ };
35
+ json(): Promise<unknown>;
36
+ }, clock: {
37
+ now(): Date;
38
+ }): Promise<ResponseError>;
39
+ //# sourceMappingURL=response-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-error.d.ts","sourceRoot":"","sources":["../../src/client/response-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAGnF;;;;;;;;GAQG;AACH,qBAAa,aAAc,SAAQ,KAAK;IAEpC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI;IACnC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;gBAFhC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,aAAa,GAAG,IAAI,EAC1B,iBAAiB,EAAE,MAAM,GAAG,IAAI;CAK5C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,OAAO,CAC3B,QAAQ,EAAE;IACR,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACvD,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B,EACD,KAAK,EAAE;IAAE,GAAG,IAAI,IAAI,CAAA;CAAE,GACrB,OAAO,CAAC,aAAa,CAAC,CAKxB"}
@@ -0,0 +1,41 @@
1
+ import { errorDocumentSchema } from '@zeroxsolutions/response';
2
+ import { retryAfterSecondsOf } from './retry-after.js';
3
+ /**
4
+ * A non-2xx response, as the client that made the request holds it.
5
+ *
6
+ * The counterpart of `JsonApiError`, which a SERVER raises for its `onError` to render: this one
7
+ * is what a caller throws once a response has already come back. `body` is null wherever the failing
8
+ * tier emitted no document - a proxy, a gateway timeout, or an auth server with a shape of its own.
9
+ * `retryAfterSeconds` is how long the response's `Retry-After` asked the caller to wait before asking
10
+ * again, and null where it named no wait or one in neither of the header's forms.
11
+ */
12
+ export class ResponseError extends Error {
13
+ status;
14
+ body;
15
+ retryAfterSeconds;
16
+ constructor(status, body, retryAfterSeconds) {
17
+ super(`Request failed with status ${status}`);
18
+ this.status = status;
19
+ this.body = body;
20
+ this.retryAfterSeconds = retryAfterSeconds;
21
+ this.name = 'ResponseError';
22
+ }
23
+ }
24
+ /**
25
+ * The {@link ResponseError} a refused response yields: its status, the error document its body holds, every error
26
+ * object kept in the order the server sent them, and the wait its `Retry-After` names.
27
+ *
28
+ * Reads the body once, so the response must not have been read before; a caller that needs the body for anything
29
+ * else hands this a clone. `body` is null where the body is no JSON, such as a proxy's HTML page or an empty body,
30
+ * and where it is JSON but no error document. It never rejects.
31
+ *
32
+ * @param clock - what a `Retry-After` HTTP-date is counted from; `retryAfterSeconds` is the whole seconds from
33
+ * `clock.now()` until that date, rounded up and never below 0. Delay-seconds are taken as sent.
34
+ * @example
35
+ * if (!response.ok) throw await errorOf(response, { now: () => new Date() });
36
+ */
37
+ export async function errorOf(response, clock) {
38
+ const document = errorDocumentSchema.safeParse(await response.json().catch(() => null));
39
+ const retryAfterSeconds = retryAfterSecondsOf(response.headers.get('Retry-After'), clock.now());
40
+ return new ResponseError(response.status, document.success ? document.data : null, retryAfterSeconds);
41
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The seconds a `Retry-After` value (RFC 9110 section 10.2.3) asks the caller to wait from `now`: delay-seconds as
3
+ * they are, an HTTP-date as the whole seconds until it, rounded up and never below 0. Null where `value` is null or
4
+ * is neither form.
5
+ */
6
+ export declare function retryAfterSecondsOf(value: string | null, now: Date): number | null;
7
+ //# sourceMappingURL=retry-after.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retry-after.d.ts","sourceRoot":"","sources":["../../src/client/retry-after.ts"],"names":[],"mappings":"AAmFA;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAOlF"}
@@ -0,0 +1,69 @@
1
+ const DELAY_SECONDS = /^\d+$/;
2
+ const DAY_NAMES = '(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)';
3
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
4
+ const MONTH = `(${MONTHS.join('|')})`;
5
+ const TIME_OF_DAY = '(\\d{2}):(\\d{2}):(\\d{2})';
6
+ /** `Sun, 06 Nov 1994 08:49:37 GMT`: day, month, year, hour, minute, second. */
7
+ const IMF_FIXDATE = new RegExp(`^${DAY_NAMES}, (\\d{2}) ${MONTH} (\\d{4}) ${TIME_OF_DAY} GMT$`);
8
+ /** `Sunday, 06-Nov-94 08:49:37 GMT`: day, month, two-digit year, hour, minute, second. */
9
+ const RFC850_DATE = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-${MONTH}-(\\d{2}) ${TIME_OF_DAY} GMT$`);
10
+ /** `Sun Nov 6 08:49:37 1994`: month, day padded with a space, hour, minute, second, year. */
11
+ const ASCTIME_DATE = new RegExp(`^${DAY_NAMES} ${MONTH} ([ \\d]\\d) ${TIME_OF_DAY} (\\d{4})$`);
12
+ /**
13
+ * The instant an HTTP-date names, in any of the three forms RFC 9110 section 5.6.7 has a recipient accept, or null
14
+ * where `value` is none of them or names no such day or time; a second of 60 is the leap second the RFC allows.
15
+ * `now` places an RFC 850 date's two-digit year.
16
+ */
17
+ function instantOf(value, now) {
18
+ const parts = partsOf(value, now);
19
+ if (parts === null)
20
+ return null;
21
+ const [day, hour, minute, second] = [parts.day, parts.hour, parts.minute, parts.second].map(Number);
22
+ const month = MONTHS.indexOf(parts.month);
23
+ if (hour > 23 || minute > 59 || second > 60)
24
+ return null;
25
+ const minuteStart = new Date(Date.UTC(parts.year, month, day, hour, minute));
26
+ if (minuteStart.getUTCDate() !== day || minuteStart.getUTCMonth() !== month)
27
+ return null;
28
+ return minuteStart.getTime() + second * 1000;
29
+ }
30
+ /** The fields of whichever of the three forms `value` is, or null where it is none of them. */
31
+ function partsOf(value, now) {
32
+ const fixdate = IMF_FIXDATE.exec(value);
33
+ if (fixdate) {
34
+ const [, day, month, year, hour, minute, second] = fixdate;
35
+ return { year: Number(year), month, day, hour, minute, second };
36
+ }
37
+ const rfc850 = RFC850_DATE.exec(value);
38
+ if (rfc850) {
39
+ const [, day, month, year, hour, minute, second] = rfc850;
40
+ return { year: fullYearOf(Number(year), now), month, day, hour, minute, second };
41
+ }
42
+ const asctime = ASCTIME_DATE.exec(value);
43
+ if (asctime) {
44
+ const [, month, day, hour, minute, second, year] = asctime;
45
+ return { year: Number(year), month, day: day.trim(), hour, minute, second };
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * The year a two-digit year names: the one ending in those digits that is no more than fifty years after `now`,
51
+ * which is how RFC 9110 section 5.6.7 has a recipient read an RFC 850 date.
52
+ */
53
+ function fullYearOf(twoDigits, now) {
54
+ const year = now.getUTCFullYear() - (now.getUTCFullYear() % 100) + twoDigits;
55
+ return year > now.getUTCFullYear() + 50 ? year - 100 : year;
56
+ }
57
+ /**
58
+ * The seconds a `Retry-After` value (RFC 9110 section 10.2.3) asks the caller to wait from `now`: delay-seconds as
59
+ * they are, an HTTP-date as the whole seconds until it, rounded up and never below 0. Null where `value` is null or
60
+ * is neither form.
61
+ */
62
+ export function retryAfterSecondsOf(value, now) {
63
+ if (value === null)
64
+ return null;
65
+ if (DELAY_SECONDS.test(value))
66
+ return Number(value);
67
+ const instant = instantOf(value, now);
68
+ return instant === null ? null : Math.max(0, Math.ceil((instant - now.getTime()) / 1000));
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/jsonapi",
3
- "version": "0.13.7",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,14 +0,0 @@
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
@@ -1 +0,0 @@
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"}
@@ -1,17 +0,0 @@
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
- }