@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.
@@ -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$1 = "#/components/schemas/";
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$1) ? ref.slice(21) : void 0;
17
+ return ref.startsWith(REF_PREFIX$2) ? ref.slice(21) : void 0;
18
18
  }
19
- function isSchema$1(value) {
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$1(constValue) && typeof constValue.const === "string") return constValue.const;
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$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
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$1(prop)) out[name] = formatHint(prop);
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$1(resource.properties?.["relationships"]) ? resource.properties["relationships"].properties : void 0;
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$1(prop) || typeof prop.$ref !== "string") continue;
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 relatedKind = this.paginatorKindOf(relatedGet) === "none" ? this.paginatorKindOf(this.operation(`${collection}/{id}/relationships/${name}`, "get")) : this.paginatorKindOf(relatedGet);
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 typeKind = relatedType !== void 0 ? this.typePaginator(relatedType) : "none";
177
- if (relatedKind !== "none" && relatedKind !== typeKind) out.paginator = relatedKind;
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$1(data)) return;
247
- if (isSchema$1(data.items)) {
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$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
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$1(data)) return;
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$1(meta) ? meta.properties?.["pivot"] : void 0;
344
- const props = isSchema$1(pivot) ? pivot.properties : void 0;
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$1(schema)) out[name] = formatHint(schema);
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$1(create?.properties?.["data"]) ? create.properties["data"] : void 0;
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 kind from the fetchMany operation's query parameter names. */
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.paginatorKindOf(this.operation(collection ?? "", "get"), collection);
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
- * Detect the paginator kind an operation advertises from its `page[...]` query parameter names.
422
- * `page[number]`+`page[size]` = `page`; `page[offset]`+`page[limit]` = `offset`; either cursor
423
- * bound (`page[after]`/`page[before]`) = `cursor` (link-driven, D44); anything else = `none`.
424
- * `label` (a path, for the drift warning) names the endpoint when page params match no known kind.
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
- paginatorKindOf(op, label) {
427
- const names = this.queryParamNames(op);
428
- if (names.has("page[number]") && names.has("page[size]")) return "page";
429
- if (names.has("page[offset]") && names.has("page[limit]")) return "offset";
430
- if (names.has("page[after]") || names.has("page[before]")) return "cursor";
431
- if (label !== void 0 && [...names].some((name) => name.startsWith("page["))) console.warn(`json-api codegen: endpoint "${label}" advertises page parameters matching no known paginator kind; treating it as unpaginated.`);
432
- return "none";
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.description, " ") : void 0;
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
- return /[|&]/.test(element) ? `(${element})[]` : `${element}[]`;
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, buildDescriptor as h, readDocument as i, emit as l, buildAtomic as m, generate as n, emitSchemas as o, DescriptorBuilder as p, schemasOutputPath as r, Emitter as s, check as t, deriveProvenance as u };
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-B24cYyjM.mjs.map
1665
+ //# sourceMappingURL=src-C3hOfyvh.mjs.map