@haddowg/json-api-codegen 1.0.1 → 1.2.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/dist/cli.d.mts +8 -2
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +20 -5
- package/dist/cli.mjs.map +1 -1
- package/dist/{index-C0becRSc.d.mts → index-BNJgU3mg.d.mts} +87 -9
- package/dist/index-BNJgU3mg.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{src-B24cYyjM.mjs → src-C3hOfyvh.mjs} +431 -44
- package/dist/src-C3hOfyvh.mjs.map +1 -0
- package/package.json +2 -2
- package/dist/index-C0becRSc.d.mts.map +0 -1
- package/dist/src-B24cYyjM.mjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { ATOMIC_EXT, HANDLE_RESERVED } from "@haddowg/json-api-client";
|
|
3
|
+
import { ATOMIC_EXT, CANONICAL_PAGE_KEYS, HANDLE_RESERVED } from "@haddowg/json-api-client";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
//#region src/build-descriptor.ts
|
|
7
|
-
const REF_PREFIX$
|
|
7
|
+
const REF_PREFIX$2 = "#/components/schemas/";
|
|
8
8
|
const JSON_API_MEDIA_TYPE$1 = "application/vnd.api+json";
|
|
9
9
|
/** Sort a record's keys for deterministic, snapshot-stable output. */
|
|
10
10
|
function sortRecord(record) {
|
|
@@ -14,9 +14,9 @@ function sortRecord(record) {
|
|
|
14
14
|
}
|
|
15
15
|
/** Resolve a local schema name from a `#/components/schemas/X` ref. */
|
|
16
16
|
function refName$1(ref) {
|
|
17
|
-
return ref.startsWith(REF_PREFIX$
|
|
17
|
+
return ref.startsWith(REF_PREFIX$2) ? ref.slice(21) : void 0;
|
|
18
18
|
}
|
|
19
|
-
function isSchema$
|
|
19
|
+
function isSchema$2(value) {
|
|
20
20
|
return typeof value === "object" && value !== null;
|
|
21
21
|
}
|
|
22
22
|
/** The set of `type` keys a JSON type node declares, with `"null"` removed. */
|
|
@@ -75,11 +75,27 @@ var DescriptorBuilder = class {
|
|
|
75
75
|
if (filterable !== void 0) descriptor.filterable = filterable;
|
|
76
76
|
if (Object.keys(actions).length > 0) descriptor.actions = actions;
|
|
77
77
|
if (Object.keys(schema.properties ?? {}).every((prop) => prop === "type") && Object.keys(descriptor.paths).length === 0) continue;
|
|
78
|
+
this.warnWriteOnlyRelations(wireType, base, descriptor.relations);
|
|
78
79
|
out[wireType] = descriptor;
|
|
79
80
|
}
|
|
80
81
|
return sortRecord(out);
|
|
81
82
|
}
|
|
82
83
|
/**
|
|
84
|
+
* Warn about relations a create or update body accepts but the resource never renders. A
|
|
85
|
+
* write-only relation (generator contract 5) is left out of the response schema, and the
|
|
86
|
+
* descriptor has no way to mark a relation as writable but unreadable, so it is missing from
|
|
87
|
+
* the client entirely rather than typed onto reads that never carry it.
|
|
88
|
+
*/
|
|
89
|
+
warnWriteOnlyRelations(wireType, base, readable) {
|
|
90
|
+
const writeOnly = /* @__PURE__ */ new Set();
|
|
91
|
+
for (const request of [`${base}CreateRequest`, `${base}UpdateRequest`]) {
|
|
92
|
+
const data = this.schemas[request]?.properties?.["data"];
|
|
93
|
+
const relationships = isSchema$2(data) ? data.properties?.["relationships"] : void 0;
|
|
94
|
+
for (const name of Object.keys(isSchema$2(relationships) ? relationships.properties ?? {} : {})) if (!(name in readable)) writeOnly.add(name);
|
|
95
|
+
}
|
|
96
|
+
for (const name of writeOnly) console.warn(`json-api codegen: relationship "${name}" on "${wireType}" is accepted on writes but never rendered (a write-only relation); the client has no slot for a relation it can't read, so it is left out.`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
83
99
|
* The server-level Atomic Operations capability: the path whose `POST` requestBody
|
|
84
100
|
* declares the atomic ext media type ({@link ATOMIC_EXT}). `null` when no such endpoint
|
|
85
101
|
* exists. Server-level, so it rides {@link buildAtomic}, not the per-type descriptor.
|
|
@@ -99,28 +115,28 @@ var DescriptorBuilder = class {
|
|
|
99
115
|
resourceType(name, schema) {
|
|
100
116
|
if (!name.endsWith("Resource") || name.endsWith("ResourceIdentifier")) return;
|
|
101
117
|
const constValue = schema.properties?.["type"];
|
|
102
|
-
if (isSchema$
|
|
118
|
+
if (isSchema$2(constValue) && typeof constValue.const === "string") return constValue.const;
|
|
103
119
|
}
|
|
104
120
|
/** Follow a `$ref` to its identifier/resource schema and read `properties.type.const`. */
|
|
105
121
|
resolveConst(ref) {
|
|
106
122
|
const target = refName$1(ref);
|
|
107
123
|
if (target === void 0) return;
|
|
108
124
|
const typeProp = this.schemas[target]?.properties?.["type"];
|
|
109
|
-
return isSchema$
|
|
125
|
+
return isSchema$2(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
|
|
110
126
|
}
|
|
111
127
|
attributes(base) {
|
|
112
128
|
const props = this.schemas[`${base}Attributes`]?.properties;
|
|
113
129
|
if (props === void 0) return {};
|
|
114
130
|
const out = {};
|
|
115
|
-
for (const [name, prop] of Object.entries(props)) if (isSchema$
|
|
131
|
+
for (const [name, prop] of Object.entries(props)) if (isSchema$2(prop)) out[name] = formatHint(prop);
|
|
116
132
|
return sortRecord(out);
|
|
117
133
|
}
|
|
118
134
|
relations(resource, collection) {
|
|
119
|
-
const relProps = isSchema$
|
|
135
|
+
const relProps = isSchema$2(resource.properties?.["relationships"]) ? resource.properties["relationships"].properties : void 0;
|
|
120
136
|
if (relProps === void 0) return {};
|
|
121
137
|
const out = {};
|
|
122
138
|
for (const [name, prop] of Object.entries(relProps)) {
|
|
123
|
-
if (!isSchema$
|
|
139
|
+
if (!isSchema$2(prop) || typeof prop.$ref !== "string") continue;
|
|
124
140
|
const component = this.schemas[refName$1(prop.$ref) ?? ""];
|
|
125
141
|
const descriptor = component ? this.relationFromComponent(component) : void 0;
|
|
126
142
|
if (descriptor === void 0) {
|
|
@@ -171,10 +187,10 @@ var DescriptorBuilder = class {
|
|
|
171
187
|
const countable = withCountParam(relatedGet) ?? withCountParam(this.operation(`${collection}/{id}/relationships/${name}`, "get"));
|
|
172
188
|
if (countable !== void 0) out.countable = countable;
|
|
173
189
|
if (descriptor.cardinality === "many") {
|
|
174
|
-
const
|
|
190
|
+
const relatedPaginator = this.paginatorOf(relatedGet) === "none" ? this.paginatorOf(this.operation(`${collection}/{id}/relationships/${name}`, "get")) : this.paginatorOf(relatedGet);
|
|
175
191
|
const relatedType = descriptor.types[0];
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
192
|
+
const typePaginator = relatedType !== void 0 ? this.typePaginator(relatedType) : "none";
|
|
193
|
+
if (relatedPaginator !== "none" && JSON.stringify(relatedPaginator) !== JSON.stringify(typePaginator)) out.paginator = relatedPaginator;
|
|
178
194
|
}
|
|
179
195
|
return out;
|
|
180
196
|
}
|
|
@@ -243,8 +259,8 @@ var DescriptorBuilder = class {
|
|
|
243
259
|
if (typeof ref !== "string") return;
|
|
244
260
|
const component = refName$1(ref);
|
|
245
261
|
const data = component === void 0 ? void 0 : this.schemas[component]?.properties?.["data"];
|
|
246
|
-
if (!isSchema$
|
|
247
|
-
if (isSchema$
|
|
262
|
+
if (!isSchema$2(data)) return;
|
|
263
|
+
if (isSchema$2(data.items)) {
|
|
248
264
|
const itemRef = data.items.$ref;
|
|
249
265
|
const many = typeof itemRef === "string" ? this.resolveConst(itemRef) : void 0;
|
|
250
266
|
return many === void 0 ? void 0 : {
|
|
@@ -262,7 +278,7 @@ var DescriptorBuilder = class {
|
|
|
262
278
|
dataConst(data) {
|
|
263
279
|
if (typeof data.$ref === "string") return this.resolveConst(data.$ref);
|
|
264
280
|
const typeProp = data.properties?.["type"];
|
|
265
|
-
return isSchema$
|
|
281
|
+
return isSchema$2(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
|
|
266
282
|
}
|
|
267
283
|
/** The `$ref` of the first 2xx JSON:API response body on an operation (the output document). */
|
|
268
284
|
firstOkJsonApiRef(op) {
|
|
@@ -275,7 +291,7 @@ var DescriptorBuilder = class {
|
|
|
275
291
|
/** Resolve a relationship component's `data` into a {@link RelationDescriptor}. */
|
|
276
292
|
relationFromComponent(component) {
|
|
277
293
|
const data = component.properties?.["data"];
|
|
278
|
-
if (!isSchema$
|
|
294
|
+
if (!isSchema$2(data)) return;
|
|
279
295
|
if (data.type === "array") {
|
|
280
296
|
const items = data.items;
|
|
281
297
|
if (items === void 0) return;
|
|
@@ -340,11 +356,11 @@ var DescriptorBuilder = class {
|
|
|
340
356
|
pivotFieldsFromAllOf(allOf) {
|
|
341
357
|
for (const entry of allOf) {
|
|
342
358
|
const meta = entry.properties?.["meta"];
|
|
343
|
-
const pivot = isSchema$
|
|
344
|
-
const props = isSchema$
|
|
359
|
+
const pivot = isSchema$2(meta) ? meta.properties?.["pivot"] : void 0;
|
|
360
|
+
const props = isSchema$2(pivot) ? pivot.properties : void 0;
|
|
345
361
|
if (props === void 0) continue;
|
|
346
362
|
const out = {};
|
|
347
|
-
for (const [name, schema] of Object.entries(props)) if (isSchema$
|
|
363
|
+
for (const [name, schema] of Object.entries(props)) if (isSchema$2(schema)) out[name] = formatHint(schema);
|
|
348
364
|
return Object.keys(out).length > 0 ? sortRecord(out) : void 0;
|
|
349
365
|
}
|
|
350
366
|
}
|
|
@@ -366,7 +382,7 @@ var DescriptorBuilder = class {
|
|
|
366
382
|
}
|
|
367
383
|
clientId(base) {
|
|
368
384
|
const create = this.schemas[`${base}CreateRequest`];
|
|
369
|
-
const data = isSchema$
|
|
385
|
+
const data = isSchema$2(create?.properties?.["data"]) ? create.properties["data"] : void 0;
|
|
370
386
|
if (data === void 0) return "forbidden";
|
|
371
387
|
if (data.properties?.["id"] === false) return "forbidden";
|
|
372
388
|
return data.required?.includes("id") ? "required" : "optional";
|
|
@@ -412,24 +428,55 @@ var DescriptorBuilder = class {
|
|
|
412
428
|
const prefix = `${collection}/{id}/relationships/`;
|
|
413
429
|
return Object.keys(this.paths).some((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/"));
|
|
414
430
|
}
|
|
415
|
-
/** Detect the paginator
|
|
431
|
+
/** Detect the paginator from the fetchMany operation's page parameters. */
|
|
416
432
|
paginator(base) {
|
|
417
433
|
const collection = this.collectionPath(base);
|
|
418
|
-
return this.
|
|
434
|
+
return this.paginatorOf(this.operation(collection ?? "", "get"), collection);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Detect the paginator an operation advertises. A page schema stamped with `x-paginator`
|
|
438
|
+
* (contract 3) names its strategy and the wire key for each role, so it decides both. Without
|
|
439
|
+
* the stamp, the strategy is inferred from canonical member names (see {@link kindOfMembers}).
|
|
440
|
+
* Anything unrecognised warns and generates as unpaginated. `label` (a path) names the endpoint
|
|
441
|
+
* in the warning; without it nothing is logged.
|
|
442
|
+
*/
|
|
443
|
+
paginatorOf(op, label) {
|
|
444
|
+
const warn = (problem) => {
|
|
445
|
+
if (label !== void 0) console.warn(`json-api codegen: endpoint "${label}" ${problem}; treating it as unpaginated.`);
|
|
446
|
+
return "none";
|
|
447
|
+
};
|
|
448
|
+
const schema = op?.parameters?.find((param) => param.name === "page")?.schema;
|
|
449
|
+
if (schema?.["x-paginator"] !== void 0) return stampedPaginator(schema, warn);
|
|
450
|
+
const { members, menu } = this.pageMembers(op);
|
|
451
|
+
if (menu === void 0) {
|
|
452
|
+
const kind = kindOfMembers(members);
|
|
453
|
+
if (kind !== void 0) return kind;
|
|
454
|
+
}
|
|
455
|
+
if (menu !== void 0) return warn(`advertises a selectable page strategy menu (${menu.map((arm) => kindOfMembers(arm) ?? sortedList(arm)).join(" | ")}) without an x-paginator stamp naming its default`);
|
|
456
|
+
return members.size > 0 ? warn(`advertises page parameters matching no known paginator kind (members ${sortedList(members)})`) : "none";
|
|
419
457
|
}
|
|
420
458
|
/**
|
|
421
|
-
*
|
|
422
|
-
* `
|
|
423
|
-
*
|
|
424
|
-
*
|
|
459
|
+
* The `page` members an operation accepts, from either wire form: one `page` parameter
|
|
460
|
+
* (`style: deepObject`) whose schema `properties` name the members, as `haddowg/json-api` v1
|
|
461
|
+
* projects it, or the older flattened `page[number]`/`page[size]` parameters. A `oneOf` page
|
|
462
|
+
* schema (a MultiPaginator menu) yields each arm's members in `menu` instead, minus the
|
|
463
|
+
* `kind` discriminator.
|
|
425
464
|
*/
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
465
|
+
pageMembers(op) {
|
|
466
|
+
const members = /* @__PURE__ */ new Set();
|
|
467
|
+
let menu;
|
|
468
|
+
for (const param of op?.parameters ?? []) {
|
|
469
|
+
const name = param.name;
|
|
470
|
+
if (name === "page") {
|
|
471
|
+
const arms = param.schema?.oneOf;
|
|
472
|
+
if (arms !== void 0) menu = arms.map((arm) => new Set(Object.keys(arm.properties ?? {}).filter((m) => m !== "kind")));
|
|
473
|
+
for (const member of Object.keys(param.schema?.properties ?? {})) members.add(member);
|
|
474
|
+
} else if (name?.startsWith("page[") === true && name.endsWith("]")) members.add(name.slice(5, -1));
|
|
475
|
+
}
|
|
476
|
+
return menu === void 0 ? { members } : {
|
|
477
|
+
members,
|
|
478
|
+
menu
|
|
479
|
+
};
|
|
433
480
|
}
|
|
434
481
|
/**
|
|
435
482
|
* The `withCount` capability (Countable profile) for a type's COLLECTION read (`list`): the
|
|
@@ -487,11 +534,6 @@ var DescriptorBuilder = class {
|
|
|
487
534
|
}
|
|
488
535
|
return keys.length > 0 ? keys.sort() : void 0;
|
|
489
536
|
}
|
|
490
|
-
queryParamNames(op) {
|
|
491
|
-
const names = /* @__PURE__ */ new Set();
|
|
492
|
-
for (const param of op?.parameters ?? []) if (typeof param.name === "string") names.add(param.name);
|
|
493
|
-
return names;
|
|
494
|
-
}
|
|
495
537
|
operation(path, method) {
|
|
496
538
|
return this.paths[path]?.[method];
|
|
497
539
|
}
|
|
@@ -508,6 +550,9 @@ function formatHint(schema) {
|
|
|
508
550
|
const types = typeKeys(schema.type);
|
|
509
551
|
if (types.length > 0) return types[0];
|
|
510
552
|
if (typeof schema.$ref === "string") return "string";
|
|
553
|
+
const isNullOnly = (branch) => branch.type !== void 0 && typeKeys(branch.type).length === 0;
|
|
554
|
+
const branches = (schema.anyOf ?? schema.oneOf ?? []).filter((branch) => !isNullOnly(branch));
|
|
555
|
+
if (branches.length === 1) return formatHint(branches[0]);
|
|
511
556
|
return "unknown";
|
|
512
557
|
}
|
|
513
558
|
function refEndsWith(ref, suffix) {
|
|
@@ -613,6 +658,234 @@ function buildDescriptor(doc) {
|
|
|
613
658
|
function buildAtomic(doc) {
|
|
614
659
|
return new DescriptorBuilder(doc).buildAtomic();
|
|
615
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* Classify a page member set: `number`+`size` = `page`; `offset`+`limit` = `offset`; either
|
|
663
|
+
* cursor bound (`after`/`before`) = `cursor` (link-driven, D44); `number` alone = `fixed` (the
|
|
664
|
+
* server fixes the size); no members = `none`; anything else = `undefined` (unrecognised).
|
|
665
|
+
*/
|
|
666
|
+
function kindOfMembers(members) {
|
|
667
|
+
if (members.has("number") && members.has("size")) return "page";
|
|
668
|
+
if (members.has("offset") && members.has("limit")) return "offset";
|
|
669
|
+
if (members.has("after") || members.has("before")) return "cursor";
|
|
670
|
+
if (members.size === 1 && members.has("number")) return "fixed";
|
|
671
|
+
return members.size === 0 ? "none" : void 0;
|
|
672
|
+
}
|
|
673
|
+
const sortedList = (members) => [...members].sort().join(", ");
|
|
674
|
+
const STRATEGIES = [
|
|
675
|
+
"page",
|
|
676
|
+
"offset",
|
|
677
|
+
"cursor",
|
|
678
|
+
"fixed"
|
|
679
|
+
];
|
|
680
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
681
|
+
/**
|
|
682
|
+
* Read a page schema's `x-paginator` stamp. A strategy keeps the wire key the stamp names for
|
|
683
|
+
* each of its roles (a missing role keeps its canonical name) and collapses to the bare kind when
|
|
684
|
+
* every key is canonical. A `multi` stamp reads each `oneOf` arm by its `kind` const: an arm's own
|
|
685
|
+
* stamp decides it, an unstamped arm falls back to name inference, and an arm neither recognises
|
|
686
|
+
* is left out of the menu.
|
|
687
|
+
*/
|
|
688
|
+
function stampedPaginator(schema, warn) {
|
|
689
|
+
const stamp = schema["x-paginator"];
|
|
690
|
+
const strategy = isRecord(stamp) ? stamp["strategy"] : void 0;
|
|
691
|
+
if (!isRecord(stamp) || typeof strategy !== "string") return warn("stamps a malformed x-paginator");
|
|
692
|
+
if (strategy === "multi") return stampedMenu(schema, stamp, warn);
|
|
693
|
+
if (!STRATEGIES.includes(strategy)) return warn(`stamps an x-paginator strategy "${strategy}" this codegen does not know`);
|
|
694
|
+
const kind = strategy;
|
|
695
|
+
const canonical = CANONICAL_PAGE_KEYS[kind];
|
|
696
|
+
const stamped = isRecord(stamp["keys"]) ? stamp["keys"] : {};
|
|
697
|
+
const keys = {};
|
|
698
|
+
let renamed = false;
|
|
699
|
+
for (const [role, fallback] of Object.entries(canonical)) {
|
|
700
|
+
const key = typeof stamped[role] === "string" ? stamped[role] : fallback;
|
|
701
|
+
keys[role] = key;
|
|
702
|
+
renamed ||= key !== fallback;
|
|
703
|
+
}
|
|
704
|
+
return renamed ? {
|
|
705
|
+
strategy: kind,
|
|
706
|
+
keys
|
|
707
|
+
} : kind;
|
|
708
|
+
}
|
|
709
|
+
function stampedMenu(schema, stamp, warn) {
|
|
710
|
+
const arms = {};
|
|
711
|
+
for (const arm of schema.oneOf ?? []) {
|
|
712
|
+
const discriminator = arm.properties?.["kind"];
|
|
713
|
+
const kind = isRecord(discriminator) ? discriminator["const"] : void 0;
|
|
714
|
+
if (typeof kind !== "string") {
|
|
715
|
+
warn("offers a page strategy menu arm without a kind const, which is left out");
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const paginator = arm["x-paginator"] !== void 0 ? stampedPaginator(arm, (problem) => warn(`offers a menu arm "${kind}" that ${problem}, which is left out`)) : kindOfMembers(new Set(Object.keys(arm.properties ?? {}).filter((m) => m !== "kind"))) ?? warn(`offers a menu arm "${kind}" matching no known paginator kind, which is left out`);
|
|
719
|
+
if (typeof paginator === "object" && paginator.strategy === "multi") {
|
|
720
|
+
warn(`nests a page strategy menu in arm "${kind}", which is left out`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (paginator !== "none") arms[kind] = paginator;
|
|
724
|
+
}
|
|
725
|
+
const fallback = stamp["default"];
|
|
726
|
+
if (typeof fallback !== "string" || arms[fallback] === void 0) return warn(`stamps a page strategy menu whose default ${typeof fallback === "string" ? `"${fallback}"` : "(missing)"} is not one of its generated arms`);
|
|
727
|
+
return {
|
|
728
|
+
strategy: "multi",
|
|
729
|
+
default: fallback,
|
|
730
|
+
arms
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/contract.ts
|
|
735
|
+
/** Below this the document predates the structures this codegen reads. */
|
|
736
|
+
const MINIMUM_CONTRACT = 1;
|
|
737
|
+
/** Above this the document carries structures this codegen does not read. */
|
|
738
|
+
const MAXIMUM_CONTRACT = 5;
|
|
739
|
+
/**
|
|
740
|
+
* Check the document's `info.x-generator.contract` against the range this codegen reads. A
|
|
741
|
+
* document older than the range throws; a newer one returns a warning, because its new
|
|
742
|
+
* structures would go unread and the client would silently miss what the server offers. A
|
|
743
|
+
* document with no contract (json-api before 1.1.0) passes.
|
|
744
|
+
*/
|
|
745
|
+
function checkContract(doc) {
|
|
746
|
+
const contract = doc.info?.["x-generator"]?.contract;
|
|
747
|
+
if (typeof contract !== "number") return;
|
|
748
|
+
if (contract < 1) throw new Error(`the document declares contract ${contract}, which predates what this codegen reads (it supports 1 to 5). Use an older codegen release, or regenerate the document from a newer server.`);
|
|
749
|
+
if (contract > 5) return `the document declares contract ${contract} and this codegen reads up to 5. It describes capabilities that were not generated; upgrade the codegen to pick them up.`;
|
|
750
|
+
}
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/error-codes.ts
|
|
753
|
+
const REF_PREFIX$1 = "#/components/schemas/";
|
|
754
|
+
const ERROR_DOCUMENT = /^ErrorDocument(\d{3})?$/;
|
|
755
|
+
function isSchema$1(value) {
|
|
756
|
+
return typeof value === "object" && value !== null;
|
|
757
|
+
}
|
|
758
|
+
/** A schema node and its `allOf` members, where a code variant keeps its narrowing. */
|
|
759
|
+
function layers(schema) {
|
|
760
|
+
return [schema, ...(schema.allOf ?? []).filter((member) => member.$ref === void 0)];
|
|
761
|
+
}
|
|
762
|
+
function property(schema, name) {
|
|
763
|
+
for (const layer of layers(schema)) {
|
|
764
|
+
const prop = layer.properties?.[name];
|
|
765
|
+
if (isSchema$1(prop)) return prop;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function examplesOf(detail) {
|
|
769
|
+
if (detail === void 0) return [];
|
|
770
|
+
const examples = Array.isArray(detail["examples"]) ? [...detail["examples"]] : [];
|
|
771
|
+
if ("example" in detail && !examples.includes(detail["example"])) examples.unshift(detail["example"]);
|
|
772
|
+
return examples;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Read the documented error codes off a document's error documents. `ErrorDocument` and each
|
|
776
|
+
* `ErrorDocument<NNN>` offer their codes as `errors.items.anyOf` branches that `$ref` a
|
|
777
|
+
* `<Code>Error` component; a branch with a `code` const names a code, and the generic `Error`
|
|
778
|
+
* branch (no const) names none.
|
|
779
|
+
*/
|
|
780
|
+
function readErrorCatalogue(doc) {
|
|
781
|
+
const schemas = doc.components?.schemas ?? {};
|
|
782
|
+
const codes = /* @__PURE__ */ new Map();
|
|
783
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
784
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
785
|
+
const match = ERROR_DOCUMENT.exec(name);
|
|
786
|
+
if (match === null) continue;
|
|
787
|
+
const items = property(schema, "errors")?.items;
|
|
788
|
+
const branches = items === void 0 ? [] : [...items.anyOf ?? [], ...items.oneOf ?? []];
|
|
789
|
+
const documentCodes = /* @__PURE__ */ new Set();
|
|
790
|
+
for (const branch of branches) {
|
|
791
|
+
const component = typeof branch.$ref === "string" && branch.$ref.startsWith(REF_PREFIX$1) ? schemas[branch.$ref.slice(21)] : branch;
|
|
792
|
+
const code = component === void 0 ? void 0 : property(component, "code")?.const;
|
|
793
|
+
if (component === void 0 || typeof code !== "string") continue;
|
|
794
|
+
documentCodes.add(code);
|
|
795
|
+
if (!codes.has(code)) {
|
|
796
|
+
const description = layers(component).map((layer) => layer.description?.trim()).find((text) => text !== void 0 && text !== "");
|
|
797
|
+
codes.set(code, {
|
|
798
|
+
code,
|
|
799
|
+
...description ? { description } : {},
|
|
800
|
+
examples: examplesOf(property(component, "detail"))
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (match[1] !== void 0) byStatus.set(Number(match[1]), [...documentCodes].sort());
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
codes: [...codes.values()].sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0),
|
|
808
|
+
byStatus: new Map([...byStatus].sort(([a], [b]) => a - b))
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/** A TSDoc block for one code: its description, then each example detail as an `@example`. */
|
|
812
|
+
function codeDoc(entry, indent) {
|
|
813
|
+
const lines = [];
|
|
814
|
+
if (entry.description !== void 0) lines.push(...entry.description.split("\n"));
|
|
815
|
+
for (const example of entry.examples) {
|
|
816
|
+
if (lines.length > 0) lines.push("");
|
|
817
|
+
const text = typeof example === "string" ? example : JSON.stringify(example);
|
|
818
|
+
lines.push(...`@example ${text}`.split("\n"));
|
|
819
|
+
}
|
|
820
|
+
if (lines.length === 0) return;
|
|
821
|
+
if (lines.length === 1) return `${indent}/** ${lines[0].replace(/\*\//g, "* /")} */`;
|
|
822
|
+
return [
|
|
823
|
+
`${indent}/**`,
|
|
824
|
+
...lines.map((line) => `${indent} * ${line.replace(/\*\//g, "* /")}`.trimEnd()),
|
|
825
|
+
`${indent} */`
|
|
826
|
+
].join("\n");
|
|
827
|
+
}
|
|
828
|
+
/** A bare object key when the code is a valid identifier, else a quoted one. */
|
|
829
|
+
function codeKey(code) {
|
|
830
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(code) ? code : JSON.stringify(code);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Emit `ErrorCodes`, a const object keyed by every documented code with its TSDoc on the key
|
|
834
|
+
* (editors show TSDoc on an object key, not on a string-literal union member), the `ErrorCode`
|
|
835
|
+
* union derived from it, and `ErrorCodeByStatus`, the codes each status's error document offers.
|
|
836
|
+
* A document with no code catalogue gets an empty `ErrorCodes` and `ErrorCode = string`, so the
|
|
837
|
+
* bound guard still takes any code.
|
|
838
|
+
*/
|
|
839
|
+
function emitErrorCodes(catalogue) {
|
|
840
|
+
const codeTypes = catalogue.codes.length === 0 ? [
|
|
841
|
+
"/** The documented error codes. This document catalogues none. */",
|
|
842
|
+
"export const ErrorCodes = {} as const",
|
|
843
|
+
"",
|
|
844
|
+
"/** The documented error codes. This document catalogues none, so any code is accepted. */",
|
|
845
|
+
"export type ErrorCode = string"
|
|
846
|
+
].join("\n") : [
|
|
847
|
+
"/**",
|
|
848
|
+
" * Every error code this server documents, keyed by itself. Hover a key for what the code",
|
|
849
|
+
" * means: `hasErrorCode(error, ErrorCodes.RESOURCE_NOT_FOUND)`.",
|
|
850
|
+
" */",
|
|
851
|
+
"export const ErrorCodes = {",
|
|
852
|
+
...catalogue.codes.flatMap((entry) => {
|
|
853
|
+
const doc = codeDoc(entry, " ");
|
|
854
|
+
const member = ` ${codeKey(entry.code)}: ${JSON.stringify(entry.code)},`;
|
|
855
|
+
return doc === void 0 ? [member] : [doc, member];
|
|
856
|
+
}),
|
|
857
|
+
"} as const",
|
|
858
|
+
"",
|
|
859
|
+
"/**",
|
|
860
|
+
" * Every error code this server documents. An error may still carry a code that is not",
|
|
861
|
+
" * listed: the catalogue is open.",
|
|
862
|
+
" */",
|
|
863
|
+
"export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]"
|
|
864
|
+
].join("\n");
|
|
865
|
+
const statusLines = [...catalogue.byStatus].map(([status, codes]) => {
|
|
866
|
+
const members = codes.map((c) => JSON.stringify(c));
|
|
867
|
+
const line = ` ${status}: ${members.length === 0 ? "never" : members.join(" | ")}`;
|
|
868
|
+
return line.length <= 100 ? line : ` ${status}:\n${members.map((m) => ` | ${m}`).join("\n")}`;
|
|
869
|
+
});
|
|
870
|
+
return [
|
|
871
|
+
codeTypes,
|
|
872
|
+
[
|
|
873
|
+
"/**",
|
|
874
|
+
" * The documented error codes per HTTP status. `never` marks a status whose error document",
|
|
875
|
+
" * documents no specific code.",
|
|
876
|
+
" */",
|
|
877
|
+
statusLines.length === 0 ? "export interface ErrorCodeByStatus {}" : `export interface ErrorCodeByStatus {\n${statusLines.join("\n")}\n}`
|
|
878
|
+
].join("\n"),
|
|
879
|
+
[
|
|
880
|
+
"/**",
|
|
881
|
+
" * True when `err` is a `JsonApiError` and any of its error objects carries `code`. Only takes a",
|
|
882
|
+
" * documented code; match an undocumented one with `hasErrorCode` from",
|
|
883
|
+
" * '@haddowg/json-api-client'.",
|
|
884
|
+
" */",
|
|
885
|
+
"export const hasErrorCode = hasErrorCodeRuntime as ErrorCodeGuard<ErrorCode>"
|
|
886
|
+
].join("\n")
|
|
887
|
+
].join("\n\n");
|
|
888
|
+
}
|
|
616
889
|
//#endregion
|
|
617
890
|
//#region src/provenance.ts
|
|
618
891
|
/**
|
|
@@ -724,6 +997,53 @@ function jsDoc(text, indent) {
|
|
|
724
997
|
`${indent} */`
|
|
725
998
|
].join("\n");
|
|
726
999
|
}
|
|
1000
|
+
/** The validation keywords a member's TSDoc states, each as a tag of the same name. */
|
|
1001
|
+
const CONSTRAINT_KEYWORDS = [
|
|
1002
|
+
"format",
|
|
1003
|
+
"pattern",
|
|
1004
|
+
"minLength",
|
|
1005
|
+
"maxLength",
|
|
1006
|
+
"minimum",
|
|
1007
|
+
"exclusiveMinimum",
|
|
1008
|
+
"maximum",
|
|
1009
|
+
"exclusiveMaximum",
|
|
1010
|
+
"multipleOf",
|
|
1011
|
+
"minItems",
|
|
1012
|
+
"maxItems",
|
|
1013
|
+
"uniqueItems",
|
|
1014
|
+
"minProperties",
|
|
1015
|
+
"maxProperties"
|
|
1016
|
+
];
|
|
1017
|
+
/** A tag value as TSDoc text: strings bare, a pattern or anything structured as code. */
|
|
1018
|
+
function tagValue(keyword, value) {
|
|
1019
|
+
if (keyword === "pattern" || typeof value !== "string" && typeof value !== "number") return `\`${typeof value === "string" ? value : JSON.stringify(value)}\``;
|
|
1020
|
+
return String(value);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* The TSDoc for an attribute: its description, then a tag per constraint the schema declares
|
|
1024
|
+
* (`@minLength 1`, `@pattern …`), its `@defaultValue`, any `@example` and `@deprecated`. Each
|
|
1025
|
+
* schema states the constraints of the context it describes, so a read member and its create
|
|
1026
|
+
* and update counterparts can document different bounds. The keywords are read off the member
|
|
1027
|
+
* and any inline `anyOf`/`oneOf` branch; a `$ref`'d component documents itself.
|
|
1028
|
+
*/
|
|
1029
|
+
function memberDoc(schema) {
|
|
1030
|
+
const layers = [schema, ...[...schema.anyOf ?? [], ...schema.oneOf ?? []].filter((branch) => branch.$ref === void 0)];
|
|
1031
|
+
const tags = [];
|
|
1032
|
+
for (const keyword of CONSTRAINT_KEYWORDS) for (const layer of layers) {
|
|
1033
|
+
const value = layer[keyword];
|
|
1034
|
+
if (value === void 0 || value === false) continue;
|
|
1035
|
+
tags.push(value === true ? `@${keyword}` : `@${keyword} ${tagValue(keyword, value)}`);
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
if ("default" in schema) tags.push(`@defaultValue \`${JSON.stringify(schema["default"])}\``);
|
|
1039
|
+
const examples = Array.isArray(schema["examples"]) ? [...schema["examples"]] : [];
|
|
1040
|
+
if ("example" in schema && !examples.includes(schema["example"])) examples.unshift(schema["example"]);
|
|
1041
|
+
for (const example of examples) tags.push(`@example ${typeof example === "string" ? example : JSON.stringify(example)}`);
|
|
1042
|
+
if (schema["deprecated"] === true) tags.push("@deprecated");
|
|
1043
|
+
const description = schema.description?.trim();
|
|
1044
|
+
if (tags.length === 0) return description;
|
|
1045
|
+
return description ? `${description}\n\n${tags.join("\n")}` : tags.join("\n");
|
|
1046
|
+
}
|
|
727
1047
|
var Emitter = class {
|
|
728
1048
|
doc;
|
|
729
1049
|
descriptor;
|
|
@@ -786,6 +1106,8 @@ var Emitter = class {
|
|
|
786
1106
|
this.resourceMap(),
|
|
787
1107
|
"export type ResourceMap = typeof resourceMap",
|
|
788
1108
|
this.atomicConst(),
|
|
1109
|
+
this.pageLimitsConst(),
|
|
1110
|
+
emitErrorCodes(readErrorCatalogue(this.doc)),
|
|
789
1111
|
this.boundFactory()
|
|
790
1112
|
].join("\n\n")}\n`;
|
|
791
1113
|
}
|
|
@@ -805,6 +1127,8 @@ var Emitter = class {
|
|
|
805
1127
|
" type ApiDescriptor,",
|
|
806
1128
|
" type ClientOptions,",
|
|
807
1129
|
" createClient as createClientRuntime,",
|
|
1130
|
+
" type ErrorCodeGuard,",
|
|
1131
|
+
" hasErrorCode as hasErrorCodeRuntime,",
|
|
808
1132
|
"} from '@haddowg/json-api-client'"
|
|
809
1133
|
].join("\n");
|
|
810
1134
|
}
|
|
@@ -931,6 +1255,54 @@ var Emitter = class {
|
|
|
931
1255
|
].join("\n")}\nexport const atomic = ${this.atomic === null ? "null" : `${this.literal(this.atomic, 0)} as const`}`;
|
|
932
1256
|
}
|
|
933
1257
|
/**
|
|
1258
|
+
* The `pageLimits` const: per collection, the `default`, `minimum` and `maximum` each `page`
|
|
1259
|
+
* member declares (generator contract 5 adds the paginator's default and page-size cap). A
|
|
1260
|
+
* menu nests them under each arm's `kind`. Members declaring none, and collections with no
|
|
1261
|
+
* such member, are left out. The client never clamps a `page` argument to these.
|
|
1262
|
+
*/
|
|
1263
|
+
pageLimitsConst() {
|
|
1264
|
+
const typeLines = [];
|
|
1265
|
+
for (const type of Object.keys(this.descriptor)) {
|
|
1266
|
+
const path = this.descriptor[type].paths["fetchMany"];
|
|
1267
|
+
const schema = (path === void 0 ? void 0 : this.doc.paths?.[path]?.get?.parameters)?.find((param) => param.name === "page")?.schema;
|
|
1268
|
+
if (schema === void 0) continue;
|
|
1269
|
+
let body = [];
|
|
1270
|
+
if (schema.oneOf !== void 0) for (const arm of schema.oneOf) {
|
|
1271
|
+
const kind = isSchema(arm.properties?.["kind"]) ? arm.properties["kind"].const : void 0;
|
|
1272
|
+
const members = this.pageMemberLimits(arm, " ");
|
|
1273
|
+
if (typeof kind === "string" && members.length > 0) body.push(` ${objectKey(kind)}: {\n${members.join("\n")}\n },`);
|
|
1274
|
+
}
|
|
1275
|
+
else body = this.pageMemberLimits(schema, " ");
|
|
1276
|
+
if (body.length > 0) typeLines.push(` ${objectKey(type)}: {\n${body.join("\n")}\n },`);
|
|
1277
|
+
}
|
|
1278
|
+
return `${[
|
|
1279
|
+
"/**",
|
|
1280
|
+
" * The bounds each collection's `page` parameter declares, keyed by type, then by strategy for a",
|
|
1281
|
+
" * collection offering several, then by wire key. `default` is what the server uses when the key",
|
|
1282
|
+
" * is absent and `maximum` is the largest value it serves. The client sends the `page` you pass",
|
|
1283
|
+
" * unchanged, so read these to bound a page-size picker.",
|
|
1284
|
+
" */"
|
|
1285
|
+
].join("\n")}\nexport const pageLimits = ${typeLines.length === 0 ? "{}" : `{\n${typeLines.join("\n")}\n}`} as const`;
|
|
1286
|
+
}
|
|
1287
|
+
/** One page schema's members that declare a `default`, `minimum` or `maximum`, as literal lines. */
|
|
1288
|
+
pageMemberLimits(schema, indent) {
|
|
1289
|
+
const lines = [];
|
|
1290
|
+
for (const [name, prop] of Object.entries(schema.properties ?? {})) {
|
|
1291
|
+
if (!isSchema(prop) || name === "kind") continue;
|
|
1292
|
+
const limits = [];
|
|
1293
|
+
for (const keyword of [
|
|
1294
|
+
"default",
|
|
1295
|
+
"minimum",
|
|
1296
|
+
"maximum"
|
|
1297
|
+
]) if (typeof prop[keyword] === "number") limits.push(`${keyword}: ${String(prop[keyword])}`);
|
|
1298
|
+
if (limits.length === 0) continue;
|
|
1299
|
+
const doc = jsDoc(memberDoc(prop), indent);
|
|
1300
|
+
if (doc !== void 0) lines.push(doc);
|
|
1301
|
+
lines.push(`${indent}${objectKey(name)}: { ${limits.join(", ")} },`);
|
|
1302
|
+
}
|
|
1303
|
+
return lines;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
934
1306
|
* Render an attribute interface from a property map, a field order, and a `required`
|
|
935
1307
|
* predicate (true => no `?`). Shared by the read and write attribute interfaces so the
|
|
936
1308
|
* tsType/JSDoc logic stays in one place.
|
|
@@ -940,7 +1312,7 @@ var Emitter = class {
|
|
|
940
1312
|
for (const field of fields) {
|
|
941
1313
|
const schema = props?.[field];
|
|
942
1314
|
const tsType = isSchema(schema) ? this.tsType(schema) : "unknown";
|
|
943
|
-
const doc = isSchema(schema) ? jsDoc(schema
|
|
1315
|
+
const doc = isSchema(schema) ? jsDoc(memberDoc(schema), " ") : void 0;
|
|
944
1316
|
if (doc) lines.push(doc);
|
|
945
1317
|
const optional = required(field) ? "" : "?";
|
|
946
1318
|
lines.push(` ${objectKey(field)}${optional}: ${tsType}`);
|
|
@@ -965,7 +1337,16 @@ var Emitter = class {
|
|
|
965
1337
|
this.expanding.delete(target);
|
|
966
1338
|
return expanded;
|
|
967
1339
|
}
|
|
1340
|
+
const branches = schema.anyOf ?? schema.oneOf;
|
|
1341
|
+
if (schema.type === void 0 && branches !== void 0 && branches.length > 0) {
|
|
1342
|
+
const members = branches.map((branch) => this.tsType(branch));
|
|
1343
|
+
const nullable = members.includes("null");
|
|
1344
|
+
const rest = [...new Set(members.filter((member) => member !== "null"))];
|
|
1345
|
+
if (rest.length === 0) return "null";
|
|
1346
|
+
return nullable ? `${rest.join(" | ")} | null` : rest.join(" | ");
|
|
1347
|
+
}
|
|
968
1348
|
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
1349
|
+
if (types.length === 1 && types[0] === "null") return "null";
|
|
969
1350
|
const nullable = types.includes("null");
|
|
970
1351
|
const base = this.baseTsType(schema, types.filter((t) => t !== "null"));
|
|
971
1352
|
return nullable ? `${base} | null` : base;
|
|
@@ -1005,7 +1386,9 @@ var Emitter = class {
|
|
|
1005
1386
|
arrayType(schema) {
|
|
1006
1387
|
const items = schema.items;
|
|
1007
1388
|
const element = isSchema(items) ? this.tsType(items) : "unknown";
|
|
1008
|
-
|
|
1389
|
+
const list = /[|&]/.test(element) ? `(${element})[]` : `${element}[]`;
|
|
1390
|
+
const minItems = schema["minItems"];
|
|
1391
|
+
return typeof minItems === "number" && minItems >= 1 ? `[${element}, ...${list}]` : list;
|
|
1009
1392
|
}
|
|
1010
1393
|
/** Union aliases for every enum component an attribute referenced, sorted by name. */
|
|
1011
1394
|
enumAliases() {
|
|
@@ -1083,10 +1466,11 @@ var Emitter = class {
|
|
|
1083
1466
|
"/**",
|
|
1084
1467
|
" * Descriptor-bound client factory; wraps the generic runtime with this API’s `resourceMap`.",
|
|
1085
1468
|
" * The server-level `atomic` capability is threaded in by default so `client.atomic` is wired",
|
|
1086
|
-
" * (a caller may still override it via `options.atomic`).",
|
|
1469
|
+
" * (a caller may still override it via `options.atomic`). The client type carries `ErrorCode`,",
|
|
1470
|
+
" * so bindings that only see the client (the Angular refs) type its errors.",
|
|
1087
1471
|
" */",
|
|
1088
1472
|
"export const createClient = (options: ClientOptions) =>",
|
|
1089
|
-
" createClientRuntime<typeof resourceMap, Attributes, WriteAttributes, ActionTypes>(",
|
|
1473
|
+
" createClientRuntime<typeof resourceMap, Attributes, WriteAttributes, ActionTypes, ErrorCode>(",
|
|
1090
1474
|
" resourceMap,",
|
|
1091
1475
|
" {",
|
|
1092
1476
|
" atomic,",
|
|
@@ -1211,8 +1595,11 @@ function schemasOutputPath(output) {
|
|
|
1211
1595
|
*/
|
|
1212
1596
|
async function render(config) {
|
|
1213
1597
|
const doc = await readDocument(config.input);
|
|
1598
|
+
const contractWarning = checkContract(doc);
|
|
1599
|
+
if (contractWarning !== void 0) console.warn(`[json-api-codegen] ${contractWarning}`);
|
|
1214
1600
|
const descriptor = buildDescriptor(doc);
|
|
1215
1601
|
for (const { type, relation } of detectVerbCollisions(descriptor)) console.warn(`[json-api-codegen] "${type}" has a relation named "${relation}" which collides with a reserved verb on the resource handle; it will be reachable via .rel("${relation}") in the fluent client.`);
|
|
1602
|
+
if (Object.keys(descriptor).length === 0) console.warn(`[json-api-codegen] found no JSON:API resource types in ${config.input}; the generated client is empty.`);
|
|
1216
1603
|
const artifacts = [{
|
|
1217
1604
|
path: config.output,
|
|
1218
1605
|
content: emit(doc, descriptor)
|
|
@@ -1273,6 +1660,6 @@ async function check(config) {
|
|
|
1273
1660
|
};
|
|
1274
1661
|
}
|
|
1275
1662
|
//#endregion
|
|
1276
|
-
export { readSchemas as a, detectVerbCollisions as c, hashJson as d, provenanceLines as f,
|
|
1663
|
+
export { checkContract as _, readSchemas as a, buildDescriptor as b, detectVerbCollisions as c, hashJson as d, provenanceLines as f, MINIMUM_CONTRACT as g, MAXIMUM_CONTRACT as h, readDocument as i, emit as l, readErrorCatalogue as m, generate as n, emitSchemas as o, emitErrorCodes as p, schemasOutputPath as r, Emitter as s, check as t, deriveProvenance as u, DescriptorBuilder as v, buildAtomic as y };
|
|
1277
1664
|
|
|
1278
|
-
//# sourceMappingURL=src-
|
|
1665
|
+
//# sourceMappingURL=src-C3hOfyvh.mjs.map
|