@haddowg/json-api-codegen 1.0.0 → 1.1.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. */
@@ -99,28 +99,28 @@ var DescriptorBuilder = class {
99
99
  resourceType(name, schema) {
100
100
  if (!name.endsWith("Resource") || name.endsWith("ResourceIdentifier")) return;
101
101
  const constValue = schema.properties?.["type"];
102
- if (isSchema$1(constValue) && typeof constValue.const === "string") return constValue.const;
102
+ if (isSchema$2(constValue) && typeof constValue.const === "string") return constValue.const;
103
103
  }
104
104
  /** Follow a `$ref` to its identifier/resource schema and read `properties.type.const`. */
105
105
  resolveConst(ref) {
106
106
  const target = refName$1(ref);
107
107
  if (target === void 0) return;
108
108
  const typeProp = this.schemas[target]?.properties?.["type"];
109
- return isSchema$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
109
+ return isSchema$2(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
110
110
  }
111
111
  attributes(base) {
112
112
  const props = this.schemas[`${base}Attributes`]?.properties;
113
113
  if (props === void 0) return {};
114
114
  const out = {};
115
- for (const [name, prop] of Object.entries(props)) if (isSchema$1(prop)) out[name] = formatHint(prop);
115
+ for (const [name, prop] of Object.entries(props)) if (isSchema$2(prop)) out[name] = formatHint(prop);
116
116
  return sortRecord(out);
117
117
  }
118
118
  relations(resource, collection) {
119
- const relProps = isSchema$1(resource.properties?.["relationships"]) ? resource.properties["relationships"].properties : void 0;
119
+ const relProps = isSchema$2(resource.properties?.["relationships"]) ? resource.properties["relationships"].properties : void 0;
120
120
  if (relProps === void 0) return {};
121
121
  const out = {};
122
122
  for (const [name, prop] of Object.entries(relProps)) {
123
- if (!isSchema$1(prop) || typeof prop.$ref !== "string") continue;
123
+ if (!isSchema$2(prop) || typeof prop.$ref !== "string") continue;
124
124
  const component = this.schemas[refName$1(prop.$ref) ?? ""];
125
125
  const descriptor = component ? this.relationFromComponent(component) : void 0;
126
126
  if (descriptor === void 0) {
@@ -171,10 +171,10 @@ var DescriptorBuilder = class {
171
171
  const countable = withCountParam(relatedGet) ?? withCountParam(this.operation(`${collection}/{id}/relationships/${name}`, "get"));
172
172
  if (countable !== void 0) out.countable = countable;
173
173
  if (descriptor.cardinality === "many") {
174
- const relatedKind = this.paginatorKindOf(relatedGet) === "none" ? this.paginatorKindOf(this.operation(`${collection}/{id}/relationships/${name}`, "get")) : this.paginatorKindOf(relatedGet);
174
+ const relatedPaginator = this.paginatorOf(relatedGet) === "none" ? this.paginatorOf(this.operation(`${collection}/{id}/relationships/${name}`, "get")) : this.paginatorOf(relatedGet);
175
175
  const relatedType = descriptor.types[0];
176
- const typeKind = relatedType !== void 0 ? this.typePaginator(relatedType) : "none";
177
- if (relatedKind !== "none" && relatedKind !== typeKind) out.paginator = relatedKind;
176
+ const typePaginator = relatedType !== void 0 ? this.typePaginator(relatedType) : "none";
177
+ if (relatedPaginator !== "none" && JSON.stringify(relatedPaginator) !== JSON.stringify(typePaginator)) out.paginator = relatedPaginator;
178
178
  }
179
179
  return out;
180
180
  }
@@ -243,8 +243,8 @@ var DescriptorBuilder = class {
243
243
  if (typeof ref !== "string") return;
244
244
  const component = refName$1(ref);
245
245
  const data = component === void 0 ? void 0 : this.schemas[component]?.properties?.["data"];
246
- if (!isSchema$1(data)) return;
247
- if (isSchema$1(data.items)) {
246
+ if (!isSchema$2(data)) return;
247
+ if (isSchema$2(data.items)) {
248
248
  const itemRef = data.items.$ref;
249
249
  const many = typeof itemRef === "string" ? this.resolveConst(itemRef) : void 0;
250
250
  return many === void 0 ? void 0 : {
@@ -262,7 +262,7 @@ var DescriptorBuilder = class {
262
262
  dataConst(data) {
263
263
  if (typeof data.$ref === "string") return this.resolveConst(data.$ref);
264
264
  const typeProp = data.properties?.["type"];
265
- return isSchema$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
265
+ return isSchema$2(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
266
266
  }
267
267
  /** The `$ref` of the first 2xx JSON:API response body on an operation (the output document). */
268
268
  firstOkJsonApiRef(op) {
@@ -275,7 +275,7 @@ var DescriptorBuilder = class {
275
275
  /** Resolve a relationship component's `data` into a {@link RelationDescriptor}. */
276
276
  relationFromComponent(component) {
277
277
  const data = component.properties?.["data"];
278
- if (!isSchema$1(data)) return;
278
+ if (!isSchema$2(data)) return;
279
279
  if (data.type === "array") {
280
280
  const items = data.items;
281
281
  if (items === void 0) return;
@@ -340,11 +340,11 @@ var DescriptorBuilder = class {
340
340
  pivotFieldsFromAllOf(allOf) {
341
341
  for (const entry of allOf) {
342
342
  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;
343
+ const pivot = isSchema$2(meta) ? meta.properties?.["pivot"] : void 0;
344
+ const props = isSchema$2(pivot) ? pivot.properties : void 0;
345
345
  if (props === void 0) continue;
346
346
  const out = {};
347
- for (const [name, schema] of Object.entries(props)) if (isSchema$1(schema)) out[name] = formatHint(schema);
347
+ for (const [name, schema] of Object.entries(props)) if (isSchema$2(schema)) out[name] = formatHint(schema);
348
348
  return Object.keys(out).length > 0 ? sortRecord(out) : void 0;
349
349
  }
350
350
  }
@@ -366,7 +366,7 @@ var DescriptorBuilder = class {
366
366
  }
367
367
  clientId(base) {
368
368
  const create = this.schemas[`${base}CreateRequest`];
369
- const data = isSchema$1(create?.properties?.["data"]) ? create.properties["data"] : void 0;
369
+ const data = isSchema$2(create?.properties?.["data"]) ? create.properties["data"] : void 0;
370
370
  if (data === void 0) return "forbidden";
371
371
  if (data.properties?.["id"] === false) return "forbidden";
372
372
  return data.required?.includes("id") ? "required" : "optional";
@@ -412,24 +412,55 @@ var DescriptorBuilder = class {
412
412
  const prefix = `${collection}/{id}/relationships/`;
413
413
  return Object.keys(this.paths).some((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/"));
414
414
  }
415
- /** Detect the paginator kind from the fetchMany operation's query parameter names. */
415
+ /** Detect the paginator from the fetchMany operation's page parameters. */
416
416
  paginator(base) {
417
417
  const collection = this.collectionPath(base);
418
- return this.paginatorKindOf(this.operation(collection ?? "", "get"), collection);
418
+ return this.paginatorOf(this.operation(collection ?? "", "get"), collection);
419
+ }
420
+ /**
421
+ * Detect the paginator an operation advertises. A page schema stamped with `x-paginator`
422
+ * (contract 3) names its strategy and the wire key for each role, so it decides both. Without
423
+ * the stamp, the strategy is inferred from canonical member names (see {@link kindOfMembers}).
424
+ * Anything unrecognised warns and generates as unpaginated. `label` (a path) names the endpoint
425
+ * in the warning; without it nothing is logged.
426
+ */
427
+ paginatorOf(op, label) {
428
+ const warn = (problem) => {
429
+ if (label !== void 0) console.warn(`json-api codegen: endpoint "${label}" ${problem}; treating it as unpaginated.`);
430
+ return "none";
431
+ };
432
+ const schema = op?.parameters?.find((param) => param.name === "page")?.schema;
433
+ if (schema?.["x-paginator"] !== void 0) return stampedPaginator(schema, warn);
434
+ const { members, menu } = this.pageMembers(op);
435
+ if (menu === void 0) {
436
+ const kind = kindOfMembers(members);
437
+ if (kind !== void 0) return kind;
438
+ }
439
+ 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`);
440
+ return members.size > 0 ? warn(`advertises page parameters matching no known paginator kind (members ${sortedList(members)})`) : "none";
419
441
  }
420
442
  /**
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.
443
+ * The `page` members an operation accepts, from either wire form: one `page` parameter
444
+ * (`style: deepObject`) whose schema `properties` name the members, as `haddowg/json-api` v1
445
+ * projects it, or the older flattened `page[number]`/`page[size]` parameters. A `oneOf` page
446
+ * schema (a MultiPaginator menu) yields each arm's members in `menu` instead, minus the
447
+ * `kind` discriminator.
425
448
  */
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";
449
+ pageMembers(op) {
450
+ const members = /* @__PURE__ */ new Set();
451
+ let menu;
452
+ for (const param of op?.parameters ?? []) {
453
+ const name = param.name;
454
+ if (name === "page") {
455
+ const arms = param.schema?.oneOf;
456
+ if (arms !== void 0) menu = arms.map((arm) => new Set(Object.keys(arm.properties ?? {}).filter((m) => m !== "kind")));
457
+ for (const member of Object.keys(param.schema?.properties ?? {})) members.add(member);
458
+ } else if (name?.startsWith("page[") === true && name.endsWith("]")) members.add(name.slice(5, -1));
459
+ }
460
+ return menu === void 0 ? { members } : {
461
+ members,
462
+ menu
463
+ };
433
464
  }
434
465
  /**
435
466
  * The `withCount` capability (Countable profile) for a type's COLLECTION read (`list`): the
@@ -487,11 +518,6 @@ var DescriptorBuilder = class {
487
518
  }
488
519
  return keys.length > 0 ? keys.sort() : void 0;
489
520
  }
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
521
  operation(path, method) {
496
522
  return this.paths[path]?.[method];
497
523
  }
@@ -613,6 +639,234 @@ function buildDescriptor(doc) {
613
639
  function buildAtomic(doc) {
614
640
  return new DescriptorBuilder(doc).buildAtomic();
615
641
  }
642
+ /**
643
+ * Classify a page member set: `number`+`size` = `page`; `offset`+`limit` = `offset`; either
644
+ * cursor bound (`after`/`before`) = `cursor` (link-driven, D44); `number` alone = `fixed` (the
645
+ * server fixes the size); no members = `none`; anything else = `undefined` (unrecognised).
646
+ */
647
+ function kindOfMembers(members) {
648
+ if (members.has("number") && members.has("size")) return "page";
649
+ if (members.has("offset") && members.has("limit")) return "offset";
650
+ if (members.has("after") || members.has("before")) return "cursor";
651
+ if (members.size === 1 && members.has("number")) return "fixed";
652
+ return members.size === 0 ? "none" : void 0;
653
+ }
654
+ const sortedList = (members) => [...members].sort().join(", ");
655
+ const STRATEGIES = [
656
+ "page",
657
+ "offset",
658
+ "cursor",
659
+ "fixed"
660
+ ];
661
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
662
+ /**
663
+ * Read a page schema's `x-paginator` stamp. A strategy keeps the wire key the stamp names for
664
+ * each of its roles (a missing role keeps its canonical name) and collapses to the bare kind when
665
+ * every key is canonical. A `multi` stamp reads each `oneOf` arm by its `kind` const: an arm's own
666
+ * stamp decides it, an unstamped arm falls back to name inference, and an arm neither recognises
667
+ * is left out of the menu.
668
+ */
669
+ function stampedPaginator(schema, warn) {
670
+ const stamp = schema["x-paginator"];
671
+ const strategy = isRecord(stamp) ? stamp["strategy"] : void 0;
672
+ if (!isRecord(stamp) || typeof strategy !== "string") return warn("stamps a malformed x-paginator");
673
+ if (strategy === "multi") return stampedMenu(schema, stamp, warn);
674
+ if (!STRATEGIES.includes(strategy)) return warn(`stamps an x-paginator strategy "${strategy}" this codegen does not know`);
675
+ const kind = strategy;
676
+ const canonical = CANONICAL_PAGE_KEYS[kind];
677
+ const stamped = isRecord(stamp["keys"]) ? stamp["keys"] : {};
678
+ const keys = {};
679
+ let renamed = false;
680
+ for (const [role, fallback] of Object.entries(canonical)) {
681
+ const key = typeof stamped[role] === "string" ? stamped[role] : fallback;
682
+ keys[role] = key;
683
+ renamed ||= key !== fallback;
684
+ }
685
+ return renamed ? {
686
+ strategy: kind,
687
+ keys
688
+ } : kind;
689
+ }
690
+ function stampedMenu(schema, stamp, warn) {
691
+ const arms = {};
692
+ for (const arm of schema.oneOf ?? []) {
693
+ const discriminator = arm.properties?.["kind"];
694
+ const kind = isRecord(discriminator) ? discriminator["const"] : void 0;
695
+ if (typeof kind !== "string") {
696
+ warn("offers a page strategy menu arm without a kind const, which is left out");
697
+ continue;
698
+ }
699
+ 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`);
700
+ if (typeof paginator === "object" && paginator.strategy === "multi") {
701
+ warn(`nests a page strategy menu in arm "${kind}", which is left out`);
702
+ continue;
703
+ }
704
+ if (paginator !== "none") arms[kind] = paginator;
705
+ }
706
+ const fallback = stamp["default"];
707
+ 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`);
708
+ return {
709
+ strategy: "multi",
710
+ default: fallback,
711
+ arms
712
+ };
713
+ }
714
+ //#endregion
715
+ //#region src/contract.ts
716
+ /** Below this the document predates the structures this codegen reads. */
717
+ const MINIMUM_CONTRACT = 1;
718
+ /** Above this the document carries structures this codegen does not read. */
719
+ const MAXIMUM_CONTRACT = 4;
720
+ /**
721
+ * Check the document's `info.x-generator.contract` against the range this codegen reads. A
722
+ * document older than the range throws; a newer one returns a warning, because its new
723
+ * structures would go unread and the client would silently miss what the server offers. A
724
+ * document with no contract (json-api before 1.1.0) passes.
725
+ */
726
+ function checkContract(doc) {
727
+ const contract = doc.info?.["x-generator"]?.contract;
728
+ if (typeof contract !== "number") return;
729
+ if (contract < 1) throw new Error(`the document declares contract ${contract}, which predates what this codegen reads (it supports 1 to 4). Use an older codegen release, or regenerate the document from a newer server.`);
730
+ if (contract > 4) return `the document declares contract ${contract} and this codegen reads up to 4. It describes capabilities that were not generated; upgrade the codegen to pick them up.`;
731
+ }
732
+ //#endregion
733
+ //#region src/error-codes.ts
734
+ const REF_PREFIX$1 = "#/components/schemas/";
735
+ const ERROR_DOCUMENT = /^ErrorDocument(\d{3})?$/;
736
+ function isSchema$1(value) {
737
+ return typeof value === "object" && value !== null;
738
+ }
739
+ /** A schema node and its `allOf` members, where a code variant keeps its narrowing. */
740
+ function layers(schema) {
741
+ return [schema, ...(schema.allOf ?? []).filter((member) => member.$ref === void 0)];
742
+ }
743
+ function property(schema, name) {
744
+ for (const layer of layers(schema)) {
745
+ const prop = layer.properties?.[name];
746
+ if (isSchema$1(prop)) return prop;
747
+ }
748
+ }
749
+ function examplesOf(detail) {
750
+ if (detail === void 0) return [];
751
+ const examples = Array.isArray(detail["examples"]) ? [...detail["examples"]] : [];
752
+ if ("example" in detail && !examples.includes(detail["example"])) examples.unshift(detail["example"]);
753
+ return examples;
754
+ }
755
+ /**
756
+ * Read the documented error codes off a document's error documents. `ErrorDocument` and each
757
+ * `ErrorDocument<NNN>` offer their codes as `errors.items.anyOf` branches that `$ref` a
758
+ * `<Code>Error` component; a branch with a `code` const names a code, and the generic `Error`
759
+ * branch (no const) names none.
760
+ */
761
+ function readErrorCatalogue(doc) {
762
+ const schemas = doc.components?.schemas ?? {};
763
+ const codes = /* @__PURE__ */ new Map();
764
+ const byStatus = /* @__PURE__ */ new Map();
765
+ for (const [name, schema] of Object.entries(schemas)) {
766
+ const match = ERROR_DOCUMENT.exec(name);
767
+ if (match === null) continue;
768
+ const items = property(schema, "errors")?.items;
769
+ const branches = items === void 0 ? [] : [...items.anyOf ?? [], ...items.oneOf ?? []];
770
+ const documentCodes = /* @__PURE__ */ new Set();
771
+ for (const branch of branches) {
772
+ const component = typeof branch.$ref === "string" && branch.$ref.startsWith(REF_PREFIX$1) ? schemas[branch.$ref.slice(21)] : branch;
773
+ const code = component === void 0 ? void 0 : property(component, "code")?.const;
774
+ if (component === void 0 || typeof code !== "string") continue;
775
+ documentCodes.add(code);
776
+ if (!codes.has(code)) {
777
+ const description = layers(component).map((layer) => layer.description?.trim()).find((text) => text !== void 0 && text !== "");
778
+ codes.set(code, {
779
+ code,
780
+ ...description ? { description } : {},
781
+ examples: examplesOf(property(component, "detail"))
782
+ });
783
+ }
784
+ }
785
+ if (match[1] !== void 0) byStatus.set(Number(match[1]), [...documentCodes].sort());
786
+ }
787
+ return {
788
+ codes: [...codes.values()].sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0),
789
+ byStatus: new Map([...byStatus].sort(([a], [b]) => a - b))
790
+ };
791
+ }
792
+ /** A TSDoc block for one code: its description, then each example detail as an `@example`. */
793
+ function codeDoc(entry, indent) {
794
+ const lines = [];
795
+ if (entry.description !== void 0) lines.push(...entry.description.split("\n"));
796
+ for (const example of entry.examples) {
797
+ if (lines.length > 0) lines.push("");
798
+ const text = typeof example === "string" ? example : JSON.stringify(example);
799
+ lines.push(...`@example ${text}`.split("\n"));
800
+ }
801
+ if (lines.length === 0) return;
802
+ if (lines.length === 1) return `${indent}/** ${lines[0].replace(/\*\//g, "* /")} */`;
803
+ return [
804
+ `${indent}/**`,
805
+ ...lines.map((line) => `${indent} * ${line.replace(/\*\//g, "* /")}`.trimEnd()),
806
+ `${indent} */`
807
+ ].join("\n");
808
+ }
809
+ /** A bare object key when the code is a valid identifier, else a quoted one. */
810
+ function codeKey(code) {
811
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(code) ? code : JSON.stringify(code);
812
+ }
813
+ /**
814
+ * Emit `ErrorCodes`, a const object keyed by every documented code with its TSDoc on the key
815
+ * (editors show TSDoc on an object key, not on a string-literal union member), the `ErrorCode`
816
+ * union derived from it, and `ErrorCodeByStatus`, the codes each status's error document offers.
817
+ * A document with no code catalogue gets an empty `ErrorCodes` and `ErrorCode = string`, so the
818
+ * bound guard still takes any code.
819
+ */
820
+ function emitErrorCodes(catalogue) {
821
+ const codeTypes = catalogue.codes.length === 0 ? [
822
+ "/** The documented error codes. This document catalogues none. */",
823
+ "export const ErrorCodes = {} as const",
824
+ "",
825
+ "/** The documented error codes. This document catalogues none, so any code is accepted. */",
826
+ "export type ErrorCode = string"
827
+ ].join("\n") : [
828
+ "/**",
829
+ " * Every error code this server documents, keyed by itself. Hover a key for what the code",
830
+ " * means: `hasErrorCode(error, ErrorCodes.RESOURCE_NOT_FOUND)`.",
831
+ " */",
832
+ "export const ErrorCodes = {",
833
+ ...catalogue.codes.flatMap((entry) => {
834
+ const doc = codeDoc(entry, " ");
835
+ const member = ` ${codeKey(entry.code)}: ${JSON.stringify(entry.code)},`;
836
+ return doc === void 0 ? [member] : [doc, member];
837
+ }),
838
+ "} as const",
839
+ "",
840
+ "/**",
841
+ " * Every error code this server documents. An error may still carry a code that is not",
842
+ " * listed: the catalogue is open.",
843
+ " */",
844
+ "export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]"
845
+ ].join("\n");
846
+ const statusLines = [...catalogue.byStatus].map(([status, codes]) => {
847
+ const members = codes.map((c) => JSON.stringify(c));
848
+ const line = ` ${status}: ${members.length === 0 ? "never" : members.join(" | ")}`;
849
+ return line.length <= 100 ? line : ` ${status}:\n${members.map((m) => ` | ${m}`).join("\n")}`;
850
+ });
851
+ return [
852
+ codeTypes,
853
+ [
854
+ "/**",
855
+ " * The documented error codes per HTTP status. `never` marks a status whose error document",
856
+ " * documents no specific code.",
857
+ " */",
858
+ statusLines.length === 0 ? "export interface ErrorCodeByStatus {}" : `export interface ErrorCodeByStatus {\n${statusLines.join("\n")}\n}`
859
+ ].join("\n"),
860
+ [
861
+ "/**",
862
+ " * True when `err` is a `JsonApiError` and any of its error objects carries `code`. Only takes a",
863
+ " * documented code; match an undocumented one with `hasErrorCode` from",
864
+ " * '@haddowg/json-api-client'.",
865
+ " */",
866
+ "export const hasErrorCode = hasErrorCodeRuntime as ErrorCodeGuard<ErrorCode>"
867
+ ].join("\n")
868
+ ].join("\n\n");
869
+ }
616
870
  //#endregion
617
871
  //#region src/provenance.ts
618
872
  /**
@@ -786,6 +1040,7 @@ var Emitter = class {
786
1040
  this.resourceMap(),
787
1041
  "export type ResourceMap = typeof resourceMap",
788
1042
  this.atomicConst(),
1043
+ emitErrorCodes(readErrorCatalogue(this.doc)),
789
1044
  this.boundFactory()
790
1045
  ].join("\n\n")}\n`;
791
1046
  }
@@ -805,6 +1060,8 @@ var Emitter = class {
805
1060
  " type ApiDescriptor,",
806
1061
  " type ClientOptions,",
807
1062
  " createClient as createClientRuntime,",
1063
+ " type ErrorCodeGuard,",
1064
+ " hasErrorCode as hasErrorCodeRuntime,",
808
1065
  "} from '@haddowg/json-api-client'"
809
1066
  ].join("\n");
810
1067
  }
@@ -1083,10 +1340,11 @@ var Emitter = class {
1083
1340
  "/**",
1084
1341
  " * Descriptor-bound client factory; wraps the generic runtime with this API’s `resourceMap`.",
1085
1342
  " * 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`).",
1343
+ " * (a caller may still override it via `options.atomic`). The client type carries `ErrorCode`,",
1344
+ " * so bindings that only see the client (the Angular refs) type its errors.",
1087
1345
  " */",
1088
1346
  "export const createClient = (options: ClientOptions) =>",
1089
- " createClientRuntime<typeof resourceMap, Attributes, WriteAttributes, ActionTypes>(",
1347
+ " createClientRuntime<typeof resourceMap, Attributes, WriteAttributes, ActionTypes, ErrorCode>(",
1090
1348
  " resourceMap,",
1091
1349
  " {",
1092
1350
  " atomic,",
@@ -1211,8 +1469,11 @@ function schemasOutputPath(output) {
1211
1469
  */
1212
1470
  async function render(config) {
1213
1471
  const doc = await readDocument(config.input);
1472
+ const contractWarning = checkContract(doc);
1473
+ if (contractWarning !== void 0) console.warn(`[json-api-codegen] ${contractWarning}`);
1214
1474
  const descriptor = buildDescriptor(doc);
1215
1475
  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.`);
1476
+ 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
1477
  const artifacts = [{
1217
1478
  path: config.output,
1218
1479
  content: emit(doc, descriptor)
@@ -1273,6 +1534,6 @@ async function check(config) {
1273
1534
  };
1274
1535
  }
1275
1536
  //#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 };
1537
+ 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
1538
 
1278
- //# sourceMappingURL=src-B24cYyjM.mjs.map
1539
+ //# sourceMappingURL=src-CDV4z7In.mjs.map