genoc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/analyzer/naming.d.ts +24 -0
- package/dist/analyzer/naming.js +122 -0
- package/dist/analyzer/path-analyzer.d.ts +53 -0
- package/dist/analyzer/path-analyzer.js +222 -0
- package/dist/analyzer/schema-mapper.d.ts +48 -0
- package/dist/analyzer/schema-mapper.js +435 -0
- package/dist/cli/app.d.ts +9 -0
- package/dist/cli/app.js +60 -0
- package/dist/cli/errors.d.ts +3 -0
- package/dist/cli/errors.js +6 -0
- package/dist/cli/impl.d.ts +3 -0
- package/dist/cli/impl.js +45 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +5 -0
- package/dist/generator/client-generator.d.ts +21 -0
- package/dist/generator/client-generator.js +287 -0
- package/dist/generator/contracts-generator.d.ts +16 -0
- package/dist/generator/contracts-generator.js +525 -0
- package/dist/generator/error-types.d.ts +24 -0
- package/dist/generator/error-types.js +94 -0
- package/dist/generator/method-generator.d.ts +9 -0
- package/dist/generator/method-generator.js +249 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/parser/ref-resolver.d.ts +24 -0
- package/dist/parser/ref-resolver.js +119 -0
- package/dist/parser/spec-reader.d.ts +4 -0
- package/dist/parser/spec-reader.js +116 -0
- package/dist/parser/validators.d.ts +7 -0
- package/dist/parser/validators.js +79 -0
- package/dist/parser/version/index.d.ts +18 -0
- package/dist/parser/version/index.js +16 -0
- package/dist/parser/version/normalized-spec.d.ts +199 -0
- package/dist/parser/version/normalized-spec.js +1 -0
- package/dist/parser/version/registry.d.ts +28 -0
- package/dist/parser/version/registry.js +44 -0
- package/dist/parser/version/v3.0/index.d.ts +3 -0
- package/dist/parser/version/v3.0/index.js +3 -0
- package/dist/parser/version/v3.0/normalizer.d.ts +15 -0
- package/dist/parser/version/v3.0/normalizer.js +389 -0
- package/dist/parser/version/v3.0/strategy.d.ts +27 -0
- package/dist/parser/version/v3.0/strategy.js +96 -0
- package/dist/parser/version/v3.0/validator.d.ts +13 -0
- package/dist/parser/version/v3.0/validator.js +117 -0
- package/dist/parser/version/v3.1/index.d.ts +1 -0
- package/dist/parser/version/v3.1/index.js +1 -0
- package/dist/parser/version/v3.1/strategy.d.ts +42 -0
- package/dist/parser/version/v3.1/strategy.js +513 -0
- package/dist/parser/version/v3.2/index.d.ts +4 -0
- package/dist/parser/version/v3.2/index.js +4 -0
- package/dist/parser/version/v3.2/strategy.d.ts +39 -0
- package/dist/parser/version/v3.2/strategy.js +57 -0
- package/dist/parser/version/version-detector.d.ts +4 -0
- package/dist/parser/version/version-detector.js +34 -0
- package/dist/parser/version/version-strategy.d.ts +31 -0
- package/dist/parser/version/version-strategy.js +1 -0
- package/dist/types/client.d.ts +25 -0
- package/dist/types/client.js +1 -0
- package/dist/types/contracts.d.ts +13 -0
- package/dist/types/contracts.js +1 -0
- package/dist/types/openapi.d.ts +173 -0
- package/dist/types/openapi.js +1 -0
- package/dist/utils/case.d.ts +5 -0
- package/dist/utils/case.js +51 -0
- package/dist/utils/generator-helpers.d.ts +23 -0
- package/dist/utils/generator-helpers.js +66 -0
- package/dist/utils/string.d.ts +34 -0
- package/dist/utils/string.js +182 -0
- package/dist/utils/url.d.ts +10 -0
- package/dist/utils/url.js +40 -0
- package/package.json +60 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { getOperationTypePrefix, getSuccessType } from '../utils/generator-helpers.js';
|
|
2
|
+
function buildParameters(op) {
|
|
3
|
+
const params = [];
|
|
4
|
+
for (const param of op.pathParams) {
|
|
5
|
+
params.push(`${param.name}: string`);
|
|
6
|
+
}
|
|
7
|
+
if (op.queryParams.length > 0) {
|
|
8
|
+
const prefix = getOperationTypePrefix(op);
|
|
9
|
+
const allOptional = op.queryParams.every((p) => !p.required);
|
|
10
|
+
const hasRequiredAfter = op.headerParams.some((p) => p.required) || !!op.requestBody?.required;
|
|
11
|
+
if (allOptional && hasRequiredAfter) {
|
|
12
|
+
// All optional query params + required param after: use explicit undefined to avoid "required param cannot follow optional" error
|
|
13
|
+
params.push(`query: ${prefix}Query | undefined`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
// Normal case: use optional notation
|
|
17
|
+
const optional = allOptional ? '?' : '';
|
|
18
|
+
params.push(`query${optional}: ${prefix}Query`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (op.headerParams.length > 0) {
|
|
22
|
+
const prefix = getOperationTypePrefix(op);
|
|
23
|
+
const allOptional = op.headerParams.every((p) => !p.required);
|
|
24
|
+
if (allOptional && op.requestBody?.required) {
|
|
25
|
+
params.push(`headers: ${prefix}Headers | undefined`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const optional = allOptional ? '?' : '';
|
|
29
|
+
params.push(`headers${optional}: ${prefix}Headers`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (op.requestBody) {
|
|
33
|
+
const prefix = getOperationTypePrefix(op);
|
|
34
|
+
const optional = op.requestBody.required ? '' : '?';
|
|
35
|
+
params.push(`body${optional}: ${prefix}Body`);
|
|
36
|
+
}
|
|
37
|
+
return params.join(', ');
|
|
38
|
+
}
|
|
39
|
+
function buildJsDoc(op) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
if (op.summary) {
|
|
42
|
+
lines.push(` * ${op.summary}`);
|
|
43
|
+
}
|
|
44
|
+
if (op.description && op.description !== op.summary) {
|
|
45
|
+
if (lines.length > 0) {
|
|
46
|
+
lines.push(' *');
|
|
47
|
+
}
|
|
48
|
+
lines.push(` * ${op.description}`);
|
|
49
|
+
}
|
|
50
|
+
const allParams = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams];
|
|
51
|
+
const paramsWithDescriptions = allParams.filter((param) => param.description);
|
|
52
|
+
if (paramsWithDescriptions.length > 0) {
|
|
53
|
+
if (lines.length > 0) {
|
|
54
|
+
lines.push(' *');
|
|
55
|
+
}
|
|
56
|
+
for (const param of paramsWithDescriptions) {
|
|
57
|
+
lines.push(` * @param ${param.name} — ${param.description}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (op.requestBody && op.requestBody.contentTypes.length > 0) {
|
|
61
|
+
if (lines.length > 0) {
|
|
62
|
+
lines.push(' *');
|
|
63
|
+
}
|
|
64
|
+
lines.push(` * @param body — request body`);
|
|
65
|
+
}
|
|
66
|
+
if (op.tags && op.tags.length > 0) {
|
|
67
|
+
if (lines.length > 0) {
|
|
68
|
+
lines.push(' *');
|
|
69
|
+
}
|
|
70
|
+
for (const tag of op.tags) {
|
|
71
|
+
lines.push(` * @category ${tag}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (op.deprecated) {
|
|
75
|
+
if (lines.length > 0) {
|
|
76
|
+
lines.push(' *');
|
|
77
|
+
}
|
|
78
|
+
lines.push(' * @deprecated');
|
|
79
|
+
}
|
|
80
|
+
const deprecatedParams = allParams.filter((param) => param.deprecated === true);
|
|
81
|
+
if (deprecatedParams.length > 0) {
|
|
82
|
+
if (lines.length > 0) {
|
|
83
|
+
lines.push(' *');
|
|
84
|
+
}
|
|
85
|
+
for (const param of deprecatedParams) {
|
|
86
|
+
lines.push(` * @deprecated ${param.name} — This parameter is deprecated`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (lines.length === 0) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
return `/**\n${lines.join('\n')}\n */`;
|
|
93
|
+
}
|
|
94
|
+
function buildUrlConstruction(op) {
|
|
95
|
+
let url = op.path;
|
|
96
|
+
for (const param of op.pathParams) {
|
|
97
|
+
url = url.replace(`{${param.name}}`, `\${encodeURIComponent(${param.name})}`);
|
|
98
|
+
}
|
|
99
|
+
return `\`${url}\``;
|
|
100
|
+
}
|
|
101
|
+
function buildImplementation(op) {
|
|
102
|
+
const successType = getSuccessType(op);
|
|
103
|
+
const prefix = getOperationTypePrefix(op);
|
|
104
|
+
const lines = [];
|
|
105
|
+
if (op.pathParams.length > 0) {
|
|
106
|
+
lines.push(`const url = ${buildUrlConstruction(op)};`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
lines.push(`const url = "${op.path}";`);
|
|
110
|
+
}
|
|
111
|
+
if (op.queryParams.length > 0) {
|
|
112
|
+
lines.push(`const queryString = query ? "?" + new URLSearchParams(query as Record<string, string>).toString() : "";`);
|
|
113
|
+
lines.push('const fullUrl = url + queryString;');
|
|
114
|
+
}
|
|
115
|
+
const finalUrl = op.queryParams.length > 0 ? 'fullUrl' : 'url';
|
|
116
|
+
const requesterOpts = [];
|
|
117
|
+
if (op.queryParams.length > 0) {
|
|
118
|
+
requesterOpts.push('query');
|
|
119
|
+
}
|
|
120
|
+
if (op.headerParams.length > 0) {
|
|
121
|
+
requesterOpts.push('headers');
|
|
122
|
+
}
|
|
123
|
+
if (op.requestBody) {
|
|
124
|
+
requesterOpts.push('body');
|
|
125
|
+
}
|
|
126
|
+
// Error responses for status-specific checks
|
|
127
|
+
const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
|
|
128
|
+
// Lines for inside `if (result instanceof ErrorResponse) { ... }`
|
|
129
|
+
const hasDefaultResponse = op.responses.some((r) => !r.isSuccess && r.statusCode === 'default');
|
|
130
|
+
const errorCheckLines = [];
|
|
131
|
+
for (const errResp of errorResponses) {
|
|
132
|
+
const status = errResp.statusCode;
|
|
133
|
+
errorCheckLines.push(`if (result.status === ${status}) throw new ApiError(${status}, result.data as ${prefix}Error${status}, result.message ?? \`Request failed with status ${status}\`);`);
|
|
134
|
+
}
|
|
135
|
+
if (hasDefaultResponse) {
|
|
136
|
+
errorCheckLines.push(`throw new DefaultApiError(result.status, result.data as ${prefix}DefaultError, result.message ?? \`Request failed with status \${result.status}\`);`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
errorCheckLines.push('throw new UnspecifiedApiError(result.status, result.data, result.message ?? `Request failed with status ${result.status}`);');
|
|
140
|
+
}
|
|
141
|
+
// Build try/catch block around requester call
|
|
142
|
+
const buildTryCatch = (opts) => {
|
|
143
|
+
const block = [];
|
|
144
|
+
block.push('try {');
|
|
145
|
+
block.push(` const result = await requester<${successType}>("${op.method.toUpperCase()}", ${finalUrl}, ${opts});`);
|
|
146
|
+
block.push(' if (result instanceof ErrorResponse) {');
|
|
147
|
+
for (const check of errorCheckLines) {
|
|
148
|
+
block.push(` ${check}`);
|
|
149
|
+
}
|
|
150
|
+
block.push(' }');
|
|
151
|
+
if (op.responses.some((r) => r.isSuccess && r.isBinary)) {
|
|
152
|
+
block.push(' if (!(result instanceof StreamResponse)) {');
|
|
153
|
+
block.push(' throw new RequesterFailError(new Error("Expected stream response"));');
|
|
154
|
+
block.push(' }');
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
block.push(' if (result instanceof StreamResponse) {');
|
|
158
|
+
block.push(' throw new RequesterFailError(new Error("Unexpected stream response"));');
|
|
159
|
+
block.push(' }');
|
|
160
|
+
}
|
|
161
|
+
block.push(' return result;');
|
|
162
|
+
block.push('} catch (error) {');
|
|
163
|
+
block.push(' if (error instanceof UnspecifiedApiError) throw error;');
|
|
164
|
+
block.push(' if (error instanceof ApiError) throw error;');
|
|
165
|
+
block.push(' throw new RequesterFailError(error);');
|
|
166
|
+
block.push('}');
|
|
167
|
+
return block;
|
|
168
|
+
};
|
|
169
|
+
if (op.requestBody?.isMultipart && op.requestBody.schema) {
|
|
170
|
+
const schema = op.requestBody.schema;
|
|
171
|
+
const requiredSet = new Set((schema?.required ?? []));
|
|
172
|
+
const properties = schema.properties ?? {};
|
|
173
|
+
const propNames = Object.keys(properties);
|
|
174
|
+
const bodyRequired = op.requestBody.required;
|
|
175
|
+
const formDataLines = [];
|
|
176
|
+
formDataLines.push('const formData = new FormData();');
|
|
177
|
+
for (const propName of propNames) {
|
|
178
|
+
const propSchema = properties[propName];
|
|
179
|
+
const isArrayBinary = propSchema?.type === 'array' && propSchema?.items?.format === 'binary';
|
|
180
|
+
if (propSchema?.format === 'binary') {
|
|
181
|
+
if (requiredSet.has(propName)) {
|
|
182
|
+
formDataLines.push(`formData.append("${propName}", body.${propName}.data, body.${propName}.filename);`);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
formDataLines.push(`if (body.${propName} !== undefined) formData.append("${propName}", body.${propName}.data, body.${propName}.filename);`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (isArrayBinary) {
|
|
189
|
+
formDataLines.push(`if (body.${propName} !== undefined) { for (const file of body.${propName}) { formData.append("${propName}", file.data, file.filename); } }`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
formDataLines.push(`if (body.${propName} !== undefined) formData.append("${propName}", body.${propName});`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const bodyIdx = requesterOpts.indexOf('body');
|
|
196
|
+
if (bodyIdx !== -1)
|
|
197
|
+
requesterOpts[bodyIdx] = 'body: formData';
|
|
198
|
+
if (op.responses.some((r) => r.isSuccess && r.isBinary)) {
|
|
199
|
+
requesterOpts.push('expectStream: true');
|
|
200
|
+
}
|
|
201
|
+
const multipartOpts = requesterOpts.length > 0 ? `{ ${requesterOpts.join(', ')} }` : '{}';
|
|
202
|
+
if (bodyRequired) {
|
|
203
|
+
lines.push(...formDataLines);
|
|
204
|
+
lines.push(...buildTryCatch(multipartOpts));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
lines.push('if (body) {');
|
|
208
|
+
for (const line of formDataLines) {
|
|
209
|
+
lines.push(` ${line}`);
|
|
210
|
+
}
|
|
211
|
+
for (const line of buildTryCatch(multipartOpts)) {
|
|
212
|
+
lines.push(` ${line}`);
|
|
213
|
+
}
|
|
214
|
+
lines.push('}');
|
|
215
|
+
const fallbackOpts = requesterOpts.filter((o) => o !== 'body: formData' && o !== 'body');
|
|
216
|
+
const fallbackOptsStr = fallbackOpts.length > 0 ? `{ ${fallbackOpts.join(', ')} }` : '{}';
|
|
217
|
+
lines.push(...buildTryCatch(fallbackOptsStr));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
if (op.responses.some((r) => r.isSuccess && r.isBinary)) {
|
|
222
|
+
requesterOpts.push('expectStream: true');
|
|
223
|
+
}
|
|
224
|
+
const optsStr = requesterOpts.length > 0 ? `{ ${requesterOpts.join(', ')} }` : '{}';
|
|
225
|
+
lines.push(...buildTryCatch(optsStr));
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n ');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Generate a client method from an analyzed OpenAPI operation.
|
|
231
|
+
*
|
|
232
|
+
* @param op - The analyzed operation
|
|
233
|
+
* @returns Generated method with name, JSDoc, signature, and implementation
|
|
234
|
+
*/
|
|
235
|
+
export function generateMethod(op) {
|
|
236
|
+
const params = buildParameters(op);
|
|
237
|
+
const successType = getSuccessType(op);
|
|
238
|
+
const jsDoc = buildJsDoc(op);
|
|
239
|
+
const signature = `${op.methodName}(${params}): Promise<${successType}>`;
|
|
240
|
+
const implementation = `${signature} {
|
|
241
|
+
${buildImplementation(op)}
|
|
242
|
+
}`;
|
|
243
|
+
return {
|
|
244
|
+
name: op.methodName,
|
|
245
|
+
jsDoc,
|
|
246
|
+
signature,
|
|
247
|
+
implementation,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ApiClient } from './generator/client-generator.js';
|
|
2
|
+
import { ApiError, DefaultApiError } from './generator/error-types.js';
|
|
3
|
+
import type { GeneratorConfig } from './types/client.js';
|
|
4
|
+
export declare function generateClient(config: GeneratorConfig): Promise<void>;
|
|
5
|
+
export type { GeneratorConfig, ApiClient, ApiError, DefaultApiError };
|
|
6
|
+
export type { GenerationOptions } from './generator/client-generator.js';
|
|
7
|
+
export { load as loadSpec } from './parser/spec-reader.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { generateFullOutput } from './generator/client-generator.js';
|
|
2
|
+
import { ApiError, DefaultApiError } from './generator/error-types.js';
|
|
3
|
+
import { load } from './parser/spec-reader.js';
|
|
4
|
+
export async function generateClient(config) {
|
|
5
|
+
const doc = await load(config.input);
|
|
6
|
+
await generateFullOutput(doc, config);
|
|
7
|
+
}
|
|
8
|
+
export { load as loadSpec } from './parser/spec-reader.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OpenAPIDocument, ReferenceObject, SchemaObject } from '../types/openapi.js';
|
|
2
|
+
export declare class RefResolver {
|
|
3
|
+
private doc;
|
|
4
|
+
private preserveRefSiblings;
|
|
5
|
+
constructor(doc: OpenAPIDocument, documentUrl?: string, options?: {
|
|
6
|
+
preserveRefSiblings?: boolean;
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* If input has `$ref`, resolve it; otherwise return as-is.
|
|
10
|
+
*/
|
|
11
|
+
resolve<T>(ref: ReferenceObject | T): T;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a JSON Pointer $ref string to the referenced object.
|
|
14
|
+
* Follows chained refs up to MAX_DEPTH hops.
|
|
15
|
+
* Throws on cycles, external refs, missing targets, and depth overflow.
|
|
16
|
+
*/
|
|
17
|
+
resolveRef(refString: string): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* Specifically resolve schema references, following chained refs.
|
|
20
|
+
* Returns a SchemaObject (never a reference).
|
|
21
|
+
*/
|
|
22
|
+
resolveSchema(schema: SchemaObject | ReferenceObject): SchemaObject;
|
|
23
|
+
private resolveRefInternal;
|
|
24
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { parseJsonPointer } from '../utils/url.js';
|
|
2
|
+
const MAX_DEPTH = 10;
|
|
3
|
+
export class RefResolver {
|
|
4
|
+
doc;
|
|
5
|
+
preserveRefSiblings;
|
|
6
|
+
constructor(doc, documentUrl, options) {
|
|
7
|
+
this.doc = doc;
|
|
8
|
+
this.preserveRefSiblings = options?.preserveRefSiblings ?? false;
|
|
9
|
+
void documentUrl;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* If input has `$ref`, resolve it; otherwise return as-is.
|
|
13
|
+
*/
|
|
14
|
+
resolve(ref) {
|
|
15
|
+
const obj = ref;
|
|
16
|
+
if (obj !== null && typeof obj === 'object' && '$ref' in obj) {
|
|
17
|
+
const resolved = this.resolveRef(obj.$ref);
|
|
18
|
+
if (this.preserveRefSiblings) {
|
|
19
|
+
const siblings = {};
|
|
20
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
21
|
+
if (key !== '$ref') {
|
|
22
|
+
siblings[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (Object.keys(siblings).length > 0) {
|
|
26
|
+
return { ...resolved, ...siblings };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
return ref;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a JSON Pointer $ref string to the referenced object.
|
|
35
|
+
* Follows chained refs up to MAX_DEPTH hops.
|
|
36
|
+
* Throws on cycles, external refs, missing targets, and depth overflow.
|
|
37
|
+
*/
|
|
38
|
+
resolveRef(refString) {
|
|
39
|
+
const resolving = new Set();
|
|
40
|
+
return this.resolveRefInternal(refString, resolving, 0);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Specifically resolve schema references, following chained refs.
|
|
44
|
+
* Returns a SchemaObject (never a reference).
|
|
45
|
+
*/
|
|
46
|
+
resolveSchema(schema) {
|
|
47
|
+
const obj = schema;
|
|
48
|
+
if (obj !== null && typeof obj === 'object' && '$ref' in obj) {
|
|
49
|
+
let resolved = this.resolveRef(obj.$ref);
|
|
50
|
+
if (this.preserveRefSiblings) {
|
|
51
|
+
const siblings = {};
|
|
52
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
53
|
+
if (key !== '$ref') {
|
|
54
|
+
siblings[key] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(siblings).length > 0) {
|
|
58
|
+
resolved = {
|
|
59
|
+
...resolved,
|
|
60
|
+
...siblings,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (resolved !== null && typeof resolved === 'object' && '$ref' in resolved) {
|
|
65
|
+
return this.resolveSchema(resolved);
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
return schema;
|
|
70
|
+
}
|
|
71
|
+
resolveRefInternal(refString, resolving, depth) {
|
|
72
|
+
if (refString.startsWith('http://') || refString.startsWith('https://')) {
|
|
73
|
+
throw new Error(`External $ref resolution is not supported: ${refString}`);
|
|
74
|
+
}
|
|
75
|
+
if (!refString.startsWith('#')) {
|
|
76
|
+
throw new Error(`External $ref resolution is not supported: ${refString}`);
|
|
77
|
+
}
|
|
78
|
+
if (depth >= MAX_DEPTH) {
|
|
79
|
+
throw new Error(`Maximum $ref depth (${MAX_DEPTH}) exceeded: ${refString}`);
|
|
80
|
+
}
|
|
81
|
+
if (resolving.has(refString)) {
|
|
82
|
+
const cyclePath = [...resolving, refString].join(' -> ');
|
|
83
|
+
throw new Error(`Circular $ref detected: ${cyclePath}`);
|
|
84
|
+
}
|
|
85
|
+
resolving.add(refString);
|
|
86
|
+
const pointer = refString.slice(1);
|
|
87
|
+
const segments = parseJsonPointer(pointer);
|
|
88
|
+
let current = this.doc;
|
|
89
|
+
for (let i = 0; i < segments.length; i++) {
|
|
90
|
+
if (current === null || current === undefined) {
|
|
91
|
+
throw new Error(`$ref "${refString}" could not be resolved: segment "${segments[i]}" not found`);
|
|
92
|
+
}
|
|
93
|
+
if (typeof current !== 'object') {
|
|
94
|
+
throw new Error(`$ref "${refString}" could not be resolved: segment "${segments[i]}" is not an object`);
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(current)) {
|
|
97
|
+
const index = Number(segments[i]);
|
|
98
|
+
if (Number.isNaN(index)) {
|
|
99
|
+
throw new Error(`$ref "${refString}" could not be resolved: "${segments[i]}" is not a valid array index`);
|
|
100
|
+
}
|
|
101
|
+
current = current[index];
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
current = current[segments[i]];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (current === undefined) {
|
|
108
|
+
throw new Error(`$ref "${refString}" could not be resolved`);
|
|
109
|
+
}
|
|
110
|
+
if (current !== null &&
|
|
111
|
+
typeof current === 'object' &&
|
|
112
|
+
!Array.isArray(current) &&
|
|
113
|
+
'$ref' in current) {
|
|
114
|
+
const chainedRef = current.$ref;
|
|
115
|
+
return this.resolveRefInternal(chainedRef, new Set(resolving), depth + 1);
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OpenAPIDocument } from '../types/openapi.js';
|
|
2
|
+
export declare function loadFromFile(filePath: string): Promise<OpenAPIDocument>;
|
|
3
|
+
export declare function loadFromUrl(url: string): Promise<OpenAPIDocument>;
|
|
4
|
+
export declare function load(source: string): Promise<OpenAPIDocument>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { isUrl } from '../utils/url.js';
|
|
5
|
+
const VALID_EXTENSIONS = ['.json', '.yaml', '.yml'];
|
|
6
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
7
|
+
function assertSupportedVersion(doc) {
|
|
8
|
+
if (typeof doc !== 'object' ||
|
|
9
|
+
doc === null ||
|
|
10
|
+
!('openapi' in doc) ||
|
|
11
|
+
typeof doc.openapi !== 'string') {
|
|
12
|
+
throw new Error("Invalid spec: missing or invalid 'openapi' field.");
|
|
13
|
+
}
|
|
14
|
+
const version = doc.openapi;
|
|
15
|
+
// Check for valid version format
|
|
16
|
+
if (!version.match(/^\d+\.\d+(\.\d+)?$/)) {
|
|
17
|
+
throw new Error(`Invalid OpenAPI version format: ${version}`);
|
|
18
|
+
}
|
|
19
|
+
// Check that major version is 3 (but don't restrict minor versions)
|
|
20
|
+
if (!version.startsWith('3.')) {
|
|
21
|
+
throw new Error(`Unsupported OpenAPI version: ${version}. Supported versions: 3.0, 3.1, 3.2`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function parseContent(content, ext) {
|
|
25
|
+
if (ext === '.json') {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
throw new Error(`Failed to parse JSON: ${err.message}`, { cause: err });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return parseYaml(content);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new Error(`Failed to parse YAML: ${err.message}`, { cause: err });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function loadFromFile(filePath) {
|
|
41
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
42
|
+
if (!VALID_EXTENSIONS.includes(ext)) {
|
|
43
|
+
throw new Error(`Unsupported file extension: "${ext}". Supported extensions: ${VALID_EXTENSIONS.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
let fileStat;
|
|
46
|
+
try {
|
|
47
|
+
fileStat = await stat(filePath);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (code === 'ENOENT') {
|
|
52
|
+
throw new Error(`Failed to load spec: file not found: ${filePath}`, { cause: err });
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Failed to load spec from file: ${err.message}`, { cause: err });
|
|
55
|
+
}
|
|
56
|
+
if (fileStat.size > MAX_FILE_SIZE) {
|
|
57
|
+
throw new Error(`Spec file too large: ${(fileStat.size / 1024 / 1024).toFixed(1)}MB exceeds 50MB limit`);
|
|
58
|
+
}
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = await readFile(filePath, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const code = err.code;
|
|
65
|
+
if (code === 'ENOENT') {
|
|
66
|
+
throw new Error(`Failed to load spec: file not found: ${filePath}`, { cause: err });
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Failed to load spec from file: ${err.message}`, { cause: err });
|
|
69
|
+
}
|
|
70
|
+
const doc = parseContent(content, ext);
|
|
71
|
+
if (typeof doc !== 'object' || doc === null) {
|
|
72
|
+
throw new Error('Failed to parse: spec is not an object.');
|
|
73
|
+
}
|
|
74
|
+
assertSupportedVersion(doc);
|
|
75
|
+
return doc;
|
|
76
|
+
}
|
|
77
|
+
export async function loadFromUrl(url) {
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(url);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new Error(`Failed to fetch spec from URL: ${err.message}`, { cause: err });
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Failed to fetch spec from URL: ${response.status} ${response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
const contentLength = response.headers.get('content-length');
|
|
89
|
+
if (contentLength && Number(contentLength) > MAX_FILE_SIZE) {
|
|
90
|
+
throw new Error(`Spec from URL too large: ${(Number(contentLength) / 1024 / 1024).toFixed(1)}MB exceeds 50MB limit`);
|
|
91
|
+
}
|
|
92
|
+
const text = await response.text();
|
|
93
|
+
let doc;
|
|
94
|
+
try {
|
|
95
|
+
doc = JSON.parse(text);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
try {
|
|
99
|
+
doc = parseYaml(text);
|
|
100
|
+
}
|
|
101
|
+
catch (yamlErr) {
|
|
102
|
+
throw new Error(`Failed to parse spec from URL as JSON or YAML: ${yamlErr.message}`, { cause: yamlErr });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (typeof doc !== 'object' || doc === null) {
|
|
106
|
+
throw new Error('Failed to parse: spec is not an object.');
|
|
107
|
+
}
|
|
108
|
+
assertSupportedVersion(doc);
|
|
109
|
+
return doc;
|
|
110
|
+
}
|
|
111
|
+
export async function load(source) {
|
|
112
|
+
if (isUrl(source)) {
|
|
113
|
+
return loadFromUrl(source);
|
|
114
|
+
}
|
|
115
|
+
return loadFromFile(source);
|
|
116
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { VersionStrategy } from './version/version-strategy.js';
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
valid: boolean;
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function validateOpenAPIVersion(version: string): boolean;
|
|
7
|
+
export declare function validateSpec(doc: unknown, strategy?: VersionStrategy): ValidationResult;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function validateOpenAPIVersion(version) {
|
|
2
|
+
return typeof version === 'string' && version.startsWith('3.1');
|
|
3
|
+
}
|
|
4
|
+
export function validateSpec(doc, strategy) {
|
|
5
|
+
if (strategy) {
|
|
6
|
+
try {
|
|
7
|
+
const normalized = strategy.normalizeSpec(doc);
|
|
8
|
+
return strategy.validateSpec(normalized);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
// normalizeSpec may throw for structurally invalid specs;
|
|
12
|
+
// but re-throw "not yet supported" errors
|
|
13
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
14
|
+
const errorMessage = error.message;
|
|
15
|
+
if (errorMessage.includes('not yet supported')) {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// fall through to default 3.1-only validation below
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const errors = [];
|
|
23
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
24
|
+
errors.push('Document must be an object');
|
|
25
|
+
return { valid: false, errors };
|
|
26
|
+
}
|
|
27
|
+
const spec = doc;
|
|
28
|
+
// Check openapi field
|
|
29
|
+
if (!spec.openapi || typeof spec.openapi !== 'string') {
|
|
30
|
+
errors.push("OpenAPI specification must have an 'openapi' field with string value");
|
|
31
|
+
}
|
|
32
|
+
else if (!validateOpenAPIVersion(spec.openapi)) {
|
|
33
|
+
errors.push(`OpenAPI version must start with '3.1', got: ${spec.openapi}`);
|
|
34
|
+
}
|
|
35
|
+
// Check info field
|
|
36
|
+
if (!spec.info || typeof spec.info !== 'object' || Array.isArray(spec.info)) {
|
|
37
|
+
errors.push("OpenAPI specification must have an 'info' field with object value");
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const info = spec.info;
|
|
41
|
+
if (!info.title || typeof info.title !== 'string') {
|
|
42
|
+
errors.push("Info object must have a 'title' field with string value");
|
|
43
|
+
}
|
|
44
|
+
if (!info.version || typeof info.version !== 'string') {
|
|
45
|
+
errors.push("Info object must have a 'version' field with string value");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Check at least one of paths, components, webhooks exists
|
|
49
|
+
if (!spec.paths && !spec.components && !spec.webhooks) {
|
|
50
|
+
errors.push("OpenAPI specification must have at least one of 'paths', 'components', or 'webhooks'");
|
|
51
|
+
}
|
|
52
|
+
// Validate paths if it exists
|
|
53
|
+
if (spec.paths) {
|
|
54
|
+
if (Array.isArray(spec.paths) || typeof spec.paths !== 'object') {
|
|
55
|
+
errors.push("'paths' field must be an object");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Validate components.schemas if it exists
|
|
59
|
+
if (spec.components && typeof spec.components === 'object' && !Array.isArray(spec.components)) {
|
|
60
|
+
const components = spec.components;
|
|
61
|
+
if (components.schemas) {
|
|
62
|
+
if (Array.isArray(components.schemas) || typeof components.schemas !== 'object') {
|
|
63
|
+
errors.push("'components.schemas' must be an object");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const schemas = components.schemas;
|
|
67
|
+
for (const [key, schema] of Object.entries(schemas)) {
|
|
68
|
+
if (typeof schema !== 'object' || Array.isArray(schema)) {
|
|
69
|
+
errors.push(`Schema '${key}' must be an object`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
valid: errors.length === 0,
|
|
77
|
+
errors,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version strategy interface for multi-version OpenAPI support
|
|
3
|
+
*/
|
|
4
|
+
export type { VersionStrategy } from './version-strategy.js';
|
|
5
|
+
/**
|
|
6
|
+
* Version detection and registry for multi-version OpenAPI support
|
|
7
|
+
*/
|
|
8
|
+
export { detectSpecVersion } from './version-detector.js';
|
|
9
|
+
export { VersionStrategyRegistry } from './registry.js';
|
|
10
|
+
/**
|
|
11
|
+
* Normalized schema and specification types for consistent multi-version handling
|
|
12
|
+
*/
|
|
13
|
+
export type { NormalizedSchema, NormalizedSpec } from './normalized-spec.js';
|
|
14
|
+
export { V3_0_VersionStrategy } from './v3.0/strategy.js';
|
|
15
|
+
export { V3_1_VersionStrategy } from './v3.1/strategy.js';
|
|
16
|
+
export { V3_2_VersionStrategy } from './v3.2/strategy.js';
|
|
17
|
+
import { VersionStrategyRegistry } from './registry.js';
|
|
18
|
+
export declare const defaultRegistry: VersionStrategyRegistry;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version detection and registry for multi-version OpenAPI support
|
|
3
|
+
*/
|
|
4
|
+
export { detectSpecVersion } from './version-detector.js';
|
|
5
|
+
export { VersionStrategyRegistry } from './registry.js';
|
|
6
|
+
export { V3_0_VersionStrategy } from './v3.0/strategy.js';
|
|
7
|
+
export { V3_1_VersionStrategy } from './v3.1/strategy.js';
|
|
8
|
+
export { V3_2_VersionStrategy } from './v3.2/strategy.js';
|
|
9
|
+
import { VersionStrategyRegistry } from './registry.js';
|
|
10
|
+
import { V3_0_VersionStrategy } from './v3.0/strategy.js';
|
|
11
|
+
import { V3_1_VersionStrategy } from './v3.1/strategy.js';
|
|
12
|
+
import { V3_2_VersionStrategy } from './v3.2/strategy.js';
|
|
13
|
+
export const defaultRegistry = new VersionStrategyRegistry();
|
|
14
|
+
defaultRegistry.register(new V3_0_VersionStrategy());
|
|
15
|
+
defaultRegistry.register(new V3_1_VersionStrategy());
|
|
16
|
+
defaultRegistry.register(new V3_2_VersionStrategy());
|