genoc 0.1.3 → 0.3.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/README.md +79 -26
- package/dist/analyzer/naming.js +3 -2
- package/dist/analyzer/path-analyzer.d.ts +4 -0
- package/dist/analyzer/path-analyzer.js +31 -1
- package/dist/analyzer/schema-mapper.d.ts +8 -0
- package/dist/analyzer/schema-mapper.js +69 -30
- package/dist/cli/app.d.ts +2 -0
- package/dist/cli/app.js +14 -0
- package/dist/cli/impl.js +11 -1
- package/dist/generator/client-generator.js +9 -44
- package/dist/generator/contracts-generator.d.ts +2 -3
- package/dist/generator/contracts-generator.js +141 -140
- package/dist/generator/method-generator.js +11 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -2
- package/dist/parser/spec-reader.d.ts +3 -2
- package/dist/parser/spec-reader.js +5 -4
- package/dist/runtime/defined-errors.d.ts +17 -0
- package/dist/runtime/defined-errors.js +27 -0
- package/dist/runtime/errors.d.ts +28 -0
- package/dist/runtime/errors.js +45 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/requester.d.ts +14 -0
- package/dist/runtime/requester.js +1 -0
- package/dist/runtime/responses.d.ts +19 -0
- package/dist/runtime/responses.js +28 -0
- package/dist/types/client.d.ts +2 -0
- package/dist/types/openapi.d.ts +4 -0
- package/dist/utils/generator-helpers.d.ts +60 -5
- package/dist/utils/generator-helpers.js +223 -14
- package/dist/utils/proxy.d.ts +6 -0
- package/dist/utils/proxy.js +81 -0
- package/package.json +7 -2
- package/dist/generator/error-types.d.ts +0 -23
- package/dist/generator/error-types.js +0 -87
|
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { analyzePaths } from '../analyzer/path-analyzer.js';
|
|
4
4
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
5
|
-
import { getOperationTypePrefix, getSuccessType, getErrorType, makeHeader, } from '../utils/generator-helpers.js';
|
|
5
|
+
import { DEFAULT_RUNTIME_IMPORT_PATH, getOperationTypePrefix, getSuccessType, getErrorType, makeHeader, } from '../utils/generator-helpers.js';
|
|
6
6
|
import { generateContracts } from './contracts-generator.js';
|
|
7
7
|
import { generateMethod } from './method-generator.js';
|
|
8
8
|
function collectImportTypes(operations) {
|
|
@@ -156,7 +156,7 @@ function buildClientMethodBody(op) {
|
|
|
156
156
|
}
|
|
157
157
|
return tryCatchLines.join('\n');
|
|
158
158
|
}
|
|
159
|
-
function buildClientFile(operations, version) {
|
|
159
|
+
function buildClientFile(operations, version, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
|
|
160
160
|
const lines = [];
|
|
161
161
|
lines.push(makeHeader(version));
|
|
162
162
|
const importTypes = collectImportTypes(operations);
|
|
@@ -180,47 +180,11 @@ function buildClientFile(operations, version) {
|
|
|
180
180
|
if (hasMultipart) {
|
|
181
181
|
lines.push('/* global FormData */');
|
|
182
182
|
}
|
|
183
|
+
lines.push(`import { decorateWithErrors } from '${runtimeImportPath}';`);
|
|
184
|
+
lines.push(`import type { Requester } from '${runtimeImportPath}';`);
|
|
183
185
|
lines.push('');
|
|
184
|
-
lines.push(
|
|
185
|
-
lines.push(
|
|
186
|
-
lines.push(' runtimeErrors: unknown,');
|
|
187
|
-
lines.push('): T & { __definedErrors: E } {');
|
|
188
|
-
lines.push(' Object.defineProperty(item, "__definedErrors", {');
|
|
189
|
-
lines.push(' value: runtimeErrors,');
|
|
190
|
-
lines.push(' enumerable: false,');
|
|
191
|
-
lines.push(' configurable: true,');
|
|
192
|
-
lines.push(' writable: false,');
|
|
193
|
-
lines.push(' });');
|
|
194
|
-
lines.push(' return item as T & { __definedErrors: E };');
|
|
195
|
-
lines.push('}');
|
|
196
|
-
lines.push('');
|
|
197
|
-
lines.push('export function isDefinedError<E extends ApiError<number, unknown>>(');
|
|
198
|
-
lines.push(' err: unknown,');
|
|
199
|
-
lines.push(' fn: { __definedErrors: E },');
|
|
200
|
-
lines.push('): err is E {');
|
|
201
|
-
lines.push(' if (err instanceof UnspecifiedApiError) return false;');
|
|
202
|
-
lines.push(' if (!(err instanceof ApiError)) return false;');
|
|
203
|
-
lines.push(' return true;');
|
|
204
|
-
lines.push('}');
|
|
205
|
-
lines.push('');
|
|
206
|
-
lines.push('/**');
|
|
207
|
-
lines.push(' * Performs an HTTP request and returns the response.');
|
|
208
|
-
lines.push(' *');
|
|
209
|
-
lines.push(' * When `expectStream` is true, the implementation should return');
|
|
210
|
-
lines.push(' * a `StreamResponse` containing the stream data, filename (from');
|
|
211
|
-
lines.push(' * Content-Disposition header), and response headers.');
|
|
212
|
-
lines.push(' */');
|
|
213
|
-
lines.push('export type Requester = <TResponse>(');
|
|
214
|
-
lines.push(' method: string,');
|
|
215
|
-
lines.push(' path: string,');
|
|
216
|
-
lines.push(' options: {');
|
|
217
|
-
lines.push(' query?: Record<string, unknown>;');
|
|
218
|
-
lines.push(' body?: unknown;');
|
|
219
|
-
lines.push(' headers?: Record<string, string>;');
|
|
220
|
-
lines.push(' expectStream?: true;');
|
|
221
|
-
lines.push(' },');
|
|
222
|
-
lines.push(') => Promise<TResponse | StreamResponse | ErrorResponse>;');
|
|
223
|
-
lines.push('');
|
|
186
|
+
lines.push(`export { isDefinedError } from '${runtimeImportPath}';`);
|
|
187
|
+
lines.push(`export type { Requester } from '${runtimeImportPath}';`);
|
|
224
188
|
lines.push('');
|
|
225
189
|
lines.push('export function createClient(requester: Requester) {');
|
|
226
190
|
lines.push(' return {');
|
|
@@ -269,9 +233,10 @@ export function generateClient(doc, config, options) {
|
|
|
269
233
|
const resolver = new RefResolver(doc, undefined, {
|
|
270
234
|
preserveRefSiblings: options?.preserveRefSiblings,
|
|
271
235
|
});
|
|
272
|
-
const
|
|
236
|
+
const runtimeImportPath = config.runtimeImportPath ?? DEFAULT_RUNTIME_IMPORT_PATH;
|
|
237
|
+
const contracts = generateContracts(doc, resolver, runtimeImportPath);
|
|
273
238
|
const operations = analyzePaths(doc, resolver, config.methodNameStrategy ?? 'path-based');
|
|
274
|
-
const client = buildClientFile(operations, doc.openapi);
|
|
239
|
+
const client = buildClientFile(operations, doc.openapi, runtimeImportPath);
|
|
275
240
|
return { contracts, client };
|
|
276
241
|
}
|
|
277
242
|
/**
|
|
@@ -10,7 +10,6 @@ import type { OpenAPIDocument } from '../types/openapi.js';
|
|
|
10
10
|
* 4. Header parameter types per operation
|
|
11
11
|
* 5. Request body types per operation
|
|
12
12
|
* 6. Response / error types per operation
|
|
13
|
-
* 7.
|
|
14
|
-
* 7b. UnspecifiedApiError class
|
|
13
|
+
* 7. Runtime re-export block (shared classes from the genoc runtime package)
|
|
15
14
|
*/
|
|
16
|
-
export declare function generateContracts(doc: OpenAPIDocument, resolver: RefResolver): string;
|
|
15
|
+
export declare function generateContracts(doc: OpenAPIDocument, resolver: RefResolver, runtimeImportPath?: string): string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { analyzePaths } from '../analyzer/path-analyzer.js';
|
|
2
2
|
import { SchemaMapper } from '../analyzer/schema-mapper.js';
|
|
3
3
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
4
|
-
import { RESERVED_TYPE_NAMES, buildSchemaRenameMap, sanitizeTypeName, toPascalCase, getOperationTypePrefix, makeHeader, } from '../utils/generator-helpers.js';
|
|
4
|
+
import { RESERVED_TYPE_NAMES, buildSchemaRenameMap, DEFAULT_RUNTIME_IMPORT_PATH, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeName, toPascalCase, getOperationTypePrefix, makeHeader, } from '../utils/generator-helpers.js';
|
|
5
5
|
function isBinaryContentType(ct) {
|
|
6
6
|
if (ct === 'application/octet-stream')
|
|
7
7
|
return true;
|
|
@@ -22,28 +22,88 @@ function substituteDiscriminatedType(tsType, schema, discriminatorInfo, renameMa
|
|
|
22
22
|
if (!refSchema || typeof refSchema !== 'object')
|
|
23
23
|
return tsType;
|
|
24
24
|
if (typeof refSchema.$ref === 'string') {
|
|
25
|
-
const
|
|
25
|
+
const rawName = refSchema.$ref.split('/').pop();
|
|
26
|
+
const schemaName = sanitizeTypeName(rawName);
|
|
26
27
|
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
27
|
-
const renamed = renameMap.get(
|
|
28
|
+
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
28
29
|
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
if (refSchema.items && typeof refSchema.items === 'object') {
|
|
32
33
|
const items = refSchema.items;
|
|
33
34
|
if (typeof items.$ref === 'string') {
|
|
34
|
-
const
|
|
35
|
+
const rawName = items.$ref.split('/').pop();
|
|
36
|
+
const schemaName = sanitizeTypeName(rawName);
|
|
35
37
|
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
36
|
-
const renamed = renameMap.get(
|
|
38
|
+
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
37
39
|
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
return tsType;
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
|
|
47
|
+
* the mapper's golden tests). Kept local to avoid modifying the mapper module.
|
|
48
|
+
*/
|
|
49
|
+
const INDENT_UNIT = ' ';
|
|
50
|
+
/**
|
|
51
|
+
* Build a description-only type-level JSDoc comment for operation-derived
|
|
52
|
+
* types (body / response). Returns '' when the description is absent or
|
|
53
|
+
* whitespace-only so callers can skip emission entirely.
|
|
54
|
+
*/
|
|
55
|
+
function buildDescriptionJsDoc(description) {
|
|
56
|
+
if (description === undefined)
|
|
57
|
+
return '';
|
|
58
|
+
const sanitized = sanitizeJsDocText(description);
|
|
59
|
+
return sanitized === '' ? '' : `/** ${sanitized} */`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Prefix a type definition with its JSDoc block (single- or multi-line),
|
|
63
|
+
* keeping the comment directly above the `export type` line.
|
|
64
|
+
*/
|
|
65
|
+
function attachTypeJsDoc(jsDoc, definition) {
|
|
66
|
+
return jsDoc === '' ? definition : `${jsDoc}\n${definition}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Indent every line of a JSDoc block by one indent unit so property comments
|
|
70
|
+
* line up exactly with their property line (mapper indentation contract).
|
|
71
|
+
*/
|
|
72
|
+
function indentJsDocBlock(jsDoc) {
|
|
73
|
+
return jsDoc.split('\n').map((line) => `${INDENT_UNIT}${line}`);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the JSDoc block for a query/header parameter property using the
|
|
77
|
+
* pinned parameter merge rule: parameter-level description / deprecated /
|
|
78
|
+
* example win over the schema's; default, title and examples come from the
|
|
79
|
+
* schema only (carried by the spread).
|
|
80
|
+
*/
|
|
81
|
+
function buildParamPropertyJsDoc(param, schema) {
|
|
82
|
+
return buildTypeJsDoc({
|
|
83
|
+
...schema,
|
|
84
|
+
description: param.description ?? schema.description,
|
|
85
|
+
deprecated: param.deprecated === true || schema.deprecated === true,
|
|
86
|
+
example: param.example ?? schema.example,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Render a `${prefix}Query` / `${prefix}Headers` object body in the mapper's
|
|
91
|
+
* multi-line format with per-property JSDoc from the parameter merge rule.
|
|
92
|
+
*/
|
|
93
|
+
function buildParamTypeBody(params, mapper) {
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (const param of params) {
|
|
96
|
+
const paramSchema = param.schema ?? { type: 'string' };
|
|
97
|
+
const result = mapper.mapSchema(paramSchema);
|
|
98
|
+
const optional = param.required ? '' : '?';
|
|
99
|
+
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
|
|
100
|
+
const jsDoc = buildParamPropertyJsDoc(param, paramSchema);
|
|
101
|
+
if (jsDoc !== '') {
|
|
102
|
+
lines.push(...indentJsDocBlock(jsDoc));
|
|
103
|
+
}
|
|
104
|
+
lines.push(`${INDENT_UNIT}${key}${optional}: ${result.tsType};`);
|
|
105
|
+
}
|
|
106
|
+
return `{\n${lines.join('\n')}\n}`;
|
|
47
107
|
}
|
|
48
108
|
function securitySchemeToTsType(scheme) {
|
|
49
109
|
const parts = [`type: "${scheme.type}"`];
|
|
@@ -150,6 +210,25 @@ function topologicalSort(entries, allNames) {
|
|
|
150
210
|
}
|
|
151
211
|
return sorted;
|
|
152
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Build the import + re-export block for the shared runtime classes.
|
|
215
|
+
* Generated code re-uses the genoc runtime as the single source of truth
|
|
216
|
+
* for class identity, so `instanceof` checks work across module boundaries.
|
|
217
|
+
*/
|
|
218
|
+
function buildRuntimeReexport(runtimeImportPath) {
|
|
219
|
+
return [
|
|
220
|
+
'',
|
|
221
|
+
`import { ApiError, StreamResponse } from '${runtimeImportPath}';`,
|
|
222
|
+
'export {',
|
|
223
|
+
' ApiError,',
|
|
224
|
+
' UnspecifiedApiError,',
|
|
225
|
+
' DefaultApiError,',
|
|
226
|
+
' RequesterFailError,',
|
|
227
|
+
' StreamResponse,',
|
|
228
|
+
' ErrorResponse,',
|
|
229
|
+
`} from '${runtimeImportPath}';`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
153
232
|
/**
|
|
154
233
|
* Generate the complete `*.contracts.ts` file content as a string.
|
|
155
234
|
*
|
|
@@ -160,22 +239,20 @@ function topologicalSort(entries, allNames) {
|
|
|
160
239
|
* 4. Header parameter types per operation
|
|
161
240
|
* 5. Request body types per operation
|
|
162
241
|
* 6. Response / error types per operation
|
|
163
|
-
* 7.
|
|
164
|
-
* 7b. UnspecifiedApiError class
|
|
242
|
+
* 7. Runtime re-export block (shared classes from the genoc runtime package)
|
|
165
243
|
*/
|
|
166
|
-
export function generateContracts(doc, resolver) {
|
|
244
|
+
export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
|
|
167
245
|
const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
|
|
168
246
|
const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
|
|
169
247
|
for (const [original, renamed] of renameMap) {
|
|
170
248
|
// TODO: Replace with structured logging solution
|
|
171
249
|
// oxlint-disable-next-line no-console
|
|
172
|
-
console.warn(`Warning: Schema "${original}" collides with a built-in type and was renamed to "${renamed}".`);
|
|
250
|
+
console.warn(`Warning: Schema "${original}" collides with a built-in type or another schema and was renamed to "${renamed}".`);
|
|
173
251
|
}
|
|
174
252
|
const renamingTypeGenerator = (refString) => {
|
|
175
253
|
const segments = refString.split('/');
|
|
176
254
|
const rawSegment = segments[segments.length - 1] || 'unknown';
|
|
177
|
-
|
|
178
|
-
return renameMap.get(lastSegment) ?? lastSegment;
|
|
255
|
+
return renameMap.get(rawSegment) ?? sanitizeTypeName(rawSegment);
|
|
179
256
|
};
|
|
180
257
|
const discriminatorInfo = new Map();
|
|
181
258
|
if (doc.components?.schemas) {
|
|
@@ -185,12 +262,13 @@ export function generateContracts(doc, resolver) {
|
|
|
185
262
|
const mapping = new Map();
|
|
186
263
|
if (resolved.discriminator.mapping) {
|
|
187
264
|
for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
|
|
188
|
-
const
|
|
189
|
-
const renamedTarget = renameMap.get(
|
|
265
|
+
const rawTarget = ref.split('/').pop() || key;
|
|
266
|
+
const renamedTarget = renameMap.get(rawTarget) ?? sanitizeTypeName(rawTarget);
|
|
190
267
|
mapping.set(key, renamedTarget);
|
|
191
268
|
}
|
|
192
269
|
}
|
|
193
270
|
discriminatorInfo.set(sanitizeTypeName(name), {
|
|
271
|
+
rawName: name,
|
|
194
272
|
propertyName: resolved.discriminator.propertyName,
|
|
195
273
|
mapping,
|
|
196
274
|
});
|
|
@@ -209,26 +287,27 @@ export function generateContracts(doc, resolver) {
|
|
|
209
287
|
const allSchemaNames = new Set();
|
|
210
288
|
if (doc.components?.schemas) {
|
|
211
289
|
for (const name of Object.keys(doc.components.schemas)) {
|
|
212
|
-
allSchemaNames.add(sanitizeTypeName(name));
|
|
290
|
+
allSchemaNames.add(renameMap.get(name) ?? sanitizeTypeName(name));
|
|
213
291
|
}
|
|
214
292
|
}
|
|
215
293
|
const mapper = new SchemaMapper(resolver, renamingTypeGenerator, discriminatorTargets, allSchemaNames);
|
|
216
294
|
const lines = [];
|
|
217
295
|
lines.push(makeHeader(doc.openapi));
|
|
296
|
+
const runtimeReexport = buildRuntimeReexport(runtimeImportPath);
|
|
297
|
+
lines.push(...runtimeReexport);
|
|
218
298
|
// Section 1: Schema types
|
|
219
299
|
const schemaEntries = [];
|
|
220
300
|
if (doc.components?.schemas) {
|
|
221
301
|
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
const result = mapper.mapSchema(schema, sanitizedName);
|
|
302
|
+
const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
|
|
303
|
+
const result = mapper.mapSchema(schema, renamedName);
|
|
225
304
|
const resolved = resolver.resolve(schema);
|
|
226
305
|
const definition = `export type ${renamedName} = ${result.tsType};`;
|
|
227
306
|
schemaEntries.push({
|
|
228
307
|
name: renamedName,
|
|
229
308
|
kind: 'type',
|
|
230
309
|
definition,
|
|
231
|
-
jsDoc:
|
|
310
|
+
jsDoc: buildTypeJsDoc(resolved) || undefined,
|
|
232
311
|
});
|
|
233
312
|
}
|
|
234
313
|
}
|
|
@@ -240,12 +319,12 @@ export function generateContracts(doc, resolver) {
|
|
|
240
319
|
}
|
|
241
320
|
lines.push(entry.definition);
|
|
242
321
|
}
|
|
243
|
-
for (const [
|
|
322
|
+
for (const [, info] of discriminatorInfo) {
|
|
244
323
|
const subtypeNames = Array.from(info.mapping.values());
|
|
245
324
|
if (subtypeNames.length === 0)
|
|
246
325
|
continue;
|
|
247
326
|
const unionType = subtypeNames.join(' | ');
|
|
248
|
-
const renamedBase = renameMap.get(
|
|
327
|
+
const renamedBase = renameMap.get(info.rawName) ?? sanitizeTypeName(info.rawName);
|
|
249
328
|
lines.push('');
|
|
250
329
|
lines.push(`export type ${renamedBase}Variant = ${unionType};`);
|
|
251
330
|
allSchemaNames.add(`${renamedBase}Variant`);
|
|
@@ -254,12 +333,21 @@ export function generateContracts(doc, resolver) {
|
|
|
254
333
|
const securitySchemes = doc.components?.securitySchemes;
|
|
255
334
|
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
|
|
256
335
|
const securityTypeNames = [];
|
|
336
|
+
const usedSecurityTypeNames = new Set();
|
|
257
337
|
for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
|
|
258
|
-
const
|
|
338
|
+
const baseTypeName = `${sanitizeTypeName(toPascalCase(schemeName))}Auth`;
|
|
339
|
+
let typeName = baseTypeName;
|
|
340
|
+
let n = 2;
|
|
341
|
+
while (usedSecurityTypeNames.has(typeName)) {
|
|
342
|
+
typeName = `${baseTypeName}${n}`;
|
|
343
|
+
n += 1;
|
|
344
|
+
}
|
|
345
|
+
usedSecurityTypeNames.add(typeName);
|
|
259
346
|
const tsType = securitySchemeToTsType(scheme);
|
|
260
|
-
|
|
347
|
+
const schemeJsDoc = buildDescriptionJsDoc(scheme.description);
|
|
348
|
+
if (schemeJsDoc !== '') {
|
|
261
349
|
lines.push('');
|
|
262
|
-
lines.push(
|
|
350
|
+
lines.push(schemeJsDoc);
|
|
263
351
|
}
|
|
264
352
|
lines.push('');
|
|
265
353
|
lines.push(`export type ${typeName} = ${tsType};`);
|
|
@@ -283,10 +371,13 @@ export function generateContracts(doc, resolver) {
|
|
|
283
371
|
const sv = variable;
|
|
284
372
|
const jsDocParts = [];
|
|
285
373
|
if (sv.description) {
|
|
286
|
-
|
|
374
|
+
const description = sanitizeJsDocText(sv.description);
|
|
375
|
+
if (description !== '') {
|
|
376
|
+
jsDocParts.push(description);
|
|
377
|
+
}
|
|
287
378
|
}
|
|
288
379
|
if (sv.default !== undefined) {
|
|
289
|
-
jsDocParts.push(`@default ${sv.default}`);
|
|
380
|
+
jsDocParts.push(`@default ${sanitizeJsDocText(String(sv.default))}`);
|
|
290
381
|
}
|
|
291
382
|
let tsType;
|
|
292
383
|
if (sv.enum && sv.enum.length > 0) {
|
|
@@ -327,31 +418,19 @@ export function generateContracts(doc, resolver) {
|
|
|
327
418
|
const opLines = [];
|
|
328
419
|
// Section 2: Query parameter types
|
|
329
420
|
if (op.queryParams.length > 0) {
|
|
330
|
-
|
|
331
|
-
const paramSchema = param.schema ?? { type: 'string' };
|
|
332
|
-
const result = mapper.mapSchema(paramSchema);
|
|
333
|
-
const optional = param.required ? '' : '?';
|
|
334
|
-
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
|
|
335
|
-
return `${key}${optional}: ${result.tsType}`;
|
|
336
|
-
});
|
|
337
|
-
opLines.push(`export type ${prefix}Query = { ${props.join('; ')}; };`);
|
|
421
|
+
opLines.push(`export type ${prefix}Query = ${buildParamTypeBody(op.queryParams, mapper)};`);
|
|
338
422
|
}
|
|
339
423
|
// Section 2b: Header parameter types
|
|
340
424
|
if (op.headerParams.length > 0) {
|
|
341
|
-
|
|
342
|
-
const paramSchema = param.schema ?? { type: 'string' };
|
|
343
|
-
const result = mapper.mapSchema(paramSchema);
|
|
344
|
-
const optional = param.required ? '' : '?';
|
|
345
|
-
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
|
|
346
|
-
return `${key}${optional}: ${result.tsType}`;
|
|
347
|
-
});
|
|
348
|
-
opLines.push(`export type ${prefix}Headers = { ${props.join('; ')}; };`);
|
|
425
|
+
opLines.push(`export type ${prefix}Headers = ${buildParamTypeBody(op.headerParams, mapper)};`);
|
|
349
426
|
}
|
|
350
427
|
// Section 3: Request body types
|
|
428
|
+
const bodyJsDoc = op.requestBody ? buildDescriptionJsDoc(op.requestBody.description) : '';
|
|
351
429
|
if (op.requestBody?.isMultipart && op.requestBody.schema) {
|
|
352
430
|
const schema = resolver.resolveSchema(op.requestBody.schema);
|
|
353
431
|
const requiredSet = new Set(schema.required ?? []);
|
|
354
|
-
const
|
|
432
|
+
const propLines = [];
|
|
433
|
+
for (const [name, propSchema] of Object.entries(schema.properties ?? {})) {
|
|
355
434
|
const resolved = resolver.resolveSchema(propSchema);
|
|
356
435
|
const optional = requiredSet.has(name) ? '' : '?';
|
|
357
436
|
let tsType;
|
|
@@ -364,23 +443,27 @@ export function generateContracts(doc, resolver) {
|
|
|
364
443
|
else {
|
|
365
444
|
tsType = 'string';
|
|
366
445
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
446
|
+
const jsDoc = buildTypeJsDoc(resolved);
|
|
447
|
+
if (jsDoc !== '') {
|
|
448
|
+
propLines.push(...indentJsDocBlock(jsDoc));
|
|
449
|
+
}
|
|
450
|
+
propLines.push(`${INDENT_UNIT}${name}${optional}: ${tsType};`);
|
|
451
|
+
}
|
|
452
|
+
if (propLines.length > 0) {
|
|
453
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = {\n${propLines.join('\n')}\n};`));
|
|
371
454
|
}
|
|
372
455
|
else {
|
|
373
|
-
opLines.push(`export type ${prefix}Body = Record<string, never>;`);
|
|
456
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = Record<string, never>;`));
|
|
374
457
|
}
|
|
375
458
|
}
|
|
376
459
|
else if (op.requestBody?.schema) {
|
|
377
460
|
const hasBinaryContentType = op.requestBody.contentTypes.some(isBinaryContentType);
|
|
378
461
|
if (hasBinaryContentType) {
|
|
379
|
-
opLines.push(`export type ${prefix}Body = Blob;`);
|
|
462
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = Blob;`));
|
|
380
463
|
}
|
|
381
464
|
else {
|
|
382
465
|
const result = mapper.mapSchema(op.requestBody.schema, undefined, 'request');
|
|
383
|
-
opLines.push(`export type ${prefix}Body = ${result.tsType};`);
|
|
466
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = ${result.tsType};`));
|
|
384
467
|
}
|
|
385
468
|
}
|
|
386
469
|
// Section 4: Response types
|
|
@@ -399,7 +482,10 @@ export function generateContracts(doc, resolver) {
|
|
|
399
482
|
return r.tsType;
|
|
400
483
|
});
|
|
401
484
|
const successType = types.length === 1 ? types[0] : types.join(' | ');
|
|
402
|
-
|
|
485
|
+
// Type-level JSDoc from the lowest-numbered 2xx response (the analyzer
|
|
486
|
+
// returns success responses in ascending status order).
|
|
487
|
+
const responseJsDoc = buildDescriptionJsDoc(successResponses[0].description);
|
|
488
|
+
opLines.push(attachTypeJsDoc(responseJsDoc, `export type ${prefix}Response = ${successType};`));
|
|
403
489
|
}
|
|
404
490
|
// Error types per status
|
|
405
491
|
const errorTypes = [];
|
|
@@ -452,92 +538,7 @@ export function generateContracts(doc, resolver) {
|
|
|
452
538
|
for (const brand of brandedTypes.values()) {
|
|
453
539
|
brandLines.push(`export type ${brand.name} = ${brand.baseType} & { readonly __format?: '${brand.format}' };`);
|
|
454
540
|
}
|
|
455
|
-
lines.splice(1, 0, '', ...brandLines);
|
|
456
|
-
}
|
|
457
|
-
// Always emit StreamResponse class (used by Requester type)
|
|
458
|
-
lines.push('');
|
|
459
|
-
lines.push('export class StreamResponse {');
|
|
460
|
-
lines.push(' constructor(');
|
|
461
|
-
lines.push(' public readonly data: ReadableStream<Uint8Array>,');
|
|
462
|
-
lines.push(' public readonly filename?: string,');
|
|
463
|
-
lines.push(' public readonly headers: Record<string, string> = {},');
|
|
464
|
-
lines.push(' ) {}');
|
|
465
|
-
lines.push('}');
|
|
466
|
-
lines.push('');
|
|
467
|
-
lines.push(`export function streamResponse(
|
|
468
|
-
data: ReadableStream<Uint8Array>,
|
|
469
|
-
filename?: string,
|
|
470
|
-
headers?: Record<string, string>,
|
|
471
|
-
): StreamResponse {
|
|
472
|
-
return new StreamResponse(data, filename, headers ?? {});
|
|
473
|
-
}`);
|
|
474
|
-
// ErrorResponse class
|
|
475
|
-
lines.push('');
|
|
476
|
-
lines.push(`export class ErrorResponse {
|
|
477
|
-
constructor(
|
|
478
|
-
public readonly status: number,
|
|
479
|
-
public readonly data: unknown,
|
|
480
|
-
public readonly headers: Record<string, string>,
|
|
481
|
-
public readonly message?: string,
|
|
482
|
-
) {}
|
|
483
|
-
}`);
|
|
484
|
-
// errorResponse() helper
|
|
485
|
-
lines.push('');
|
|
486
|
-
lines.push(`export function errorResponse(
|
|
487
|
-
status: number,
|
|
488
|
-
data: unknown,
|
|
489
|
-
headers?: Record<string, string>,
|
|
490
|
-
message?: string,
|
|
491
|
-
): ErrorResponse {
|
|
492
|
-
return new ErrorResponse(status, data, headers ?? {}, message);
|
|
493
|
-
}`);
|
|
494
|
-
// RequesterFailError class
|
|
495
|
-
lines.push('');
|
|
496
|
-
lines.push(`export class RequesterFailError extends Error {
|
|
497
|
-
constructor(
|
|
498
|
-
public readonly cause: unknown,
|
|
499
|
-
) {
|
|
500
|
-
super(\`Request failed: \${cause instanceof Error ? cause.message : String(cause)}\`);
|
|
501
|
-
this.name = "RequesterFailError";
|
|
502
|
-
}
|
|
503
|
-
}`);
|
|
504
|
-
// Section 5: ApiError class
|
|
505
|
-
lines.push('');
|
|
506
|
-
lines.push(`export class ApiError<TStatus extends number, TData> extends Error {
|
|
507
|
-
constructor(
|
|
508
|
-
public readonly status: TStatus,
|
|
509
|
-
public readonly data: TData,
|
|
510
|
-
message: string,
|
|
511
|
-
) {
|
|
512
|
-
super(message);
|
|
513
|
-
this.name = "ApiError";
|
|
514
|
-
}
|
|
515
|
-
}`);
|
|
516
|
-
// Section 5b: UnspecifiedApiError class
|
|
517
|
-
lines.push('');
|
|
518
|
-
lines.push(`export class UnspecifiedApiError extends ApiError<number, unknown> {
|
|
519
|
-
constructor(
|
|
520
|
-
status: number,
|
|
521
|
-
data: unknown,
|
|
522
|
-
message: string,
|
|
523
|
-
) {
|
|
524
|
-
super(status, data, message);
|
|
525
|
-
this.name = "UnspecifiedApiError";
|
|
526
|
-
}
|
|
527
|
-
}`);
|
|
528
|
-
const needsDefaultApiError = operations.some((op) => op.responses.some((r) => !r.isSuccess && r.statusCode === 'default'));
|
|
529
|
-
if (needsDefaultApiError) {
|
|
530
|
-
lines.push('');
|
|
531
|
-
lines.push(`export class DefaultApiError<TData> extends Error {
|
|
532
|
-
constructor(
|
|
533
|
-
public readonly status: number,
|
|
534
|
-
public readonly data: TData,
|
|
535
|
-
message: string,
|
|
536
|
-
) {
|
|
537
|
-
super(message);
|
|
538
|
-
this.name = "DefaultApiError";
|
|
539
|
-
}
|
|
540
|
-
}`);
|
|
541
|
+
lines.splice(1 + runtimeReexport.length, 0, '', ...brandLines);
|
|
541
542
|
}
|
|
542
543
|
return lines.join('\n');
|
|
543
544
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getOperationTypePrefix, getSuccessType } from '../utils/generator-helpers.js';
|
|
1
|
+
import { getOperationTypePrefix, getSuccessType, sanitizeJsDocText, } from '../utils/generator-helpers.js';
|
|
2
2
|
function buildParameters(op) {
|
|
3
3
|
const params = [];
|
|
4
4
|
for (const param of op.pathParams) {
|
|
@@ -42,6 +42,16 @@ function buildJsDoc(op) {
|
|
|
42
42
|
}
|
|
43
43
|
lines.push(` * ${op.description}`);
|
|
44
44
|
}
|
|
45
|
+
// Responses iterate ascending-numeric with 'default' last (Object.entries
|
|
46
|
+
// integer-key semantics in path-analyzer), so the first success is the
|
|
47
|
+
// lowest-numbered 2xx. No fallback to later 2xx when it is undescribed.
|
|
48
|
+
const firstSuccess = op.responses.find((response) => response.isSuccess);
|
|
49
|
+
if (firstSuccess?.description !== undefined && firstSuccess.description.trim() !== '') {
|
|
50
|
+
if (lines.length > 0) {
|
|
51
|
+
lines.push(' *');
|
|
52
|
+
}
|
|
53
|
+
lines.push(` * ${sanitizeJsDocText(firstSuccess.description)}`);
|
|
54
|
+
}
|
|
45
55
|
const allParams = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams];
|
|
46
56
|
const paramsWithDescriptions = allParams.filter((param) => param.description);
|
|
47
57
|
if (paramsWithDescriptions.length > 0) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type ApiClient } from './generator/client-generator.js';
|
|
2
|
-
import { ApiError, DefaultApiError } from './generator/error-types.js';
|
|
3
2
|
import type { GeneratorConfig } from './types/client.js';
|
|
3
|
+
import { type LoadOptions } from './utils/proxy.js';
|
|
4
4
|
export declare function generateClient(config: GeneratorConfig): Promise<void>;
|
|
5
|
-
export
|
|
5
|
+
export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
|
|
6
|
+
export type { Requester } from './runtime/index.js';
|
|
7
|
+
export type { GeneratorConfig, ApiClient, LoadOptions };
|
|
6
8
|
export type { GenerationOptions } from './generator/client-generator.js';
|
|
7
9
|
export { load as loadSpec } from './parser/spec-reader.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { generateFullOutput } from './generator/client-generator.js';
|
|
2
|
-
import { ApiError, DefaultApiError } from './generator/error-types.js';
|
|
3
2
|
import { load } from './parser/spec-reader.js';
|
|
3
|
+
import { assertValidProxyUrl } from './utils/proxy.js';
|
|
4
4
|
export async function generateClient(config) {
|
|
5
|
-
|
|
5
|
+
if (config.proxy)
|
|
6
|
+
assertValidProxyUrl(config.proxy);
|
|
7
|
+
const doc = await load(config.input, { proxy: config.proxy });
|
|
6
8
|
await generateFullOutput(doc, config);
|
|
7
9
|
}
|
|
10
|
+
export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
|
|
8
11
|
export { load as loadSpec } from './parser/spec-reader.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpenAPIDocument } from '../types/openapi.js';
|
|
2
|
+
import { type LoadOptions } from '../utils/proxy.js';
|
|
2
3
|
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>;
|
|
4
|
+
export declare function loadFromUrl(url: string, opts?: LoadOptions): Promise<OpenAPIDocument>;
|
|
5
|
+
export declare function load(source: string, opts?: LoadOptions): Promise<OpenAPIDocument>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { fetchSpec } from '../utils/proxy.js';
|
|
4
5
|
import { isUrl } from '../utils/url.js';
|
|
5
6
|
const VALID_EXTENSIONS = ['.json', '.yaml', '.yml'];
|
|
6
7
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
@@ -74,10 +75,10 @@ export async function loadFromFile(filePath) {
|
|
|
74
75
|
assertSupportedVersion(doc);
|
|
75
76
|
return doc;
|
|
76
77
|
}
|
|
77
|
-
export async function loadFromUrl(url) {
|
|
78
|
+
export async function loadFromUrl(url, opts) {
|
|
78
79
|
let response;
|
|
79
80
|
try {
|
|
80
|
-
response = await
|
|
81
|
+
response = await fetchSpec(url, opts);
|
|
81
82
|
}
|
|
82
83
|
catch (err) {
|
|
83
84
|
throw new Error(`Failed to fetch spec from URL: ${err.message}`, { cause: err });
|
|
@@ -108,9 +109,9 @@ export async function loadFromUrl(url) {
|
|
|
108
109
|
assertSupportedVersion(doc);
|
|
109
110
|
return doc;
|
|
110
111
|
}
|
|
111
|
-
export async function load(source) {
|
|
112
|
+
export async function load(source, opts) {
|
|
112
113
|
if (isUrl(source)) {
|
|
113
|
-
return loadFromUrl(source);
|
|
114
|
+
return loadFromUrl(source, opts);
|
|
114
115
|
}
|
|
115
116
|
return loadFromFile(source);
|
|
116
117
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ApiError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Attaches the `__definedErrors` metadata property to a client method.
|
|
4
|
+
* The property carries the method's error codes and is used by
|
|
5
|
+
* `isDefinedError` for type narrowing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function decorateWithErrors<T, E>(item: T, runtimeErrors: unknown): T & {
|
|
8
|
+
__definedErrors: E;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Type guard that narrows a caught error to a method's defined error union.
|
|
12
|
+
* Returns false for `UnspecifiedApiError` (status not declared in the spec)
|
|
13
|
+
* and for anything that is not an `ApiError`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isDefinedError<E extends ApiError<number, unknown>>(err: unknown, _fn: {
|
|
16
|
+
__definedErrors: E;
|
|
17
|
+
}): err is E;
|