genoc 0.1.2 → 0.2.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.
@@ -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 { 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;
@@ -17,31 +17,91 @@ function isBinaryContentType(ct) {
17
17
  * If the schema is a $ref to a discriminated base type (or an array whose items
18
18
  * are), replace the type name with the {Base}Variant union type.
19
19
  */
20
- function substituteDiscriminatedType(tsType, schema, discriminatorInfo) {
20
+ function substituteDiscriminatedType(tsType, schema, discriminatorInfo, renameMap) {
21
21
  const refSchema = schema;
22
22
  if (!refSchema || typeof refSchema !== 'object')
23
23
  return tsType;
24
24
  if (typeof refSchema.$ref === 'string') {
25
- const schemaName = refSchema.$ref.split('/').pop();
25
+ const schemaName = sanitizeTypeName(refSchema.$ref.split('/').pop());
26
26
  if (schemaName && discriminatorInfo.has(schemaName)) {
27
- return tsType.replace(new RegExp(`\\b${schemaName}\\b`, 'g'), `${schemaName}Variant`);
27
+ const renamed = renameMap.get(schemaName) ?? schemaName;
28
+ return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
28
29
  }
29
30
  }
30
31
  if (refSchema.items && typeof refSchema.items === 'object') {
31
32
  const items = refSchema.items;
32
33
  if (typeof items.$ref === 'string') {
33
- const schemaName = items.$ref.split('/').pop();
34
+ const schemaName = sanitizeTypeName(items.$ref.split('/').pop());
34
35
  if (schemaName && discriminatorInfo.has(schemaName)) {
35
- return tsType.replace(new RegExp(`\\b${schemaName}\\b`, 'g'), `${schemaName}Variant`);
36
+ const renamed = renameMap.get(schemaName) ?? schemaName;
37
+ return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
36
38
  }
37
39
  }
38
40
  }
39
41
  return tsType;
40
42
  }
41
- function buildJsDoc(description) {
42
- if (!description)
43
- return undefined;
44
- return `/** ${description} */`;
43
+ /**
44
+ * Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
45
+ * the mapper's golden tests). Kept local to avoid modifying the mapper module.
46
+ */
47
+ const INDENT_UNIT = ' ';
48
+ /**
49
+ * Build a description-only type-level JSDoc comment for operation-derived
50
+ * types (body / response). Returns '' when the description is absent or
51
+ * whitespace-only so callers can skip emission entirely.
52
+ */
53
+ function buildDescriptionJsDoc(description) {
54
+ if (description === undefined)
55
+ return '';
56
+ const sanitized = sanitizeJsDocText(description);
57
+ return sanitized === '' ? '' : `/** ${sanitized} */`;
58
+ }
59
+ /**
60
+ * Prefix a type definition with its JSDoc block (single- or multi-line),
61
+ * keeping the comment directly above the `export type` line.
62
+ */
63
+ function attachTypeJsDoc(jsDoc, definition) {
64
+ return jsDoc === '' ? definition : `${jsDoc}\n${definition}`;
65
+ }
66
+ /**
67
+ * Indent every line of a JSDoc block by one indent unit so property comments
68
+ * line up exactly with their property line (mapper indentation contract).
69
+ */
70
+ function indentJsDocBlock(jsDoc) {
71
+ return jsDoc.split('\n').map((line) => `${INDENT_UNIT}${line}`);
72
+ }
73
+ /**
74
+ * Build the JSDoc block for a query/header parameter property using the
75
+ * pinned parameter merge rule: parameter-level description / deprecated /
76
+ * example win over the schema's; default, title and examples come from the
77
+ * schema only (carried by the spread).
78
+ */
79
+ function buildParamPropertyJsDoc(param, schema) {
80
+ return buildTypeJsDoc({
81
+ ...schema,
82
+ description: param.description ?? schema.description,
83
+ deprecated: param.deprecated === true || schema.deprecated === true,
84
+ example: param.example ?? schema.example,
85
+ });
86
+ }
87
+ /**
88
+ * Render a `${prefix}Query` / `${prefix}Headers` object body in the mapper's
89
+ * multi-line format with per-property JSDoc from the parameter merge rule.
90
+ */
91
+ function buildParamTypeBody(params, mapper) {
92
+ const lines = [];
93
+ for (const param of params) {
94
+ const paramSchema = param.schema ?? { type: 'string' };
95
+ const result = mapper.mapSchema(paramSchema);
96
+ const optional = param.required ? '' : '?';
97
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
98
+ const jsDoc = buildParamPropertyJsDoc(param, paramSchema);
99
+ if (jsDoc !== '') {
100
+ lines.push(...indentJsDocBlock(jsDoc));
101
+ }
102
+ lines.push(`${INDENT_UNIT}${key}${optional}: ${result.tsType};`);
103
+ }
104
+ return `{\n${lines.join('\n')}\n}`;
45
105
  }
46
106
  function securitySchemeToTsType(scheme) {
47
107
  const parts = [`type: "${scheme.type}"`];
@@ -148,6 +208,25 @@ function topologicalSort(entries, allNames) {
148
208
  }
149
209
  return sorted;
150
210
  }
211
+ /**
212
+ * Build the import + re-export block for the shared runtime classes.
213
+ * Generated code re-uses the genoc runtime as the single source of truth
214
+ * for class identity, so `instanceof` checks work across module boundaries.
215
+ */
216
+ function buildRuntimeReexport(runtimeImportPath) {
217
+ return [
218
+ '',
219
+ `import { ApiError, StreamResponse } from '${runtimeImportPath}';`,
220
+ 'export {',
221
+ ' ApiError,',
222
+ ' UnspecifiedApiError,',
223
+ ' DefaultApiError,',
224
+ ' RequesterFailError,',
225
+ ' StreamResponse,',
226
+ ' ErrorResponse,',
227
+ `} from '${runtimeImportPath}';`,
228
+ ];
229
+ }
151
230
  /**
152
231
  * Generate the complete `*.contracts.ts` file content as a string.
153
232
  *
@@ -158,10 +237,22 @@ function topologicalSort(entries, allNames) {
158
237
  * 4. Header parameter types per operation
159
238
  * 5. Request body types per operation
160
239
  * 6. Response / error types per operation
161
- * 7. ApiError class
162
- * 7b. UnspecifiedApiError class
240
+ * 7. Runtime re-export block (shared classes from the genoc runtime package)
163
241
  */
164
- export function generateContracts(doc, resolver) {
242
+ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
243
+ const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
244
+ const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
245
+ for (const [original, renamed] of renameMap) {
246
+ // TODO: Replace with structured logging solution
247
+ // oxlint-disable-next-line no-console
248
+ console.warn(`Warning: Schema "${original}" collides with a built-in type and was renamed to "${renamed}".`);
249
+ }
250
+ const renamingTypeGenerator = (refString) => {
251
+ const segments = refString.split('/');
252
+ const rawSegment = segments[segments.length - 1] || 'unknown';
253
+ const lastSegment = sanitizeTypeName(rawSegment);
254
+ return renameMap.get(lastSegment) ?? lastSegment;
255
+ };
165
256
  const discriminatorInfo = new Map();
166
257
  if (doc.components?.schemas) {
167
258
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
@@ -170,11 +261,12 @@ export function generateContracts(doc, resolver) {
170
261
  const mapping = new Map();
171
262
  if (resolved.discriminator.mapping) {
172
263
  for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
173
- const targetName = ref.split('/').pop() || key;
174
- mapping.set(key, targetName);
264
+ const targetName = sanitizeTypeName(ref.split('/').pop() || key);
265
+ const renamedTarget = renameMap.get(targetName) ?? targetName;
266
+ mapping.set(key, renamedTarget);
175
267
  }
176
268
  }
177
- discriminatorInfo.set(name, {
269
+ discriminatorInfo.set(sanitizeTypeName(name), {
178
270
  propertyName: resolved.discriminator.propertyName,
179
271
  mapping,
180
272
  });
@@ -190,28 +282,31 @@ export function generateContracts(doc, resolver) {
190
282
  });
191
283
  }
192
284
  }
193
- // reservedNames: prevent branded types from colliding with user-defined schema names
194
285
  const allSchemaNames = new Set();
195
286
  if (doc.components?.schemas) {
196
287
  for (const name of Object.keys(doc.components.schemas)) {
197
- allSchemaNames.add(name);
288
+ allSchemaNames.add(sanitizeTypeName(name));
198
289
  }
199
290
  }
200
- const mapper = new SchemaMapper(resolver, undefined, discriminatorTargets, allSchemaNames);
291
+ const mapper = new SchemaMapper(resolver, renamingTypeGenerator, discriminatorTargets, allSchemaNames);
201
292
  const lines = [];
202
293
  lines.push(makeHeader(doc.openapi));
294
+ const runtimeReexport = buildRuntimeReexport(runtimeImportPath);
295
+ lines.push(...runtimeReexport);
203
296
  // Section 1: Schema types
204
297
  const schemaEntries = [];
205
298
  if (doc.components?.schemas) {
206
299
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
207
- const result = mapper.mapSchema(schema, name);
300
+ const sanitizedName = sanitizeTypeName(name);
301
+ const renamedName = renameMap.get(sanitizedName) ?? sanitizedName;
302
+ const result = mapper.mapSchema(schema, sanitizedName);
208
303
  const resolved = resolver.resolve(schema);
209
- const definition = `export type ${name} = ${result.tsType};`;
304
+ const definition = `export type ${renamedName} = ${result.tsType};`;
210
305
  schemaEntries.push({
211
- name,
306
+ name: renamedName,
212
307
  kind: 'type',
213
308
  definition,
214
- jsDoc: buildJsDoc(resolved.description),
309
+ jsDoc: buildTypeJsDoc(resolved) || undefined,
215
310
  });
216
311
  }
217
312
  }
@@ -228,9 +323,10 @@ export function generateContracts(doc, resolver) {
228
323
  if (subtypeNames.length === 0)
229
324
  continue;
230
325
  const unionType = subtypeNames.join(' | ');
326
+ const renamedBase = renameMap.get(sanitizeTypeName(baseName)) ?? sanitizeTypeName(baseName);
231
327
  lines.push('');
232
- lines.push(`export type ${baseName}Variant = ${unionType};`);
233
- allSchemaNames.add(`${baseName}Variant`);
328
+ lines.push(`export type ${renamedBase}Variant = ${unionType};`);
329
+ allSchemaNames.add(`${renamedBase}Variant`);
234
330
  }
235
331
  // Section 1b: Security scheme types
236
332
  const securitySchemes = doc.components?.securitySchemes;
@@ -239,9 +335,10 @@ export function generateContracts(doc, resolver) {
239
335
  for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
240
336
  const typeName = `${toPascalCase(schemeName)}Auth`;
241
337
  const tsType = securitySchemeToTsType(scheme);
242
- if (scheme.description) {
338
+ const schemeJsDoc = buildDescriptionJsDoc(scheme.description);
339
+ if (schemeJsDoc !== '') {
243
340
  lines.push('');
244
- lines.push(buildJsDoc(scheme.description));
341
+ lines.push(schemeJsDoc);
245
342
  }
246
343
  lines.push('');
247
344
  lines.push(`export type ${typeName} = ${tsType};`);
@@ -265,10 +362,13 @@ export function generateContracts(doc, resolver) {
265
362
  const sv = variable;
266
363
  const jsDocParts = [];
267
364
  if (sv.description) {
268
- jsDocParts.push(sv.description);
365
+ const description = sanitizeJsDocText(sv.description);
366
+ if (description !== '') {
367
+ jsDocParts.push(description);
368
+ }
269
369
  }
270
370
  if (sv.default !== undefined) {
271
- jsDocParts.push(`@default ${sv.default}`);
371
+ jsDocParts.push(`@default ${sanitizeJsDocText(String(sv.default))}`);
272
372
  }
273
373
  let tsType;
274
374
  if (sv.enum && sv.enum.length > 0) {
@@ -309,31 +409,19 @@ export function generateContracts(doc, resolver) {
309
409
  const opLines = [];
310
410
  // Section 2: Query parameter types
311
411
  if (op.queryParams.length > 0) {
312
- const props = op.queryParams.map((param) => {
313
- const paramSchema = param.schema ?? { type: 'string' };
314
- const result = mapper.mapSchema(paramSchema);
315
- const optional = param.required ? '' : '?';
316
- const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
317
- return `${key}${optional}: ${result.tsType}`;
318
- });
319
- opLines.push(`export type ${prefix}Query = { ${props.join('; ')}; };`);
412
+ opLines.push(`export type ${prefix}Query = ${buildParamTypeBody(op.queryParams, mapper)};`);
320
413
  }
321
414
  // Section 2b: Header parameter types
322
415
  if (op.headerParams.length > 0) {
323
- const props = op.headerParams.map((param) => {
324
- const paramSchema = param.schema ?? { type: 'string' };
325
- const result = mapper.mapSchema(paramSchema);
326
- const optional = param.required ? '' : '?';
327
- const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
328
- return `${key}${optional}: ${result.tsType}`;
329
- });
330
- opLines.push(`export type ${prefix}Headers = { ${props.join('; ')}; };`);
416
+ opLines.push(`export type ${prefix}Headers = ${buildParamTypeBody(op.headerParams, mapper)};`);
331
417
  }
332
418
  // Section 3: Request body types
419
+ const bodyJsDoc = op.requestBody ? buildDescriptionJsDoc(op.requestBody.description) : '';
333
420
  if (op.requestBody?.isMultipart && op.requestBody.schema) {
334
421
  const schema = resolver.resolveSchema(op.requestBody.schema);
335
422
  const requiredSet = new Set(schema.required ?? []);
336
- const props = Object.entries(schema.properties ?? {}).map(([name, propSchema]) => {
423
+ const propLines = [];
424
+ for (const [name, propSchema] of Object.entries(schema.properties ?? {})) {
337
425
  const resolved = resolver.resolveSchema(propSchema);
338
426
  const optional = requiredSet.has(name) ? '' : '?';
339
427
  let tsType;
@@ -346,23 +434,27 @@ export function generateContracts(doc, resolver) {
346
434
  else {
347
435
  tsType = 'string';
348
436
  }
349
- return `${name}${optional}: ${tsType}`;
350
- });
351
- if (props.length > 0) {
352
- opLines.push(`export type ${prefix}Body = { ${props.join('; ')}; };`);
437
+ const jsDoc = buildTypeJsDoc(resolved);
438
+ if (jsDoc !== '') {
439
+ propLines.push(...indentJsDocBlock(jsDoc));
440
+ }
441
+ propLines.push(`${INDENT_UNIT}${name}${optional}: ${tsType};`);
442
+ }
443
+ if (propLines.length > 0) {
444
+ opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = {\n${propLines.join('\n')}\n};`));
353
445
  }
354
446
  else {
355
- opLines.push(`export type ${prefix}Body = Record<string, never>;`);
447
+ opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = Record<string, never>;`));
356
448
  }
357
449
  }
358
450
  else if (op.requestBody?.schema) {
359
451
  const hasBinaryContentType = op.requestBody.contentTypes.some(isBinaryContentType);
360
452
  if (hasBinaryContentType) {
361
- opLines.push(`export type ${prefix}Body = Blob;`);
453
+ opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = Blob;`));
362
454
  }
363
455
  else {
364
456
  const result = mapper.mapSchema(op.requestBody.schema, undefined, 'request');
365
- opLines.push(`export type ${prefix}Body = ${result.tsType};`);
457
+ opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${prefix}Body = ${result.tsType};`));
366
458
  }
367
459
  }
368
460
  // Section 4: Response types
@@ -376,12 +468,15 @@ export function generateContracts(doc, resolver) {
376
468
  return 'StreamResponse';
377
469
  if (r.schema) {
378
470
  const result = mapper.mapSchema(r.schema, undefined, 'response').tsType;
379
- return substituteDiscriminatedType(result, r.schema, discriminatorInfo);
471
+ return substituteDiscriminatedType(result, r.schema, discriminatorInfo, renameMap);
380
472
  }
381
473
  return r.tsType;
382
474
  });
383
475
  const successType = types.length === 1 ? types[0] : types.join(' | ');
384
- opLines.push(`export type ${prefix}Response = ${successType};`);
476
+ // Type-level JSDoc from the lowest-numbered 2xx response (the analyzer
477
+ // returns success responses in ascending status order).
478
+ const responseJsDoc = buildDescriptionJsDoc(successResponses[0].description);
479
+ opLines.push(attachTypeJsDoc(responseJsDoc, `export type ${prefix}Response = ${successType};`));
385
480
  }
386
481
  // Error types per status
387
482
  const errorTypes = [];
@@ -434,92 +529,7 @@ export function generateContracts(doc, resolver) {
434
529
  for (const brand of brandedTypes.values()) {
435
530
  brandLines.push(`export type ${brand.name} = ${brand.baseType} & { readonly __format?: '${brand.format}' };`);
436
531
  }
437
- lines.splice(1, 0, '', ...brandLines);
438
- }
439
- // Always emit StreamResponse class (used by Requester type)
440
- lines.push('');
441
- lines.push('export class StreamResponse {');
442
- lines.push(' constructor(');
443
- lines.push(' public readonly data: ReadableStream<Uint8Array>,');
444
- lines.push(' public readonly filename?: string,');
445
- lines.push(' public readonly headers: Record<string, string> = {},');
446
- lines.push(' ) {}');
447
- lines.push('}');
448
- lines.push('');
449
- lines.push(`export function streamResponse(
450
- data: ReadableStream<Uint8Array>,
451
- filename?: string,
452
- headers?: Record<string, string>,
453
- ): StreamResponse {
454
- return new StreamResponse(data, filename, headers ?? {});
455
- }`);
456
- // ErrorResponse class
457
- lines.push('');
458
- lines.push(`export class ErrorResponse {
459
- constructor(
460
- public readonly status: number,
461
- public readonly data: unknown,
462
- public readonly headers: Record<string, string>,
463
- public readonly message?: string,
464
- ) {}
465
- }`);
466
- // errorResponse() helper
467
- lines.push('');
468
- lines.push(`export function errorResponse(
469
- status: number,
470
- data: unknown,
471
- headers?: Record<string, string>,
472
- message?: string,
473
- ): ErrorResponse {
474
- return new ErrorResponse(status, data, headers ?? {}, message);
475
- }`);
476
- // RequesterFailError class
477
- lines.push('');
478
- lines.push(`export class RequesterFailError extends Error {
479
- constructor(
480
- public readonly cause: unknown,
481
- ) {
482
- super(\`Request failed: \${cause instanceof Error ? cause.message : String(cause)}\`);
483
- this.name = "RequesterFailError";
484
- }
485
- }`);
486
- // Section 5: ApiError class
487
- lines.push('');
488
- lines.push(`export class ApiError<TStatus extends number, TData> extends Error {
489
- constructor(
490
- public readonly status: TStatus,
491
- public readonly data: TData,
492
- message: string,
493
- ) {
494
- super(message);
495
- this.name = "ApiError";
496
- }
497
- }`);
498
- // Section 5b: UnspecifiedApiError class
499
- lines.push('');
500
- lines.push(`export class UnspecifiedApiError extends ApiError<number, unknown> {
501
- constructor(
502
- status: number,
503
- data: unknown,
504
- message: string,
505
- ) {
506
- super(status, data, message);
507
- this.name = "UnspecifiedApiError";
508
- }
509
- }`);
510
- const needsDefaultApiError = operations.some((op) => op.responses.some((r) => !r.isSuccess && r.statusCode === 'default'));
511
- if (needsDefaultApiError) {
512
- lines.push('');
513
- lines.push(`export class DefaultApiError<TData> extends Error {
514
- constructor(
515
- public readonly status: number,
516
- public readonly data: TData,
517
- message: string,
518
- ) {
519
- super(message);
520
- this.name = "DefaultApiError";
521
- }
522
- }`);
532
+ lines.splice(1 + runtimeReexport.length, 0, '', ...brandLines);
523
533
  }
524
534
  return lines.join('\n');
525
535
  }
@@ -1,24 +1,13 @@
1
1
  import type { AnalyzedOperation } from '../analyzer/path-analyzer.js';
2
- export declare class ApiError<TStatus extends number, TData> extends Error {
3
- readonly status: TStatus;
4
- readonly data: TData;
5
- constructor(status: TStatus, data: TData, message: string);
6
- }
7
- export declare class DefaultApiError<TData> extends Error {
8
- readonly status: number;
9
- readonly data: TData;
10
- constructor(status: number, data: TData, message: string);
11
- }
12
2
  /**
13
3
  * Generate error types for a set of analyzed operations.
14
4
  *
15
5
  * Produces:
16
6
  * 1. `ApiError<TStatus, TData>` class
17
7
  * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
18
- * 3. `isError` type guard function
19
- * 4. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
20
- * 5. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
21
- * 6. `DefaultErrorBody` type for `default` responses
22
- * 7. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
8
+ * 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
9
+ * 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
10
+ * 5. `DefaultErrorBody` type for `default` responses
11
+ * 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
23
12
  */
24
13
  export declare function generateErrorTypes(operations: AnalyzedOperation[]): string;
@@ -1,34 +1,13 @@
1
- export class ApiError extends Error {
2
- status;
3
- data;
4
- constructor(status, data, message) {
5
- super(message);
6
- this.status = status;
7
- this.data = data;
8
- this.name = 'ApiError';
9
- }
10
- }
11
- export class DefaultApiError extends Error {
12
- status;
13
- data;
14
- constructor(status, data, message) {
15
- super(message);
16
- this.status = status;
17
- this.data = data;
18
- this.name = 'DefaultApiError';
19
- }
20
- }
21
1
  /**
22
2
  * Generate error types for a set of analyzed operations.
23
3
  *
24
4
  * Produces:
25
5
  * 1. `ApiError<TStatus, TData>` class
26
6
  * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
27
- * 3. `isError` type guard function
28
- * 4. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
29
- * 5. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
30
- * 6. `DefaultErrorBody` type for `default` responses
31
- * 7. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
7
+ * 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
8
+ * 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
9
+ * 5. `DefaultErrorBody` type for `default` responses
10
+ * 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
32
11
  */
33
12
  export function generateErrorTypes(operations) {
34
13
  const lines = [];
@@ -54,12 +33,6 @@ export function generateErrorTypes(operations) {
54
33
  }
55
34
  }`);
56
35
  lines.push('');
57
- lines.push(`export function isError<T extends { status: number }, S extends number>(
58
- response: T,
59
- status: S,
60
- ): response is Extract<T, { status: S }> {
61
- return response.status === status;
62
- }`);
63
36
  let needsDefaultErrorBody = false;
64
37
  for (const op of operations) {
65
38
  const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
@@ -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,8 @@
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';
4
3
  export declare function generateClient(config: GeneratorConfig): Promise<void>;
5
- export type { GeneratorConfig, ApiClient, ApiError, DefaultApiError };
4
+ export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
5
+ export type { Requester } from './runtime/index.js';
6
+ export type { GeneratorConfig, ApiClient };
6
7
  export type { GenerationOptions } from './generator/client-generator.js';
7
8
  export { load as loadSpec } from './parser/spec-reader.js';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
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';
4
3
  export async function generateClient(config) {
5
4
  const doc = await load(config.input);
6
5
  await generateFullOutput(doc, config);
7
6
  }
7
+ export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
8
8
  export { load as loadSpec } from './parser/spec-reader.js';
@@ -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;
@@ -0,0 +1,27 @@
1
+ import { ApiError, UnspecifiedApiError } 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 function decorateWithErrors(item, runtimeErrors) {
8
+ Object.defineProperty(item, '__definedErrors', {
9
+ value: runtimeErrors,
10
+ enumerable: false,
11
+ configurable: true,
12
+ writable: false,
13
+ });
14
+ return item;
15
+ }
16
+ /**
17
+ * Type guard that narrows a caught error to a method's defined error union.
18
+ * Returns false for `UnspecifiedApiError` (status not declared in the spec)
19
+ * and for anything that is not an `ApiError`.
20
+ */
21
+ export function isDefinedError(err, _fn) {
22
+ if (err instanceof UnspecifiedApiError)
23
+ return false;
24
+ if (!(err instanceof ApiError))
25
+ return false;
26
+ return true;
27
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared error classes for generated clients.
3
+ *
4
+ * This module is the single source of truth for error class identity:
5
+ * both generated client code and user-provided `Requester` implementations
6
+ * import these classes, so `instanceof` checks work across module boundaries.
7
+ */
8
+ /** Error thrown for a specific status code declared in the spec. */
9
+ export declare class ApiError<TStatus extends number, TData> extends Error {
10
+ readonly status: TStatus;
11
+ readonly data: TData;
12
+ constructor(status: TStatus, data: TData, message: string);
13
+ }
14
+ /** Error thrown for a status code not declared in the spec. */
15
+ export declare class UnspecifiedApiError extends ApiError<number, unknown> {
16
+ constructor(status: number, data: unknown, message: string);
17
+ }
18
+ /** Error thrown for operations that declare a `default` response. */
19
+ export declare class DefaultApiError<TData> extends Error {
20
+ readonly status: number;
21
+ readonly data: TData;
22
+ constructor(status: number, data: TData, message: string);
23
+ }
24
+ /** Wraps unexpected failures inside a `Requester` implementation. */
25
+ export declare class RequesterFailError extends Error {
26
+ readonly cause: unknown;
27
+ constructor(cause: unknown);
28
+ }