@haddowg/json-api-codegen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1278 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { ATOMIC_EXT, HANDLE_RESERVED } from "@haddowg/json-api-client";
4
+ import { createHash } from "node:crypto";
5
+ import { parse } from "yaml";
6
+ //#region src/build-descriptor.ts
7
+ const REF_PREFIX$1 = "#/components/schemas/";
8
+ const JSON_API_MEDIA_TYPE$1 = "application/vnd.api+json";
9
+ /** Sort a record's keys for deterministic, snapshot-stable output. */
10
+ function sortRecord(record) {
11
+ const out = {};
12
+ for (const key of Object.keys(record).sort()) out[key] = record[key];
13
+ return out;
14
+ }
15
+ /** Resolve a local schema name from a `#/components/schemas/X` ref. */
16
+ function refName$1(ref) {
17
+ return ref.startsWith(REF_PREFIX$1) ? ref.slice(21) : void 0;
18
+ }
19
+ function isSchema$1(value) {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+ /** The set of `type` keys a JSON type node declares, with `"null"` removed. */
23
+ function typeKeys(type) {
24
+ if (type === void 0) return [];
25
+ return (Array.isArray(type) ? type : [type]).filter((t) => t !== "null");
26
+ }
27
+ var DescriptorBuilder = class {
28
+ doc;
29
+ schemas;
30
+ paths;
31
+ /** Lazily-built wire-type -> collection-level paginator kind (for per-relation divergence, D6a). */
32
+ typePaginators;
33
+ constructor(doc) {
34
+ this.doc = doc;
35
+ this.schemas = doc.components?.schemas ?? {};
36
+ this.paths = doc.paths ?? {};
37
+ }
38
+ /**
39
+ * The collection-level paginator kind for a WIRE type (`tracks`), memoised. Resolves each
40
+ * resource schema's wire type (its `type.const`) to the paginator of its collection GET — the
41
+ * baseline a per-relation related-endpoint paginator is compared against (D6a).
42
+ */
43
+ typePaginator(wireType) {
44
+ if (this.typePaginators === void 0) {
45
+ this.typePaginators = /* @__PURE__ */ new Map();
46
+ for (const [name, schema] of Object.entries(this.schemas)) {
47
+ const type = this.resourceType(name, schema);
48
+ if (type !== void 0) this.typePaginators.set(type, this.paginator(name.slice(0, -8)));
49
+ }
50
+ }
51
+ return this.typePaginators.get(wireType) ?? "none";
52
+ }
53
+ build() {
54
+ const out = {};
55
+ for (const [name, schema] of Object.entries(this.schemas)) {
56
+ const wireType = this.resourceType(name, schema);
57
+ if (wireType === void 0) continue;
58
+ const base = name.slice(0, -8);
59
+ const collection = this.collectionPath(base);
60
+ const actions = collection ? this.actions(collection) : {};
61
+ const descriptor = {
62
+ attributes: this.attributes(base),
63
+ relations: this.relations(schema, collection),
64
+ paths: this.operationPaths(base),
65
+ paginator: this.paginator(base),
66
+ clientId: this.clientId(base)
67
+ };
68
+ const countable = this.countable(collection);
69
+ if (countable !== void 0) descriptor.countable = countable;
70
+ const includable = this.includable(collection);
71
+ if (includable !== void 0) descriptor.includable = includable;
72
+ const sortable = this.sortable(collection);
73
+ if (sortable !== void 0) descriptor.sortable = sortable;
74
+ const filterable = this.filterable(collection);
75
+ if (filterable !== void 0) descriptor.filterable = filterable;
76
+ if (Object.keys(actions).length > 0) descriptor.actions = actions;
77
+ if (Object.keys(schema.properties ?? {}).every((prop) => prop === "type") && Object.keys(descriptor.paths).length === 0) continue;
78
+ out[wireType] = descriptor;
79
+ }
80
+ return sortRecord(out);
81
+ }
82
+ /**
83
+ * The server-level Atomic Operations capability: the path whose `POST` requestBody
84
+ * declares the atomic ext media type ({@link ATOMIC_EXT}). `null` when no such endpoint
85
+ * exists. Server-level, so it rides {@link buildAtomic}, not the per-type descriptor.
86
+ */
87
+ buildAtomic() {
88
+ for (const [path, item] of Object.entries(this.paths)) {
89
+ const content = item.post?.requestBody?.content ?? {};
90
+ if (Object.keys(content).some(isAtomicMediaType)) return { path };
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * A resource schema is one whose name ends in `Resource` (but not
96
+ * `ResourceIdentifier`) and carries `properties.type.const` (a string) — that const
97
+ * is the descriptor key (the wire type). Never inferred from the schema name.
98
+ */
99
+ resourceType(name, schema) {
100
+ if (!name.endsWith("Resource") || name.endsWith("ResourceIdentifier")) return;
101
+ const constValue = schema.properties?.["type"];
102
+ if (isSchema$1(constValue) && typeof constValue.const === "string") return constValue.const;
103
+ }
104
+ /** Follow a `$ref` to its identifier/resource schema and read `properties.type.const`. */
105
+ resolveConst(ref) {
106
+ const target = refName$1(ref);
107
+ if (target === void 0) return;
108
+ const typeProp = this.schemas[target]?.properties?.["type"];
109
+ return isSchema$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
110
+ }
111
+ attributes(base) {
112
+ const props = this.schemas[`${base}Attributes`]?.properties;
113
+ if (props === void 0) return {};
114
+ const out = {};
115
+ for (const [name, prop] of Object.entries(props)) if (isSchema$1(prop)) out[name] = formatHint(prop);
116
+ return sortRecord(out);
117
+ }
118
+ relations(resource, collection) {
119
+ const relProps = isSchema$1(resource.properties?.["relationships"]) ? resource.properties["relationships"].properties : void 0;
120
+ if (relProps === void 0) return {};
121
+ const out = {};
122
+ for (const [name, prop] of Object.entries(relProps)) {
123
+ if (!isSchema$1(prop) || typeof prop.$ref !== "string") continue;
124
+ const component = this.schemas[refName$1(prop.$ref) ?? ""];
125
+ const descriptor = component ? this.relationFromComponent(component) : void 0;
126
+ if (descriptor === void 0) {
127
+ console.warn(`json-api codegen: relationship "${name}" has an unrecognized linkage shape; dropping it from the descriptor.`);
128
+ continue;
129
+ }
130
+ out[name] = collection === void 0 ? descriptor : this.withRelationExposure(descriptor, collection, name);
131
+ }
132
+ return sortRecord(out);
133
+ }
134
+ /**
135
+ * The per-relation mutation capability, derived from the HTTP methods the relationship
136
+ * endpoint (`{collection}/{id}/relationships/{rel}`) advertises. For a to-many:
137
+ * `add` <- POST, `remove` <- DELETE, `replace` <- PATCH. For a to-one: `set` <- PATCH.
138
+ * Only `true` verbs are emitted (a missing verb is simply absent). Returns `undefined`
139
+ * when the relation exposes no relationship endpoint at all.
140
+ */
141
+ relationMutations(collection, rel, cardinality) {
142
+ const item = this.paths[`${collection}/{id}/relationships/${rel}`];
143
+ if (item === void 0) return;
144
+ const mutations = {};
145
+ if (cardinality === "many") {
146
+ if (item.post) mutations.add = true;
147
+ if (item.delete) mutations.remove = true;
148
+ if (item.patch) mutations.replace = true;
149
+ } else if (item.patch) mutations.set = true;
150
+ return mutations;
151
+ }
152
+ /**
153
+ * Attach the per-relation endpoint-exposure signal to a relation descriptor (D24). The bundle
154
+ * can suppress a relation's related or relationship endpoint (`withoutRelatedEndpoint()` /
155
+ * `withoutRelationshipEndpoint()`, ADR 0027), which the OpenAPI reflects by simply omitting that
156
+ * relation's path — but codegen synthesizes generic `{rel}` templates, erasing the per-relation
157
+ * distinction. So mark `related: false` / `relationship: false` when this relation's specific
158
+ * path is absent, and ALWAYS emit an explicit `mutations` object (the verb flags when the
159
+ * relationship endpoint exists, else `{}`) so the client gates a suppressed relation OFF rather
160
+ * than failing open (offering `.get()`/`.related()`/mutations that 404).
161
+ */
162
+ withRelationExposure(descriptor, collection, name) {
163
+ const out = { ...descriptor };
164
+ const relatedGet = this.operation(`${collection}/{id}/${name}`, "get");
165
+ if (relatedGet === void 0) out.related = false;
166
+ const mutations = this.relationMutations(collection, name, descriptor.cardinality);
167
+ if (mutations === void 0) {
168
+ out.relationship = false;
169
+ out.mutations = {};
170
+ } else out.mutations = mutations;
171
+ const countable = withCountParam(relatedGet) ?? withCountParam(this.operation(`${collection}/{id}/relationships/${name}`, "get"));
172
+ if (countable !== void 0) out.countable = countable;
173
+ if (descriptor.cardinality === "many") {
174
+ const relatedKind = this.paginatorKindOf(relatedGet) === "none" ? this.paginatorKindOf(this.operation(`${collection}/{id}/relationships/${name}`, "get")) : this.paginatorKindOf(relatedGet);
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;
178
+ }
179
+ return out;
180
+ }
181
+ /**
182
+ * Collect the custom actions for a type from its `{collection}/-actions/{name}` and
183
+ * `{collection}/{id}/-actions/{name}` paths. The action name is the last segment; scope
184
+ * is `resource` when the path carries `/{id}/`, else `collection`. `input` is `document`
185
+ * (a JSON:API requestBody), `none` (no requestBody) or `raw` (a non-JSON:API body);
186
+ * `output` is `document` (a 2xx returns a JSON:API document) or `none` (only `204`).
187
+ */
188
+ actions(collection) {
189
+ const out = {};
190
+ const collectionPrefix = `${collection}/-actions/`;
191
+ const resourcePrefix = `${collection}/{id}/-actions/`;
192
+ for (const [path, item] of Object.entries(this.paths)) {
193
+ let scope;
194
+ let name;
195
+ if (path.startsWith(resourcePrefix)) {
196
+ scope = "resource";
197
+ name = path.slice(resourcePrefix.length);
198
+ } else if (path.startsWith(collectionPrefix)) {
199
+ scope = "collection";
200
+ name = path.slice(collectionPrefix.length);
201
+ } else continue;
202
+ if (name.length === 0 || name.includes("/")) continue;
203
+ const picked = pickOperation(item);
204
+ if (picked === void 0) {
205
+ console.warn(`json-api codegen: custom action path "${path}" advertises no usable operation; skipping.`);
206
+ continue;
207
+ }
208
+ const { method, op } = picked;
209
+ const input = actionInput(op);
210
+ const output = actionOutput(op);
211
+ const action = {
212
+ scope,
213
+ path,
214
+ input,
215
+ output
216
+ };
217
+ if (method !== "post") action.method = method.toUpperCase();
218
+ if (input === "document") {
219
+ const inputType = this.documentPrimary(actionRequestRef(op))?.type;
220
+ if (inputType !== void 0) action.inputType = inputType;
221
+ }
222
+ if (output === "document") {
223
+ const primary = this.documentPrimary(this.firstOkJsonApiRef(op));
224
+ if (primary !== void 0) {
225
+ action.outputType = primary.type;
226
+ action.outputCardinality = primary.cardinality;
227
+ }
228
+ }
229
+ if (input === "raw") {
230
+ const contentType = rawContentType(op);
231
+ if (contentType !== void 0) action.contentType = contentType;
232
+ }
233
+ out[name] = action;
234
+ }
235
+ return sortRecord(out);
236
+ }
237
+ /**
238
+ * The wire type (and cardinality) of a document component's primary `data` — a `$ref` to a
239
+ * resource, an inline object carrying `properties.type.const` (a create-request body), or an
240
+ * array of resource/identifier refs (a collection). `undefined` when the type can't be resolved.
241
+ */
242
+ documentPrimary(ref) {
243
+ if (typeof ref !== "string") return;
244
+ const component = refName$1(ref);
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)) {
248
+ const itemRef = data.items.$ref;
249
+ const many = typeof itemRef === "string" ? this.resolveConst(itemRef) : void 0;
250
+ return many === void 0 ? void 0 : {
251
+ type: many,
252
+ cardinality: "many"
253
+ };
254
+ }
255
+ const one = this.dataConst(data);
256
+ return one === void 0 ? void 0 : {
257
+ type: one,
258
+ cardinality: "one"
259
+ };
260
+ }
261
+ /** The wire type of a single `data` schema — a `$ref` to a resource, or an inline `type.const`. */
262
+ dataConst(data) {
263
+ if (typeof data.$ref === "string") return this.resolveConst(data.$ref);
264
+ const typeProp = data.properties?.["type"];
265
+ return isSchema$1(typeProp) && typeof typeProp.const === "string" ? typeProp.const : void 0;
266
+ }
267
+ /** The `$ref` of the first 2xx JSON:API response body on an operation (the output document). */
268
+ firstOkJsonApiRef(op) {
269
+ for (const [code, response] of Object.entries(op.responses ?? {})) {
270
+ if (!code.startsWith("2")) continue;
271
+ const ref = response.content?.[JSON_API_MEDIA_TYPE$1]?.schema?.$ref;
272
+ if (typeof ref === "string") return ref;
273
+ }
274
+ }
275
+ /** Resolve a relationship component's `data` into a {@link RelationDescriptor}. */
276
+ relationFromComponent(component) {
277
+ const data = component.properties?.["data"];
278
+ if (!isSchema$1(data)) return;
279
+ if (data.type === "array") {
280
+ const items = data.items;
281
+ if (items === void 0) return;
282
+ if (typeof items.$ref === "string") {
283
+ const type = this.resolveConst(items.$ref);
284
+ return type === void 0 ? void 0 : {
285
+ cardinality: "many",
286
+ types: [type],
287
+ pivot: false
288
+ };
289
+ }
290
+ if (Array.isArray(items.allOf)) {
291
+ const type = this.constFromAllOf(items.allOf);
292
+ if (type === void 0) return;
293
+ const relation = {
294
+ cardinality: "many",
295
+ types: [type],
296
+ pivot: true
297
+ };
298
+ const pivotFields = this.pivotFieldsFromAllOf(items.allOf);
299
+ if (pivotFields !== void 0) relation.pivotFields = pivotFields;
300
+ return relation;
301
+ }
302
+ if (Array.isArray(items.anyOf)) return {
303
+ cardinality: "many",
304
+ types: this.constsFromRefs(items.anyOf),
305
+ pivot: false
306
+ };
307
+ return;
308
+ }
309
+ if (Array.isArray(data.anyOf)) {
310
+ const types = [];
311
+ let pivot = false;
312
+ let pivotFields;
313
+ for (const entry of data.anyOf) if (typeof entry.$ref === "string") {
314
+ const type = this.resolveConst(entry.$ref);
315
+ if (type !== void 0) types.push(type);
316
+ } else if (Array.isArray(entry.allOf)) {
317
+ const type = this.constFromAllOf(entry.allOf);
318
+ if (type !== void 0) {
319
+ types.push(type);
320
+ pivot = true;
321
+ pivotFields = this.pivotFieldsFromAllOf(entry.allOf);
322
+ }
323
+ } else if (Array.isArray(entry.anyOf)) types.push(...this.constsFromRefs(entry.anyOf));
324
+ if (types.length === 0) return;
325
+ const relation = {
326
+ cardinality: "one",
327
+ types,
328
+ pivot
329
+ };
330
+ if (pivotFields !== void 0) relation.pivotFields = pivotFields;
331
+ return relation;
332
+ }
333
+ }
334
+ /**
335
+ * Extract the pivot field-shape from a pivot `allOf` — the entry carrying
336
+ * `properties.meta.properties.pivot.properties` (a `belongsToMany` member's `meta.pivot`), as a
337
+ * field-name → wire-format-hint map (D33). Returns `undefined` when the allOf carries no pivot
338
+ * property block.
339
+ */
340
+ pivotFieldsFromAllOf(allOf) {
341
+ for (const entry of allOf) {
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;
345
+ if (props === void 0) continue;
346
+ const out = {};
347
+ for (const [name, schema] of Object.entries(props)) if (isSchema$1(schema)) out[name] = formatHint(schema);
348
+ return Object.keys(out).length > 0 ? sortRecord(out) : void 0;
349
+ }
350
+ }
351
+ /** Pull the related type from the `$ref` entry of an `allOf` (the pivot shape). */
352
+ constFromAllOf(allOf) {
353
+ for (const entry of allOf) if (typeof entry.$ref === "string") {
354
+ const type = this.resolveConst(entry.$ref);
355
+ if (type !== void 0) return type;
356
+ }
357
+ }
358
+ /** Resolve every `$ref` entry in a list to its wire type. */
359
+ constsFromRefs(entries) {
360
+ const types = [];
361
+ for (const entry of entries) if (typeof entry.$ref === "string") {
362
+ const type = this.resolveConst(entry.$ref);
363
+ if (type !== void 0) types.push(type);
364
+ }
365
+ return types;
366
+ }
367
+ clientId(base) {
368
+ const create = this.schemas[`${base}CreateRequest`];
369
+ const data = isSchema$1(create?.properties?.["data"]) ? create.properties["data"] : void 0;
370
+ if (data === void 0) return "forbidden";
371
+ if (data.properties?.["id"] === false) return "forbidden";
372
+ return data.required?.includes("id") ? "required" : "optional";
373
+ }
374
+ /**
375
+ * Find the collection path for a type, then derive each per-operation path that
376
+ * actually exists. The collection path P is the one whose POST requestBody refs
377
+ * `<Base>CreateRequest`, else the GET whose 200 response refs `<Base>Collection`.
378
+ */
379
+ operationPaths(base) {
380
+ const collection = this.collectionPath(base);
381
+ const out = {};
382
+ if (collection === void 0) return out;
383
+ if (this.operation(collection, "get")) out["fetchMany"] = collection;
384
+ if (this.operation(collection, "post")) out["create"] = collection;
385
+ const item = `${collection}/{id}`;
386
+ if (this.operation(item, "get")) out["fetchOne"] = item;
387
+ if (this.operation(item, "patch")) out["update"] = item;
388
+ if (this.operation(item, "delete")) out["delete"] = item;
389
+ if (this.hasRelatedPath(collection)) out["fetchRelated"] = `${collection}/{id}/{rel}`;
390
+ if (this.hasRelationshipPath(collection)) out["fetchRelationship"] = `${collection}/{id}/relationships/{rel}`;
391
+ return sortRecord(out);
392
+ }
393
+ collectionPath(base) {
394
+ let byCollection;
395
+ for (const [path, item] of Object.entries(this.paths)) {
396
+ const post = item.post;
397
+ if (post && refEndsWith(this.requestBodyRef(post), `${base}CreateRequest`)) return path;
398
+ if (byCollection === void 0 && !path.includes("/{id}/") && refEndsWith(this.okResponseRef(item.get), `${base}Collection`)) byCollection = path;
399
+ }
400
+ return byCollection;
401
+ }
402
+ /** Any `P/{id}/<rel>` related path (excludes relationships/ and custom `-actions`). */
403
+ hasRelatedPath(collection) {
404
+ const prefix = `${collection}/{id}/`;
405
+ return Object.keys(this.paths).some((path) => {
406
+ if (!path.startsWith(prefix)) return false;
407
+ const rest = path.slice(prefix.length);
408
+ return rest.length > 0 && !rest.includes("/") && !rest.startsWith("-");
409
+ });
410
+ }
411
+ hasRelationshipPath(collection) {
412
+ const prefix = `${collection}/{id}/relationships/`;
413
+ return Object.keys(this.paths).some((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/"));
414
+ }
415
+ /** Detect the paginator kind from the fetchMany operation's query parameter names. */
416
+ paginator(base) {
417
+ const collection = this.collectionPath(base);
418
+ return this.paginatorKindOf(this.operation(collection ?? "", "get"), collection);
419
+ }
420
+ /**
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.
425
+ */
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";
433
+ }
434
+ /**
435
+ * The `withCount` capability (Countable profile) for a type's COLLECTION read (`list`): the
436
+ * count tokens (`schema.items.enum`) + the negotiation profile (`x-profile`) carried by the
437
+ * collection GET's `withCount` query parameter. Returns `undefined` when the collection
438
+ * doesn't exist or advertises no `withCount`. The profile URI is never hardcoded — it is read
439
+ * from the parameter's `x-profile`.
440
+ *
441
+ * Deliberately scoped to the collection GET only. The per-endpoint token sets differ
442
+ * (a collection counts e.g. `tracks`; a related/relationship endpoint counts `_self_` and its
443
+ * own relations), so a single per-type `countable` can only honestly describe one endpoint.
444
+ * The fluent surface exposes `withCount` on `list` (the collection), so `countable` mirrors
445
+ * that endpoint — a related-GET fallback would advertise tokens `list` cannot legally send.
446
+ */
447
+ countable(collection) {
448
+ if (collection === void 0) return;
449
+ return withCountParam(this.operation(collection, "get"));
450
+ }
451
+ /**
452
+ * The relation paths the read endpoints accept in `include` — the `include` query
453
+ * parameter's `schema.items.enum` (already including nested dotted paths) on the collection
454
+ * GET. Returns `undefined` when the collection advertises no `include` (nothing includable).
455
+ *
456
+ * Read from the collection GET to mirror {@see sortable}/{@see filterable}: the whole builder
457
+ * is collection-centric (a type with no collection path gets no `paths` at all), and the bundle
458
+ * advertises the same `include` enum on the collection and single-resource GETs.
459
+ */
460
+ includable(collection) {
461
+ if (collection === void 0) return;
462
+ const tokens = paramItemsEnum(this.operation(collection, "get"), "include");
463
+ return tokens !== void 0 && tokens.length > 0 ? tokens : void 0;
464
+ }
465
+ /**
466
+ * The sort tokens the COLLECTION read accepts — the `sort` query parameter's
467
+ * `schema.items.enum` (signed field names) on the collection GET. Returns `undefined` when
468
+ * the collection advertises no `sort` parameter (sorting unsupported).
469
+ */
470
+ sortable(collection) {
471
+ if (collection === void 0) return;
472
+ const tokens = paramItemsEnum(this.operation(collection, "get"), "sort");
473
+ return tokens !== void 0 && tokens.length > 0 ? tokens : void 0;
474
+ }
475
+ /**
476
+ * The filter keys the COLLECTION read accepts in `filter[...]` (the `filter[<key>]` parameter
477
+ * names, `<key>` extracted), sorted for deterministic output. Returns `undefined` when the
478
+ * collection advertises no `filter[...]` parameters (filtering unsupported). Value shapes are
479
+ * deliberately not captured — `filter` values stay `unknown` (out of scope for v0.1).
480
+ */
481
+ filterable(collection) {
482
+ if (collection === void 0) return;
483
+ const keys = [];
484
+ for (const param of this.operation(collection, "get")?.parameters ?? []) {
485
+ const name = param.name;
486
+ if (typeof name === "string" && name.startsWith("filter[") && name.endsWith("]")) keys.push(name.slice(7, -1));
487
+ }
488
+ return keys.length > 0 ? keys.sort() : void 0;
489
+ }
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
+ operation(path, method) {
496
+ return this.paths[path]?.[method];
497
+ }
498
+ requestBodyRef(op) {
499
+ return op?.requestBody?.content?.["application/vnd.api+json"]?.schema?.$ref;
500
+ }
501
+ okResponseRef(op) {
502
+ return op?.responses?.["200"]?.content?.["application/vnd.api+json"]?.schema?.$ref;
503
+ }
504
+ };
505
+ /** A schema's wire format hint: explicit `format`, else the JSON type, else a ref-enum is `string`. */
506
+ function formatHint(schema) {
507
+ if (typeof schema.format === "string") return schema.format;
508
+ const types = typeKeys(schema.type);
509
+ if (types.length > 0) return types[0];
510
+ if (typeof schema.$ref === "string") return "string";
511
+ return "unknown";
512
+ }
513
+ function refEndsWith(ref, suffix) {
514
+ return ref !== void 0 && refName$1(ref) === suffix;
515
+ }
516
+ /**
517
+ * Read the {@link CountableDescriptor} off an operation's `withCount` query parameter: the
518
+ * count tokens from its `schema.items.enum` and the negotiation profile from `x-profile`.
519
+ * Returns `undefined` when the operation has no `withCount` parameter, or when one is present
520
+ * but carries no `x-profile` (a `withCount` we can't tell a client how to negotiate). An empty
521
+ * or absent token enum yields `[]` tokens (the profile alone is still actionable).
522
+ */
523
+ function withCountParam(op) {
524
+ for (const param of op?.parameters ?? []) {
525
+ if (param.name !== "withCount") continue;
526
+ const profile = param["x-profile"];
527
+ if (typeof profile !== "string") return;
528
+ const enumValues = param.schema?.items?.enum;
529
+ return {
530
+ tokens: Array.isArray(enumValues) ? enumValues.filter((v) => typeof v === "string") : [],
531
+ profile
532
+ };
533
+ }
534
+ }
535
+ /**
536
+ * The string members of an array-valued query parameter's `schema.items.enum` (the shape the
537
+ * `include` and `sort` parameters use). Returns `undefined` when the operation carries no
538
+ * parameter of that name, or one whose schema declares no enum.
539
+ */
540
+ function paramItemsEnum(op, paramName) {
541
+ for (const param of op?.parameters ?? []) {
542
+ if (param.name !== paramName) continue;
543
+ const enumValues = param.schema?.items?.enum;
544
+ return Array.isArray(enumValues) ? enumValues.filter((v) => typeof v === "string") : void 0;
545
+ }
546
+ }
547
+ /**
548
+ * True for the Atomic Operations ext media type — `application/vnd.api+json` carrying the
549
+ * atomic `ext` parameter ({@link ATOMIC_EXT}). Tolerant of quoting/whitespace so any of
550
+ * `ext="…"` / `ext=…` matches.
551
+ */
552
+ function isAtomicMediaType(mediaType) {
553
+ return mediaType.startsWith(JSON_API_MEDIA_TYPE$1) && mediaType.includes(ATOMIC_EXT);
554
+ }
555
+ /** The HTTP methods an action operation may be advertised under, in resolution preference order. */
556
+ const ACTION_METHODS = [
557
+ "post",
558
+ "get",
559
+ "put",
560
+ "patch",
561
+ "delete"
562
+ ];
563
+ /**
564
+ * The single operation an action is invoked over: POST when advertised (the common default), else
565
+ * the first advertised method. `undefined` when the path item carries no operation at all.
566
+ */
567
+ function pickOperation(item) {
568
+ const available = ACTION_METHODS.filter((method) => item[method] !== void 0);
569
+ const method = available.includes("post") ? "post" : available[0];
570
+ if (method === void 0) return;
571
+ const op = item[method];
572
+ return op === void 0 ? void 0 : {
573
+ method,
574
+ op
575
+ };
576
+ }
577
+ /** The body shape a custom action accepts: a JSON:API document, none, or a raw payload. */
578
+ function actionInput(op) {
579
+ const content = op.requestBody?.content;
580
+ if (content === void 0 || Object.keys(content).length === 0) return "none";
581
+ return JSON_API_MEDIA_TYPE$1 in content ? "document" : "raw";
582
+ }
583
+ /** The `$ref` of an action's JSON:API request body (a `document` input), or `undefined`. */
584
+ function actionRequestRef(op) {
585
+ const ref = op.requestBody?.content?.[JSON_API_MEDIA_TYPE$1]?.schema?.$ref;
586
+ return typeof ref === "string" ? ref : void 0;
587
+ }
588
+ /** The declared media type of a `raw`-input action — the first non-JSON:API content type. */
589
+ function rawContentType(op) {
590
+ const content = op.requestBody?.content;
591
+ if (content === void 0) return;
592
+ return Object.keys(content).find((mediaType) => mediaType !== JSON_API_MEDIA_TYPE$1);
593
+ }
594
+ /**
595
+ * What a custom action returns from its first 2xx response: a `meta`-only document (a `$ref` to the
596
+ * shared `MetaDocument` — no `data`), a resource `document` (any other JSON:API body), or `none`
597
+ * (only a `204`).
598
+ */
599
+ function actionOutput(op) {
600
+ for (const [code, response] of Object.entries(op.responses ?? {})) {
601
+ if (!code.startsWith("2")) continue;
602
+ const schema = response.content?.[JSON_API_MEDIA_TYPE$1]?.schema;
603
+ if (schema === void 0) continue;
604
+ return typeof schema.$ref === "string" && refName$1(schema.$ref) === "MetaDocument" ? "meta" : "document";
605
+ }
606
+ return "none";
607
+ }
608
+ /** Build the {@link ApiDescriptor} from a parsed OpenAPI document. */
609
+ function buildDescriptor(doc) {
610
+ return new DescriptorBuilder(doc).build();
611
+ }
612
+ /** Detect the server-level Atomic Operations capability (`null` when none). */
613
+ function buildAtomic(doc) {
614
+ return new DescriptorBuilder(doc).buildAtomic();
615
+ }
616
+ //#endregion
617
+ //#region src/provenance.ts
618
+ /**
619
+ * Canonicalise a JSON value with object keys sorted recursively, so the content hash is
620
+ * insensitive to key ordering in the served document (two byte-different-but-equivalent
621
+ * specs hash the same). Arrays keep their order (it is significant in OpenAPI).
622
+ */
623
+ function canonicalise(value) {
624
+ if (Array.isArray(value)) return value.map(canonicalise);
625
+ if (value !== null && typeof value === "object") {
626
+ const out = {};
627
+ for (const key of Object.keys(value).sort()) out[key] = canonicalise(value[key]);
628
+ return out;
629
+ }
630
+ return value;
631
+ }
632
+ /** A SHA-256 over an arbitrary JSON value (canonicalised), truncated to 16 hex chars (64 bits). */
633
+ function hashJson(value) {
634
+ return createHash("sha256").update(JSON.stringify(canonicalise(value))).digest("hex").slice(0, 16);
635
+ }
636
+ /**
637
+ * Derive the provenance record for a source OpenAPI document. The `source` identifier is
638
+ * `<info.title> <info.version>` (each part `unknown` when absent); the `hash` is a stable
639
+ * content hash of the whole document.
640
+ */
641
+ function deriveProvenance(doc) {
642
+ return {
643
+ source: `${doc.info?.title ?? "unknown"} ${doc.info?.version ?? "unknown"}`,
644
+ server: doc.servers?.[0]?.url,
645
+ hash: hashJson(doc)
646
+ };
647
+ }
648
+ /**
649
+ * Render the provenance record as the JSDoc header lines stamped into a generated artifact
650
+ * (each already prefixed with ` * `). Deterministic: no timestamp, so re-generating from the
651
+ * same spec yields byte-identical output.
652
+ */
653
+ function provenanceLines(provenance) {
654
+ const lines = [` * Source spec: ${provenance.source}`, ` * Spec hash: ${provenance.hash}`];
655
+ if (provenance.server !== void 0) lines.splice(1, 0, ` * Server: ${provenance.server}`);
656
+ return lines;
657
+ }
658
+ //#endregion
659
+ //#region src/emit.ts
660
+ const REF_PREFIX = "#/components/schemas/";
661
+ const JSON_API_MEDIA_TYPE = "application/vnd.api+json";
662
+ /** A loose JSON:API document shape — the fallback an action body/result types as when its component can't be resolved. */
663
+ const LOOSE_DOCUMENT = "Record<string, unknown>";
664
+ /**
665
+ * The reserved relation names: the members the read handle actually shadows (kept in sync
666
+ * via the runtime's {@link HANDLE_RESERVED}) plus the future Phase-3 write verbs. A relation
667
+ * named like any of these must route through `.rel(name)`, so the codegen warns at build time.
668
+ * (`type`/`id` from {@link HANDLE_RESERVED} can never be JSON:API relation names.)
669
+ */
670
+ const RESERVED_VERBS = /* @__PURE__ */ new Set([
671
+ ...HANDLE_RESERVED,
672
+ "update",
673
+ "delete",
674
+ "create",
675
+ "list",
676
+ "actions"
677
+ ]);
678
+ /**
679
+ * Find relations whose name shadows a reserved fluent-surface member (a handle accessor or a
680
+ * future write verb). Such relations are routed via `.rel(name)`; the codegen warns at build time.
681
+ */
682
+ function detectVerbCollisions(descriptor) {
683
+ const collisions = [];
684
+ for (const type of Object.keys(descriptor)) for (const relation of Object.keys(descriptor[type].relations)) if (RESERVED_VERBS.has(relation)) collisions.push({
685
+ type,
686
+ relation
687
+ });
688
+ return collisions;
689
+ }
690
+ function refName(ref) {
691
+ return ref.startsWith(REF_PREFIX) ? ref.slice(21) : void 0;
692
+ }
693
+ function isSchema(value) {
694
+ return typeof value === "object" && value !== null;
695
+ }
696
+ /** PascalCase a JSON:API wire type for use in a TS identifier (e.g. `public-profiles` -> `PublicProfiles`). */
697
+ function pascalCase(value) {
698
+ return value.split(/[^a-zA-Z0-9]+/).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
699
+ }
700
+ /** Emit a JSON-string literal (used for keys that aren't bare identifiers and enum members). */
701
+ function quote(value) {
702
+ return JSON.stringify(value);
703
+ }
704
+ /** True when a string is a safe bare object-key identifier. */
705
+ function isIdentifier(value) {
706
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
707
+ }
708
+ function objectKey(value) {
709
+ return isIdentifier(value) ? value : quote(value);
710
+ }
711
+ /**
712
+ * Render a description as a JSDoc comment at the given indent — single-line when it fits,
713
+ * a block otherwise. Returns `undefined` for empty text. Neutralises any `*\/` so a
714
+ * description can never terminate the comment early.
715
+ */
716
+ function jsDoc(text, indent) {
717
+ const trimmed = text?.trim();
718
+ if (!trimmed) return;
719
+ const lines = trimmed.replace(/\*\//g, "* /").split("\n");
720
+ if (lines.length === 1) return `${indent}/** ${lines[0]} */`;
721
+ return [
722
+ `${indent}/**`,
723
+ ...lines.map((line) => `${indent} * ${line}`.trimEnd()),
724
+ `${indent} */`
725
+ ].join("\n");
726
+ }
727
+ var Emitter = class {
728
+ doc;
729
+ descriptor;
730
+ atomic;
731
+ schemas;
732
+ /** Wire type -> the schema `Base` (the `<Base>Resource` name minus `Resource`). */
733
+ baseByType;
734
+ /** Enum component names referenced by an attribute, in first-seen order. */
735
+ usedEnums = /* @__PURE__ */ new Map();
736
+ /**
737
+ * Component-schema names whose dedicated interface is emitted (e.g. `AlbumsAttributes`,
738
+ * `AlbumsCreateAttributes`). A `$ref` to one of these resolves to the interface name
739
+ * rather than being re-expanded structurally — so an action envelope nests the precise,
740
+ * already-named attribute type. Populated as interfaces are emitted.
741
+ */
742
+ emittedInterfaces = /* @__PURE__ */ new Set();
743
+ /** Component names mid-expansion in {@link tsType} — a cycle guard for self/mutually-referential `$ref`s. */
744
+ expanding = /* @__PURE__ */ new Set();
745
+ /**
746
+ * The per-action alias names actually emitted, keyed `type -> actionName -> { input?; output? }`.
747
+ * Populated by {@link actionTypeAliases}; consumed by {@link actionTypesMap} so the action
748
+ * type-map only references aliases that exist (a `none` input/output contributes neither).
749
+ */
750
+ actionAliasNames = /* @__PURE__ */ new Map();
751
+ /** Provenance of the source document, stamped into the generated header for drift tracking. */
752
+ provenance;
753
+ constructor(doc, descriptor, atomic) {
754
+ this.doc = doc;
755
+ this.descriptor = descriptor;
756
+ this.atomic = atomic;
757
+ this.schemas = doc.components?.schemas ?? {};
758
+ this.baseByType = this.indexBases();
759
+ this.provenance = deriveProvenance(doc);
760
+ }
761
+ /** Map each declared wire type back to the `Base` of its resource schema. */
762
+ indexBases() {
763
+ const out = /* @__PURE__ */ new Map();
764
+ for (const [name, schema] of Object.entries(this.schemas)) {
765
+ if (!name.endsWith("Resource") || name.endsWith("ResourceIdentifier")) continue;
766
+ const typeProp = schema.properties?.["type"];
767
+ if (isSchema(typeProp) && typeof typeProp.const === "string") out.set(typeProp.const, name.slice(0, -8));
768
+ }
769
+ return out;
770
+ }
771
+ emit() {
772
+ const interfaces = Object.keys(this.descriptor).map((type) => this.attributeInterface(type));
773
+ const writeInterfaces = Object.keys(this.descriptor).flatMap((type) => this.writeAttributeInterfaces(type));
774
+ const actionAliases = this.actionTypeAliases();
775
+ const enums = this.enumAliases();
776
+ return `${[
777
+ this.header(),
778
+ this.imports(),
779
+ ...enums ? [enums] : [],
780
+ ...interfaces,
781
+ ...writeInterfaces,
782
+ ...actionAliases,
783
+ this.attributesMap(),
784
+ this.writeAttributesMap(),
785
+ this.actionTypesMap(),
786
+ this.resourceMap(),
787
+ "export type ResourceMap = typeof resourceMap",
788
+ this.atomicConst(),
789
+ this.boundFactory()
790
+ ].join("\n\n")}\n`;
791
+ }
792
+ header() {
793
+ return [
794
+ "/**",
795
+ " * AUTO-GENERATED by @haddowg/json-api-codegen — do not edit by hand.",
796
+ " * Regenerate from the source OpenAPI document instead.",
797
+ " *",
798
+ ...provenanceLines(this.provenance),
799
+ " */"
800
+ ].join("\n");
801
+ }
802
+ imports() {
803
+ return [
804
+ "import {",
805
+ " type ApiDescriptor,",
806
+ " type ClientOptions,",
807
+ " createClient as createClientRuntime,",
808
+ "} from '@haddowg/json-api-client'"
809
+ ].join("\n");
810
+ }
811
+ /** Per-type attribute interface, e.g. `export interface AlbumsAttributes { ... }`. */
812
+ attributeInterface(type) {
813
+ const base = this.baseByType.get(type);
814
+ const props = base ? this.schemas[`${base}Attributes`]?.properties : void 0;
815
+ const fields = Object.keys(this.descriptor[type].attributes);
816
+ return this.renderInterface(`${pascalCase(type)}Attributes`, props, fields, () => true);
817
+ }
818
+ /**
819
+ * The per-type WRITE attribute interfaces — `<Pascal>CreateAttributes` and
820
+ * `<Pascal>UpdateAttributes` — from the `<Base>CreateAttributes`/`<Base>UpdateAttributes`
821
+ * components (readOnly fields are already excluded vs the read attributes by the bundle).
822
+ * Create marks a field required iff it sits in the component's `required`; Update makes
823
+ * every field optional. A read-only type (no create/update component) contributes nothing.
824
+ */
825
+ writeAttributeInterfaces(type) {
826
+ const base = this.baseByType.get(type);
827
+ if (base === void 0) return [];
828
+ const pascal = pascalCase(type);
829
+ const out = [];
830
+ const create = this.schemas[`${base}CreateAttributes`];
831
+ if (create !== void 0) {
832
+ const required = new Set(create.required ?? []);
833
+ const fields = Object.keys(create.properties ?? {}).sort();
834
+ out.push(this.renderInterface(`${pascal}CreateAttributes`, create.properties, fields, (field) => required.has(field)));
835
+ }
836
+ const update = this.schemas[`${base}UpdateAttributes`];
837
+ if (update !== void 0) {
838
+ const fields = Object.keys(update.properties ?? {}).sort();
839
+ out.push(this.renderInterface(`${pascal}UpdateAttributes`, update.properties, fields, () => false));
840
+ }
841
+ return out;
842
+ }
843
+ /**
844
+ * Per-action input/output type aliases for every type that declares custom actions. An
845
+ * action whose `input`/`output` resolves to a component schema gets a named alias
846
+ * (`<Pascal><Action>Input` / `<Pascal><Action>Output`) expanding that component via the
847
+ * shared {@link tsType} machinery — a `none` input/output (no body / a `204`) contributes
848
+ * no alias. Deterministic order: types, then action names (the descriptor is sorted).
849
+ */
850
+ actionTypeAliases() {
851
+ const out = [];
852
+ for (const type of Object.keys(this.descriptor)) {
853
+ const actions = this.descriptor[type]?.actions;
854
+ if (actions === void 0) continue;
855
+ const pascal = pascalCase(type);
856
+ for (const [actionName, action] of Object.entries(actions)) {
857
+ const member = `${pascal}${pascalCase(actionName)}`;
858
+ const names = {};
859
+ const inputType = this.actionBodyType(action, "input");
860
+ if (inputType !== void 0) {
861
+ out.push(`export type ${member}Input = ${inputType}`);
862
+ names.input = `${member}Input`;
863
+ }
864
+ const outputType = this.actionBodyType(action, "output");
865
+ if (outputType !== void 0) {
866
+ out.push(`export type ${member}Output = ${outputType}`);
867
+ names.output = `${member}Output`;
868
+ }
869
+ if (names.input !== void 0 || names.output !== void 0) {
870
+ let byAction = this.actionAliasNames.get(type);
871
+ if (byAction === void 0) {
872
+ byAction = /* @__PURE__ */ new Map();
873
+ this.actionAliasNames.set(type, byAction);
874
+ }
875
+ byAction.set(actionName, names);
876
+ }
877
+ }
878
+ }
879
+ return out;
880
+ }
881
+ /**
882
+ * Emit the `ActionTypes` interface wiring the emitted per-action aliases onto the typed
883
+ * action surface — `type -> actionName -> { input?; output? }`, the client's fourth type
884
+ * argument. So `client.albums.id(id).actions.reissue(body)` takes `AlbumsReissueInput` and
885
+ * resolves `AlbumsReissueOutput` rather than a loose `Record<string,unknown>` / `unknown`.
886
+ * Only actions whose alias was emitted appear (a `none`-input/output action contributes the
887
+ * absent side, which the runtime types fall back to loosely). Emits `{}` when none exist.
888
+ */
889
+ actionTypesMap() {
890
+ const typeLines = [];
891
+ for (const type of Object.keys(this.descriptor)) {
892
+ const byAction = this.actionAliasNames.get(type);
893
+ if (byAction === void 0 || byAction.size === 0) continue;
894
+ const actionLines = [];
895
+ for (const [actionName, names] of byAction) {
896
+ const members = [];
897
+ if (names.input !== void 0) members.push(`input: ${names.input}`);
898
+ if (names.output !== void 0) members.push(`output: ${names.output}`);
899
+ actionLines.push(` ${objectKey(actionName)}: { ${members.join("; ")} }`);
900
+ }
901
+ typeLines.push(` ${objectKey(type)}: {\n${actionLines.join("\n")}\n }`);
902
+ }
903
+ if (typeLines.length === 0) return "export interface ActionTypes {}";
904
+ return `export interface ActionTypes {\n${typeLines.join("\n")}\n}`;
905
+ }
906
+ /**
907
+ * The TS type alias (if any) for an action's request body.
908
+ *
909
+ * An action's RESULT and its FLAT input are derived at the type level from the descriptor
910
+ * (`outputType`/`outputCardinality` → the materialised resource view; `inputType` → the
911
+ * `CreateInput` of that type), so no `output` alias is emitted and a `document` input whose
912
+ * resource type resolves emits no `input` alias either. Only a bespoke command document (a
913
+ * `document` input with no resolvable `inputType`) keeps a raw-envelope alias as its typed body,
914
+ * expanded via {@link tsType} (falling back to a loose JSON:API document shape).
915
+ */
916
+ actionBodyType(action, side) {
917
+ if (side === "output" || action.input !== "document" || action.inputType !== void 0) return;
918
+ const ref = (this.doc.paths?.[action.path]?.post)?.requestBody?.content?.[JSON_API_MEDIA_TYPE]?.schema?.$ref;
919
+ if (typeof ref !== "string") return LOOSE_DOCUMENT;
920
+ const target = refName(ref);
921
+ const resolved = target ? this.schemas[target] : void 0;
922
+ return resolved ? this.tsType(resolved) : LOOSE_DOCUMENT;
923
+ }
924
+ /** Emit the server-level atomic capability constant (`{ path }` or `null`). */
925
+ atomicConst() {
926
+ return `${[
927
+ "/**",
928
+ " * The server-level Atomic Operations endpoint (the atomic ext media type), or `null`",
929
+ " * when this server exposes none. The runtime `client.atomic` builder posts the batch here.",
930
+ " */"
931
+ ].join("\n")}\nexport const atomic = ${this.atomic === null ? "null" : `${this.literal(this.atomic, 0)} as const`}`;
932
+ }
933
+ /**
934
+ * Render an attribute interface from a property map, a field order, and a `required`
935
+ * predicate (true => no `?`). Shared by the read and write attribute interfaces so the
936
+ * tsType/JSDoc logic stays in one place.
937
+ */
938
+ renderInterface(name, props, fields, required) {
939
+ const lines = [];
940
+ for (const field of fields) {
941
+ const schema = props?.[field];
942
+ const tsType = isSchema(schema) ? this.tsType(schema) : "unknown";
943
+ const doc = isSchema(schema) ? jsDoc(schema.description, " ") : void 0;
944
+ if (doc) lines.push(doc);
945
+ const optional = required(field) ? "" : "?";
946
+ lines.push(` ${objectKey(field)}${optional}: ${tsType}`);
947
+ }
948
+ this.emittedInterfaces.add(name);
949
+ if (lines.length === 0) return `export interface ${name} {}`;
950
+ return `export interface ${name} {\n${lines.join("\n")}\n}`;
951
+ }
952
+ /** Map a JSON Schema attribute node to a precise TypeScript type. */
953
+ tsType(schema) {
954
+ if (typeof schema.$ref === "string") {
955
+ const target = refName(schema.$ref);
956
+ const resolved = target ? this.schemas[target] : void 0;
957
+ if (target && resolved && Array.isArray(resolved.enum)) {
958
+ this.usedEnums.set(target, resolved);
959
+ return target;
960
+ }
961
+ if (target && this.emittedInterfaces.has(target)) return target;
962
+ if (resolved === void 0 || target === void 0 || this.expanding.has(target)) return "unknown";
963
+ this.expanding.add(target);
964
+ const expanded = this.tsType(resolved);
965
+ this.expanding.delete(target);
966
+ return expanded;
967
+ }
968
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
969
+ const nullable = types.includes("null");
970
+ const base = this.baseTsType(schema, types.filter((t) => t !== "null"));
971
+ return nullable ? `${base} | null` : base;
972
+ }
973
+ /** The non-nullable TS type for the declared JSON type(s). */
974
+ baseTsType(schema, types) {
975
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum.map((value) => quote(String(value))).join(" | ");
976
+ if (types.length === 0) return "unknown";
977
+ const mapped = types.map((t) => this.scalarOrComposite(schema, t));
978
+ return [...new Set(mapped)].join(" | ");
979
+ }
980
+ scalarOrComposite(schema, type) {
981
+ switch (type) {
982
+ case "string": return "string";
983
+ case "number":
984
+ case "integer": return "number";
985
+ case "boolean": return "boolean";
986
+ case "object": return this.objectType(schema);
987
+ case "array": return this.arrayType(schema);
988
+ default: return "unknown";
989
+ }
990
+ }
991
+ /** A nested object type from its `properties` (falls back to an index signature). */
992
+ objectType(schema) {
993
+ const props = schema.properties;
994
+ if (props === void 0) return "Record<string, unknown>";
995
+ const required = new Set(schema.required ?? []);
996
+ const members = [];
997
+ for (const [name, prop] of Object.entries(props)) {
998
+ if (!isSchema(prop)) continue;
999
+ const optional = required.has(name) ? "" : "?";
1000
+ members.push(`${objectKey(name)}${optional}: ${this.tsType(prop)}`);
1001
+ }
1002
+ if (members.length === 0) return "Record<string, unknown>";
1003
+ return `{ ${members.join("; ")} }`;
1004
+ }
1005
+ arrayType(schema) {
1006
+ const items = schema.items;
1007
+ const element = isSchema(items) ? this.tsType(items) : "unknown";
1008
+ return /[|&]/.test(element) ? `(${element})[]` : `${element}[]`;
1009
+ }
1010
+ /** Union aliases for every enum component an attribute referenced, sorted by name. */
1011
+ enumAliases() {
1012
+ if (this.usedEnums.size === 0) return;
1013
+ return [...this.usedEnums.keys()].sort().map((name) => {
1014
+ const schema = this.usedEnums.get(name);
1015
+ const alias = `export type ${name} = ${(schema.enum ?? []).map((value) => quote(String(value))).join(" | ")}`;
1016
+ const doc = jsDoc(this.enumDoc(schema), "");
1017
+ return doc ? `${doc}\n${alias}` : alias;
1018
+ }).join("\n\n");
1019
+ }
1020
+ /**
1021
+ * Documentation for an enum alias: a per-value list built from `x-enum-descriptions`
1022
+ * (the richest hover DX, since a union alias can't carry per-member JSDoc), falling
1023
+ * back to the component's own `description`.
1024
+ */
1025
+ enumDoc(schema) {
1026
+ const values = schema.enum ?? [];
1027
+ const descriptions = schema["x-enum-descriptions"];
1028
+ if (Array.isArray(descriptions) && descriptions.length === values.length && values.length > 0) return values.map((value, i) => `- \`${String(value)}\` — ${String(descriptions[i])}`).join("\n");
1029
+ return schema.description;
1030
+ }
1031
+ /**
1032
+ * Emit the `Attributes` interface mapping each wire type to its per-type attribute
1033
+ * interface. The runtime descriptor only carries coarse format hints; this is how the
1034
+ * precise per-type attribute types reach the bound client (passed as the second type
1035
+ * argument to the runtime factory).
1036
+ */
1037
+ attributesMap() {
1038
+ return `export interface Attributes {\n${Object.keys(this.descriptor).map((type) => ` ${objectKey(type)}: ${pascalCase(type)}Attributes`).join("\n")}\n}`;
1039
+ }
1040
+ /**
1041
+ * Emit the `WriteAttributes` interface mapping each writable wire type to its
1042
+ * `{ create; update }` attribute pair. A read-only type (no create/update component)
1043
+ * contributes no entry, so the bound client only exposes writes where the API allows
1044
+ * them. Passed as the third type argument to the runtime factory.
1045
+ */
1046
+ writeAttributesMap() {
1047
+ const lines = [];
1048
+ for (const type of Object.keys(this.descriptor)) {
1049
+ const base = this.baseByType.get(type);
1050
+ const hasCreate = base !== void 0 && this.schemas[`${base}CreateAttributes`] !== void 0;
1051
+ const hasUpdate = base !== void 0 && this.schemas[`${base}UpdateAttributes`] !== void 0;
1052
+ if (!hasCreate && !hasUpdate) continue;
1053
+ const pascal = pascalCase(type);
1054
+ const members = [];
1055
+ if (hasCreate) members.push(`create: ${pascal}CreateAttributes`);
1056
+ if (hasUpdate) members.push(`update: ${pascal}UpdateAttributes`);
1057
+ lines.push(` ${objectKey(type)}: { ${members.join("; ")} }`);
1058
+ }
1059
+ if (lines.length === 0) return "export interface WriteAttributes {}";
1060
+ return `export interface WriteAttributes {\n${lines.join("\n")}\n}`;
1061
+ }
1062
+ /** Emit the descriptor as a literal `as const satisfies ApiDescriptor`. */
1063
+ resourceMap() {
1064
+ return `export const resourceMap = ${this.literal(this.descriptor, 0)} as const satisfies ApiDescriptor`;
1065
+ }
1066
+ /** Render a JSON-serialisable descriptor value as deterministic TS object-literal source. */
1067
+ literal(value, depth) {
1068
+ const indent = " ".repeat(depth);
1069
+ const inner = " ".repeat(depth + 1);
1070
+ if (Array.isArray(value)) {
1071
+ if (value.length === 0) return "[]";
1072
+ return `[\n${value.map((item) => `${inner}${this.literal(item, depth + 1)}`).join(",\n")}\n${indent}]`;
1073
+ }
1074
+ if (value !== null && typeof value === "object") {
1075
+ const entries = Object.entries(value);
1076
+ if (entries.length === 0) return "{}";
1077
+ return `{\n${entries.map(([key, v]) => `${inner}${objectKey(key)}: ${this.literal(v, depth + 1)}`).join(",\n")}\n${indent}}`;
1078
+ }
1079
+ return JSON.stringify(value);
1080
+ }
1081
+ boundFactory() {
1082
+ return [
1083
+ "/**",
1084
+ " * Descriptor-bound client factory; wraps the generic runtime with this API’s `resourceMap`.",
1085
+ " * 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`).",
1087
+ " */",
1088
+ "export const createClient = (options: ClientOptions) =>",
1089
+ " createClientRuntime<typeof resourceMap, Attributes, WriteAttributes, ActionTypes>(",
1090
+ " resourceMap,",
1091
+ " {",
1092
+ " atomic,",
1093
+ " ...options,",
1094
+ " },",
1095
+ " )"
1096
+ ].join("\n");
1097
+ }
1098
+ };
1099
+ /**
1100
+ * Generate the full source of the client module for a document + its built descriptor.
1101
+ * The server-level atomic capability is derived from the document (override via `atomic`).
1102
+ */
1103
+ function emit(doc, descriptor, atomic = buildAtomic(doc)) {
1104
+ return new Emitter(doc, descriptor, atomic).emit();
1105
+ }
1106
+ //#endregion
1107
+ //#region src/emit-schemas.ts
1108
+ /**
1109
+ * Emit the per-type JSON Schema map as a standalone generated artifact:
1110
+ *
1111
+ * ```ts
1112
+ * export const schemas = { <type>: { …json schema… }, … } as const
1113
+ * ```
1114
+ *
1115
+ * The values are the raw, self-contained resource-object JSON Schema documents the bundle
1116
+ * serves at `GET /schemas.json` — suitable to hand straight to a validation engine via the
1117
+ * opt-in `validate?` seam (ADR 0004). This is a separate file from the client so the main
1118
+ * descriptor/client output is unaffected when `--schemas` is absent.
1119
+ *
1120
+ * The schemas are emitted as JSON (deterministic, key order preserved) so the artifact is
1121
+ * stable to diff; `as const` keeps the literal shapes narrow.
1122
+ */
1123
+ function emitSchemas(bundle) {
1124
+ return `${[header(bundle), body(bundle)].join("\n\n")}\n`;
1125
+ }
1126
+ function header(bundle) {
1127
+ return [
1128
+ "/**",
1129
+ " * AUTO-GENERATED by @haddowg/json-api-codegen — do not edit by hand.",
1130
+ " * Per-type JSON Schemas for the opt-in validation seam; regenerate from the source bundle.",
1131
+ " *",
1132
+ ` * Schema hash: ${hashJson(bundle)}`,
1133
+ " */"
1134
+ ].join("\n");
1135
+ }
1136
+ function body(bundle) {
1137
+ return `export const schemas = ${JSON.stringify(bundle, null, 2)} as const`;
1138
+ }
1139
+ //#endregion
1140
+ //#region src/reader.ts
1141
+ /** True when the input looks like an http(s) URL rather than a local path. */
1142
+ function isHttpUrl(input) {
1143
+ return /^https?:\/\//i.test(input);
1144
+ }
1145
+ /** Decide YAML vs JSON by extension first, then by a cheap content sniff. */
1146
+ function looksLikeYaml(source, text) {
1147
+ if (/\.ya?ml$/i.test(source)) return true;
1148
+ if (/\.json$/i.test(source)) return false;
1149
+ return !/^\s*[{[]/.test(text);
1150
+ }
1151
+ function parse$1(source, text, label) {
1152
+ const doc = looksLikeYaml(source, text) ? parse(text) : JSON.parse(text);
1153
+ if (doc === null || typeof doc !== "object") throw new Error(`${label} at ${source} did not parse to an object`);
1154
+ return doc;
1155
+ }
1156
+ /**
1157
+ * Fetch (URL) or read (file) `input`, then parse it as JSON or YAML (decided by extension,
1158
+ * falling back to a content sniff). Shared by the OpenAPI and JSON Schema readers.
1159
+ */
1160
+ async function readSource(input, label) {
1161
+ if (isHttpUrl(input)) {
1162
+ const res = await fetch(input);
1163
+ if (!res.ok) throw new Error(`Failed to fetch ${label} from ${input}: ${res.status}`);
1164
+ return parse$1(input, await res.text(), label);
1165
+ }
1166
+ return parse$1(input, await readFile(input, "utf8"), label);
1167
+ }
1168
+ /**
1169
+ * Read an OpenAPI document from an http(s) URL or a local file path. Supports both JSON
1170
+ * and YAML (decided by extension, falling back to a content sniff).
1171
+ */
1172
+ async function readDocument(input) {
1173
+ return readSource(input, "OpenAPI document");
1174
+ }
1175
+ /**
1176
+ * Read the bundle's JSON Schema bundle (`GET /schemas.json`) from an http(s) URL or a
1177
+ * local file path — a map keyed by JSON:API type, each value a self-contained JSON Schema
1178
+ * 2020-12 resource-object document. Supports both JSON and YAML, like {@link readDocument}.
1179
+ */
1180
+ async function readSchemas(input) {
1181
+ return readSource(input, "JSON Schema bundle");
1182
+ }
1183
+ //#endregion
1184
+ //#region src/index.ts
1185
+ /**
1186
+ * @haddowg/json-api-codegen — reads the bundle's OpenAPI 3.1 document (from a URL or
1187
+ * a local file) and emits the runtime descriptor + derived types + a bound
1188
+ * `createClient` (ADR 0001). Optionally reads the bundle's JSON Schemas to wire the
1189
+ * opt-in validation seam.
1190
+ *
1191
+ * The spec is fully self-describing for our needs (see CONTEXT.md): type identity is
1192
+ * `properties.type.const`; relationships, cardinality and related types come from the
1193
+ * relationship components' `data`; allowed `?include` paths are a bounded, pre-expanded
1194
+ * enum per endpoint; the create client-id policy is encoded in the create request
1195
+ * schema (`id: false` / required / optional).
1196
+ */
1197
+ /**
1198
+ * The schema-artifact path beside a client output path: `client.gen.ts` -> `client.schemas.gen.ts`
1199
+ * (any `.gen.ts`/`.ts` suffix is preserved; a suffix-less path just gains `.schemas`).
1200
+ */
1201
+ function schemasOutputPath(output) {
1202
+ if (output.endsWith(".gen.ts")) return `${output.slice(0, -7)}.schemas.gen.ts`;
1203
+ if (output.endsWith(".ts")) return `${output.slice(0, -3)}.schemas.ts`;
1204
+ return `${output}.schemas`;
1205
+ }
1206
+ /**
1207
+ * Read the source spec(s), build the descriptor and render every generated artifact in
1208
+ * memory (the client source first, then the schema artifact when `config.schemas` is set)
1209
+ * — the shared core of {@link generate} (which writes them) and {@link check} (which
1210
+ * compares them). Verb-collision warnings are emitted here so both paths surface them.
1211
+ */
1212
+ async function render(config) {
1213
+ const doc = await readDocument(config.input);
1214
+ const descriptor = buildDescriptor(doc);
1215
+ 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.`);
1216
+ const artifacts = [{
1217
+ path: config.output,
1218
+ content: emit(doc, descriptor)
1219
+ }];
1220
+ if (config.schemas !== void 0) {
1221
+ const bundle = await readSchemas(config.schemas);
1222
+ artifacts.push({
1223
+ path: schemasOutputPath(config.output),
1224
+ content: emitSchemas(bundle)
1225
+ });
1226
+ }
1227
+ return artifacts;
1228
+ }
1229
+ /**
1230
+ * Read the OpenAPI document, build the runtime descriptor, emit the generated client
1231
+ * module and write it to `config.output` (creating parent directories as needed).
1232
+ *
1233
+ * When `config.schemas` is set, also read the bundle's JSON Schema bundle and write a
1234
+ * separate per-type schema artifact (`<output>.schemas.gen.ts`) beside the client, wiring
1235
+ * the opt-in validation seam (ADR 0004). The main client output is unchanged either way.
1236
+ *
1237
+ * `config.server` is metadata-only for now: the served document is already per-server,
1238
+ * so the caller selects the server by pointing `input` at the right document.
1239
+ *
1240
+ * Returns the generated client source. When `config.check` is set nothing is written;
1241
+ * call {@link check} instead for the drift-gate result.
1242
+ */
1243
+ async function generate(config) {
1244
+ const artifacts = await render(config);
1245
+ await mkdir(dirname(config.output), { recursive: true });
1246
+ for (const { path, content } of artifacts) await writeFile(path, content, "utf8");
1247
+ return artifacts[0].content;
1248
+ }
1249
+ /**
1250
+ * Drift gate: regenerate every artifact in memory and compare it byte-for-byte against the
1251
+ * committed file on disk, WITHOUT writing anything. A missing committed file counts as drift
1252
+ * (not up to date). Returns per-artifact status plus an aggregate `ok` (true iff all match) —
1253
+ * the CI drift check reads `ok` and exits non-zero when it is false.
1254
+ */
1255
+ async function check(config) {
1256
+ const artifacts = await render(config);
1257
+ const results = [];
1258
+ for (const { path, content } of artifacts) {
1259
+ let committed;
1260
+ try {
1261
+ committed = await readFile(path, "utf8");
1262
+ } catch {
1263
+ committed = void 0;
1264
+ }
1265
+ results.push({
1266
+ path,
1267
+ upToDate: committed === content
1268
+ });
1269
+ }
1270
+ return {
1271
+ ok: results.every((r) => r.upToDate),
1272
+ artifacts: results
1273
+ };
1274
+ }
1275
+ //#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 };
1277
+
1278
+ //# sourceMappingURL=src-B24cYyjM.mjs.map