openapi-contract-kit 0.0.1
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/bin/openapi-contract-kit.mjs +5 -0
- package/package.json +58 -0
- package/src/generator/config.mjs +92 -0
- package/src/generator/documents.mjs +119 -0
- package/src/generator/emitEndpoints.mjs +182 -0
- package/src/generator/emitMakers.mjs +459 -0
- package/src/generator/emitTypes.mjs +104 -0
- package/src/generator/generateOpenApiRuntime.mjs +48 -0
- package/src/generator/model.mjs +415 -0
- package/src/generator/schemaModel.mjs +497 -0
- package/src/generator/writeOutput.mjs +196 -0
- package/src/index.d.ts +16 -0
- package/src/index.mjs +4 -0
- package/src/runtime.d.ts +14 -0
- package/src/runtime.mjs +1 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DocumentStore,
|
|
5
|
+
escapePointerSegment,
|
|
6
|
+
locationOf,
|
|
7
|
+
pointerChild,
|
|
8
|
+
requireRecord,
|
|
9
|
+
} from './documents.mjs';
|
|
10
|
+
import { SchemaRegistry, toPascalIdentifier } from './schemaModel.mjs';
|
|
11
|
+
|
|
12
|
+
const HTTP_METHODS = [
|
|
13
|
+
'delete',
|
|
14
|
+
'get',
|
|
15
|
+
'head',
|
|
16
|
+
'options',
|
|
17
|
+
'patch',
|
|
18
|
+
'post',
|
|
19
|
+
'put',
|
|
20
|
+
'trace',
|
|
21
|
+
];
|
|
22
|
+
const JSON_MEDIA_TYPE = 'application/json';
|
|
23
|
+
|
|
24
|
+
class ModelBuilder {
|
|
25
|
+
#documents = new DocumentStore();
|
|
26
|
+
#registry;
|
|
27
|
+
#rootDocument;
|
|
28
|
+
#rootPath;
|
|
29
|
+
|
|
30
|
+
async build(specPath) {
|
|
31
|
+
this.#rootPath = resolve(specPath);
|
|
32
|
+
this.#rootDocument = await this.#documents.load(this.#rootPath);
|
|
33
|
+
this.#registry = new SchemaRegistry(this.#documents, this.#rootPath);
|
|
34
|
+
this.#validateOpenApiRoot();
|
|
35
|
+
this.#seedComponentSchemas();
|
|
36
|
+
await this.#seedResponseSchemas();
|
|
37
|
+
const operationDrafts = this.#collectOperationDrafts();
|
|
38
|
+
const operations = await this.#buildOperations(operationDrafts);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
operations: operations.sort((left, right) =>
|
|
42
|
+
left.name.localeCompare(right.name)
|
|
43
|
+
),
|
|
44
|
+
schemas: await this.#registry.build(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#validateOpenApiRoot() {
|
|
49
|
+
const version = this.#rootDocument.openapi;
|
|
50
|
+
|
|
51
|
+
if (typeof version !== 'string' || !version.startsWith('3.1.')) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`OpenAPI document "${this.#rootPath}" must use OpenAPI 3.1`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
requireRecord(
|
|
57
|
+
this.#rootDocument.paths,
|
|
58
|
+
locationOf(this.#rootPath, '/paths'),
|
|
59
|
+
'OpenAPI paths'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#seedComponentSchemas() {
|
|
64
|
+
const schemas = this.#rootDocument.components?.schemas;
|
|
65
|
+
|
|
66
|
+
if (schemas === undefined) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
requireRecord(
|
|
70
|
+
schemas,
|
|
71
|
+
locationOf(this.#rootPath, '/components/schemas'),
|
|
72
|
+
'OpenAPI component schemas'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
for (const componentName of Object.keys(schemas).sort()) {
|
|
76
|
+
this.#registry.register(
|
|
77
|
+
Reflect.get(schemas, componentName),
|
|
78
|
+
{
|
|
79
|
+
documentPath: this.#rootPath,
|
|
80
|
+
pointer: `/components/schemas/${escapePointerSegment(componentName)}`,
|
|
81
|
+
},
|
|
82
|
+
componentName
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #seedResponseSchemas() {
|
|
88
|
+
const responses = this.#rootDocument.components?.responses;
|
|
89
|
+
|
|
90
|
+
if (responses === undefined) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
requireRecord(
|
|
94
|
+
responses,
|
|
95
|
+
locationOf(this.#rootPath, '/components/responses'),
|
|
96
|
+
'OpenAPI component responses'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
for (const responseName of Object.keys(responses).sort()) {
|
|
100
|
+
const context = {
|
|
101
|
+
documentPath: this.#rootPath,
|
|
102
|
+
pointer: `/components/responses/${escapePointerSegment(responseName)}`,
|
|
103
|
+
};
|
|
104
|
+
const resolved = await this.#resolveReferenceObject(
|
|
105
|
+
Reflect.get(responses, responseName),
|
|
106
|
+
context,
|
|
107
|
+
'Response'
|
|
108
|
+
);
|
|
109
|
+
const body = this.#readJsonBody(
|
|
110
|
+
resolved.value,
|
|
111
|
+
resolved.context,
|
|
112
|
+
'Response'
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (body !== null) {
|
|
116
|
+
this.#registry.register(body.schema, body.context, responseName);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#collectOperationDrafts() {
|
|
122
|
+
const drafts = [];
|
|
123
|
+
const operationIds = new Set();
|
|
124
|
+
|
|
125
|
+
for (const path of Object.keys(this.#rootDocument.paths).sort()) {
|
|
126
|
+
const pathPointer = `/paths/${escapePointerSegment(path)}`;
|
|
127
|
+
const pathItem = requireRecord(
|
|
128
|
+
Reflect.get(this.#rootDocument.paths, path),
|
|
129
|
+
locationOf(this.#rootPath, pathPointer),
|
|
130
|
+
'Path item'
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (pathItem.$ref !== undefined) {
|
|
134
|
+
throw new Error(`Referenced path items are unsupported at ${path}`);
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
Array.isArray(pathItem.parameters) &&
|
|
138
|
+
pathItem.parameters.length > 0
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(`Path parameters are unsupported at ${path}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const method of HTTP_METHODS) {
|
|
144
|
+
if (pathItem[method] === undefined) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const operation = requireRecord(
|
|
148
|
+
pathItem[method],
|
|
149
|
+
locationOf(this.#rootPath, `${pathPointer}/${method}`),
|
|
150
|
+
'Operation'
|
|
151
|
+
);
|
|
152
|
+
const operationId = operation.operationId;
|
|
153
|
+
|
|
154
|
+
if (typeof operationId !== 'string' || operationId.length === 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Missing operationId for ${method.toUpperCase()} ${path}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (operationIds.has(operationId)) {
|
|
160
|
+
throw new Error(`Duplicate operationId "${operationId}"`);
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
Array.isArray(operation.parameters) &&
|
|
164
|
+
operation.parameters.length > 0
|
|
165
|
+
) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Operation parameters are unsupported for ${operationId}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
operationIds.add(operationId);
|
|
172
|
+
drafts.push({ method, operation, operationId, path, pathPointer });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const leafCounts = new Map();
|
|
177
|
+
for (const draft of drafts) {
|
|
178
|
+
const segments = draft.operationId
|
|
179
|
+
.split(/[^A-Za-z0-9_$]+/u)
|
|
180
|
+
.filter(Boolean);
|
|
181
|
+
const leaf = toPascalIdentifier(
|
|
182
|
+
segments.at(-1) ?? draft.operationId,
|
|
183
|
+
'Operation'
|
|
184
|
+
);
|
|
185
|
+
draft.leafName = leaf;
|
|
186
|
+
leafCounts.set(
|
|
187
|
+
leaf.toLowerCase(),
|
|
188
|
+
(leafCounts.get(leaf.toLowerCase()) ?? 0) + 1
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const names = new Set();
|
|
193
|
+
for (const draft of drafts) {
|
|
194
|
+
draft.name =
|
|
195
|
+
leafCounts.get(draft.leafName.toLowerCase()) === 1
|
|
196
|
+
? draft.leafName
|
|
197
|
+
: toPascalIdentifier(draft.operationId, 'Operation');
|
|
198
|
+
const key = draft.name.toLowerCase();
|
|
199
|
+
if (names.has(key)) {
|
|
200
|
+
throw new Error(`Operation name collision for "${draft.name}"`);
|
|
201
|
+
}
|
|
202
|
+
names.add(key);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return drafts;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #buildOperations(drafts) {
|
|
209
|
+
const operations = [];
|
|
210
|
+
|
|
211
|
+
for (const draft of drafts) {
|
|
212
|
+
const operationPointer = `${draft.pathPointer}/${draft.method}`;
|
|
213
|
+
const context = {
|
|
214
|
+
documentPath: this.#rootPath,
|
|
215
|
+
pointer: operationPointer,
|
|
216
|
+
};
|
|
217
|
+
operations.push({
|
|
218
|
+
method: draft.method.toUpperCase(),
|
|
219
|
+
name: draft.name,
|
|
220
|
+
operationId: draft.operationId,
|
|
221
|
+
path: draft.path,
|
|
222
|
+
request: await this.#buildRequest(draft.operation, context, draft.name),
|
|
223
|
+
responses: await this.#buildResponses(
|
|
224
|
+
draft.operation,
|
|
225
|
+
context,
|
|
226
|
+
draft.name
|
|
227
|
+
),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return operations;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async #buildRequest(operation, context, operationName) {
|
|
235
|
+
if (operation.requestBody === undefined) {
|
|
236
|
+
return { schemaName: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const requestContext = {
|
|
240
|
+
documentPath: context.documentPath,
|
|
241
|
+
pointer: pointerChild(context.pointer, 'requestBody'),
|
|
242
|
+
};
|
|
243
|
+
const resolved = await this.#resolveReferenceObject(
|
|
244
|
+
operation.requestBody,
|
|
245
|
+
requestContext,
|
|
246
|
+
'Request body'
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (resolved.value.required !== true) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Optional request bodies are unsupported for ${operationName}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const body = this.#readJsonBody(
|
|
255
|
+
resolved.value,
|
|
256
|
+
resolved.context,
|
|
257
|
+
'Request body'
|
|
258
|
+
);
|
|
259
|
+
if (body === null) {
|
|
260
|
+
throw new Error(`Request body for ${operationName} has no JSON schema`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
schemaName: await this.#registry.schemaNameFor(
|
|
265
|
+
body.schema,
|
|
266
|
+
body.context,
|
|
267
|
+
`${operationName}Request`
|
|
268
|
+
),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #buildResponses(operation, context, operationName) {
|
|
273
|
+
const responsesPointer = pointerChild(context.pointer, 'responses');
|
|
274
|
+
const responses = requireRecord(
|
|
275
|
+
operation.responses,
|
|
276
|
+
locationOf(context.documentPath, responsesPointer),
|
|
277
|
+
'Operation responses'
|
|
278
|
+
);
|
|
279
|
+
const result = [];
|
|
280
|
+
|
|
281
|
+
for (const statusKey of Object.keys(responses).sort(
|
|
282
|
+
(left, right) => Number(left) - Number(right)
|
|
283
|
+
)) {
|
|
284
|
+
if (!/^\d{3}$/u.test(statusKey)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Response status "${statusKey}" is unsupported for ${operationName}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const status = Number(statusKey);
|
|
290
|
+
if (status < 100 || status > 599) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Invalid response status "${statusKey}" for ${operationName}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const responseContext = {
|
|
296
|
+
documentPath: context.documentPath,
|
|
297
|
+
pointer: pointerChild(responsesPointer, statusKey),
|
|
298
|
+
};
|
|
299
|
+
const resolved = await this.#resolveReferenceObject(
|
|
300
|
+
Reflect.get(responses, statusKey),
|
|
301
|
+
responseContext,
|
|
302
|
+
'Response'
|
|
303
|
+
);
|
|
304
|
+
const body = this.#readJsonBody(
|
|
305
|
+
resolved.value,
|
|
306
|
+
resolved.context,
|
|
307
|
+
'Response'
|
|
308
|
+
);
|
|
309
|
+
const schemaName =
|
|
310
|
+
body === null
|
|
311
|
+
? null
|
|
312
|
+
: await this.#registry.schemaNameFor(
|
|
313
|
+
body.schema,
|
|
314
|
+
body.context,
|
|
315
|
+
`${operationName}Response${statusKey}`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
result.push({
|
|
319
|
+
isSuccess: status >= 200 && status <= 299,
|
|
320
|
+
schemaName,
|
|
321
|
+
status,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (result.length === 0) {
|
|
326
|
+
throw new Error(`Operation ${operationName} must document a response`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#readJsonBody(value, context, label) {
|
|
333
|
+
if (value.content === undefined) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const content = requireRecord(
|
|
337
|
+
value.content,
|
|
338
|
+
locationOf(
|
|
339
|
+
context.documentPath,
|
|
340
|
+
pointerChild(context.pointer, 'content')
|
|
341
|
+
),
|
|
342
|
+
`${label} content`
|
|
343
|
+
);
|
|
344
|
+
const mediaTypes = Object.keys(content);
|
|
345
|
+
|
|
346
|
+
if (mediaTypes.length === 0) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if (mediaTypes.length !== 1 || mediaTypes[0] !== JSON_MEDIA_TYPE) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`${label} at ${locationOf(context.documentPath, context.pointer)} must use only ${JSON_MEDIA_TYPE}`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const media = requireRecord(
|
|
355
|
+
content[JSON_MEDIA_TYPE],
|
|
356
|
+
locationOf(
|
|
357
|
+
context.documentPath,
|
|
358
|
+
pointerChild(pointerChild(context.pointer, 'content'), JSON_MEDIA_TYPE)
|
|
359
|
+
),
|
|
360
|
+
`${label} media type`
|
|
361
|
+
);
|
|
362
|
+
if (media.schema === undefined) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`${label} at ${locationOf(context.documentPath, context.pointer)} has no schema`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
context: {
|
|
370
|
+
documentPath: context.documentPath,
|
|
371
|
+
pointer: pointerChild(
|
|
372
|
+
pointerChild(
|
|
373
|
+
pointerChild(context.pointer, 'content'),
|
|
374
|
+
JSON_MEDIA_TYPE
|
|
375
|
+
),
|
|
376
|
+
'schema'
|
|
377
|
+
),
|
|
378
|
+
},
|
|
379
|
+
schema: media.schema,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #resolveReferenceObject(value, context, label, seen = new Set()) {
|
|
384
|
+
const object = requireRecord(
|
|
385
|
+
value,
|
|
386
|
+
locationOf(context.documentPath, context.pointer),
|
|
387
|
+
label
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (object.$ref === undefined) {
|
|
391
|
+
return { context, value: object };
|
|
392
|
+
}
|
|
393
|
+
const resolved = await this.#documents.resolveReference(
|
|
394
|
+
object.$ref,
|
|
395
|
+
context.documentPath
|
|
396
|
+
);
|
|
397
|
+
if (seen.has(resolved.canonicalKey)) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Cyclic ${label.toLowerCase()} reference at ${resolved.canonicalKey}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
seen.add(resolved.canonicalKey);
|
|
403
|
+
return this.#resolveReferenceObject(
|
|
404
|
+
resolved.value,
|
|
405
|
+
{ documentPath: resolved.documentPath, pointer: resolved.pointer },
|
|
406
|
+
label,
|
|
407
|
+
seen
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function buildOpenApiModel(specPath) {
|
|
413
|
+
return new ModelBuilder().build(specPath);
|
|
414
|
+
}
|
|
415
|
+
|