@zeroxsolutions/response 0.14.2 → 0.14.3
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 +16 -0
- package/README.md +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/relationship.d.ts +8 -2
- package/dist/relationship.d.ts.map +1 -1
- package/dist/relationship.js +2 -0
- package/dist/request-document.d.ts +183 -0
- package/dist/request-document.d.ts.map +1 -0
- package/dist/request-document.js +170 -0
- package/dist/request-refusal.d.ts +24 -0
- package/dist/request-refusal.d.ts.map +1 -0
- package/dist/request-refusal.js +29 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 0.14.3 (2026-09-30)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **response:** declare a write's request document off its resource ([3eea112](https://github.com/zeroxsolutions/zeroxsolutions/commit/3eea112))
|
|
6
|
+
|
|
7
|
+
### 🩹 Fixes
|
|
8
|
+
|
|
9
|
+
- **response:** refuse an update that names an attribute with a default ([4e9ee0a](https://github.com/zeroxsolutions/zeroxsolutions/commit/4e9ee0a))
|
|
10
|
+
- **response:** accept the request members JSON:API defines ([35e8395](https://github.com/zeroxsolutions/zeroxsolutions/commit/35e8395))
|
|
11
|
+
|
|
12
|
+
### ❤️ Thank You
|
|
13
|
+
|
|
14
|
+
- Claude Opus 5.5 (1M context)
|
|
15
|
+
- Lương Văn Tú
|
|
16
|
+
|
|
1
17
|
## 0.14.2 (2026-09-29)
|
|
2
18
|
|
|
3
19
|
### 🩹 Fixes
|
package/README.md
CHANGED
|
@@ -271,6 +271,42 @@ It locates each issue by the slot it was read from: a body member as `source.poi
|
|
|
271
271
|
`source.parameter`, a header as `source.header`. A path segment gets `meta` instead - the spec defines
|
|
272
272
|
those three members and none of them means a path segment, so borrowing one would say something false.
|
|
273
273
|
|
|
274
|
+
## A request document
|
|
275
|
+
|
|
276
|
+
A write's body is declared off the same resource the route answers with, so the document a client reads
|
|
277
|
+
and the one it writes cannot drift:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { readRequestDocument, requestDocumentSchema } from '@zeroxsolutions/response';
|
|
281
|
+
|
|
282
|
+
export const createItemDocument = requestDocumentSchema(itemResource, 'create', {
|
|
283
|
+
attributes: ['startsAt', 'durationMinutes'],
|
|
284
|
+
relationships: ['place'],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// in the handler; relationships.place is the id the linkage named, or null
|
|
288
|
+
const { attributes, relationships } = readRequestDocument(c.req.valid('json'));
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`attributes` and `relationships` name what a client may write, checked against the resource at compile
|
|
292
|
+
time. A `'create'` requires `data.type`, refuses `data.id`, and keeps each named attribute as optional as
|
|
293
|
+
its schema makes it; it requires `attributes` itself only where one named attribute is required. An `'update'` requires `data.type` and `data.id`, and every member is optional: a
|
|
294
|
+
missing one keeps its current value. So an update throws when it is declared if it names an attribute
|
|
295
|
+
whose schema fills a value for a missing key - a `.default()`, a `.prefault()` or a `.catch()`, beneath
|
|
296
|
+
any wrapper - since the fill would overwrite the stored value; drop the default from the resource's
|
|
297
|
+
attribute, or leave the attribute out of the update. An attribute whose schema is async is not checked,
|
|
298
|
+
since a sync parse cannot run it. The schema throws `RequestConflict` for a `type` other than the
|
|
299
|
+
endpoint's, and `RequestForbidden` for a client id on a create or a member the route does not write. It
|
|
300
|
+
throws from its first step, out of `safeParse` as well, so `@zeroxsolutions/server`'s error handler
|
|
301
|
+
answers them 409 and 403. A member that fails its own schema stays the validation hook's 422, at
|
|
302
|
+
`/data/attributes/<name>`. On an update, `readRequestDocument(document, { id })` throws `RequestConflict`
|
|
303
|
+
when `data.id` is not the id the URL names.
|
|
304
|
+
|
|
305
|
+
The other members JSON:API 1.1 defines for a request are accepted, and nothing reads them: `lid`, `meta`
|
|
306
|
+
and `links` on the resource object, `meta` and `links` on a relationship object, `lid` and `meta` on an
|
|
307
|
+
identifier, and the top-level `meta` and `jsonapi`. A member the standard does not define still fails the
|
|
308
|
+
schema, so it is a 422.
|
|
309
|
+
|
|
274
310
|
## Exports
|
|
275
311
|
|
|
276
312
|
One root, no subpath, and `src/index.ts` lists every name it publishes:
|
|
@@ -281,6 +317,8 @@ One root, no subpath, and `src/index.ts` lists every name it publishes:
|
|
|
281
317
|
passes to `listResponse`;
|
|
282
318
|
- the resource declaration - `jsonApiResource`, `toOne` / `toMany`, `declaredDocumentShape` - and the
|
|
283
319
|
types their signatures name;
|
|
320
|
+
- the request document - `requestDocumentSchema`, `readRequestDocument`, the types their signatures name,
|
|
321
|
+
and `RequestConflict` / `RequestForbidden`, which its checks throw;
|
|
284
322
|
- the error document's types, `JsonApiError`, `BASE_ERROR_CODES`, `toErrorObject` / `toErrorDocument`;
|
|
285
323
|
- `defineErrorMap` / `errorEntry` / `createDomainErrorResolver` and `createUpstreamErrorTranslator`, with
|
|
286
324
|
the answer, entry and map types they take;
|
package/dist/index.d.ts
CHANGED
|
@@ -16,4 +16,6 @@ export { toMany, toOne, type LoadContextArguments, type RelatedResource, type Re
|
|
|
16
16
|
export { jsonApiResource, type JsonApiResource, type ResourceOptions } from './resource.js';
|
|
17
17
|
export type { CollectionExtras, LoadedRows, RequestedDocumentShape, ResourceSerializer, Row } from './serialize.js';
|
|
18
18
|
export { createUpstreamErrorTranslator, type UpstreamErrorAnswer } from './upstream-error.js';
|
|
19
|
+
export { readRequestDocument, requestDocumentSchema, type AttributeName, type ReadRequestDocument, type RelationshipName, type RequestDocument, type RequestDocumentSchema, type RequestMode, type WritableResource, } from './request-document.js';
|
|
20
|
+
export { RequestConflict, RequestForbidden } from './request-refusal.js';
|
|
19
21
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,aAAa,EACb,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,gBAAgB,EAChB,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EACL,yBAAyB,EACzB,cAAc,EACd,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,GACtB,MAAM,4BAA4B,CAAC;AACpC,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACnF,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,aAAa,EACb,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAC;AAC3F,YAAY,EACV,aAAa,EACb,IAAI,EACJ,IAAI,EACJ,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,EACb,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,MAAM,EACN,KAAK,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5F,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AACpH,OAAO,EAAE,6BAA6B,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,aAAa,EACb,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,gBAAgB,EAChB,cAAc,GACf,MAAM,eAAe,CAAC;AACvB,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EACL,yBAAyB,EACzB,cAAc,EACd,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,GACtB,MAAM,4BAA4B,CAAC;AACpC,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACnF,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,aAAa,EACb,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAC;AAC3F,YAAY,EACV,aAAa,EACb,IAAI,EACJ,IAAI,EACJ,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,EACb,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,MAAM,EACN,KAAK,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5F,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AACpH,OAAO,EAAE,6BAA6B,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,WAAW,EAChB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,5 @@ export { countedCollectionMetaSchema, offsetPaginationLinksSchema } from './prim
|
|
|
12
12
|
export { toMany, toOne, } from './relationship.js';
|
|
13
13
|
export { jsonApiResource } from './resource.js';
|
|
14
14
|
export { createUpstreamErrorTranslator } from './upstream-error.js';
|
|
15
|
+
export { readRequestDocument, requestDocumentSchema, } from './request-document.js';
|
|
16
|
+
export { RequestConflict, RequestForbidden } from './request-refusal.js';
|
package/dist/relationship.d.ts
CHANGED
|
@@ -61,10 +61,16 @@ export interface RelationshipDef<C = unknown, R extends RelatedResource<never> =
|
|
|
61
61
|
*
|
|
62
62
|
* The context is inferred from the loader and from the related resource. An explicit `toOne<Ctx>(...)`
|
|
63
63
|
* would stop TypeScript inferring the related resource, which then widens and drops every literal below it.
|
|
64
|
+
*
|
|
65
|
+
* Its cardinality is the literal 'one', so a request document typed off the declaration tells a to-one from a to-many.
|
|
64
66
|
*/
|
|
65
|
-
export declare function toOne<const R extends RelatedResource<never>, C = ContextOf<R>>(related: () => R, read: (row: Row) => unknown, options?: RelationshipOptions<C>): RelationshipDef<NoInfer<C>, R
|
|
67
|
+
export declare function toOne<const R extends RelatedResource<never>, C = ContextOf<R>>(related: () => R, read: (row: Row) => unknown, options?: RelationshipOptions<C>): RelationshipDef<NoInfer<C>, R> & {
|
|
68
|
+
readonly cardinality: 'one';
|
|
69
|
+
};
|
|
66
70
|
/** Declares a to-many relationship - its `data` is an array of identifiers. The context is inferred as for `toOne`. */
|
|
67
|
-
export declare function toMany<const R extends RelatedResource<never>, C = ContextOf<R>>(related: () => R, read: (row: Row) => unknown, options?: RelationshipOptions<C>): RelationshipDef<NoInfer<C>, R
|
|
71
|
+
export declare function toMany<const R extends RelatedResource<never>, C = ContextOf<R>>(related: () => R, read: (row: Row) => unknown, options?: RelationshipOptions<C>): RelationshipDef<NoInfer<C>, R> & {
|
|
72
|
+
readonly cardinality: 'many';
|
|
73
|
+
};
|
|
68
74
|
/** The linkage schema for one relationship, pinned to the related `type` so a wrong type fails the parse. */
|
|
69
75
|
export declare function relationshipSchema<C>(def: RelationshipDef<C>): ZodType;
|
|
70
76
|
/** The `relationships` member for a whole resource - one key per declaration, absent when it declares none. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../src/relationship.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAE9C,OAAO,KAAK,EAAE,UAAU,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAElG;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC;IACnC,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,SAAS,GAAG,EAAE,CAAC,CAAC;CAC/D;AAED,kGAAkG;AAClG,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,kHAAkH;IAClH,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;CACtC;AAED;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,CAAC,CAAC,IAAI,OAAO,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;AAE/F,sFAAsF;AACtF,wBAAgB,WAAW,CAAC,CAAC,EAAE,gBAAgB,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAAG,CAAC,CAG3E;AAED,6GAA6G;AAC7G,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,OAAO;IAC1C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACrE,gHAAgH;IAChH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,wGAAwG;IACxG,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;OAIG;IACH,aAAa,CAAC,SAAS,CAAC,EAAE,sBAAsB,EAAE,MAAM,CAAC,EAAE,UAAU,GAAG,kBAAkB,CAAC;CAC5F;AAED,6GAA6G;AAC7G,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,SAAS,eAAe,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC;AAE5E,uGAAuG;AACvG,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,SAAS,eAAe,CAAC,KAAK,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;IACjG,QAAQ,CAAC,WAAW,EAAE,KAAK,GAAG,MAAM,CAAC;IACrC,uFAAuF;IACvF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC1B,qGAAqG;IACrG,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,IAAI,CAAC,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;CACvC;AAED
|
|
1
|
+
{"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../src/relationship.ts"],"names":[],"mappings":"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"}
|
package/dist/relationship.js
CHANGED
|
@@ -10,6 +10,8 @@ export function loadContext(contextArguments) {
|
|
|
10
10
|
*
|
|
11
11
|
* The context is inferred from the loader and from the related resource. An explicit `toOne<Ctx>(...)`
|
|
12
12
|
* would stop TypeScript inferring the related resource, which then widens and drops every literal below it.
|
|
13
|
+
*
|
|
14
|
+
* Its cardinality is the literal 'one', so a request document typed off the declaration tells a to-one from a to-many.
|
|
13
15
|
*/
|
|
14
16
|
export function toOne(related, read, options) {
|
|
15
17
|
return { cardinality: 'one', related, read, load: options?.load };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { input, output, ZodObject, ZodType } from 'zod';
|
|
2
|
+
import { type JsonApiObject, type Meta, type RelationshipLinks, type ResourceIdentifier, type ResourceLinks } from './primitives.js';
|
|
3
|
+
/** Whether a request document creates its resource, or updates the one its URL names. */
|
|
4
|
+
export type RequestMode = 'create' | 'update';
|
|
5
|
+
/** What a request document needs of a relationship: how many it holds, and the type it points at. */
|
|
6
|
+
interface WritableRelationship {
|
|
7
|
+
readonly cardinality: 'one' | 'many';
|
|
8
|
+
readonly related: () => {
|
|
9
|
+
readonly type: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** What a request document needs of a resource declaration - every `jsonApiResource` is one. */
|
|
13
|
+
export interface WritableResource {
|
|
14
|
+
readonly type: string;
|
|
15
|
+
readonly attributes: ZodObject;
|
|
16
|
+
readonly relationships: Readonly<Record<string, WritableRelationship>>;
|
|
17
|
+
}
|
|
18
|
+
/** The attribute names a resource declares. */
|
|
19
|
+
export type AttributeName<R extends WritableResource> = keyof R['attributes']['shape'] & string;
|
|
20
|
+
/** The relationship names a resource declares, and none where it declares no relationship. */
|
|
21
|
+
export type RelationshipName<R extends WritableResource> = string extends keyof R['relationships'] ? never : keyof R['relationships'] & string;
|
|
22
|
+
/** Which side of the schema a document type describes: what a client sends, or what a handler reads. */
|
|
23
|
+
type Side = 'input' | 'output';
|
|
24
|
+
/** `T`'s members named in `K`, each keeping the optionality `T` gives it. */
|
|
25
|
+
type PickMembers<T, K> = {
|
|
26
|
+
[P in keyof T as P extends K ? P : never]: T[P];
|
|
27
|
+
};
|
|
28
|
+
type AttributesOf<R extends WritableResource, K, M extends RequestMode, S extends Side> = M extends 'update' ? Partial<PickMembers<S extends 'input' ? input<R['attributes']> : output<R['attributes']>, K>> : PickMembers<S extends 'input' ? input<R['attributes']> : output<R['attributes']>, K>;
|
|
29
|
+
/**
|
|
30
|
+
* The identifier a relationship's linkage carries, typed by the resource it points at. Its `lid` and `meta` are
|
|
31
|
+
* members JSON:API 1.1 allows, which the schema accepts and nothing reads.
|
|
32
|
+
*/
|
|
33
|
+
type IdentifierOf<D> = D extends {
|
|
34
|
+
related: () => {
|
|
35
|
+
type: infer T extends string;
|
|
36
|
+
};
|
|
37
|
+
} ? {
|
|
38
|
+
type: T;
|
|
39
|
+
id: string;
|
|
40
|
+
lid?: string;
|
|
41
|
+
meta?: Meta;
|
|
42
|
+
} : never;
|
|
43
|
+
/** The members JSON:API 1.1 lets a relationship object carry beside its `data`, accepted and never read. */
|
|
44
|
+
interface LinkageExtras {
|
|
45
|
+
meta?: Meta;
|
|
46
|
+
links?: RelationshipLinks;
|
|
47
|
+
}
|
|
48
|
+
/** A relationship as a request writes it: a to-one's identifier or `null`, a to-many's identifiers. */
|
|
49
|
+
type LinkageOf<D> = (D extends {
|
|
50
|
+
cardinality: 'many';
|
|
51
|
+
} ? {
|
|
52
|
+
data: readonly IdentifierOf<D>[];
|
|
53
|
+
} : D extends {
|
|
54
|
+
cardinality: 'one';
|
|
55
|
+
} ? {
|
|
56
|
+
data: IdentifierOf<D> | null;
|
|
57
|
+
} : {
|
|
58
|
+
data: IdentifierOf<D> | readonly IdentifierOf<D>[] | null;
|
|
59
|
+
}) & LinkageExtras;
|
|
60
|
+
type LinkagesOf<R extends WritableResource, K extends RelationshipName<R>, M extends RequestMode> = M extends 'update' ? {
|
|
61
|
+
[P in K]?: LinkageOf<R['relationships'][P]>;
|
|
62
|
+
} : {
|
|
63
|
+
[P in K]: LinkageOf<R['relationships'][P]>;
|
|
64
|
+
};
|
|
65
|
+
/** A member a route names nothing under, or any member of an update, may be left out; a create sends what it names. */
|
|
66
|
+
type Member<N extends string, K, V, M extends RequestMode> = [K] extends [never] ? {
|
|
67
|
+
[P in N]?: V;
|
|
68
|
+
} : M extends 'update' ? {
|
|
69
|
+
[P in N]?: V;
|
|
70
|
+
} : {
|
|
71
|
+
[P in N]: V;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A create's `attributes`, which it sends only where one attribute it names is required: JSON:API makes the member
|
|
75
|
+
* optional, so a create whose named attributes are all optional may leave it out.
|
|
76
|
+
*/
|
|
77
|
+
type AttributesMember<R extends WritableResource, AK, M extends RequestMode, S extends Side> = Record<never, never> extends PickMembers<input<R['attributes']>, AK> ? {
|
|
78
|
+
attributes?: AttributesOf<R, AK, M, S>;
|
|
79
|
+
} : Member<'attributes', AK, AttributesOf<R, AK, M, S>, M>;
|
|
80
|
+
/** The members JSON:API 1.1 lets a resource object carry beside its type, id and fields, accepted and never read. */
|
|
81
|
+
interface ResourceObjectExtras {
|
|
82
|
+
lid?: string;
|
|
83
|
+
meta?: Meta;
|
|
84
|
+
links?: ResourceLinks;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The request document a route declares for `R`: one resource object under `data`, the attributes it names under
|
|
88
|
+
* `attributes`, and each relationship it names as a relationship object. `S` picks what a client sends (`'input'`) or
|
|
89
|
+
* what the handler reads (`'output'`).
|
|
90
|
+
*/
|
|
91
|
+
export type RequestDocument<R extends WritableResource, M extends RequestMode, AK extends AttributeName<R> = never, RK extends RelationshipName<R> = never, S extends Side = 'output'> = {
|
|
92
|
+
data: {
|
|
93
|
+
type: R['type'];
|
|
94
|
+
} & ResourceObjectExtras & (M extends 'update' ? {
|
|
95
|
+
id: string;
|
|
96
|
+
} : {
|
|
97
|
+
id?: never;
|
|
98
|
+
}) & AttributesMember<R, AK, M, S> & Member<'relationships', RK, LinkagesOf<R, RK, M>, M>;
|
|
99
|
+
meta?: Meta;
|
|
100
|
+
jsonapi?: JsonApiObject;
|
|
101
|
+
};
|
|
102
|
+
/** The schema {@link requestDocumentSchema} builds: it reads what a client sends into what a handler reads. */
|
|
103
|
+
export type RequestDocumentSchema<R extends WritableResource, M extends RequestMode, AK extends AttributeName<R>, RK extends RelationshipName<R>> = ZodType<RequestDocument<R, M, AK, RK, 'output'>, RequestDocument<R, M, AK, RK, 'input'>>;
|
|
104
|
+
/**
|
|
105
|
+
* The body schema of a write, built from the same resource declaration the route answers with, so the document a
|
|
106
|
+
* client reads and the one it writes cannot drift.
|
|
107
|
+
*
|
|
108
|
+
* `'create'` requires `data.type`, refuses `data.id`, keeps each named attribute as optional as its schema makes it,
|
|
109
|
+
* and requires each named relationship (a to-one left empty is sent as `{ data: null }`). It requires `attributes`
|
|
110
|
+
* itself only where one named attribute is required, since JSON:API makes the member optional. `'update'` requires
|
|
111
|
+
* `data.type` and `data.id`, and makes every member optional, because JSON:API 1.1 has a server read a missing member
|
|
112
|
+
* as its current value. An update therefore refuses, at declaration, to name an attribute whose schema fills a value
|
|
113
|
+
* for a missing key, through a `.default()`, a `.prefault()` or a `.catch()` beneath any wrapper, since the fill would
|
|
114
|
+
* overwrite the stored value. An attribute whose schema is async is not asked, since a sync parse cannot run it, and
|
|
115
|
+
* is accepted as it is.
|
|
116
|
+
*
|
|
117
|
+
* Every other member JSON:API 1.1 defines for a request is accepted and read by nothing: `lid`, `meta` and `links` on
|
|
118
|
+
* the resource object, `meta` and `links` on a relationship object, `lid` and `meta` on an identifier, and the
|
|
119
|
+
* top-level `meta` and `jsonapi`. A member it does not define fails the schema.
|
|
120
|
+
*
|
|
121
|
+
* Call it once every related resource it names has been declared, since it reads each one's `type`.
|
|
122
|
+
*
|
|
123
|
+
* @param writes - the attributes and relationships a client may write; a name the resource does not declare fails to
|
|
124
|
+
* compile
|
|
125
|
+
* @throws Error at declaration on a name the resource does not declare, or on an update naming an attribute that
|
|
126
|
+
* fills a value for a missing key
|
|
127
|
+
* @throws RequestConflict on a `data.type` other than the resource's - out of `safeParse` as well, because a throwing
|
|
128
|
+
* transform escapes it, against what zod documents (zod@4.3.6)
|
|
129
|
+
* @throws RequestForbidden on a `data.id` in a create, or on an attribute or relationship `writes` does not name - out
|
|
130
|
+
* of `safeParse` as well
|
|
131
|
+
*/
|
|
132
|
+
export declare function requestDocumentSchema<R extends WritableResource, M extends RequestMode, const AK extends AttributeName<R> = never, const RK extends RelationshipName<R> = never>(resource: R, mode: M, writes?: {
|
|
133
|
+
readonly attributes?: readonly AK[];
|
|
134
|
+
readonly relationships?: readonly RK[];
|
|
135
|
+
}): RequestDocumentSchema<R, M, AK, RK>;
|
|
136
|
+
/** What {@link readRequestDocument} reads: any document {@link requestDocumentSchema} produced. */
|
|
137
|
+
interface RequestDocumentLike {
|
|
138
|
+
data: {
|
|
139
|
+
id?: string;
|
|
140
|
+
attributes?: object;
|
|
141
|
+
relationships?: Readonly<Record<string, {
|
|
142
|
+
data: ResourceIdentifier | readonly ResourceIdentifier[] | null;
|
|
143
|
+
} | undefined>>;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The ids a relationship's linkage names: one or `null` for a to-one, a list for a to-many. */
|
|
147
|
+
type IdsOf<L> = L extends readonly {
|
|
148
|
+
id: string;
|
|
149
|
+
}[] ? string[] : L extends {
|
|
150
|
+
id: string;
|
|
151
|
+
} ? string : null;
|
|
152
|
+
/** What a handler writes from a request document: its attributes, and each relationship as the ids it names. */
|
|
153
|
+
export type ReadRequestDocument<D> = D extends {
|
|
154
|
+
data: infer Data;
|
|
155
|
+
} ? {
|
|
156
|
+
attributes: Data extends {
|
|
157
|
+
attributes?: infer A;
|
|
158
|
+
} ? Exclude<A, undefined> : Record<never, never>;
|
|
159
|
+
relationships: Data extends {
|
|
160
|
+
relationships?: infer L;
|
|
161
|
+
} ? {
|
|
162
|
+
[K in keyof Exclude<L, undefined>]: IdsOf<Exclude<Exclude<L, undefined>[K], undefined> extends {
|
|
163
|
+
data: infer X;
|
|
164
|
+
} ? X : never>;
|
|
165
|
+
} : Record<never, never>;
|
|
166
|
+
} : never;
|
|
167
|
+
/**
|
|
168
|
+
* Hands a handler what a request document writes: the attributes as sent, and each relationship reduced to the id it
|
|
169
|
+
* names, `null` for a cleared to-one, or a list. A member the document left out stays out, which on an update means
|
|
170
|
+
* keep its current value.
|
|
171
|
+
*
|
|
172
|
+
* @param options - on an update, the id the URL names, spelled as the path carries it; a create takes none
|
|
173
|
+
* @throws RequestConflict when an update's `data.id` is not the URL's, which JSON:API 1.1 answers 409
|
|
174
|
+
*/
|
|
175
|
+
export declare function readRequestDocument<D extends RequestDocumentLike>(document: D, ...options: D extends {
|
|
176
|
+
data: {
|
|
177
|
+
id: string;
|
|
178
|
+
};
|
|
179
|
+
} ? [options: {
|
|
180
|
+
id: string;
|
|
181
|
+
}] : []): ReadRequestDocument<D>;
|
|
182
|
+
export {};
|
|
183
|
+
//# sourceMappingURL=request-document.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-document.d.ts","sourceRoot":"","sources":["../src/request-document.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { jsonApiObjectSchema, metaSchema, relationshipLinksSchema, resourceLinksSchema, } from './primitives.js';
|
|
3
|
+
import { RequestConflict, RequestForbidden } from './request-refusal.js';
|
|
4
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
/** One JSON Pointer segment: RFC 6901 escapes `~` as `~0` and `/` as `~1`, in that order. */
|
|
6
|
+
const pointerSegment = (name) => name.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
7
|
+
/**
|
|
8
|
+
* Throws what JSON:API 1.1 answers 409 or 403, in the order the standard's checks come: the type, a client id, then
|
|
9
|
+
* each member the route does not write. It reads the body as sent, so it checks only what it can read and leaves
|
|
10
|
+
* every other defect to the schema's own issue, which the validation hook answers 422.
|
|
11
|
+
*/
|
|
12
|
+
function refuseUnsupported(body, type, mode, writes) {
|
|
13
|
+
if (!isRecord(body) || !isRecord(body['data']))
|
|
14
|
+
return;
|
|
15
|
+
const data = body['data'];
|
|
16
|
+
const sentType = data['type'];
|
|
17
|
+
if (typeof sentType === 'string' && sentType !== type) {
|
|
18
|
+
throw new RequestConflict('/data/type', `This endpoint writes "${type}", and the document names "${sentType}".`);
|
|
19
|
+
}
|
|
20
|
+
if (mode === 'create' && Object.hasOwn(data, 'id')) {
|
|
21
|
+
throw new RequestForbidden('/data/id', `A "${type}" is given its id by the server, so a create names none.`);
|
|
22
|
+
}
|
|
23
|
+
for (const member of ['attributes', 'relationships']) {
|
|
24
|
+
const sent = data[member];
|
|
25
|
+
if (!isRecord(sent))
|
|
26
|
+
continue;
|
|
27
|
+
const refused = Object.keys(sent).find((name) => !writes[member].includes(name));
|
|
28
|
+
if (refused !== undefined) {
|
|
29
|
+
throw new RequestForbidden(`/data/${member}/${pointerSegment(refused)}`, `This endpoint does not write "${refused}" on "${type}".`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether `schema` fills a value for a key left out, asked of the schema itself so any `.default()`, `.prefault()` or
|
|
35
|
+
* `.catch()`, beneath whatever wraps it, answers. An async schema cannot be asked in a sync parse and reads as `false`.
|
|
36
|
+
*/
|
|
37
|
+
function fillsWhenMissing(schema) {
|
|
38
|
+
try {
|
|
39
|
+
const result = schema.safeParse(undefined);
|
|
40
|
+
return result.success && result.data !== undefined;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof z.core.$ZodAsyncError)
|
|
44
|
+
return false;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The body schema of a write, built from the same resource declaration the route answers with, so the document a
|
|
50
|
+
* client reads and the one it writes cannot drift.
|
|
51
|
+
*
|
|
52
|
+
* `'create'` requires `data.type`, refuses `data.id`, keeps each named attribute as optional as its schema makes it,
|
|
53
|
+
* and requires each named relationship (a to-one left empty is sent as `{ data: null }`). It requires `attributes`
|
|
54
|
+
* itself only where one named attribute is required, since JSON:API makes the member optional. `'update'` requires
|
|
55
|
+
* `data.type` and `data.id`, and makes every member optional, because JSON:API 1.1 has a server read a missing member
|
|
56
|
+
* as its current value. An update therefore refuses, at declaration, to name an attribute whose schema fills a value
|
|
57
|
+
* for a missing key, through a `.default()`, a `.prefault()` or a `.catch()` beneath any wrapper, since the fill would
|
|
58
|
+
* overwrite the stored value. An attribute whose schema is async is not asked, since a sync parse cannot run it, and
|
|
59
|
+
* is accepted as it is.
|
|
60
|
+
*
|
|
61
|
+
* Every other member JSON:API 1.1 defines for a request is accepted and read by nothing: `lid`, `meta` and `links` on
|
|
62
|
+
* the resource object, `meta` and `links` on a relationship object, `lid` and `meta` on an identifier, and the
|
|
63
|
+
* top-level `meta` and `jsonapi`. A member it does not define fails the schema.
|
|
64
|
+
*
|
|
65
|
+
* Call it once every related resource it names has been declared, since it reads each one's `type`.
|
|
66
|
+
*
|
|
67
|
+
* @param writes - the attributes and relationships a client may write; a name the resource does not declare fails to
|
|
68
|
+
* compile
|
|
69
|
+
* @throws Error at declaration on a name the resource does not declare, or on an update naming an attribute that
|
|
70
|
+
* fills a value for a missing key
|
|
71
|
+
* @throws RequestConflict on a `data.type` other than the resource's - out of `safeParse` as well, because a throwing
|
|
72
|
+
* transform escapes it, against what zod documents (zod@4.3.6)
|
|
73
|
+
* @throws RequestForbidden on a `data.id` in a create, or on an attribute or relationship `writes` does not name - out
|
|
74
|
+
* of `safeParse` as well
|
|
75
|
+
*/
|
|
76
|
+
export function requestDocumentSchema(resource, mode, writes = {}) {
|
|
77
|
+
const attributes = writes.attributes ?? [];
|
|
78
|
+
const relationships = writes.relationships ?? [];
|
|
79
|
+
for (const name of attributes) {
|
|
80
|
+
if (!Object.hasOwn(resource.attributes.shape, name)) {
|
|
81
|
+
throw new Error(`"${resource.type}" declares no attribute "${name}".`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const name of relationships) {
|
|
85
|
+
if (!Object.hasOwn(resource.relationships, name)) {
|
|
86
|
+
throw new Error(`"${resource.type}" declares no relationship "${name}".`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (mode === 'update') {
|
|
90
|
+
const filling = attributes.find((name) => fillsWhenMissing(resource.attributes.shape[name]));
|
|
91
|
+
if (filling !== undefined) {
|
|
92
|
+
throw new Error(`The update names "${filling}", whose schema fills a value when the key is missing; a PATCH would overwrite ` +
|
|
93
|
+
`the stored value. Drop the default from the resource's attribute, or leave "${filling}" out of the update.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// A member is left out only where the route names nothing under it that a create must send, or on an update.
|
|
97
|
+
const present = (schema, required) => mode === 'update' || !required ? schema.optional() : schema;
|
|
98
|
+
const mask = Object.fromEntries(attributes.map((name) => [name, true]));
|
|
99
|
+
const picked = resource.attributes.pick(mask);
|
|
100
|
+
const attributesSchema = (mode === 'update' ? picked.partial() : picked).strict();
|
|
101
|
+
// What zod documents as the test of an optional schema, which is also what an object runs on a missing key.
|
|
102
|
+
const someAttributeRequired = mode === 'create' && Object.values(picked.shape).some((schema) => !schema.safeParse(undefined).success);
|
|
103
|
+
const relationshipsSchema = z
|
|
104
|
+
.object(Object.fromEntries(relationships.map((name) => {
|
|
105
|
+
const def = resource.relationships[name];
|
|
106
|
+
const identifier = z
|
|
107
|
+
.object({
|
|
108
|
+
type: z.literal(def.related().type),
|
|
109
|
+
id: z.string(),
|
|
110
|
+
lid: z.string().optional(),
|
|
111
|
+
meta: metaSchema.optional(),
|
|
112
|
+
})
|
|
113
|
+
.strict();
|
|
114
|
+
const linkage = z
|
|
115
|
+
.object({
|
|
116
|
+
data: def.cardinality === 'one' ? identifier.nullable() : z.array(identifier),
|
|
117
|
+
meta: metaSchema.optional(),
|
|
118
|
+
links: relationshipLinksSchema.optional(),
|
|
119
|
+
})
|
|
120
|
+
.strict();
|
|
121
|
+
return [name, mode === 'update' ? linkage.optional() : linkage];
|
|
122
|
+
})))
|
|
123
|
+
.strict();
|
|
124
|
+
const document = z
|
|
125
|
+
.object({
|
|
126
|
+
data: z
|
|
127
|
+
.object({
|
|
128
|
+
type: z.literal(resource.type),
|
|
129
|
+
...(mode === 'update' ? { id: z.string() } : {}),
|
|
130
|
+
lid: z.string().optional(),
|
|
131
|
+
attributes: present(attributesSchema, someAttributeRequired),
|
|
132
|
+
relationships: present(relationshipsSchema, relationships.length > 0),
|
|
133
|
+
meta: metaSchema.optional(),
|
|
134
|
+
links: resourceLinksSchema.optional(),
|
|
135
|
+
})
|
|
136
|
+
.strict(),
|
|
137
|
+
meta: metaSchema.optional(),
|
|
138
|
+
jsonapi: jsonApiObjectSchema.optional(),
|
|
139
|
+
})
|
|
140
|
+
.strict();
|
|
141
|
+
const checked = z.preprocess((body) => {
|
|
142
|
+
refuseUnsupported(body, resource.type, mode, { attributes, relationships });
|
|
143
|
+
return body;
|
|
144
|
+
}, document);
|
|
145
|
+
// zod types a preprocessed input as `unknown`; what a client sends is the document itself, which a typed client reads.
|
|
146
|
+
return checked;
|
|
147
|
+
}
|
|
148
|
+
const idsOf = (data) => data === null
|
|
149
|
+
? null
|
|
150
|
+
: Array.isArray(data)
|
|
151
|
+
? data.map((one) => one.id)
|
|
152
|
+
: data.id;
|
|
153
|
+
/**
|
|
154
|
+
* Hands a handler what a request document writes: the attributes as sent, and each relationship reduced to the id it
|
|
155
|
+
* names, `null` for a cleared to-one, or a list. A member the document left out stays out, which on an update means
|
|
156
|
+
* keep its current value.
|
|
157
|
+
*
|
|
158
|
+
* @param options - on an update, the id the URL names, spelled as the path carries it; a create takes none
|
|
159
|
+
* @throws RequestConflict when an update's `data.id` is not the URL's, which JSON:API 1.1 answers 409
|
|
160
|
+
*/
|
|
161
|
+
export function readRequestDocument(document, ...options) {
|
|
162
|
+
const { data } = document;
|
|
163
|
+
const [url] = options;
|
|
164
|
+
if (url !== undefined && data.id !== url.id) {
|
|
165
|
+
throw new RequestConflict('/data/id', `The document names "${String(data.id)}", and the URL names "${url.id}".`);
|
|
166
|
+
}
|
|
167
|
+
const relationships = Object.fromEntries(Object.entries(data.relationships ?? {}).flatMap(([name, linkage]) => linkage === undefined ? [] : [[name, idsOf(linkage.data)]]));
|
|
168
|
+
// The shape is the document's own, which the route's schema declared; the reduction above is what differs.
|
|
169
|
+
return { attributes: data.attributes ?? {}, relationships };
|
|
170
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request document naming another resource than its endpoint: a `type` outside the collection, or an `id` other
|
|
3
|
+
* than the one its URL names. It carries no status and no wire `code`, as `QueryNotPermitted` carries none: the
|
|
4
|
+
* transport that catches it answers 409, which JSON:API 1.1 requires of both.
|
|
5
|
+
*/
|
|
6
|
+
export declare class RequestConflict extends Error {
|
|
7
|
+
/** The JSON Pointer (RFC 6901) to the member that conflicts. */
|
|
8
|
+
readonly pointer: string;
|
|
9
|
+
constructor(
|
|
10
|
+
/** The JSON Pointer (RFC 6901) to the member that conflicts. */
|
|
11
|
+
pointer: string, message: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A request document asking for a write its endpoint does not support: a client-generated id on a create, or an
|
|
15
|
+
* attribute or relationship the route does not write. The transport answers 403, which JSON:API 1.1 requires of both.
|
|
16
|
+
*/
|
|
17
|
+
export declare class RequestForbidden extends Error {
|
|
18
|
+
/** The JSON Pointer (RFC 6901) to the member refused. */
|
|
19
|
+
readonly pointer: string;
|
|
20
|
+
constructor(
|
|
21
|
+
/** The JSON Pointer (RFC 6901) to the member refused. */
|
|
22
|
+
pointer: string, message: string);
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=request-refusal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-refusal.d.ts","sourceRoot":"","sources":["../src/request-refusal.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IAEtC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM;;IADxB,gEAAgE;IACvD,OAAO,EAAE,MAAM,EACxB,OAAO,EAAE,MAAM;CAKlB;AAED;;;GAGG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IAEvC,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,MAAM;;IADxB,yDAAyD;IAChD,OAAO,EAAE,MAAM,EACxB,OAAO,EAAE,MAAM;CAKlB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request document naming another resource than its endpoint: a `type` outside the collection, or an `id` other
|
|
3
|
+
* than the one its URL names. It carries no status and no wire `code`, as `QueryNotPermitted` carries none: the
|
|
4
|
+
* transport that catches it answers 409, which JSON:API 1.1 requires of both.
|
|
5
|
+
*/
|
|
6
|
+
export class RequestConflict extends Error {
|
|
7
|
+
pointer;
|
|
8
|
+
constructor(
|
|
9
|
+
/** The JSON Pointer (RFC 6901) to the member that conflicts. */
|
|
10
|
+
pointer, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.pointer = pointer;
|
|
13
|
+
this.name = 'RequestConflict';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A request document asking for a write its endpoint does not support: a client-generated id on a create, or an
|
|
18
|
+
* attribute or relationship the route does not write. The transport answers 403, which JSON:API 1.1 requires of both.
|
|
19
|
+
*/
|
|
20
|
+
export class RequestForbidden extends Error {
|
|
21
|
+
pointer;
|
|
22
|
+
constructor(
|
|
23
|
+
/** The JSON Pointer (RFC 6901) to the member refused. */
|
|
24
|
+
pointer, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.pointer = pointer;
|
|
27
|
+
this.name = 'RequestForbidden';
|
|
28
|
+
}
|
|
29
|
+
}
|