openapi-contract-kit 0.0.1 → 0.0.3
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 +10 -0
- package/README.md +13 -5
- package/dist/bin/openapi-contract-kit.d.ts +2 -0
- package/dist/bin/openapi-contract-kit.js +3 -0
- package/dist/src/cli/formatOutput.d.ts +6 -0
- package/dist/src/cli/formatOutput.js +32 -0
- package/dist/src/generator/config.d.ts +3 -0
- package/dist/src/generator/config.js +72 -0
- package/dist/src/generator/documents.d.ts +11 -0
- package/dist/src/generator/documents.js +92 -0
- package/dist/src/generator/emitEndpoints.d.ts +2 -0
- package/{src/generator/emitEndpoints.mjs → dist/src/generator/emitEndpoints.js} +48 -80
- package/dist/src/generator/emitMakers.d.ts +2 -0
- package/dist/src/generator/emitMakers.js +328 -0
- package/dist/src/generator/emitTypes.d.ts +3 -0
- package/dist/src/generator/emitTypes.js +87 -0
- package/dist/src/generator/generateOpenApiRuntime.d.ts +3 -0
- package/dist/src/generator/generateOpenApiRuntime.js +41 -0
- package/dist/src/generator/model.d.ts +2 -0
- package/dist/src/generator/model.js +278 -0
- package/dist/src/generator/schemaModel.d.ts +9 -0
- package/dist/src/generator/schemaModel.js +408 -0
- package/dist/src/generator/types.d.ts +82 -0
- package/dist/src/generator/writeOutput.d.ts +5 -0
- package/dist/src/generator/writeOutput.js +141 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/runtime.d.ts +14 -0
- package/dist/src/runtime.js +1 -0
- package/package.json +30 -18
- package/bin/openapi-contract-kit.mjs +0 -5
- package/src/generator/config.mjs +0 -92
- package/src/generator/documents.mjs +0 -119
- package/src/generator/emitMakers.mjs +0 -459
- package/src/generator/emitTypes.mjs +0 -104
- package/src/generator/generateOpenApiRuntime.mjs +0 -48
- package/src/generator/model.mjs +0 -415
- package/src/generator/schemaModel.mjs +0 -497
- package/src/generator/writeOutput.mjs +0 -196
- package/src/index.d.ts +0 -16
- package/src/index.mjs +0 -4
- package/src/runtime.d.ts +0 -14
- /package/{src/runtime.mjs → dist/src/generator/types.js} +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { DocumentStore, escapePointerSegment, isRecord, locationOf, pointerChild, requireRecord, } from './documents.js';
|
|
3
|
+
import { SchemaRegistry, toPascalIdentifier } from './schemaModel.js';
|
|
4
|
+
const HTTP_METHODS = [
|
|
5
|
+
'delete',
|
|
6
|
+
'get',
|
|
7
|
+
'head',
|
|
8
|
+
'options',
|
|
9
|
+
'patch',
|
|
10
|
+
'post',
|
|
11
|
+
'put',
|
|
12
|
+
'trace',
|
|
13
|
+
];
|
|
14
|
+
const JSON_MEDIA_TYPE = 'application/json';
|
|
15
|
+
class ModelBuilder {
|
|
16
|
+
#documents = new DocumentStore();
|
|
17
|
+
#registry = null;
|
|
18
|
+
#rootDocument = null;
|
|
19
|
+
#rootPath = null;
|
|
20
|
+
async build(specPath) {
|
|
21
|
+
this.#rootPath = resolve(specPath);
|
|
22
|
+
this.#rootDocument = await this.#documents.load(this.#rootPath);
|
|
23
|
+
this.#registry = new SchemaRegistry(this.#documents, this.#rootPath);
|
|
24
|
+
this.#validateOpenApiRoot();
|
|
25
|
+
this.#seedComponentSchemas();
|
|
26
|
+
await this.#seedResponseSchemas();
|
|
27
|
+
const operations = await this.#buildOperations(this.#collectOperationDrafts());
|
|
28
|
+
return {
|
|
29
|
+
operations: operations.sort((left, right) => left.name.localeCompare(right.name)),
|
|
30
|
+
schemas: await this.#registry.build(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
#getRegistry() {
|
|
34
|
+
if (this.#registry === null) {
|
|
35
|
+
throw new Error('OpenAPI model registry is unavailable');
|
|
36
|
+
}
|
|
37
|
+
return this.#registry;
|
|
38
|
+
}
|
|
39
|
+
#getRootDocument() {
|
|
40
|
+
if (this.#rootDocument === null) {
|
|
41
|
+
throw new Error('OpenAPI root document is unavailable');
|
|
42
|
+
}
|
|
43
|
+
return this.#rootDocument;
|
|
44
|
+
}
|
|
45
|
+
#getRootPath() {
|
|
46
|
+
if (this.#rootPath === null) {
|
|
47
|
+
throw new Error('OpenAPI root path is unavailable');
|
|
48
|
+
}
|
|
49
|
+
return this.#rootPath;
|
|
50
|
+
}
|
|
51
|
+
#validateOpenApiRoot() {
|
|
52
|
+
const rootDocument = this.#getRootDocument();
|
|
53
|
+
const rootPath = this.#getRootPath();
|
|
54
|
+
const version = rootDocument.openapi;
|
|
55
|
+
if (typeof version !== 'string' || !version.startsWith('3.1.')) {
|
|
56
|
+
throw new Error(`OpenAPI document "${rootPath}" must use OpenAPI 3.1`);
|
|
57
|
+
}
|
|
58
|
+
requireRecord(rootDocument.paths, locationOf(rootPath, '/paths'), 'OpenAPI paths');
|
|
59
|
+
}
|
|
60
|
+
#seedComponentSchemas() {
|
|
61
|
+
const rootDocument = this.#getRootDocument();
|
|
62
|
+
const rootPath = this.#getRootPath();
|
|
63
|
+
const components = rootDocument.components;
|
|
64
|
+
const schemas = isRecord(components) ? components.schemas : undefined;
|
|
65
|
+
if (schemas === undefined) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const schemaRecord = requireRecord(schemas, locationOf(rootPath, '/components/schemas'), 'OpenAPI component schemas');
|
|
69
|
+
for (const componentName of Object.keys(schemaRecord).sort()) {
|
|
70
|
+
this.#getRegistry().register(Reflect.get(schemaRecord, componentName), {
|
|
71
|
+
documentPath: rootPath,
|
|
72
|
+
pointer: `/components/schemas/${escapePointerSegment(componentName)}`,
|
|
73
|
+
}, componentName);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async #seedResponseSchemas() {
|
|
77
|
+
const rootDocument = this.#getRootDocument();
|
|
78
|
+
const rootPath = this.#getRootPath();
|
|
79
|
+
const components = rootDocument.components;
|
|
80
|
+
const responses = isRecord(components) ? components.responses : undefined;
|
|
81
|
+
if (responses === undefined) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const responseRecord = requireRecord(responses, locationOf(rootPath, '/components/responses'), 'OpenAPI component responses');
|
|
85
|
+
for (const responseName of Object.keys(responseRecord).sort()) {
|
|
86
|
+
const context = {
|
|
87
|
+
documentPath: rootPath,
|
|
88
|
+
pointer: `/components/responses/${escapePointerSegment(responseName)}`,
|
|
89
|
+
};
|
|
90
|
+
const resolved = await this.#resolveReferenceObject(Reflect.get(responseRecord, responseName), context, 'Response');
|
|
91
|
+
const body = this.#readJsonBody(resolved.value, resolved.context, 'Response');
|
|
92
|
+
if (body !== null) {
|
|
93
|
+
this.#getRegistry().register(body.schema, body.context, responseName);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
#collectOperationDrafts() {
|
|
98
|
+
const rootDocument = this.#getRootDocument();
|
|
99
|
+
const rootPath = this.#getRootPath();
|
|
100
|
+
const paths = requireRecord(rootDocument.paths, locationOf(rootPath, '/paths'), 'OpenAPI paths');
|
|
101
|
+
const drafts = [];
|
|
102
|
+
const operationIds = new Set();
|
|
103
|
+
for (const path of Object.keys(paths).sort()) {
|
|
104
|
+
const pathPointer = `/paths/${escapePointerSegment(path)}`;
|
|
105
|
+
const pathItem = requireRecord(Reflect.get(paths, path), locationOf(rootPath, pathPointer), 'Path item');
|
|
106
|
+
if (pathItem.$ref !== undefined) {
|
|
107
|
+
throw new Error(`Referenced path items are unsupported at ${path}`);
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(pathItem.parameters) &&
|
|
110
|
+
pathItem.parameters.length > 0) {
|
|
111
|
+
throw new Error(`Path parameters are unsupported at ${path}`);
|
|
112
|
+
}
|
|
113
|
+
for (const method of HTTP_METHODS) {
|
|
114
|
+
const rawOperation = pathItem[method];
|
|
115
|
+
if (rawOperation === undefined) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const operation = requireRecord(rawOperation, locationOf(rootPath, `${pathPointer}/${method}`), 'Operation');
|
|
119
|
+
const operationId = operation.operationId;
|
|
120
|
+
if (typeof operationId !== 'string' || operationId.length === 0) {
|
|
121
|
+
throw new Error(`Missing operationId for ${method.toUpperCase()} ${path}`);
|
|
122
|
+
}
|
|
123
|
+
if (operationIds.has(operationId)) {
|
|
124
|
+
throw new Error(`Duplicate operationId "${operationId}"`);
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(operation.parameters) &&
|
|
127
|
+
operation.parameters.length > 0) {
|
|
128
|
+
throw new Error(`Operation parameters are unsupported for ${operationId}`);
|
|
129
|
+
}
|
|
130
|
+
operationIds.add(operationId);
|
|
131
|
+
drafts.push({ method, operation, operationId, path, pathPointer });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const draftsWithLeaves = drafts.map((draft) => {
|
|
135
|
+
const segments = draft.operationId
|
|
136
|
+
.split(/[^A-Za-z0-9_$]+/u)
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
return {
|
|
139
|
+
...draft,
|
|
140
|
+
leafName: toPascalIdentifier(segments.at(-1) ?? draft.operationId, 'Operation'),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
const leafCounts = new Map();
|
|
144
|
+
for (const draft of draftsWithLeaves) {
|
|
145
|
+
const key = draft.leafName.toLowerCase();
|
|
146
|
+
leafCounts.set(key, (leafCounts.get(key) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const names = new Set();
|
|
149
|
+
return draftsWithLeaves.map((draft) => {
|
|
150
|
+
const name = leafCounts.get(draft.leafName.toLowerCase()) === 1
|
|
151
|
+
? draft.leafName
|
|
152
|
+
: toPascalIdentifier(draft.operationId, 'Operation');
|
|
153
|
+
const key = name.toLowerCase();
|
|
154
|
+
if (names.has(key)) {
|
|
155
|
+
throw new Error(`Operation name collision for "${name}"`);
|
|
156
|
+
}
|
|
157
|
+
names.add(key);
|
|
158
|
+
return {
|
|
159
|
+
method: draft.method,
|
|
160
|
+
name,
|
|
161
|
+
operation: draft.operation,
|
|
162
|
+
operationId: draft.operationId,
|
|
163
|
+
path: draft.path,
|
|
164
|
+
pathPointer: draft.pathPointer,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async #buildOperations(drafts) {
|
|
169
|
+
const operations = [];
|
|
170
|
+
const rootPath = this.#getRootPath();
|
|
171
|
+
for (const draft of drafts) {
|
|
172
|
+
const context = {
|
|
173
|
+
documentPath: rootPath,
|
|
174
|
+
pointer: `${draft.pathPointer}/${draft.method}`,
|
|
175
|
+
};
|
|
176
|
+
operations.push({
|
|
177
|
+
method: draft.method.toUpperCase(),
|
|
178
|
+
name: draft.name,
|
|
179
|
+
operationId: draft.operationId,
|
|
180
|
+
path: draft.path,
|
|
181
|
+
request: await this.#buildRequest(draft.operation, context, draft.name),
|
|
182
|
+
responses: await this.#buildResponses(draft.operation, context, draft.name),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return operations;
|
|
186
|
+
}
|
|
187
|
+
async #buildRequest(operation, context, operationName) {
|
|
188
|
+
if (operation.requestBody === undefined) {
|
|
189
|
+
return { schemaName: null };
|
|
190
|
+
}
|
|
191
|
+
const requestContext = {
|
|
192
|
+
documentPath: context.documentPath,
|
|
193
|
+
pointer: pointerChild(context.pointer, 'requestBody'),
|
|
194
|
+
};
|
|
195
|
+
const resolved = await this.#resolveReferenceObject(operation.requestBody, requestContext, 'Request body');
|
|
196
|
+
if (resolved.value.required !== true) {
|
|
197
|
+
throw new Error(`Optional request bodies are unsupported for ${operationName}`);
|
|
198
|
+
}
|
|
199
|
+
const body = this.#readJsonBody(resolved.value, resolved.context, 'Request body');
|
|
200
|
+
if (body === null) {
|
|
201
|
+
throw new Error(`Request body for ${operationName} has no JSON schema`);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
schemaName: await this.#getRegistry().schemaNameFor(body.schema, body.context, `${operationName}Request`),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async #buildResponses(operation, context, operationName) {
|
|
208
|
+
const responsesPointer = pointerChild(context.pointer, 'responses');
|
|
209
|
+
const responses = requireRecord(operation.responses, locationOf(context.documentPath, responsesPointer), 'Operation responses');
|
|
210
|
+
const result = [];
|
|
211
|
+
for (const statusKey of Object.keys(responses).sort((left, right) => Number(left) - Number(right))) {
|
|
212
|
+
if (!/^\d{3}$/u.test(statusKey)) {
|
|
213
|
+
throw new Error(`Response status "${statusKey}" is unsupported for ${operationName}`);
|
|
214
|
+
}
|
|
215
|
+
const status = Number(statusKey);
|
|
216
|
+
if (status < 100 || status > 599) {
|
|
217
|
+
throw new Error(`Invalid response status "${statusKey}" for ${operationName}`);
|
|
218
|
+
}
|
|
219
|
+
const responseContext = {
|
|
220
|
+
documentPath: context.documentPath,
|
|
221
|
+
pointer: pointerChild(responsesPointer, statusKey),
|
|
222
|
+
};
|
|
223
|
+
const resolved = await this.#resolveReferenceObject(Reflect.get(responses, statusKey), responseContext, 'Response');
|
|
224
|
+
const body = this.#readJsonBody(resolved.value, resolved.context, 'Response');
|
|
225
|
+
const schemaName = body === null
|
|
226
|
+
? null
|
|
227
|
+
: await this.#getRegistry().schemaNameFor(body.schema, body.context, `${operationName}Response${statusKey}`);
|
|
228
|
+
result.push({
|
|
229
|
+
isSuccess: status >= 200 && status <= 299,
|
|
230
|
+
schemaName,
|
|
231
|
+
status,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (result.length === 0) {
|
|
235
|
+
throw new Error(`Operation ${operationName} must document a response`);
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
#readJsonBody(value, context, label) {
|
|
240
|
+
if (value.content === undefined) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const content = requireRecord(value.content, locationOf(context.documentPath, pointerChild(context.pointer, 'content')), `${label} content`);
|
|
244
|
+
const mediaTypes = Object.keys(content);
|
|
245
|
+
if (mediaTypes.length === 0) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (mediaTypes.length !== 1 || mediaTypes[0] !== JSON_MEDIA_TYPE) {
|
|
249
|
+
throw new Error(`${label} at ${locationOf(context.documentPath, context.pointer)} must use only ${JSON_MEDIA_TYPE}`);
|
|
250
|
+
}
|
|
251
|
+
const media = requireRecord(content[JSON_MEDIA_TYPE], locationOf(context.documentPath, pointerChild(pointerChild(context.pointer, 'content'), JSON_MEDIA_TYPE)), `${label} media type`);
|
|
252
|
+
if (media.schema === undefined) {
|
|
253
|
+
throw new Error(`${label} at ${locationOf(context.documentPath, context.pointer)} has no schema`);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
context: {
|
|
257
|
+
documentPath: context.documentPath,
|
|
258
|
+
pointer: pointerChild(pointerChild(pointerChild(context.pointer, 'content'), JSON_MEDIA_TYPE), 'schema'),
|
|
259
|
+
},
|
|
260
|
+
schema: media.schema,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async #resolveReferenceObject(value, context, label, seen = new Set()) {
|
|
264
|
+
const object = requireRecord(value, locationOf(context.documentPath, context.pointer), label);
|
|
265
|
+
if (object.$ref === undefined) {
|
|
266
|
+
return { context, value: object };
|
|
267
|
+
}
|
|
268
|
+
const resolved = await this.#documents.resolveReference(object.$ref, context.documentPath);
|
|
269
|
+
if (seen.has(resolved.canonicalKey)) {
|
|
270
|
+
throw new Error(`Cyclic ${label.toLowerCase()} reference at ${resolved.canonicalKey}`);
|
|
271
|
+
}
|
|
272
|
+
seen.add(resolved.canonicalKey);
|
|
273
|
+
return this.#resolveReferenceObject(resolved.value, { documentPath: resolved.documentPath, pointer: resolved.pointer }, label, seen);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function buildOpenApiModel(specPath) {
|
|
277
|
+
return new ModelBuilder().build(specPath);
|
|
278
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DocumentContext, SchemaEntry } from './types.js';
|
|
2
|
+
export declare function toPascalIdentifier(value: string, fallback?: string): string;
|
|
3
|
+
export declare class SchemaRegistry {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(documents: import('./documents.js').DocumentStore, rootPath: string);
|
|
6
|
+
register(rawSchema: unknown, context: DocumentContext, suggestedName: string): string;
|
|
7
|
+
schemaNameFor(schema: unknown, context: DocumentContext, suggestedName: string): Promise<string>;
|
|
8
|
+
build(): Promise<readonly SchemaEntry[]>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { basename, extname } from 'node:path';
|
|
2
|
+
import { isRecord, locationOf, pointerChild, requireRecord, } from './documents.js';
|
|
3
|
+
const SCHEMA_TYPES = new Set([
|
|
4
|
+
'array',
|
|
5
|
+
'boolean',
|
|
6
|
+
'integer',
|
|
7
|
+
'null',
|
|
8
|
+
'number',
|
|
9
|
+
'object',
|
|
10
|
+
'string',
|
|
11
|
+
]);
|
|
12
|
+
const ANNOTATION_KEYWORDS = new Set([
|
|
13
|
+
'$anchor',
|
|
14
|
+
'$comment',
|
|
15
|
+
'$defs',
|
|
16
|
+
'$id',
|
|
17
|
+
'$schema',
|
|
18
|
+
'default',
|
|
19
|
+
'deprecated',
|
|
20
|
+
'description',
|
|
21
|
+
'discriminator',
|
|
22
|
+
'example',
|
|
23
|
+
'examples',
|
|
24
|
+
'externalDocs',
|
|
25
|
+
'readOnly',
|
|
26
|
+
'title',
|
|
27
|
+
'writeOnly',
|
|
28
|
+
'xml',
|
|
29
|
+
]);
|
|
30
|
+
const ASSERTION_KEYWORDS = new Set([
|
|
31
|
+
'$ref',
|
|
32
|
+
'additionalProperties',
|
|
33
|
+
'allOf',
|
|
34
|
+
'anyOf',
|
|
35
|
+
'const',
|
|
36
|
+
'enum',
|
|
37
|
+
'exclusiveMaximum',
|
|
38
|
+
'exclusiveMinimum',
|
|
39
|
+
'format',
|
|
40
|
+
'items',
|
|
41
|
+
'maxLength',
|
|
42
|
+
'maximum',
|
|
43
|
+
'minLength',
|
|
44
|
+
'minimum',
|
|
45
|
+
'oneOf',
|
|
46
|
+
'pattern',
|
|
47
|
+
'properties',
|
|
48
|
+
'required',
|
|
49
|
+
'type',
|
|
50
|
+
]);
|
|
51
|
+
const RESERVED_IDENTIFIERS = new Set([
|
|
52
|
+
'any',
|
|
53
|
+
'boolean',
|
|
54
|
+
'constructor',
|
|
55
|
+
'declare',
|
|
56
|
+
'default',
|
|
57
|
+
'enum',
|
|
58
|
+
'extends',
|
|
59
|
+
'false',
|
|
60
|
+
'infer',
|
|
61
|
+
'interface',
|
|
62
|
+
'keyof',
|
|
63
|
+
'never',
|
|
64
|
+
'null',
|
|
65
|
+
'number',
|
|
66
|
+
'object',
|
|
67
|
+
'makeresult',
|
|
68
|
+
'string',
|
|
69
|
+
'symbol',
|
|
70
|
+
'true',
|
|
71
|
+
'type',
|
|
72
|
+
'undefined',
|
|
73
|
+
'unknown',
|
|
74
|
+
'result',
|
|
75
|
+
'validationissue',
|
|
76
|
+
'validationpath',
|
|
77
|
+
]);
|
|
78
|
+
function requireNumber(value, keyword, location) {
|
|
79
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
80
|
+
throw new Error(`Schema keyword "${keyword}" at ${location} must be finite`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
function requirePrimitive(value, keyword, location) {
|
|
85
|
+
if (value !== null &&
|
|
86
|
+
typeof value !== 'string' &&
|
|
87
|
+
typeof value !== 'number' &&
|
|
88
|
+
typeof value !== 'boolean') {
|
|
89
|
+
throw new Error(`Schema keyword "${keyword}" at ${location} supports primitive JSON values only`);
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
92
|
+
throw new Error(`Schema keyword "${keyword}" at ${location} must be finite`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function isSchemaType(value) {
|
|
97
|
+
return typeof value === 'string' && SCHEMA_TYPES.has(value);
|
|
98
|
+
}
|
|
99
|
+
function isStringArray(value) {
|
|
100
|
+
return (Array.isArray(value) && value.every((item) => typeof item === 'string'));
|
|
101
|
+
}
|
|
102
|
+
export function toPascalIdentifier(value, fallback = 'Schema') {
|
|
103
|
+
const words = value
|
|
104
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
|
|
105
|
+
.split(/[^A-Za-z0-9_$]+/u)
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
let identifier = words
|
|
108
|
+
.map((word) => `${word[0] ?? ''}${word.slice(1)}`)
|
|
109
|
+
.map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
|
|
110
|
+
.join('');
|
|
111
|
+
if (identifier.length === 0) {
|
|
112
|
+
identifier = fallback;
|
|
113
|
+
}
|
|
114
|
+
if (!/^[A-Za-z_$]/u.test(identifier)) {
|
|
115
|
+
identifier = `${fallback}${identifier}`;
|
|
116
|
+
}
|
|
117
|
+
if (RESERVED_IDENTIFIERS.has(identifier.toLowerCase())) {
|
|
118
|
+
identifier = `${fallback}${identifier}`;
|
|
119
|
+
}
|
|
120
|
+
return identifier;
|
|
121
|
+
}
|
|
122
|
+
export class SchemaRegistry {
|
|
123
|
+
#documents;
|
|
124
|
+
#names = new Map();
|
|
125
|
+
#rawSchemas = new Map();
|
|
126
|
+
#rootPath;
|
|
127
|
+
constructor(documents, rootPath) {
|
|
128
|
+
this.#documents = documents;
|
|
129
|
+
this.#rootPath = rootPath;
|
|
130
|
+
}
|
|
131
|
+
register(rawSchema, context, suggestedName) {
|
|
132
|
+
const canonicalKey = locationOf(context.documentPath, context.pointer);
|
|
133
|
+
const existing = this.#rawSchemas.get(canonicalKey);
|
|
134
|
+
if (existing !== undefined) {
|
|
135
|
+
return existing.name;
|
|
136
|
+
}
|
|
137
|
+
const name = toPascalIdentifier(suggestedName);
|
|
138
|
+
const nameKey = name.toLowerCase();
|
|
139
|
+
const existingCanonicalKey = this.#names.get(nameKey);
|
|
140
|
+
if (existingCanonicalKey !== undefined &&
|
|
141
|
+
existingCanonicalKey !== canonicalKey) {
|
|
142
|
+
throw new Error(`Schema name collision for "${name}" at ${canonicalKey} and ${existingCanonicalKey}`);
|
|
143
|
+
}
|
|
144
|
+
this.#names.set(nameKey, canonicalKey);
|
|
145
|
+
this.#rawSchemas.set(canonicalKey, {
|
|
146
|
+
context,
|
|
147
|
+
name,
|
|
148
|
+
rawSchema,
|
|
149
|
+
});
|
|
150
|
+
return name;
|
|
151
|
+
}
|
|
152
|
+
async schemaNameFor(schema, context, suggestedName) {
|
|
153
|
+
if (isRecord(schema) &&
|
|
154
|
+
schema.$ref !== undefined &&
|
|
155
|
+
Object.keys(schema).length === 1) {
|
|
156
|
+
const resolved = await this.#documents.resolveReference(schema.$ref, context.documentPath);
|
|
157
|
+
return this.register(resolved.value, { documentPath: resolved.documentPath, pointer: resolved.pointer }, this.#suggestReferenceName(resolved));
|
|
158
|
+
}
|
|
159
|
+
return this.register(schema, context, suggestedName);
|
|
160
|
+
}
|
|
161
|
+
async build() {
|
|
162
|
+
const schemas = await this.#normaliseAllSchemas();
|
|
163
|
+
this.#rejectReferenceCycles(schemas);
|
|
164
|
+
return schemas.sort((left, right) => left.name.localeCompare(right.name));
|
|
165
|
+
}
|
|
166
|
+
#suggestReferenceName(resolved) {
|
|
167
|
+
const segments = resolved.pointer.split('/').filter(Boolean);
|
|
168
|
+
const pointerName = segments.at(-1) ?? 'Schema';
|
|
169
|
+
if (resolved.documentPath === this.#rootPath) {
|
|
170
|
+
return pointerName;
|
|
171
|
+
}
|
|
172
|
+
const fileName = basename(resolved.documentPath, extname(resolved.documentPath));
|
|
173
|
+
return `${fileName}-${pointerName}`;
|
|
174
|
+
}
|
|
175
|
+
async #normaliseAllSchemas() {
|
|
176
|
+
const normalised = new Map();
|
|
177
|
+
const entries = [...this.#rawSchemas.entries()];
|
|
178
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
179
|
+
const entryPair = entries[index];
|
|
180
|
+
if (entryPair === undefined) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const [canonicalKey, entry] = entryPair;
|
|
184
|
+
if (normalised.has(canonicalKey)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
normalised.set(canonicalKey, {
|
|
188
|
+
canonicalKey,
|
|
189
|
+
name: entry.name,
|
|
190
|
+
schema: await this.#normaliseSchema(entry.rawSchema, entry.context),
|
|
191
|
+
});
|
|
192
|
+
for (const nextEntry of this.#rawSchemas.entries()) {
|
|
193
|
+
if (!entries.some(([key]) => key === nextEntry[0])) {
|
|
194
|
+
entries.push(nextEntry);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...normalised.values()];
|
|
199
|
+
}
|
|
200
|
+
async #normaliseSchema(rawSchema, context) {
|
|
201
|
+
const location = locationOf(context.documentPath, context.pointer);
|
|
202
|
+
if (typeof rawSchema === 'boolean') {
|
|
203
|
+
return { booleanSchema: rawSchema, location };
|
|
204
|
+
}
|
|
205
|
+
const schema = requireRecord(rawSchema, location, 'Schema');
|
|
206
|
+
for (const keyword of Object.keys(schema)) {
|
|
207
|
+
if (!ANNOTATION_KEYWORDS.has(keyword) &&
|
|
208
|
+
!ASSERTION_KEYWORDS.has(keyword)) {
|
|
209
|
+
throw new Error(`Unsupported schema keyword "${keyword}" at ${location}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
let reference = null;
|
|
213
|
+
if (schema.$ref !== undefined) {
|
|
214
|
+
const resolved = await this.#documents.resolveReference(schema.$ref, context.documentPath);
|
|
215
|
+
reference = this.register(resolved.value, { documentPath: resolved.documentPath, pointer: resolved.pointer }, this.#suggestReferenceName(resolved));
|
|
216
|
+
}
|
|
217
|
+
let types = null;
|
|
218
|
+
if (schema.type !== undefined) {
|
|
219
|
+
const rawTypes = Array.isArray(schema.type)
|
|
220
|
+
? schema.type
|
|
221
|
+
: [schema.type];
|
|
222
|
+
if (rawTypes.length === 0) {
|
|
223
|
+
throw new Error(`Unsupported schema type at ${location}`);
|
|
224
|
+
}
|
|
225
|
+
const uniqueTypes = [];
|
|
226
|
+
for (const type of rawTypes) {
|
|
227
|
+
if (!isSchemaType(type)) {
|
|
228
|
+
throw new Error(`Unsupported schema type at ${location}`);
|
|
229
|
+
}
|
|
230
|
+
if (!uniqueTypes.includes(type)) {
|
|
231
|
+
uniqueTypes.push(type);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
types = uniqueTypes;
|
|
235
|
+
}
|
|
236
|
+
const rawRequired = schema.required;
|
|
237
|
+
if (rawRequired !== undefined && !isStringArray(rawRequired)) {
|
|
238
|
+
throw new Error(`Schema required list at ${location} must contain strings`);
|
|
239
|
+
}
|
|
240
|
+
const required = rawRequired ?? [];
|
|
241
|
+
const rawProperties = schema.properties ?? {};
|
|
242
|
+
requireRecord(rawProperties, location, 'Schema properties');
|
|
243
|
+
const properties = [];
|
|
244
|
+
const propertyNames = [
|
|
245
|
+
...Object.keys(rawProperties),
|
|
246
|
+
...required.filter((property) => !Object.prototype.hasOwnProperty.call(rawProperties, property)),
|
|
247
|
+
];
|
|
248
|
+
for (const propertyName of propertyNames) {
|
|
249
|
+
const propertySchema = Object.prototype.hasOwnProperty.call(rawProperties, propertyName)
|
|
250
|
+
? Reflect.get(rawProperties, propertyName)
|
|
251
|
+
: true;
|
|
252
|
+
properties.push({
|
|
253
|
+
name: propertyName,
|
|
254
|
+
required: required.includes(propertyName),
|
|
255
|
+
schema: await this.#normaliseSchema(propertySchema, {
|
|
256
|
+
documentPath: context.documentPath,
|
|
257
|
+
pointer: pointerChild(pointerChild(context.pointer, 'properties'), propertyName),
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
let additionalProperties = true;
|
|
262
|
+
if (schema.additionalProperties !== undefined) {
|
|
263
|
+
additionalProperties =
|
|
264
|
+
typeof schema.additionalProperties === 'boolean'
|
|
265
|
+
? schema.additionalProperties
|
|
266
|
+
: await this.#normaliseSchema(schema.additionalProperties, {
|
|
267
|
+
documentPath: context.documentPath,
|
|
268
|
+
pointer: pointerChild(context.pointer, 'additionalProperties'),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const normaliseList = async (keyword) => {
|
|
272
|
+
const rawValue = schema[keyword];
|
|
273
|
+
if (rawValue === undefined) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(rawValue) || rawValue.length === 0) {
|
|
277
|
+
throw new Error(`Schema keyword "${keyword}" at ${location} must be non-empty`);
|
|
278
|
+
}
|
|
279
|
+
return Promise.all(rawValue.map((child, index) => this.#normaliseSchema(child, {
|
|
280
|
+
documentPath: context.documentPath,
|
|
281
|
+
pointer: pointerChild(pointerChild(context.pointer, keyword), index),
|
|
282
|
+
})));
|
|
283
|
+
};
|
|
284
|
+
const items = schema.items === undefined
|
|
285
|
+
? null
|
|
286
|
+
: await this.#normaliseSchema(schema.items, {
|
|
287
|
+
documentPath: context.documentPath,
|
|
288
|
+
pointer: pointerChild(context.pointer, 'items'),
|
|
289
|
+
});
|
|
290
|
+
let enumValues = null;
|
|
291
|
+
if (schema.enum !== undefined) {
|
|
292
|
+
if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
|
|
293
|
+
throw new Error(`Schema enum at ${location} must be non-empty`);
|
|
294
|
+
}
|
|
295
|
+
enumValues = schema.enum.map((value) => requirePrimitive(value, 'enum', location));
|
|
296
|
+
}
|
|
297
|
+
const constValue = schema.const === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: requirePrimitive(schema.const, 'const', location);
|
|
300
|
+
let pattern = null;
|
|
301
|
+
if (schema.pattern !== undefined) {
|
|
302
|
+
if (typeof schema.pattern !== 'string') {
|
|
303
|
+
throw new Error(`Schema pattern at ${location} must be a string`);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
new RegExp(schema.pattern, 'u');
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
throw new Error(`Schema pattern at ${location} is not valid JavaScript`);
|
|
310
|
+
}
|
|
311
|
+
pattern = schema.pattern;
|
|
312
|
+
}
|
|
313
|
+
let format = null;
|
|
314
|
+
if (schema.format !== undefined) {
|
|
315
|
+
if (schema.format !== 'email') {
|
|
316
|
+
throw new Error(`Unsupported schema format "${String(schema.format)}" at ${location}`);
|
|
317
|
+
}
|
|
318
|
+
format = 'email';
|
|
319
|
+
}
|
|
320
|
+
const numberKeyword = (keyword) => {
|
|
321
|
+
const value = schema[keyword];
|
|
322
|
+
return value === undefined
|
|
323
|
+
? null
|
|
324
|
+
: requireNumber(value, keyword, location);
|
|
325
|
+
};
|
|
326
|
+
const integerKeyword = (keyword) => {
|
|
327
|
+
const value = numberKeyword(keyword);
|
|
328
|
+
if (value !== null && (!Number.isInteger(value) || value < 0)) {
|
|
329
|
+
throw new Error(`Schema keyword "${keyword}" at ${location} must be >= 0`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
additionalProperties,
|
|
335
|
+
allOf: await normaliseList('allOf'),
|
|
336
|
+
anyOf: await normaliseList('anyOf'),
|
|
337
|
+
booleanSchema: null,
|
|
338
|
+
constValue,
|
|
339
|
+
enumValues,
|
|
340
|
+
exclusiveMaximum: numberKeyword('exclusiveMaximum'),
|
|
341
|
+
exclusiveMinimum: numberKeyword('exclusiveMinimum'),
|
|
342
|
+
format,
|
|
343
|
+
items,
|
|
344
|
+
location,
|
|
345
|
+
maximum: numberKeyword('maximum'),
|
|
346
|
+
maxLength: integerKeyword('maxLength'),
|
|
347
|
+
minimum: numberKeyword('minimum'),
|
|
348
|
+
minLength: integerKeyword('minLength'),
|
|
349
|
+
oneOf: await normaliseList('oneOf'),
|
|
350
|
+
pattern,
|
|
351
|
+
properties,
|
|
352
|
+
reference,
|
|
353
|
+
types,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
#rejectReferenceCycles(schemas) {
|
|
357
|
+
const schemaByName = new Map(schemas.map((entry) => [entry.name, entry.schema]));
|
|
358
|
+
const visiting = [];
|
|
359
|
+
const visited = new Set();
|
|
360
|
+
const collectReferences = (schema, references) => {
|
|
361
|
+
if (schema.booleanSchema !== null) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (schema.reference !== null) {
|
|
365
|
+
references.add(schema.reference);
|
|
366
|
+
}
|
|
367
|
+
for (const property of schema.properties) {
|
|
368
|
+
collectReferences(property.schema, references);
|
|
369
|
+
}
|
|
370
|
+
if (typeof schema.additionalProperties !== 'boolean') {
|
|
371
|
+
collectReferences(schema.additionalProperties, references);
|
|
372
|
+
}
|
|
373
|
+
if (schema.items !== null) {
|
|
374
|
+
collectReferences(schema.items, references);
|
|
375
|
+
}
|
|
376
|
+
for (const children of [schema.allOf, schema.anyOf, schema.oneOf]) {
|
|
377
|
+
for (const child of children) {
|
|
378
|
+
collectReferences(child, references);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const visit = (name) => {
|
|
383
|
+
if (visited.has(name)) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const cycleIndex = visiting.indexOf(name);
|
|
387
|
+
if (cycleIndex !== -1) {
|
|
388
|
+
const cycle = [...visiting.slice(cycleIndex), name].join(' -> ');
|
|
389
|
+
throw new Error(`Cyclic schema reference: ${cycle}`);
|
|
390
|
+
}
|
|
391
|
+
const schema = schemaByName.get(name);
|
|
392
|
+
if (schema === undefined) {
|
|
393
|
+
throw new Error(`Unresolved schema model reference "${name}"`);
|
|
394
|
+
}
|
|
395
|
+
visiting.push(name);
|
|
396
|
+
const references = new Set();
|
|
397
|
+
collectReferences(schema, references);
|
|
398
|
+
for (const reference of [...references].sort()) {
|
|
399
|
+
visit(reference);
|
|
400
|
+
}
|
|
401
|
+
visiting.pop();
|
|
402
|
+
visited.add(name);
|
|
403
|
+
};
|
|
404
|
+
for (const { name } of schemas) {
|
|
405
|
+
visit(name);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|