@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/cli.d.mts +14 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/cli.mjs +66 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index-C0becRSc.d.mts +471 -0
- package/dist/index-C0becRSc.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/src-B24cYyjM.mjs +1278 -0
- package/dist/src-B24cYyjM.mjs.map +1 -0
- package/package.json +57 -0
|
@@ -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
|