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.
Files changed (36) hide show
  1. package/README.md +79 -26
  2. package/dist/analyzer/naming.js +3 -2
  3. package/dist/analyzer/path-analyzer.d.ts +4 -0
  4. package/dist/analyzer/path-analyzer.js +31 -1
  5. package/dist/analyzer/schema-mapper.d.ts +8 -0
  6. package/dist/analyzer/schema-mapper.js +69 -30
  7. package/dist/cli/app.d.ts +2 -0
  8. package/dist/cli/app.js +14 -0
  9. package/dist/cli/impl.js +11 -1
  10. package/dist/generator/client-generator.js +9 -44
  11. package/dist/generator/contracts-generator.d.ts +2 -3
  12. package/dist/generator/contracts-generator.js +141 -140
  13. package/dist/generator/method-generator.js +11 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.js +5 -2
  16. package/dist/parser/spec-reader.d.ts +3 -2
  17. package/dist/parser/spec-reader.js +5 -4
  18. package/dist/runtime/defined-errors.d.ts +17 -0
  19. package/dist/runtime/defined-errors.js +27 -0
  20. package/dist/runtime/errors.d.ts +28 -0
  21. package/dist/runtime/errors.js +45 -0
  22. package/dist/runtime/index.d.ts +11 -0
  23. package/dist/runtime/index.js +10 -0
  24. package/dist/runtime/requester.d.ts +14 -0
  25. package/dist/runtime/requester.js +1 -0
  26. package/dist/runtime/responses.d.ts +19 -0
  27. package/dist/runtime/responses.js +28 -0
  28. package/dist/types/client.d.ts +2 -0
  29. package/dist/types/openapi.d.ts +4 -0
  30. package/dist/utils/generator-helpers.d.ts +60 -5
  31. package/dist/utils/generator-helpers.js +223 -14
  32. package/dist/utils/proxy.d.ts +6 -0
  33. package/dist/utils/proxy.js +81 -0
  34. package/package.json +7 -2
  35. package/dist/generator/error-types.d.ts +0 -23
  36. 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('function decorateWithErrors<T, E>(');
185
- lines.push(' item: T,');
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 contracts = generateContracts(doc, resolver);
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. ApiError class
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 schemaName = sanitizeTypeName(refSchema.$ref.split('/').pop());
25
+ const rawName = refSchema.$ref.split('/').pop();
26
+ const schemaName = sanitizeTypeName(rawName);
26
27
  if (schemaName && discriminatorInfo.has(schemaName)) {
27
- const renamed = renameMap.get(schemaName) ?? schemaName;
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 schemaName = sanitizeTypeName(items.$ref.split('/').pop());
35
+ const rawName = items.$ref.split('/').pop();
36
+ const schemaName = sanitizeTypeName(rawName);
35
37
  if (schemaName && discriminatorInfo.has(schemaName)) {
36
- const renamed = renameMap.get(schemaName) ?? schemaName;
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
- function buildJsDoc(description) {
44
- if (!description)
45
- return undefined;
46
- return `/** ${description} */`;
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. ApiError class
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
- const lastSegment = sanitizeTypeName(rawSegment);
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 targetName = sanitizeTypeName(ref.split('/').pop() || key);
189
- const renamedTarget = renameMap.get(targetName) ?? targetName;
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 sanitizedName = sanitizeTypeName(name);
223
- const renamedName = renameMap.get(sanitizedName) ?? sanitizedName;
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: buildJsDoc(resolved.description),
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 [baseName, info] of discriminatorInfo) {
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(sanitizeTypeName(baseName)) ?? sanitizeTypeName(baseName);
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 typeName = `${toPascalCase(schemeName)}Auth`;
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
- if (scheme.description) {
347
+ const schemeJsDoc = buildDescriptionJsDoc(scheme.description);
348
+ if (schemeJsDoc !== '') {
261
349
  lines.push('');
262
- lines.push(buildJsDoc(scheme.description));
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
- jsDocParts.push(sv.description);
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
- const props = op.queryParams.map((param) => {
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
- const props = op.headerParams.map((param) => {
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 props = Object.entries(schema.properties ?? {}).map(([name, propSchema]) => {
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
- return `${name}${optional}: ${tsType}`;
368
- });
369
- if (props.length > 0) {
370
- opLines.push(`export type ${prefix}Body = { ${props.join('; ')}; };`);
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
- opLines.push(`export type ${prefix}Response = ${successType};`);
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 type { GeneratorConfig, ApiClient, ApiError, DefaultApiError };
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
- const doc = await load(config.input);
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 fetch(url);
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;