@haddowg/json-api-client 1.0.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/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/ajv.cjs +62 -0
- package/dist/ajv.d.cts +55 -0
- package/dist/ajv.d.cts.map +1 -0
- package/dist/ajv.d.mts +55 -0
- package/dist/ajv.d.mts.map +1 -0
- package/dist/ajv.mjs +62 -0
- package/dist/ajv.mjs.map +1 -0
- package/dist/index.cjs +1254 -0
- package/dist/index.d.cts +198 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +198 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1232 -0
- package/dist/index.mjs.map +1 -0
- package/dist/validate-BmyPbJCK.d.cts +1254 -0
- package/dist/validate-BmyPbJCK.d.cts.map +1 -0
- package/dist/validate-BmyPbJCK.d.mts +1254 -0
- package/dist/validate-BmyPbJCK.d.mts.map +1 -0
- package/package.json +84 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by the always-on light structural guards (ADR 0004) when a parsed response is not a
|
|
4
|
+
* JSON:API document, or a `data`/`included` member is not a resource object carrying `type`+`id`.
|
|
5
|
+
* Distinct from {@link JsonApiError} (a non-2xx server response): this signals the wire shape the
|
|
6
|
+
* runtime relies on was violated, not an application error the server reported. The opt-in
|
|
7
|
+
* per-field validator throws its engine's own error type, never this one.
|
|
8
|
+
*/
|
|
9
|
+
var StructuralGuardError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "StructuralGuardError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Thrown for any non-2xx JSON:API response. Carries the parsed error document plus
|
|
17
|
+
* expressive status matchers and pointer-grouping helpers.
|
|
18
|
+
*/
|
|
19
|
+
var JsonApiError = class extends Error {
|
|
20
|
+
status;
|
|
21
|
+
errors;
|
|
22
|
+
constructor(status, errors, message) {
|
|
23
|
+
super(message ?? `JSON:API request failed with status ${status}`);
|
|
24
|
+
this.name = "JsonApiError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.errors = errors;
|
|
27
|
+
}
|
|
28
|
+
hasStatus(status) {
|
|
29
|
+
return this.status === status;
|
|
30
|
+
}
|
|
31
|
+
is4xx() {
|
|
32
|
+
return this.status >= 400 && this.status < 500;
|
|
33
|
+
}
|
|
34
|
+
is5xx() {
|
|
35
|
+
return this.status >= 500 && this.status < 600;
|
|
36
|
+
}
|
|
37
|
+
isBadRequest() {
|
|
38
|
+
return this.status === 400;
|
|
39
|
+
}
|
|
40
|
+
isUnauthorized() {
|
|
41
|
+
return this.status === 401;
|
|
42
|
+
}
|
|
43
|
+
isForbidden() {
|
|
44
|
+
return this.status === 403;
|
|
45
|
+
}
|
|
46
|
+
isNotFound() {
|
|
47
|
+
return this.status === 404;
|
|
48
|
+
}
|
|
49
|
+
isNotAcceptable() {
|
|
50
|
+
return this.status === 406;
|
|
51
|
+
}
|
|
52
|
+
isConflict() {
|
|
53
|
+
return this.status === 409;
|
|
54
|
+
}
|
|
55
|
+
isUnsupportedMediaType() {
|
|
56
|
+
return this.status === 415;
|
|
57
|
+
}
|
|
58
|
+
isUnprocessable() {
|
|
59
|
+
return this.status === 422;
|
|
60
|
+
}
|
|
61
|
+
/** Alias of {@link isUnprocessable} — the JSON:API validation status. */
|
|
62
|
+
isValidationError() {
|
|
63
|
+
return this.status === 422;
|
|
64
|
+
}
|
|
65
|
+
isRateLimited() {
|
|
66
|
+
return this.status === 429;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Group errors by user-facing input path (falls back to the raw pointer, then the
|
|
70
|
+
* query parameter). The payoff for form/validation UX — 422 `source.pointer`s are
|
|
71
|
+
* remapped to the flat shape the caller actually supplied.
|
|
72
|
+
*/
|
|
73
|
+
byPath() {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const e of this.errors) {
|
|
76
|
+
const key = e.path ?? e.source?.pointer ?? e.source?.parameter ?? "_";
|
|
77
|
+
(out[key] ??= []).push(e);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/validate.ts
|
|
84
|
+
/** Whether a `validate` option is the schema-driven config (vs a bare validator function). */
|
|
85
|
+
function isConfig(option) {
|
|
86
|
+
return typeof option === "object" && option !== null && !Array.isArray(option);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Normalise the `validate?` option to a single per-resource validator. The config form looks the
|
|
90
|
+
* schema up by `resource.type` and skips a type with no schema (the seam stays graceful for a
|
|
91
|
+
* partially-covered bundle); the bare-function form calls the validator with `schema = undefined`,
|
|
92
|
+
* delegating the whole decision to the caller. Returns `undefined` when validation is off.
|
|
93
|
+
*/
|
|
94
|
+
function resolveValidator(option) {
|
|
95
|
+
if (option === void 0) return;
|
|
96
|
+
if (isConfig(option)) {
|
|
97
|
+
const { schemas, validator } = option;
|
|
98
|
+
return { validate(resource) {
|
|
99
|
+
const schema = schemas[resource.type];
|
|
100
|
+
if (schema !== void 0) validator(resource, schema);
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
return { validate(resource) {
|
|
104
|
+
option(resource, void 0);
|
|
105
|
+
} };
|
|
106
|
+
}
|
|
107
|
+
const isObject$3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
108
|
+
/**
|
|
109
|
+
* The light structural guards every parsed response passes through (ADR 0004): assert the body
|
|
110
|
+
* is a JSON:API document and that each `data`/`included` member carries a string `type` (and,
|
|
111
|
+
* for a full resource, an `id`). This is the always-on, engine-free floor — it never duplicates
|
|
112
|
+
* the opt-in per-field validation; it only proves the envelope invariant the runtime relies on.
|
|
113
|
+
*
|
|
114
|
+
* A primary-`data` resource and every `included` resource must carry `type`+`id`. A relationship
|
|
115
|
+
* endpoint's primary data is pure resource-identifier linkage, which also carries `type`+`id`, so
|
|
116
|
+
* the same guard holds; `linkage` is accepted for symmetry with the materialiser but the assertion
|
|
117
|
+
* is identical (an identifier is `type`+`id`). `data: null` (an empty to-one) and a missing
|
|
118
|
+
* `data` (a meta-only document) are valid and pass.
|
|
119
|
+
*/
|
|
120
|
+
function assertJsonApiDocument(body) {
|
|
121
|
+
if (!isObject$3(body)) throw new StructuralGuardError("Response body is not a JSON:API document (expected an object)");
|
|
122
|
+
if (!("data" in body) && !("meta" in body)) throw new StructuralGuardError("Response body is not a JSON:API document (missing top-level \"data\" or \"meta\")");
|
|
123
|
+
const data = body["data"];
|
|
124
|
+
if (Array.isArray(data)) data.forEach((member, i) => assertResource(member, `data[${i}]`));
|
|
125
|
+
else if (data !== null && data !== void 0) assertResource(data, "data");
|
|
126
|
+
const included = body["included"];
|
|
127
|
+
if (included !== void 0) {
|
|
128
|
+
if (!Array.isArray(included)) throw new StructuralGuardError("Response \"included\" is not an array");
|
|
129
|
+
included.forEach((member, i) => assertResource(member, `included[${i}]`));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Assert a single member is a resource object/identifier carrying string `type` + `id`. */
|
|
133
|
+
function assertResource(member, at) {
|
|
134
|
+
if (!isObject$3(member)) throw new StructuralGuardError(`${at} is not a JSON:API resource object`);
|
|
135
|
+
if (typeof member["type"] !== "string") throw new StructuralGuardError(`${at} is missing a string "type"`);
|
|
136
|
+
if (typeof member["id"] !== "string") throw new StructuralGuardError(`${at} (type "${member["type"]}") is missing a string "id"`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Run the opt-in validator over every wire resource in a document's `data` + `included`, each
|
|
140
|
+
* against its per-type schema. Called only when a validator is configured (off by default). The
|
|
141
|
+
* structural guards have already proven each member carries `type`+`id`, so the cast is sound.
|
|
142
|
+
*/
|
|
143
|
+
function validateDocument(body, validator) {
|
|
144
|
+
const data = body.data;
|
|
145
|
+
if (Array.isArray(data)) for (const member of data) validateResourceMember(member, validator);
|
|
146
|
+
else if (data !== null && data !== void 0) validateResourceMember(data, validator);
|
|
147
|
+
if (body.included !== void 0) for (const member of body.included) validator.validate(member);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Validate a `data` member, but only when it is a full RESOURCE OBJECT. A relationship-linkage
|
|
151
|
+
* endpoint's `data` is bare resource IDENTIFIERS (`type`+`id`[+`meta`], no `attributes`/
|
|
152
|
+
* `relationships`); the per-type schema describes the resource object, so running it over an
|
|
153
|
+
* identifier would spuriously fail. The structural guard already covered the identifier shape.
|
|
154
|
+
*/
|
|
155
|
+
function validateResourceMember(member, validator) {
|
|
156
|
+
if (isObject$3(member) && ("attributes" in member || "relationships" in member)) validator.validate(member);
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/materialise.ts
|
|
160
|
+
const isObject$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
161
|
+
const define = (target, key, value) => {
|
|
162
|
+
Object.defineProperty(target, key, {
|
|
163
|
+
value,
|
|
164
|
+
enumerable: false,
|
|
165
|
+
configurable: true
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const defineGetter = (target, key, get) => {
|
|
169
|
+
Object.defineProperty(target, key, {
|
|
170
|
+
get,
|
|
171
|
+
enumerable: false,
|
|
172
|
+
configurable: true
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Materialise a JSON:API document into a hydrated graph (ADR 0002 / CONTEXT.md):
|
|
177
|
+
*
|
|
178
|
+
* - single resource -> a flattened resource object (own enumerable type/id/attributes/
|
|
179
|
+
* relations + non-enumerable `$`-accessors);
|
|
180
|
+
* - collection / related collection -> an augmented array of resource objects;
|
|
181
|
+
* - relationship linkage -> an augmented array of identifier views (to-many) or a single
|
|
182
|
+
* identifier / null (to-one).
|
|
183
|
+
*
|
|
184
|
+
* Included resources hydrate into nested per-edge views (reads through to the shared
|
|
185
|
+
* node for attributes, owns its own `$edge`/`$pivot`); linked-but-not-included relations
|
|
186
|
+
* stay as identifiers; links-only relations are `undefined`. The envelope rides
|
|
187
|
+
* non-enumerable `$`-accessors so `{...res}` / `JSON.stringify(res)` are clean.
|
|
188
|
+
*
|
|
189
|
+
* `linkage` discriminates a relationship-endpoint response (primary `data` is pure
|
|
190
|
+
* resource-identifier linkage) from a resource/collection response (primary `data` is a
|
|
191
|
+
* full resource) — a structural guess can't tell them apart for an attribute-less,
|
|
192
|
+
* relation-less resource, so the caller passes the surface it issued.
|
|
193
|
+
*
|
|
194
|
+
* `paginatorOverride` sets the top-level array's `$page.kind` for a related/relationship read,
|
|
195
|
+
* where the paginator is the RELATION's (which may diverge from the related type's collection —
|
|
196
|
+
* D6a) and an empty page carries no member to sniff a type from. `primaryType` is preferred for a
|
|
197
|
+
* top-level collection read (statically-known); both override the sniff-from-`data[0]` fallback.
|
|
198
|
+
*/
|
|
199
|
+
function materialise(doc, ctx, linkage = false, primaryType, paginatorOverride) {
|
|
200
|
+
assertJsonApiDocument(doc);
|
|
201
|
+
if (ctx.validate !== void 0) validateDocument(doc, ctx.validate);
|
|
202
|
+
const env = {};
|
|
203
|
+
if (doc.jsonapi !== void 0) env.jsonapi = doc.jsonapi;
|
|
204
|
+
if (doc.meta !== void 0) env.meta = doc.meta;
|
|
205
|
+
if (doc.links !== void 0) env.links = doc.links;
|
|
206
|
+
const index = buildIndex(doc.included);
|
|
207
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
208
|
+
const build = (raw) => buildNode(raw, env, index, nodes, ctx);
|
|
209
|
+
const data = doc.data;
|
|
210
|
+
if (data === null) return null;
|
|
211
|
+
if (Array.isArray(data)) {
|
|
212
|
+
const raws = data;
|
|
213
|
+
return augmentArray(linkage ? raws.map((raw) => buildIdentifierView(raw)) : raws.map((raw) => buildEdgeView(build(raw), raw)), doc, ctx, linkage, primaryType, paginatorOverride);
|
|
214
|
+
}
|
|
215
|
+
if (isObject$2(data)) {
|
|
216
|
+
const raw = data;
|
|
217
|
+
if (linkage) return buildIdentifierView(raw);
|
|
218
|
+
return build(raw);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Index `included` by `type:id` so relationship linkage can be hydrated. */
|
|
222
|
+
function buildIndex(included) {
|
|
223
|
+
const index = /* @__PURE__ */ new Map();
|
|
224
|
+
if (!included) return index;
|
|
225
|
+
for (const raw of included) if (isObject$2(raw) && typeof raw["type"] === "string" && typeof raw["id"] === "string") index.set(key(raw["type"], raw["id"]), raw);
|
|
226
|
+
return index;
|
|
227
|
+
}
|
|
228
|
+
const key = (type, id) => `${type}:${id}`;
|
|
229
|
+
/**
|
|
230
|
+
* Build (or reuse) the shared NODE for a resource: flattened type/id/attributes + hydrated
|
|
231
|
+
* relations as own enumerable props, plus the resource-level `$`-accessors. The node is
|
|
232
|
+
* memoised by `type:id` so attribute reads share one object; per-edge data never lands here.
|
|
233
|
+
*/
|
|
234
|
+
function buildNode(raw, env, index, nodes, ctx) {
|
|
235
|
+
const id = key(raw.type, raw.id);
|
|
236
|
+
const existing = nodes.get(id);
|
|
237
|
+
if (existing) return existing;
|
|
238
|
+
const node = {
|
|
239
|
+
type: raw.type,
|
|
240
|
+
id: raw.id
|
|
241
|
+
};
|
|
242
|
+
nodes.set(id, node);
|
|
243
|
+
if (raw.attributes) for (const [k, v] of Object.entries(raw.attributes)) node[k] = v;
|
|
244
|
+
const rels = raw.relationships ?? {};
|
|
245
|
+
const resType = ctx.descriptor[raw.type];
|
|
246
|
+
for (const [name, rel] of Object.entries(rels)) {
|
|
247
|
+
if (!("data" in rel)) continue;
|
|
248
|
+
const relDesc = resType?.relations[name];
|
|
249
|
+
node[name] = hydrateRelation(rel, relDesc?.cardinality, relatedPaginatorKind(relDesc, ctx), env, index, nodes, ctx);
|
|
250
|
+
}
|
|
251
|
+
attachResourceEnvelope(node, raw, env, rels);
|
|
252
|
+
return node;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Resolve one relationship's value: an included member hydrates to a per-edge view, a
|
|
256
|
+
* linked-but-not-included member stays an identifier, an empty to-one is `null`, and a
|
|
257
|
+
* to-many is always an augmented array (of views and/or identifiers).
|
|
258
|
+
*
|
|
259
|
+
* `relatedKind` is the related type's paginator kind (from the descriptor), threaded so a
|
|
260
|
+
* to-many array reports the same `$page.kind` it would on its dedicated endpoint.
|
|
261
|
+
*/
|
|
262
|
+
function hydrateRelation(rel, cardinality, relatedKind, env, index, nodes, ctx) {
|
|
263
|
+
const data = rel.data;
|
|
264
|
+
if (Array.isArray(data)) return augmentRelationArray(data.map((member) => resolveMember(member, env, index, nodes, ctx)), rel, relatedKind, ctx);
|
|
265
|
+
if (data === void 0 && cardinality === "many") return augmentRelationArray([], rel, relatedKind, ctx);
|
|
266
|
+
if (data === null || data === void 0) return data === null ? null : void 0;
|
|
267
|
+
if (isObject$2(data)) return resolveMember(data, env, index, nodes, ctx, {
|
|
268
|
+
links: rel.links,
|
|
269
|
+
meta: rel.meta
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** A single linkage member -> a hydrated per-edge view if included, else an identifier view. */
|
|
273
|
+
function resolveMember(member, env, index, nodes, ctx, relationEdge) {
|
|
274
|
+
const included = index.get(key(member.type, member.id));
|
|
275
|
+
if (included) return buildEdgeView(buildNode(included, env, index, nodes, ctx), member, relationEdge);
|
|
276
|
+
return buildIdentifierView(member, relationEdge);
|
|
277
|
+
}
|
|
278
|
+
/** Attach the resource-level `$`-accessors ($meta/$links/$self/$document/$raw/$rel). */
|
|
279
|
+
function attachResourceEnvelope(node, raw, env, rels) {
|
|
280
|
+
defineGetter(node, "$meta", () => raw.meta);
|
|
281
|
+
defineGetter(node, "$links", () => raw.links);
|
|
282
|
+
defineGetter(node, "$self", () => raw.links?.self);
|
|
283
|
+
defineGetter(node, "$document", () => env);
|
|
284
|
+
defineGetter(node, "$raw", () => raw);
|
|
285
|
+
define(node, "$rel", (name) => {
|
|
286
|
+
const rel = rels[name];
|
|
287
|
+
if (!rel) return void 0;
|
|
288
|
+
const view = {};
|
|
289
|
+
if ("data" in rel) view.data = rel.data;
|
|
290
|
+
if (rel.links !== void 0) view.links = rel.links;
|
|
291
|
+
if (rel.meta !== void 0) view.meta = rel.meta;
|
|
292
|
+
return view;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* A per-edge VIEW over a node: a distinct wrapper per membership that reads through to the
|
|
297
|
+
* shared node for attributes (and the resource-level `$`-accessors) but owns its own
|
|
298
|
+
* edge-local `$edge`/`$pivot`. Identity is by `type:id`, never reference.
|
|
299
|
+
*
|
|
300
|
+
* `relationEdge` (set for a to-one) carries the relationship object's self/related links and
|
|
301
|
+
* meta, which ride the value's `$edge` per CONTEXT.md.
|
|
302
|
+
*/
|
|
303
|
+
function buildEdgeView(node, member, relationEdge) {
|
|
304
|
+
const view = {};
|
|
305
|
+
Object.assign(view, node);
|
|
306
|
+
for (const k of [
|
|
307
|
+
"$meta",
|
|
308
|
+
"$links",
|
|
309
|
+
"$self",
|
|
310
|
+
"$document",
|
|
311
|
+
"$raw",
|
|
312
|
+
"$rel"
|
|
313
|
+
]) delegate(view, node, k);
|
|
314
|
+
attachEdgeEnvelope(view, member, false, relationEdge);
|
|
315
|
+
return view;
|
|
316
|
+
}
|
|
317
|
+
/** An identifier VIEW for a linked-but-not-included member: a clean `{type,id,meta?}` + edge. */
|
|
318
|
+
function buildIdentifierView(member, relationEdge) {
|
|
319
|
+
const view = {
|
|
320
|
+
type: member.type,
|
|
321
|
+
id: member.id
|
|
322
|
+
};
|
|
323
|
+
if (member.meta !== void 0) view.meta = member.meta;
|
|
324
|
+
attachEdgeEnvelope(view, member, true, relationEdge);
|
|
325
|
+
return view;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Attach `$edge` (the member's identifier-level edge meta/links) and `$pivot` sugar over it.
|
|
329
|
+
* For a to-one, the relationship object's self/related links + meta (`relationEdge`) merge in,
|
|
330
|
+
* with member-level meta winning on key collision.
|
|
331
|
+
*/
|
|
332
|
+
function attachEdgeEnvelope(view, member, includeLinks, relationEdge) {
|
|
333
|
+
defineGetter(view, "$edge", () => {
|
|
334
|
+
const links = (includeLinks ? member.links : void 0) ?? relationEdge?.links;
|
|
335
|
+
const meta = mergeMeta(relationEdge?.meta, member.meta);
|
|
336
|
+
if (links === void 0 && meta === void 0) return void 0;
|
|
337
|
+
const edge = {};
|
|
338
|
+
if (links !== void 0) edge.links = links;
|
|
339
|
+
if (meta !== void 0) edge.meta = meta;
|
|
340
|
+
return edge;
|
|
341
|
+
});
|
|
342
|
+
defineGetter(view, "$pivot", () => {
|
|
343
|
+
const pivot = member.meta?.["pivot"];
|
|
344
|
+
return isObject$2(pivot) ? pivot : void 0;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** Merge a relationship-object meta with member-level meta (member wins on key collision). */
|
|
348
|
+
function mergeMeta(base, override) {
|
|
349
|
+
if (base === void 0) return override;
|
|
350
|
+
if (override === void 0) return base;
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
...override
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** Mirror a non-enumerable accessor from `source` onto `target`, reading through live. */
|
|
357
|
+
function delegate(target, source, k) {
|
|
358
|
+
const value = source[k];
|
|
359
|
+
if (typeof value === "function") define(target, k, value.bind(source));
|
|
360
|
+
else defineGetter(target, k, () => source[k]);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* A top-level / related collection array: `$page` from `doc`, links/meta from the document.
|
|
364
|
+
* `linkage` propagates to `$next()`/`$prev()` so a relationship-endpoint page re-materialises
|
|
365
|
+
* as identifiers, not resources.
|
|
366
|
+
*/
|
|
367
|
+
function augmentArray(members, doc, ctx, linkage, primaryType, paginatorOverride) {
|
|
368
|
+
const arr = [...members];
|
|
369
|
+
const links = doc.links;
|
|
370
|
+
defineGetter(arr, "$links", () => links);
|
|
371
|
+
defineGetter(arr, "$meta", () => doc.meta);
|
|
372
|
+
attachPage(arr, paginatorOverride ?? paginatorKind(doc, ctx, primaryType), pageMeta(doc.meta), links, ctx, linkage);
|
|
373
|
+
return arr;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* A to-many relationship array: `$page`/links/meta come from the relationship object. `kind`
|
|
377
|
+
* is the related type's paginator (so an in-document compound-include to-many reports the same
|
|
378
|
+
* discriminant it would when fetched at its own relationship/related endpoint — "one model,
|
|
379
|
+
* three surfaces").
|
|
380
|
+
*/
|
|
381
|
+
function augmentRelationArray(members, rel, kind, ctx) {
|
|
382
|
+
const arr = [...members];
|
|
383
|
+
const links = rel.links;
|
|
384
|
+
defineGetter(arr, "$links", () => links);
|
|
385
|
+
defineGetter(arr, "$meta", () => rel.meta);
|
|
386
|
+
attachPage(arr, kind, pageMeta(rel.meta), links, ctx, false);
|
|
387
|
+
return arr;
|
|
388
|
+
}
|
|
389
|
+
/** Attach `$page` + `$next()`/`$prev()` driven by link presence (count-free-safe). */
|
|
390
|
+
function attachPage(arr, kind, meta, links, ctx, linkage) {
|
|
391
|
+
const page = {
|
|
392
|
+
kind,
|
|
393
|
+
links: pageLinks(links)
|
|
394
|
+
};
|
|
395
|
+
if (meta !== void 0) page.meta = meta;
|
|
396
|
+
defineGetter(arr, "$page", () => page);
|
|
397
|
+
define(arr, "$next", () => navigateLink(page.links.next, ctx, linkage, kind));
|
|
398
|
+
define(arr, "$prev", () => navigateLink(page.links.prev, ctx, linkage, kind));
|
|
399
|
+
}
|
|
400
|
+
const navigateLink = (link, ctx, linkage, paginator) => link === void 0 ? Promise.resolve(void 0) : ctx.navigate(link, linkage, paginator);
|
|
401
|
+
/** Extract the navigation links (a link may be a string or a `{href}` object). */
|
|
402
|
+
function pageLinks(links) {
|
|
403
|
+
const out = {};
|
|
404
|
+
if (!links) return out;
|
|
405
|
+
for (const k of [
|
|
406
|
+
"first",
|
|
407
|
+
"prev",
|
|
408
|
+
"next",
|
|
409
|
+
"last"
|
|
410
|
+
]) {
|
|
411
|
+
const href = linkHref(links[k]);
|
|
412
|
+
if (href !== void 0) out[k] = href;
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
const linkHref = (link) => {
|
|
417
|
+
if (typeof link === "string") return link;
|
|
418
|
+
if (isObject$2(link) && typeof link["href"] === "string") return link["href"];
|
|
419
|
+
};
|
|
420
|
+
const pageMeta = (meta) => isObject$2(meta?.["page"]) ? meta["page"] : void 0;
|
|
421
|
+
/**
|
|
422
|
+
* The document's paginator kind (from the primary type's descriptor; default `none`). Prefers the
|
|
423
|
+
* statically-known `primaryType` (passed by the descriptor binding at the call site) so an EMPTY
|
|
424
|
+
* page still reports the collection's real kind; falls back to sniffing the first member's type
|
|
425
|
+
* (for a re-materialised `$next()` page or a related read, where the primary type isn't threaded).
|
|
426
|
+
*/
|
|
427
|
+
function paginatorKind(doc, ctx, primaryType) {
|
|
428
|
+
const first = Array.isArray(doc.data) ? doc.data[0] : doc.data;
|
|
429
|
+
const type = primaryType ?? (isObject$2(first) && typeof first["type"] === "string" ? first["type"] : void 0);
|
|
430
|
+
return type !== void 0 ? ctx.descriptor[type]?.paginator ?? "none" : "none";
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A to-many relation's paginator kind. The relation's OWN related/relationship endpoint may
|
|
434
|
+
* paginate differently than the related type's top-level collection (D6a), so prefer the
|
|
435
|
+
* per-relation `paginator` (carried in the descriptor only when it diverges); otherwise fall back
|
|
436
|
+
* to the related type's own paginator (the relationship and related endpoints both paginate that
|
|
437
|
+
* type), so the discriminant is identical across the three surfaces. A polymorphic relation
|
|
438
|
+
* resolves off its first declared related type (its members span types, matching the relationship
|
|
439
|
+
* endpoint's first-member resolution).
|
|
440
|
+
*/
|
|
441
|
+
function relatedPaginatorKind(relation, ctx) {
|
|
442
|
+
if (relation?.paginator !== void 0) return relation.paginator;
|
|
443
|
+
const relatedType = relation?.types[0];
|
|
444
|
+
if (relatedType === void 0) return "none";
|
|
445
|
+
return ctx.descriptor[relatedType]?.paginator ?? "none";
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/types.ts
|
|
449
|
+
/** The JSON:API media type. */
|
|
450
|
+
const JSON_API_MEDIA_TYPE = "application/vnd.api+json";
|
|
451
|
+
/** The Atomic Operations extension URI. */
|
|
452
|
+
const ATOMIC_EXT = "https://jsonapi.org/ext/atomic";
|
|
453
|
+
/**
|
|
454
|
+
* Compose the JSON:API media type with any opted-in `ext`/`profile` parameters. Each parameter
|
|
455
|
+
* is a space-joined, double-quoted URI list, in the canonical order `ext` then `profile` so the
|
|
456
|
+
* output is deterministic (stable for caching/assertions). Empty/absent lists are omitted; with
|
|
457
|
+
* neither, the bare {@link JSON_API_MEDIA_TYPE} is returned (no behaviour change for a plain
|
|
458
|
+
* request). Used for both `Accept` and (when a body is present) `Content-Type`.
|
|
459
|
+
*/
|
|
460
|
+
function mediaType(params = {}) {
|
|
461
|
+
let out = JSON_API_MEDIA_TYPE;
|
|
462
|
+
if (params.ext !== void 0 && params.ext.length > 0) out += `; ext="${params.ext.join(" ")}"`;
|
|
463
|
+
if (params.profiles !== void 0 && params.profiles.length > 0) out += `; profile="${params.profiles.join(" ")}"`;
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The media type for an Atomic Operations request/response: the JSON:API media type carrying
|
|
468
|
+
* the atomic `ext` parameter. Sent as both `Content-Type` and `Accept` on a `client.atomic`
|
|
469
|
+
* batch (per the JSON:API atomic extension).
|
|
470
|
+
*/
|
|
471
|
+
const ATOMIC_MEDIA_TYPE = mediaType({ ext: [ATOMIC_EXT] });
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/request.ts
|
|
474
|
+
const isAbsolute = (path) => /^https?:\/\//i.test(path);
|
|
475
|
+
const append = (parts, key, value) => {
|
|
476
|
+
if (value === void 0 || value === null || value === "") return;
|
|
477
|
+
parts.push(`${key}=${encodeURIComponent(String(value))}`);
|
|
478
|
+
};
|
|
479
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
480
|
+
/**
|
|
481
|
+
* Append a filter value under its (already-bracketed) key. A plain object is a STRUCTURED filter
|
|
482
|
+
* value — a `Range`/`DateRange` deepObject (`{ min, max }`) — and recurses into nested bracketed
|
|
483
|
+
* keys (`filter[k][min]=`), matching the server's deepObject wire shape; an array joins with `,`;
|
|
484
|
+
* a scalar is appended directly. Without the object branch a `{ min, max }` value would reach the
|
|
485
|
+
* wire as `filter[k]=[object Object]` and be silently ignored by the server (D23).
|
|
486
|
+
*/
|
|
487
|
+
const appendFilterValue = (parts, key, value) => {
|
|
488
|
+
if (isPlainObject(value)) {
|
|
489
|
+
for (const [subKey, subValue] of Object.entries(value)) appendFilterValue(parts, `${key}[${subKey}]`, subValue);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
append(parts, key, Array.isArray(value) ? value.join(",") : value);
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* Serialise a read query to a query string (no leading `?`). Bracketed keys
|
|
496
|
+
* (`filter[title]`, `page[number]`, `fields[albums]`) are kept literal; only values
|
|
497
|
+
* are percent-encoded. Empty/undefined entries are skipped; key order is deterministic
|
|
498
|
+
* (family order, then insertion order within a family) so URLs are stable for caching.
|
|
499
|
+
*/
|
|
500
|
+
function serializeQuery(query) {
|
|
501
|
+
const parts = [];
|
|
502
|
+
if (query.filter) for (const [key, value] of Object.entries(query.filter)) appendFilterValue(parts, `filter[${key}]`, value);
|
|
503
|
+
if (query.sort !== void 0) append(parts, "sort", Array.isArray(query.sort) ? query.sort.join(",") : query.sort);
|
|
504
|
+
if (query.include) append(parts, "include", query.include.join(","));
|
|
505
|
+
if (query.fields) for (const [type, names] of Object.entries(query.fields)) parts.push(`fields[${type}]=${encodeURIComponent(names.join(","))}`);
|
|
506
|
+
if (query.withCount && query.withCount.length > 0) append(parts, "withCount", query.withCount.join(","));
|
|
507
|
+
if (query.page) for (const [key, value] of Object.entries(query.page)) append(parts, `page[${key}]`, Array.isArray(value) ? value.join(",") : value);
|
|
508
|
+
return parts.join("&");
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Execute a request: build the URL (verbatim when already absolute — for page links —
|
|
512
|
+
* else `baseUrl + path`), apply content negotiation and any async per-request headers,
|
|
513
|
+
* then drive the transport. A 2xx with a body parses to a {@link Document}; an empty /
|
|
514
|
+
* 204 body resolves to `undefined`. A non-2xx throws a {@link JsonApiError} carrying the
|
|
515
|
+
* parsed error objects (tolerating a non-JSON error body with an empty list).
|
|
516
|
+
*/
|
|
517
|
+
async function execute(ctx, req) {
|
|
518
|
+
let url = isAbsolute(req.path) ? req.path : ctx.baseUrl + req.path;
|
|
519
|
+
if (req.query) {
|
|
520
|
+
const qs = serializeQuery(req.query);
|
|
521
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
522
|
+
}
|
|
523
|
+
const negotiated = mediaType({
|
|
524
|
+
ext: req.ext,
|
|
525
|
+
profiles: req.profiles
|
|
526
|
+
});
|
|
527
|
+
const headers = { Accept: req.accept ?? negotiated };
|
|
528
|
+
const transportReq = {
|
|
529
|
+
method: req.method,
|
|
530
|
+
url,
|
|
531
|
+
headers
|
|
532
|
+
};
|
|
533
|
+
if (req.body !== void 0) {
|
|
534
|
+
headers["Content-Type"] = req.contentType ?? negotiated;
|
|
535
|
+
transportReq.body = req.raw && typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
536
|
+
}
|
|
537
|
+
if (ctx.headers) mergeHeaders(headers, await ctx.headers());
|
|
538
|
+
const res = await ctx.transport(transportReq);
|
|
539
|
+
if (res.status >= 200 && res.status < 300) return parseDocument(res.body);
|
|
540
|
+
throw new JsonApiError(res.status, parseErrors(res.body));
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Merge a `HeadersInit` (object / tuple array / `Headers`) into the outgoing map,
|
|
544
|
+
* preserving the caller's casing (unlike the WHATWG `Headers`, which lowercases). The
|
|
545
|
+
* caller's headers override the runtime defaults, so per-request `Accept` etc. can win.
|
|
546
|
+
*/
|
|
547
|
+
function mergeHeaders(into, extra) {
|
|
548
|
+
if (extra instanceof Headers) extra.forEach((value, key) => {
|
|
549
|
+
into[key] = value;
|
|
550
|
+
});
|
|
551
|
+
else if (Array.isArray(extra)) {
|
|
552
|
+
for (const [key, value] of extra) if (key !== void 0 && value !== void 0) into[key] = String(value);
|
|
553
|
+
} else for (const [key, value] of Object.entries(extra)) into[key] = String(value);
|
|
554
|
+
}
|
|
555
|
+
function parseDocument(body) {
|
|
556
|
+
if (body === "") return;
|
|
557
|
+
return JSON.parse(body);
|
|
558
|
+
}
|
|
559
|
+
function parseErrors(body) {
|
|
560
|
+
if (body === "") return [];
|
|
561
|
+
try {
|
|
562
|
+
const doc = JSON.parse(body);
|
|
563
|
+
return Array.isArray(doc.errors) ? doc.errors : [];
|
|
564
|
+
} catch {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region src/serialize-write.ts
|
|
570
|
+
/** Per-edge writable pivot data, supplied on a to-many member as the `$pivot` key. */
|
|
571
|
+
const PIVOT_KEY = "$pivot";
|
|
572
|
+
/** The reserved input keys never routed to attributes/relationships. */
|
|
573
|
+
const RESERVED_INPUT = /* @__PURE__ */ new Set(["type", "id"]);
|
|
574
|
+
const isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
575
|
+
/**
|
|
576
|
+
* Build a JSON:API write document from FLAT input. Each input key is routed via the
|
|
577
|
+
* descriptor: a declared relation -> `data.relationships[name] = { data: <linkage> }`;
|
|
578
|
+
* anything else -> `data.attributes[name]`. Relationship linkage is coerced by cardinality:
|
|
579
|
+
*
|
|
580
|
+
* - to-one accepts an identifier `{type,id}`, a materialised resource object (its `{type,id}`
|
|
581
|
+
* are extracted), or `null` (-> `data: null`, clearing the relationship);
|
|
582
|
+
* - to-many accepts an array of those; a member may carry a `$pivot` object, rendered as the
|
|
583
|
+
* member identifier's `meta.pivot` (writable pivot fields).
|
|
584
|
+
*
|
|
585
|
+
* `data.type` is always the accessor `type`. `data.id` is written from `opts.id` (an update),
|
|
586
|
+
* else from `input.id` only when the type's `clientId` policy allows it (omitted when
|
|
587
|
+
* `forbidden`, passed through when `optional`/`required`).
|
|
588
|
+
*/
|
|
589
|
+
function toDocument(descriptor, type, input, opts) {
|
|
590
|
+
const resource = descriptor[type];
|
|
591
|
+
const relations = resource?.relations ?? {};
|
|
592
|
+
const data = { type };
|
|
593
|
+
const id = resolveId(input, opts, resource?.clientId);
|
|
594
|
+
if (id !== void 0) data.id = id;
|
|
595
|
+
const attributes = {};
|
|
596
|
+
const relationships = {};
|
|
597
|
+
for (const [keyName, value] of Object.entries(input)) {
|
|
598
|
+
if (RESERVED_INPUT.has(keyName)) continue;
|
|
599
|
+
const relation = relations[keyName];
|
|
600
|
+
if (relation !== void 0) relationships[keyName] = { data: coerceLinkage(value, relation.cardinality) };
|
|
601
|
+
else attributes[keyName] = value;
|
|
602
|
+
}
|
|
603
|
+
if (Object.keys(attributes).length > 0) data.attributes = attributes;
|
|
604
|
+
if (Object.keys(relationships).length > 0) data.relationships = relationships;
|
|
605
|
+
return { data };
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Build a relationship-mutation document `{ data: <linkage> }` for the
|
|
609
|
+
* `/{type}/{id}/relationships/{rel}` endpoints, coercing the caller's refs by cardinality:
|
|
610
|
+
*
|
|
611
|
+
* - to-one accepts an identifier `{type,id}`, a materialised resource object, or `null`
|
|
612
|
+
* (-> `data: null`, clearing the relationship);
|
|
613
|
+
* - to-many accepts an array of those; a member may carry `$pivot`, rendered as the member
|
|
614
|
+
* identifier's `meta.pivot`.
|
|
615
|
+
*
|
|
616
|
+
* The same {@link coerceLinkage} the whole-resource writer uses, so a relationship written
|
|
617
|
+
* standalone and one embedded in a resource serialise identically.
|
|
618
|
+
*/
|
|
619
|
+
function toRelationshipDocument(refs, cardinality) {
|
|
620
|
+
return { data: coerceLinkage(refs, cardinality) };
|
|
621
|
+
}
|
|
622
|
+
/** Resolve the document id: an update's handle id always wins; a create honours the policy. */
|
|
623
|
+
function resolveId(input, opts, clientId) {
|
|
624
|
+
if (opts?.id !== void 0) return opts.id;
|
|
625
|
+
if (clientId === "forbidden") return;
|
|
626
|
+
const candidate = input["id"];
|
|
627
|
+
return typeof candidate === "string" ? candidate : void 0;
|
|
628
|
+
}
|
|
629
|
+
/** Coerce one relationship value to linkage by cardinality (array for to-many, identifier/null for to-one). */
|
|
630
|
+
function coerceLinkage(value, cardinality) {
|
|
631
|
+
if (cardinality === "many") return (Array.isArray(value) ? value : value == null ? [] : [value]).map((member) => toIdentifier(member));
|
|
632
|
+
if (value === null || value === void 0) return null;
|
|
633
|
+
return toIdentifier(value);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Extract a resource identifier from a linkage input value: an identifier `{type,id}`, a
|
|
637
|
+
* materialised resource object (the same `{type,id}` enumerable props), or — inside an atomic
|
|
638
|
+
* batch — a local identifier `{type,lid}` referencing a just-created resource (the handle a
|
|
639
|
+
* `tx.create` returns). A `$pivot` key is lifted onto `meta.pivot` (writable pivot fields).
|
|
640
|
+
* Throws on a value carrying no `type`, or neither an `id` nor a `lid`.
|
|
641
|
+
*/
|
|
642
|
+
function toIdentifier(value) {
|
|
643
|
+
if (!isObject$1(value)) throw new TypeError(`Relationship linkage must be an identifier or resource object; received ${describe(value)}`);
|
|
644
|
+
const { type, id, lid } = value;
|
|
645
|
+
if (typeof type !== "string") throw new TypeError("Relationship linkage requires a `type`");
|
|
646
|
+
const identifier = typeof id === "string" ? {
|
|
647
|
+
type,
|
|
648
|
+
id
|
|
649
|
+
} : typeof lid === "string" ? {
|
|
650
|
+
type,
|
|
651
|
+
lid
|
|
652
|
+
} : (() => {
|
|
653
|
+
throw new TypeError("Relationship linkage requires an `id` or a `lid`");
|
|
654
|
+
})();
|
|
655
|
+
const pivot = value[PIVOT_KEY];
|
|
656
|
+
if (isObject$1(pivot)) identifier.meta = { pivot };
|
|
657
|
+
return identifier;
|
|
658
|
+
}
|
|
659
|
+
const describe = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value;
|
|
660
|
+
/**
|
|
661
|
+
* Invert a write error's `source.pointer` to the caller's FLAT input path, descriptor-aware:
|
|
662
|
+
*
|
|
663
|
+
* - `/data/attributes/title` -> `title`
|
|
664
|
+
* - `/data/relationships/artist/data` -> `artist`
|
|
665
|
+
* - `/data/id` -> `id` (client-id create conflict)
|
|
666
|
+
* - `/data/attributes/releaseInfo/label` -> `releaseInfo.label` (nested map)
|
|
667
|
+
* - `/data/relationships/orderedTracks/data/0/meta/pivot/position` -> `orderedTracks[0].$pivot.position`
|
|
668
|
+
*
|
|
669
|
+
* Pivot values nest under a member's `meta.pivot` (symmetric with reads — see
|
|
670
|
+
* `JsonPointerBuilder::forLinkageMeta` / bundle ADR 0103), so a `meta/pivot/<field>` tail on
|
|
671
|
+
* a member of a pivot-bearing to-many is surfaced as `.$pivot.<field>`.
|
|
672
|
+
*
|
|
673
|
+
* A query-side pointer is left untouched (those are already user-facing — see
|
|
674
|
+
* {@link withRemappedPaths}). An unrecognised / non-write pointer is returned verbatim.
|
|
675
|
+
* `/data/type` and a bare `/data` are passed through (the client owns those; the caller
|
|
676
|
+
* never supplies them).
|
|
677
|
+
*/
|
|
678
|
+
function remapPointer(descriptor, type, pointer) {
|
|
679
|
+
const segments = pointer.split("/").filter((s) => s !== "");
|
|
680
|
+
if (segments[0] !== "data") return pointer;
|
|
681
|
+
if (segments[1] === "attributes") return remapAttribute(segments.slice(2));
|
|
682
|
+
if (segments[1] === "id") return "id";
|
|
683
|
+
if (segments[1] === "relationships") return remapRelationship(descriptor[type]?.relations ?? {}, segments.slice(2));
|
|
684
|
+
return pointer;
|
|
685
|
+
}
|
|
686
|
+
/** `[releaseInfo, label]` -> `releaseInfo.label`; a bare `[title]` -> `title`. */
|
|
687
|
+
function remapAttribute(rest) {
|
|
688
|
+
return rest.join(".");
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Remap a relationship pointer's tail (everything after `relationships/`, or — for a
|
|
692
|
+
* relationship-endpoint document — the relation name spliced ahead of the `data` tail). The
|
|
693
|
+
* leading segment is the relation name; a `data` segment is dropped; a numeric index becomes
|
|
694
|
+
* `[n]`; a `meta/pivot/<field>` tail on a pivot-bearing relation becomes `.$pivot.<field>`. The
|
|
695
|
+
* `relations` map tells us whether the relation carries pivot (so a member's `meta.pivot`
|
|
696
|
+
* fields are the pivot data the bundle validates).
|
|
697
|
+
*/
|
|
698
|
+
function remapRelationship(relations, rest) {
|
|
699
|
+
const name = rest[0];
|
|
700
|
+
if (name === void 0) return "data/relationships";
|
|
701
|
+
const hasPivot = relations[name]?.pivot === true;
|
|
702
|
+
let path = name;
|
|
703
|
+
for (let i = 1; i < rest.length; i++) {
|
|
704
|
+
const seg = rest[i];
|
|
705
|
+
if (seg === "data") continue;
|
|
706
|
+
if (/^\d+$/.test(seg)) path += `[${seg}]`;
|
|
707
|
+
else if (seg === "meta" && hasPivot && rest[i + 1] === "pivot") {
|
|
708
|
+
path += ".$pivot";
|
|
709
|
+
i++;
|
|
710
|
+
} else path += `.${seg}`;
|
|
711
|
+
}
|
|
712
|
+
return path;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Invert a relationship-MUTATION error's `source.pointer` to the caller's flat path, for the
|
|
716
|
+
* `/{type}/{id}/relationships/{rel}` endpoints. Two pointer shapes reach here:
|
|
717
|
+
*
|
|
718
|
+
* - the LINKAGE document shape (validation on the linkage members), rooted at `data` — the
|
|
719
|
+
* relation name is supplied by the route, not the wire:
|
|
720
|
+
* - `/data` -> `<rel>` (to-one clear/set)
|
|
721
|
+
* - `/data/meta/pivot/position` -> `<rel>.$pivot.position` (to-one pivot, rare)
|
|
722
|
+
* - `/data/0/meta/pivot/position` -> `<rel>[0].$pivot.position`
|
|
723
|
+
* - the RESOURCE-document shape that core's relationship prohibitions emit unchanged
|
|
724
|
+
* (`FullReplacementProhibited`/`RemovalProhibited`/`AdditionProhibited`/
|
|
725
|
+
* `RelationshipTypeInappropriate` all point at `/data/relationships/<name>`) — short-circuited
|
|
726
|
+
* to the relation name (drop the `relationships/<name>` prefix) rather than treating
|
|
727
|
+
* `relationships` as a linkage sub-segment.
|
|
728
|
+
*
|
|
729
|
+
* A non-`data` pointer is returned verbatim.
|
|
730
|
+
*/
|
|
731
|
+
function remapRelationshipPointer(rel, hasPivot, pointer) {
|
|
732
|
+
const segments = pointer.split("/").filter((s) => s !== "");
|
|
733
|
+
if (segments[0] !== "data") return pointer;
|
|
734
|
+
const tail = segments[1] === "relationships" ? segments.slice(3) : segments.slice(1);
|
|
735
|
+
return remapRelationship({ [rel]: { pivot: hasPivot } }, [rel, ...tail]);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Return a {@link JsonApiError} with each error object's `path` populated by remapping its
|
|
739
|
+
* `source.pointer` (descriptor-aware) — so {@link JsonApiError.byPath} groups by the flat input
|
|
740
|
+
* shape. Errors with no write pointer (e.g. a `source.parameter` query-side error, or no
|
|
741
|
+
* source) are passed through unchanged. The original error is not mutated.
|
|
742
|
+
*/
|
|
743
|
+
function withRemappedPaths(error, descriptor, type) {
|
|
744
|
+
return mapErrors(error, (pointer) => ({ path: remapPointer(descriptor, type, pointer) }));
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* The relationship-mutation twin of {@link withRemappedPaths}: remap each error's pointer
|
|
748
|
+
* keyed by the relation name from the route. Handles both the linkage-document shape (rooted
|
|
749
|
+
* at `data`) and the resource-document shape core's prohibition exceptions emit
|
|
750
|
+
* (`/data/relationships/<rel>`). `hasPivot` lets a member-level `meta` field surface as
|
|
751
|
+
* `$pivot`. The original error is not mutated.
|
|
752
|
+
*/
|
|
753
|
+
function withRemappedRelationshipPaths(error, rel, hasPivot) {
|
|
754
|
+
return mapErrors(error, (pointer) => ({ path: remapRelationshipPointer(rel, hasPivot, pointer) }));
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Invert an atomic-batch error's `source.pointer` to `(opIndex, flatPath)`: an atomic 422
|
|
758
|
+
* points at `/atomic:operations/{n}/data/...`, so strip the `atomic:operations/{n}` prefix to
|
|
759
|
+
* the op index, resolve that op's wire type (`typeForOp`), and delegate the remaining `/data`
|
|
760
|
+
* tail to the same {@link remapPointer} a standalone write uses (so the flat path is identical
|
|
761
|
+
* — `title`, `releaseInfo.label`, `artist`, …). A pointer without the atomic prefix is remapped
|
|
762
|
+
* verbatim under the (whole-batch) document at op index `undefined`. The original error is not
|
|
763
|
+
* mutated.
|
|
764
|
+
*/
|
|
765
|
+
function withRemappedAtomicPaths(error, descriptor, typeForOp) {
|
|
766
|
+
return mapErrors(error, (pointer) => {
|
|
767
|
+
const segments = pointer.split("/").filter((s) => s !== "");
|
|
768
|
+
if (segments[0] !== "atomic:operations" || segments[1] === void 0) return { path: pointer };
|
|
769
|
+
const opIndex = Number(segments[1]);
|
|
770
|
+
if (!Number.isInteger(opIndex)) return { path: pointer };
|
|
771
|
+
const type = typeForOp(opIndex);
|
|
772
|
+
const tail = `/${segments.slice(2).join("/")}`;
|
|
773
|
+
return {
|
|
774
|
+
opIndex,
|
|
775
|
+
path: type === void 0 ? tail : remapPointer(descriptor, type, tail)
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
/** Shared error-mapper: clone each error with `path`/`opIndex` from its `source.pointer`, else pass through. */
|
|
780
|
+
function mapErrors(error, remap) {
|
|
781
|
+
const remapped = error.errors.map((e) => {
|
|
782
|
+
const pointer = e.source?.pointer;
|
|
783
|
+
if (pointer === void 0) return e;
|
|
784
|
+
const { path, opIndex } = remap(pointer);
|
|
785
|
+
return opIndex === void 0 ? {
|
|
786
|
+
...e,
|
|
787
|
+
path
|
|
788
|
+
} : {
|
|
789
|
+
...e,
|
|
790
|
+
path,
|
|
791
|
+
opIndex
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
return new JsonApiError(error.status, remapped, error.message);
|
|
795
|
+
}
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/atomic.ts
|
|
798
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
799
|
+
/** The deterministic local id for the create at `opIndex` (stable + human-readable). */
|
|
800
|
+
const lidFor = (opIndex) => `atomic-${opIndex}`;
|
|
801
|
+
async function runAtomic(request, materialiseCtx, descriptor, path, build) {
|
|
802
|
+
const operations = [];
|
|
803
|
+
/** The wire type of each op, by index — drives the error-pointer remap. */
|
|
804
|
+
const typeByOp = [];
|
|
805
|
+
const returned = build({
|
|
806
|
+
create(input) {
|
|
807
|
+
const opIndex = operations.length;
|
|
808
|
+
const lid = lidFor(opIndex);
|
|
809
|
+
const data = atomicResourceData(descriptor, input, { lid });
|
|
810
|
+
operations.push({
|
|
811
|
+
op: "add",
|
|
812
|
+
data
|
|
813
|
+
});
|
|
814
|
+
typeByOp.push(input.type);
|
|
815
|
+
return {
|
|
816
|
+
type: input.type,
|
|
817
|
+
lid,
|
|
818
|
+
kind: "create",
|
|
819
|
+
opIndex
|
|
820
|
+
};
|
|
821
|
+
},
|
|
822
|
+
update(input) {
|
|
823
|
+
const opIndex = operations.length;
|
|
824
|
+
const identity = input.id !== void 0 ? { id: input.id } : { lid: input.lid };
|
|
825
|
+
const data = atomicResourceData(descriptor, input, identity);
|
|
826
|
+
operations.push({
|
|
827
|
+
op: "update",
|
|
828
|
+
ref: {
|
|
829
|
+
type: input.type,
|
|
830
|
+
...identity
|
|
831
|
+
},
|
|
832
|
+
data
|
|
833
|
+
});
|
|
834
|
+
typeByOp.push(input.type);
|
|
835
|
+
return {
|
|
836
|
+
type: input.type,
|
|
837
|
+
kind: "update",
|
|
838
|
+
opIndex
|
|
839
|
+
};
|
|
840
|
+
},
|
|
841
|
+
delete(ref) {
|
|
842
|
+
const opIndex = operations.length;
|
|
843
|
+
const { type, identity } = toRef(ref);
|
|
844
|
+
operations.push({
|
|
845
|
+
op: "remove",
|
|
846
|
+
ref: {
|
|
847
|
+
type,
|
|
848
|
+
...identity
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
typeByOp.push(type);
|
|
852
|
+
return {
|
|
853
|
+
kind: "delete",
|
|
854
|
+
opIndex
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
const req = {
|
|
859
|
+
method: "POST",
|
|
860
|
+
path,
|
|
861
|
+
body: { "atomic:operations": operations },
|
|
862
|
+
ext: [ATOMIC_EXT]
|
|
863
|
+
};
|
|
864
|
+
let doc;
|
|
865
|
+
try {
|
|
866
|
+
doc = await execute(request, req);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (error instanceof JsonApiError) throw withRemappedAtomicPaths(error, descriptor, (opIndex) => typeByOp[opIndex]);
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
871
|
+
const results = parseResults(doc, materialiseCtx);
|
|
872
|
+
if (!(Array.isArray(returned) && returned.every((h) => h !== null && typeof h === "object" && "opIndex" in h))) return results;
|
|
873
|
+
return returned.map((handle) => handle.kind === "delete" ? void 0 : results[handle.opIndex]);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Build the resource `data` for an `add`/`update` op from flat input via {@link toDocument}
|
|
877
|
+
* (so attributes/relationships route identically to a standalone write), then carry the op's
|
|
878
|
+
* identity onto the resource: a server `id` (a create's server-assigned id is omitted; an
|
|
879
|
+
* update by id keeps it) or a same-batch `lid` (a create's generated lid, or an update/remove
|
|
880
|
+
* targeting an earlier op's resource).
|
|
881
|
+
*/
|
|
882
|
+
function atomicResourceData(descriptor, input, identity) {
|
|
883
|
+
const { id: _id, lid: _lid, ...fields } = input;
|
|
884
|
+
const { data } = toDocument(descriptor, input.type, fields, "id" in identity ? { id: identity.id } : void 0);
|
|
885
|
+
const out = { ...data };
|
|
886
|
+
if ("lid" in identity) {
|
|
887
|
+
delete out["id"];
|
|
888
|
+
out["lid"] = identity.lid;
|
|
889
|
+
}
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Coerce a remove ref (an identifier, a materialised resource, or a same-batch `{type,lid}`
|
|
894
|
+
* handle) to `{ type, identity }` — `identity` an `id` or a `lid`; throws if neither is present.
|
|
895
|
+
*/
|
|
896
|
+
function toRef(ref) {
|
|
897
|
+
if (!isObject(ref)) throw new TypeError("atomic delete requires an identifier `{ type, id }` or `{ type, lid }`");
|
|
898
|
+
const { type, id, lid } = ref;
|
|
899
|
+
if (typeof type !== "string") throw new TypeError("atomic delete requires a `type`");
|
|
900
|
+
if (typeof id === "string") return {
|
|
901
|
+
type,
|
|
902
|
+
identity: { id }
|
|
903
|
+
};
|
|
904
|
+
if (typeof lid === "string") return {
|
|
905
|
+
type,
|
|
906
|
+
identity: { lid }
|
|
907
|
+
};
|
|
908
|
+
throw new TypeError("atomic delete requires an `id` or a `lid`");
|
|
909
|
+
}
|
|
910
|
+
/** Parse `{ "atomic:results": [...] }` positionally, materialising each result's `data`. An absent results array (an all-`204` batch) yields `[]`. */
|
|
911
|
+
function parseResults(doc, ctx) {
|
|
912
|
+
const results = doc?.["atomic:results"];
|
|
913
|
+
if (!Array.isArray(results)) return [];
|
|
914
|
+
return results.map((result) => {
|
|
915
|
+
const entry = isObject(result) ? result : {};
|
|
916
|
+
const out = { data: entry["data"] === void 0 ? void 0 : materialise({ data: entry["data"] }, ctx) };
|
|
917
|
+
if (isObject(entry["meta"])) out.meta = entry["meta"];
|
|
918
|
+
return out;
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/transport.ts
|
|
923
|
+
/** The default transport, backed by the global `fetch`. */
|
|
924
|
+
const fetchTransport = async (req) => {
|
|
925
|
+
const init = {
|
|
926
|
+
method: req.method,
|
|
927
|
+
headers: req.headers
|
|
928
|
+
};
|
|
929
|
+
if (req.body !== void 0) init.body = req.body;
|
|
930
|
+
const res = await fetch(req.url, init);
|
|
931
|
+
const headers = {};
|
|
932
|
+
res.headers.forEach((value, key) => {
|
|
933
|
+
headers[key] = value;
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
status: res.status,
|
|
937
|
+
headers,
|
|
938
|
+
body: await res.text()
|
|
939
|
+
};
|
|
940
|
+
};
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/client.ts
|
|
943
|
+
/** Substitute `{key}` placeholders in a path template (`/albums/{id}/{rel}` -> `/albums/1/tracks`). */
|
|
944
|
+
const fill = (template, vars) => template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
|
945
|
+
/**
|
|
946
|
+
* Run an operation: resolve the path template from the type's descriptor, fill its
|
|
947
|
+
* placeholders, execute, and materialise the resulting document. A no-body / 204 response
|
|
948
|
+
* materialises to `undefined`; an operation the type does not declare (no path template) throws
|
|
949
|
+
* a local `Error` before any request. `linkage` marks a relationship-endpoint read (primary
|
|
950
|
+
* `data` is pure resource-identifier linkage) so an attribute-less, relation-less member is
|
|
951
|
+
* still materialised as an identifier.
|
|
952
|
+
*/
|
|
953
|
+
async function run(ctx, type, operation, vars, query, linkage = false, relationForCount) {
|
|
954
|
+
const template = ctx.descriptor[type]?.paths[operation];
|
|
955
|
+
if (template === void 0) throw new Error(`No "${operation}" path declared for type "${type}"`);
|
|
956
|
+
const req = {
|
|
957
|
+
method: "GET",
|
|
958
|
+
path: fill(template, vars)
|
|
959
|
+
};
|
|
960
|
+
if (query !== void 0) {
|
|
961
|
+
req.query = query;
|
|
962
|
+
negotiateProfiles(ctx, type, query, req, relationForCount);
|
|
963
|
+
}
|
|
964
|
+
const doc = await execute(ctx.request, req);
|
|
965
|
+
const primaryType = operation === "fetchMany" ? type : void 0;
|
|
966
|
+
const paginatorOverride = (operation === "fetchRelated" || operation === "fetchRelationship") && vars["rel"] !== void 0 ? relatedPaginatorKind(ctx.descriptor[type]?.relations[vars["rel"]], ctx.materialise) : void 0;
|
|
967
|
+
return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, linkage, primaryType, paginatorOverride);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Add any profiles a query's parameters require to the request's negotiated `profiles`. A
|
|
971
|
+
* `withCount` read needs the type's Countable profile in `Accept` — else the server rejects it
|
|
972
|
+
* (400) under strict query-param validation. The profile URI is read from the descriptor's
|
|
973
|
+
* `countable` block (never hardcoded); absent when the type advertises no Countable profile.
|
|
974
|
+
*/
|
|
975
|
+
function negotiateProfiles(ctx, type, query, req, relationForCount) {
|
|
976
|
+
if (query.withCount === void 0 || query.withCount.length === 0) return;
|
|
977
|
+
const profile = relationForCount !== void 0 ? ctx.descriptor[type]?.relations[relationForCount]?.countable?.profile : ctx.descriptor[type]?.countable?.profile;
|
|
978
|
+
if (profile !== void 0) req.profiles = [...req.profiles ?? [], profile];
|
|
979
|
+
}
|
|
980
|
+
/** Project a write's opts to the read-query families the response honours (`include`/`fields`). */
|
|
981
|
+
function writeQuery(opts) {
|
|
982
|
+
if (opts === void 0) return;
|
|
983
|
+
const query = {};
|
|
984
|
+
if (opts.include !== void 0) query.include = [...opts.include];
|
|
985
|
+
if (opts.fields !== void 0) {
|
|
986
|
+
const fields = {};
|
|
987
|
+
for (const [k, v] of Object.entries(opts.fields)) fields[k] = [...v];
|
|
988
|
+
query.fields = fields;
|
|
989
|
+
}
|
|
990
|
+
return Object.keys(query).length > 0 ? query : void 0;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Drive a write: fill the path template, attach the body (and any `include`/`fields` query),
|
|
994
|
+
* execute, and materialise the response (`undefined` for a `204`/no body — so create/update
|
|
995
|
+
* return the resource and delete/`204` relationship mutations resolve `undefined`/`void`).
|
|
996
|
+
* On a thrown {@link JsonApiError}, each error's flat `path` is populated (via `spec.remap`)
|
|
997
|
+
* before it propagates, so a caller's `byPath()` keys are the flat input shape.
|
|
998
|
+
*/
|
|
999
|
+
async function runWrite(ctx, spec) {
|
|
1000
|
+
const template = ctx.descriptor[spec.type]?.paths[spec.operation];
|
|
1001
|
+
if (template === void 0) throw new Error(`No "${spec.operation}" path declared for type "${spec.type}"`);
|
|
1002
|
+
const req = {
|
|
1003
|
+
method: spec.method,
|
|
1004
|
+
path: fill(template, spec.vars),
|
|
1005
|
+
body: spec.body
|
|
1006
|
+
};
|
|
1007
|
+
if (spec.query !== void 0) req.query = spec.query;
|
|
1008
|
+
let doc;
|
|
1009
|
+
try {
|
|
1010
|
+
doc = await execute(ctx.request, req);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (error instanceof JsonApiError) throw spec.remap(error);
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, spec.linkage ?? false);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Invoke a custom action over its declared HTTP method (`action.method`, default `POST`) at its
|
|
1019
|
+
* (filled) path, with a body shaped by the action's `input` mode:
|
|
1020
|
+
*
|
|
1021
|
+
* - `none` — no body;
|
|
1022
|
+
* - `document` — when the action names an `inputType`, FLAT input (the resource's attributes /
|
|
1023
|
+
* relationships) is built into the JSON:API envelope like `create`, and a `422`'s
|
|
1024
|
+
* `source.pointer`s are remapped to the flat paths; a bespoke command document (no `inputType`)
|
|
1025
|
+
* is sent verbatim;
|
|
1026
|
+
* - `raw` — the caller's payload, sent with the action's declared media type (e.g.
|
|
1027
|
+
* `application/octet-stream`), falling back to a wildcard when the spec declared none.
|
|
1028
|
+
*
|
|
1029
|
+
* The response is shaped by the action's `output`: `document` materialises into the resource /
|
|
1030
|
+
* collection view, `meta` returns the document's top-level `meta`, `none` resolves `undefined` (a
|
|
1031
|
+
* `204` / no body).
|
|
1032
|
+
*/
|
|
1033
|
+
async function runAction(ctx, action, vars, input) {
|
|
1034
|
+
const req = {
|
|
1035
|
+
method: action.method ?? "POST",
|
|
1036
|
+
path: fill(action.path, vars)
|
|
1037
|
+
};
|
|
1038
|
+
let remap;
|
|
1039
|
+
if (action.input === "document") if (action.inputType !== void 0) {
|
|
1040
|
+
const inputType = action.inputType;
|
|
1041
|
+
req.body = toDocument(ctx.descriptor, inputType, input ?? {});
|
|
1042
|
+
remap = (error) => withRemappedPaths(error, ctx.descriptor, inputType);
|
|
1043
|
+
} else req.body = input;
|
|
1044
|
+
else if (action.input === "raw") {
|
|
1045
|
+
req.body = input;
|
|
1046
|
+
req.contentType = action.contentType ?? "*/*";
|
|
1047
|
+
req.raw = true;
|
|
1048
|
+
}
|
|
1049
|
+
let doc;
|
|
1050
|
+
try {
|
|
1051
|
+
doc = await execute(ctx.request, req);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (remap !== void 0 && error instanceof JsonApiError) throw remap(error);
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
if (action.output === "none" || doc === void 0) return;
|
|
1057
|
+
if (action.output === "meta") return doc.meta;
|
|
1058
|
+
return materialise(doc, ctx.materialise);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Build the typed `.actions` accessor at one scope: a plain object exposing exactly the
|
|
1062
|
+
* type's actions declared at `scope` (collection on the type accessor, resource on a handle),
|
|
1063
|
+
* each a function dispatching to {@link runAction} with the action descriptor + the scope's
|
|
1064
|
+
* path vars (resource scope supplies `{id}`). Names not declared at this scope are absent —
|
|
1065
|
+
* `client.albums.actions.reissue` (resource-scoped) is undefined; reach it via `.id(id)`.
|
|
1066
|
+
*/
|
|
1067
|
+
function actionsAccessor(ctx, type, scope, vars) {
|
|
1068
|
+
const actions = ctx.descriptor[type]?.actions ?? {};
|
|
1069
|
+
const out = {};
|
|
1070
|
+
for (const [name, action] of Object.entries(actions)) if (action.scope === scope) out[name] = (input) => runAction(ctx, action, vars, input);
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Build the relationship accessor for `client.<type>.id(id).<rel>`: reads
|
|
1075
|
+
* (`.get()` linkage / `.related()` collection) plus mutations — to-many `.add` (POST) /
|
|
1076
|
+
* `.remove` (DELETE) / `.replace` (PATCH) and to-one `.set` (PATCH). Every mutation posts
|
|
1077
|
+
* a `{ data: <linkage> }` body to `fetchRelationship` and materialises the response as
|
|
1078
|
+
* linkage (or `undefined`/`void` for a `204`). The cardinality is read off the descriptor
|
|
1079
|
+
* so the linkage is coerced correctly; the static types gate which verb is callable.
|
|
1080
|
+
*/
|
|
1081
|
+
function relationshipAccessor(ctx, type, id, rel) {
|
|
1082
|
+
const vars = {
|
|
1083
|
+
id,
|
|
1084
|
+
rel
|
|
1085
|
+
};
|
|
1086
|
+
const relation = ctx.descriptor[type]?.relations[rel];
|
|
1087
|
+
const cardinality = relation?.cardinality ?? "many";
|
|
1088
|
+
const hasPivot = relation?.pivot === true;
|
|
1089
|
+
const mutate = (method, refs) => runWrite(ctx, {
|
|
1090
|
+
type,
|
|
1091
|
+
operation: "fetchRelationship",
|
|
1092
|
+
vars,
|
|
1093
|
+
method,
|
|
1094
|
+
body: toRelationshipDocument(refs, cardinality),
|
|
1095
|
+
linkage: true,
|
|
1096
|
+
remap: (error) => withRemappedRelationshipPaths(error, rel, hasPivot)
|
|
1097
|
+
});
|
|
1098
|
+
const advertises = (verb) => {
|
|
1099
|
+
const mutations = relation?.mutations;
|
|
1100
|
+
return mutations === void 0 ? true : mutations[verb] === true;
|
|
1101
|
+
};
|
|
1102
|
+
const accessor = {};
|
|
1103
|
+
if (relation?.relationship !== false) accessor["get"] = (query) => run(ctx, type, "fetchRelationship", vars, query, true, rel);
|
|
1104
|
+
if (relation?.related !== false) accessor["related"] = (query) => run(ctx, type, "fetchRelated", vars, query, false, rel);
|
|
1105
|
+
if (cardinality === "many") {
|
|
1106
|
+
if (advertises("add")) accessor["add"] = (refs) => mutate("POST", refs);
|
|
1107
|
+
if (advertises("remove")) accessor["remove"] = (refs) => mutate("DELETE", refs);
|
|
1108
|
+
if (advertises("replace")) accessor["replace"] = (refs) => mutate("PATCH", refs);
|
|
1109
|
+
} else if (advertises("set")) accessor["set"] = (ref) => mutate("PATCH", ref);
|
|
1110
|
+
return accessor;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* The reserved members on a {@link ResourceHandle} — names the Proxy resolves itself, so a
|
|
1114
|
+
* relation of the same name is shadowed and must route through `.rel(name)`. The codegen
|
|
1115
|
+
* imports this to warn at build time (keeping the runtime + the collision detector in sync).
|
|
1116
|
+
* `then` is reserved so a handle is never mistaken for a thenable.
|
|
1117
|
+
*/
|
|
1118
|
+
const HANDLE_RESERVED = /* @__PURE__ */ new Set([
|
|
1119
|
+
"type",
|
|
1120
|
+
"id",
|
|
1121
|
+
"get",
|
|
1122
|
+
"update",
|
|
1123
|
+
"delete",
|
|
1124
|
+
"rel",
|
|
1125
|
+
"actions",
|
|
1126
|
+
"then"
|
|
1127
|
+
]);
|
|
1128
|
+
/** Build the id-scoped resource handle (a Proxy resolving relation accessors by name). */
|
|
1129
|
+
function resourceHandle(ctx, type, id) {
|
|
1130
|
+
const relations = ctx.descriptor[type]?.relations ?? {};
|
|
1131
|
+
const base = {
|
|
1132
|
+
type,
|
|
1133
|
+
id,
|
|
1134
|
+
get: (query) => run(ctx, type, "fetchOne", { id }, query),
|
|
1135
|
+
update: (patch, opts) => runWrite(ctx, {
|
|
1136
|
+
type,
|
|
1137
|
+
operation: "update",
|
|
1138
|
+
vars: { id },
|
|
1139
|
+
method: "PATCH",
|
|
1140
|
+
body: toDocument(ctx.descriptor, type, patch, { id }),
|
|
1141
|
+
query: writeQuery(opts),
|
|
1142
|
+
remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
|
|
1143
|
+
}),
|
|
1144
|
+
delete: () => runWrite(ctx, {
|
|
1145
|
+
type,
|
|
1146
|
+
operation: "delete",
|
|
1147
|
+
vars: { id },
|
|
1148
|
+
method: "DELETE",
|
|
1149
|
+
body: void 0,
|
|
1150
|
+
remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
|
|
1151
|
+
}),
|
|
1152
|
+
rel: (name) => relationshipAccessor(ctx, type, id, name),
|
|
1153
|
+
actions: actionsAccessor(ctx, type, "resource", { id })
|
|
1154
|
+
};
|
|
1155
|
+
return new Proxy(base, { get(target, prop, receiver) {
|
|
1156
|
+
if (typeof prop === "string" && !HANDLE_RESERVED.has(prop) && prop in relations) return relationshipAccessor(ctx, type, id, prop);
|
|
1157
|
+
return Reflect.get(target, prop, receiver);
|
|
1158
|
+
} });
|
|
1159
|
+
}
|
|
1160
|
+
/** Build the collection-scoped accessor for one wire type (`client.albums`). */
|
|
1161
|
+
function typeAccessor(ctx, type) {
|
|
1162
|
+
return {
|
|
1163
|
+
list: (query) => run(ctx, type, "fetchMany", {}, query),
|
|
1164
|
+
get: (id, query) => run(ctx, type, "fetchOne", { id }, query),
|
|
1165
|
+
create: (input, opts) => runWrite(ctx, {
|
|
1166
|
+
type,
|
|
1167
|
+
operation: "create",
|
|
1168
|
+
vars: {},
|
|
1169
|
+
method: "POST",
|
|
1170
|
+
body: toDocument(ctx.descriptor, type, input),
|
|
1171
|
+
query: writeQuery(opts),
|
|
1172
|
+
remap: (error) => withRemappedPaths(error, ctx.descriptor, type)
|
|
1173
|
+
}),
|
|
1174
|
+
id: (id) => resourceHandle(ctx, type, id),
|
|
1175
|
+
actions: actionsAccessor(ctx, type, "collection", {})
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Build a descriptor-driven read client. Generic at runtime (a Proxy over the descriptor),
|
|
1180
|
+
* not per-type codegen: `client.<type>` yields a {@link TypeAccessor}, `.id(id)` a handle,
|
|
1181
|
+
* and a handle's relation accessors drive the relationship/related endpoints. Navigation
|
|
1182
|
+
* (`$next()`/`$prev()`, related links) is wired through `materialise`'s `navigate` seam,
|
|
1183
|
+
* which executes the (absolute) link and re-materialises.
|
|
1184
|
+
*/
|
|
1185
|
+
function createClient(descriptor, options) {
|
|
1186
|
+
const request = {
|
|
1187
|
+
baseUrl: options.baseUrl,
|
|
1188
|
+
transport: options.transport ?? fetchTransport,
|
|
1189
|
+
...options.headers !== void 0 ? { headers: options.headers } : {}
|
|
1190
|
+
};
|
|
1191
|
+
const validator = resolveValidator(options.validate);
|
|
1192
|
+
const ctx = {
|
|
1193
|
+
request,
|
|
1194
|
+
descriptor,
|
|
1195
|
+
materialise: {
|
|
1196
|
+
descriptor,
|
|
1197
|
+
navigate: async (url, linkage = false, paginator) => {
|
|
1198
|
+
const doc = await execute(request, {
|
|
1199
|
+
method: "GET",
|
|
1200
|
+
path: url
|
|
1201
|
+
});
|
|
1202
|
+
return doc === void 0 ? void 0 : materialise(doc, ctx.materialise, linkage, void 0, paginator);
|
|
1203
|
+
},
|
|
1204
|
+
...validator !== void 0 ? { validate: validator } : {}
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
const atomicDescriptor = options.atomic ?? null;
|
|
1208
|
+
const atomic = (build) => {
|
|
1209
|
+
if (atomicDescriptor === null) throw new Error("This API does not expose an Atomic Operations endpoint");
|
|
1210
|
+
return runAtomic(request, ctx.materialise, descriptor, atomicDescriptor.path, build);
|
|
1211
|
+
};
|
|
1212
|
+
const accessors = /* @__PURE__ */ new Map();
|
|
1213
|
+
return new Proxy(Object.create(null), {
|
|
1214
|
+
get(_target, prop) {
|
|
1215
|
+
if (prop === "atomic") return atomic;
|
|
1216
|
+
if (typeof prop !== "string" || !(prop in descriptor)) return;
|
|
1217
|
+
let accessor = accessors.get(prop);
|
|
1218
|
+
if (accessor === void 0) {
|
|
1219
|
+
accessor = typeAccessor(ctx, prop);
|
|
1220
|
+
accessors.set(prop, accessor);
|
|
1221
|
+
}
|
|
1222
|
+
return accessor;
|
|
1223
|
+
},
|
|
1224
|
+
has(_target, prop) {
|
|
1225
|
+
return prop === "atomic" || typeof prop === "string" && prop in descriptor;
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
//#endregion
|
|
1230
|
+
export { ATOMIC_EXT, ATOMIC_MEDIA_TYPE, HANDLE_RESERVED, JSON_API_MEDIA_TYPE, JsonApiError, StructuralGuardError, assertJsonApiDocument, createClient, execute, fetchTransport, materialise, mediaType, relatedPaginatorKind, remapPointer, remapRelationshipPointer, resolveValidator, runAtomic, serializeQuery, toDocument, toRelationshipDocument, validateDocument, withRemappedAtomicPaths, withRemappedPaths, withRemappedRelationshipPaths };
|
|
1231
|
+
|
|
1232
|
+
//# sourceMappingURL=index.mjs.map
|