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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # genoc
2
2
 
3
3
  Generate TypeScript HTTP clients from OpenAPI 3.0 / 3.1 specifications.
4
- The generated code has zero runtime dependencies. Full type safety. Bring your own HTTP client.
4
+ Generated code depends only on the tiny `genoc/runtime` module. Full type safety. Bring your own HTTP client.
5
5
 
6
6
  [![npm version](https://img.shields.io/npm/v/genoc)](https://www.npmjs.com/package/genoc)
7
7
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org/)
@@ -13,6 +13,7 @@ The generated code has zero runtime dependencies. Full type safety. Bring your o
13
13
  - Full OpenAPI 3.0 and 3.1 specification support with automatic version detection
14
14
  - End-to-end type safety — requests, responses, and errors are fully typed
15
15
  - HTTP-client agnostic — adapter pattern lets you plug in fetch, axios, or anything else
16
+ - Shared runtime contract — `genoc/runtime` exports the `Requester` type and response/error classes, so one requester implementation works with every generated client
16
17
  - Error types with per-status-code narrowing and type guards
17
18
  - File and binary upload/download with stream handling
18
19
  - Flexible method naming strategies (path-based, operationId, operationId-with-fallback)
@@ -23,9 +24,12 @@ The generated code has zero runtime dependencies. Full type safety. Bring your o
23
24
  Install:
24
25
 
25
26
  ```bash
26
- npm install -D genoc
27
+ npm install genoc
27
28
  ```
28
29
 
30
+ Generated code imports from `genoc/runtime`, so `genoc` is a runtime
31
+ dependency (not just `devDependencies`).
32
+
29
33
  Generate:
30
34
 
31
35
  ```bash
@@ -40,8 +44,13 @@ This creates two files in `./src/api`:
40
44
  ## Usage
41
45
 
42
46
  The generated client requires a `Requester` implementation — a function that
43
- performs the actual HTTP call and returns the result. This is the type your
44
- implementation must satisfy:
47
+ performs the actual HTTP call and returns the result. The type lives in
48
+ `genoc/runtime`, so you can write and compile a requester before generating
49
+ anything:
50
+
51
+ ```typescript
52
+ import type { Requester } from 'genoc/runtime';
53
+ ```
45
54
 
46
55
  ```typescript
47
56
  type Requester = <TResponse>(
@@ -60,7 +69,8 @@ type Requester = <TResponse>(
60
69
 
61
70
  ```typescript
62
71
  import { createClient } from './client.js';
63
- import { ApiError, RequesterFailError, ErrorResponse } from './contracts.js';
72
+ import type { Requester } from 'genoc/runtime';
73
+ import { RequesterFailError, ErrorResponse } from 'genoc/runtime';
64
74
 
65
75
  const baseUrl = 'https://api.example.com';
66
76
 
@@ -85,7 +95,7 @@ const requester: Requester = async (method, path, options) => {
85
95
  return new ErrorResponse(
86
96
  response.status,
87
97
  await response.json(),
88
- response.headers,
98
+ Object.fromEntries(response.headers.entries()),
89
99
  response.statusText
90
100
  );
91
101
  }
@@ -101,6 +111,27 @@ const pets = await client.getPets({ limit: 10 });
101
111
 
102
112
  See [Binary / File Responses](#binary--file-responses) for handling `expectStream: true`.
103
113
 
114
+ ## Shared Runtime (`genoc/runtime`)
115
+
116
+ Generated clients import their response and error classes from `genoc/runtime`
117
+ instead of declaring inline copies (the generated `contracts.ts` re-exports
118
+ them, so existing imports keep working). This gives every generated client the
119
+ same class identity — `instanceof` checks work across clients, and you can
120
+ implement **one shared requester** typed against the package:
121
+
122
+ ```typescript
123
+ // common-requester.ts — reusable across all generated clients,
124
+ // no generated imports needed
125
+ import type { Requester } from 'genoc/runtime';
126
+
127
+ export const requester: Requester = async (method, path, options) => {
128
+ // ...your fetch/axios/etc. implementation
129
+ };
130
+ ```
131
+
132
+ To pin a specific version or point at a mirror, override the import specifier
133
+ via `--runtime-import-path` (CLI) or `runtimeImportPath` (programmatic config).
134
+
104
135
  ## Binary / File Responses
105
136
 
106
137
  When your spec defines binary responses (e.g. `format: binary`,
@@ -109,14 +140,14 @@ When your spec defines binary responses (e.g. `format: binary`,
109
140
  `StreamResponse` in that case:
110
141
 
111
142
  ```typescript
112
- import { StreamResponse } from './contracts.js';
143
+ import { StreamResponse } from 'genoc/runtime';
113
144
 
114
145
  // Inside your Requester implementation:
115
146
  if (options.expectStream === true) {
116
147
  return new StreamResponse(
117
148
  response.body as ReadableStream<Uint8Array>,
118
149
  getFilename(response.headers), // extract from Content-Disposition
119
- response.headers
150
+ Object.fromEntries(response.headers.entries())
120
151
  );
121
152
  }
122
153
  ```
@@ -131,17 +162,6 @@ class StreamResponse {
131
162
  }
132
163
  ```
133
164
 
134
- ### Response Helpers
135
-
136
- The generated `contracts.ts` includes helper functions for constructing
137
- responses in your `Requester` implementation:
138
-
139
- - `streamResponse(data, filename?, headers?)` — Creates a `StreamResponse` instance
140
- - `errorResponse(status, data, headers?, message?)` — Creates an `ErrorResponse` instance
141
-
142
- These are convenience wrappers around the `StreamResponse` and `ErrorResponse`
143
- constructors.
144
-
145
165
  ## CLI Reference
146
166
 
147
167
  ```bash
@@ -150,12 +170,13 @@ genoc <spec> [flags]
150
170
 
151
171
  `<spec>` — Path or URL to an OpenAPI 3.0 / 3.1 spec (JSON or YAML).
152
172
 
153
- | Flag | Default | Description |
154
- | ------------------------ | ------------ | ---------------------------------------------------- |
155
- | `--output-dir` | (required) | Output directory for generated files |
156
- | `--method-name-strategy` | `path-based` | Method naming strategy |
157
- | `--spec-version` | auto-detect | Override version detection (`"3.0"` or `"3.1"`) |
158
- | `--strict-version` | `true` | Warn if `--spec-version` mismatches detected version |
173
+ | Flag | Default | Description |
174
+ | ------------------------ | --------------- | ------------------------------------------------------------ |
175
+ | `--output-dir` | (required) | Output directory for generated files |
176
+ | `--method-name-strategy` | `path-based` | Method naming strategy |
177
+ | `--spec-version` | auto-detect | Override version detection (`"3.0"` or `"3.1"`) |
178
+ | `--strict-version` | `true` | Warn if `--spec-version` mismatches detected version |
179
+ | `--runtime-import-path` | `genoc/runtime` | Module specifier generated code imports runtime classes from |
159
180
 
160
181
  ## Method Naming Strategies
161
182
 
@@ -168,6 +189,15 @@ genoc <spec> [flags]
168
189
  - **`operationId-with-fallback`** — Use `operationId` if present, otherwise
169
190
  fall back to path-based naming.
170
191
 
192
+ ## JSDoc Documentation Output
193
+
194
+ `description`, `deprecated`, `default`, `example`/`examples`, and `title`
195
+ fields from your spec become JSDoc comments in the generated code — on object
196
+ type properties, named schemas, and client methods. Object types are always
197
+ emitted multi-line, so per-property JSDoc stays aligned at each nesting depth.
198
+ `@deprecated` tags give you editor strikethrough on deprecated fields and
199
+ methods for free.
200
+
171
201
  ## Programmatic API
172
202
 
173
203
  ```typescript
@@ -194,7 +224,7 @@ union, and `isDefinedError` narrows a caught error to that union:
194
224
 
195
225
  ```typescript
196
226
  import { UnspecifiedApiError, RequesterFailError } from './contracts.js';
197
- import { isDefinedError } from './client.js';
227
+ import { isDefinedError } from 'genoc/runtime';
198
228
 
199
229
  try {
200
230
  const result = await client.getPets();
@@ -9,6 +9,7 @@ export interface AnalyzedParameter {
9
9
  tsType: string;
10
10
  description?: string;
11
11
  deprecated?: boolean;
12
+ example?: unknown;
12
13
  }
13
14
  export interface AnalyzedRequestBody {
14
15
  required: boolean;
@@ -16,6 +17,7 @@ export interface AnalyzedRequestBody {
16
17
  schema: SchemaObject | ReferenceObject | undefined;
17
18
  tsType: string;
18
19
  isMultipart: boolean;
20
+ description?: string;
19
21
  }
20
22
  export interface AnalyzedResponse {
21
23
  statusCode: string;
@@ -1,4 +1,5 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
+ import { sanitizeTypeName } from '../utils/generator-helpers.js';
2
3
  import { getMethodName } from './naming.js';
3
4
  const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
4
5
  function isRef(obj) {
@@ -21,7 +22,7 @@ function schemaToTsType(schema, resolver) {
21
22
  if (isRef(schema)) {
22
23
  const resolved = resolver.resolve(schema);
23
24
  const refStr = schema.$ref;
24
- const lastSegment = refStr.split('/').pop();
25
+ const lastSegment = sanitizeTypeName(refStr.split('/').pop());
25
26
  if (lastSegment && resolved.type) {
26
27
  return lastSegment;
27
28
  }
@@ -77,6 +78,7 @@ function analyzeParameter(param, resolver) {
77
78
  tsType: schemaToTsType(param.schema, resolver),
78
79
  description: param.description,
79
80
  deprecated: param.deprecated,
81
+ example: param.example,
80
82
  };
81
83
  }
82
84
  function mergeParameters(pathItemParams, operationParams, resolver) {
@@ -119,6 +121,7 @@ function analyzeRequestBody(body, resolver) {
119
121
  schema,
120
122
  tsType,
121
123
  isMultipart,
124
+ description: resolved.description,
122
125
  };
123
126
  }
124
127
  function analyzeResponses(responses, resolver) {
@@ -44,5 +44,13 @@ export declare class SchemaMapper {
44
44
  private mapDiscriminatedUnion;
45
45
  private resolveDiscriminantValue;
46
46
  private mapArray;
47
+ /**
48
+ * Render the per-property JSDoc comment lines for an object property.
49
+ *
50
+ * Metadata comes from the property's own schema node; for a `$ref` property
51
+ * the resolved target's own node metadata is used (one level only — never
52
+ * recursed), so self-referential schemas cannot loop here.
53
+ */
54
+ private renderPropertyJsDoc;
47
55
  private mapObject;
48
56
  }
@@ -1,13 +1,20 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import { formatToBrandTypeName } from '../utils/case.js';
3
+ import { buildTypeJsDoc, sanitizeTypeName } from '../utils/generator-helpers.js';
3
4
  import { quoteKey } from '../utils/string.js';
5
+ /** Indentation contract for multi-line types — 2-space unit pinned from the generated ServerParams interface; binding for downstream generators. */
6
+ const INDENT_UNIT = ' ';
7
+ function indentBy(level) {
8
+ return INDENT_UNIT.repeat(level);
9
+ }
4
10
  /**
5
11
  * Default: extracts the last segment of the JSON pointer.
6
12
  * "#/components/schemas/User" -> "User"
7
13
  */
8
14
  function defaultTypeNameGenerator(refString) {
9
15
  const segments = refString.split('/');
10
- return segments[segments.length - 1] || 'unknown';
16
+ const rawSegment = segments[segments.length - 1] || 'unknown';
17
+ return sanitizeTypeName(rawSegment);
11
18
  }
12
19
  function isRefObject(obj) {
13
20
  return (obj !== null &&
@@ -56,7 +63,7 @@ export class SchemaMapper {
56
63
  return { tsType: schema ? 'unknown' : 'never', imports: [] };
57
64
  }
58
65
  const visited = new Set();
59
- const result = this.mapInternal(schema, name, context, visited);
66
+ const result = this.mapInternal(schema, name, context, visited, 0);
60
67
  if (name && this.discriminatorTargets.has(name)) {
61
68
  const target = this.discriminatorTargets.get(name);
62
69
  result.tsType += ` & { ${quoteKey(target.propertyName)}: '${target.literalValue}' }`;
@@ -73,7 +80,7 @@ export class SchemaMapper {
73
80
  return null;
74
81
  return brandName;
75
82
  }
76
- mapInternal(schema, name, context, visited) {
83
+ mapInternal(schema, name, context, visited, indent) {
77
84
  if (isRefObject(schema)) {
78
85
  const refStr = schema.$ref;
79
86
  const refName = this.typeNameGenerator(refStr);
@@ -87,7 +94,7 @@ export class SchemaMapper {
87
94
  if (resolved) {
88
95
  const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
89
96
  if (discInfo) {
90
- const expanded = this.mapInternal(resolved, undefined, context, visited);
97
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
91
98
  expanded.tsType += ` & { ${quoteKey(discInfo.propertyName)}: '${discInfo.literalValue}' }`;
92
99
  return expanded;
93
100
  }
@@ -119,7 +126,7 @@ export class SchemaMapper {
119
126
  return { tsType, imports: [] };
120
127
  }
121
128
  if (s.allOf !== undefined && s.allOf.length > 0) {
122
- const result = this.mapCombinator(s.allOf, '&', context, visited);
129
+ const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
123
130
  if (s.nullable === true) {
124
131
  return {
125
132
  tsType: needsParens(result.tsType)
@@ -132,8 +139,8 @@ export class SchemaMapper {
132
139
  }
133
140
  if (s.oneOf !== undefined && s.oneOf.length > 0) {
134
141
  const result = s.discriminator
135
- ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited)
136
- : this.mapCombinator(s.oneOf, '|', context, visited);
142
+ ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
143
+ : this.mapCombinator(s.oneOf, '|', context, visited, indent);
137
144
  if (s.nullable === true) {
138
145
  return {
139
146
  tsType: needsParens(result.tsType)
@@ -146,8 +153,8 @@ export class SchemaMapper {
146
153
  }
147
154
  if (s.anyOf !== undefined && s.anyOf.length > 0) {
148
155
  const result = s.discriminator
149
- ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited)
150
- : this.mapCombinator(s.anyOf, '|', context, visited);
156
+ ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
157
+ : this.mapCombinator(s.anyOf, '|', context, visited, indent);
151
158
  if (s.nullable === true) {
152
159
  return {
153
160
  tsType: needsParens(result.tsType)
@@ -167,7 +174,7 @@ export class SchemaMapper {
167
174
  if (nonNull.length === 0) {
168
175
  return { tsType: 'null', imports: [] };
169
176
  }
170
- const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited));
177
+ const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent);
171
178
  if (hasNull) {
172
179
  return {
173
180
  tsType: `${baseResult.tsType} | null`,
@@ -222,15 +229,15 @@ export class SchemaMapper {
222
229
  case 'null':
223
230
  return { tsType: 'null', imports: [] };
224
231
  case 'array':
225
- return this.mapArray(s, context, visited);
232
+ return this.mapArray(s, context, visited, indent);
226
233
  case 'object':
227
- return this.mapObject(s, name, context, visited);
234
+ return this.mapObject(s, name, context, visited, indent);
228
235
  default:
229
236
  return { tsType: 'unknown', imports: [] };
230
237
  }
231
238
  }
232
239
  resolveDiscriminatorInfo(schema, refStr) {
233
- const schemaName = refStr.split('/').pop();
240
+ const schemaName = sanitizeTypeName(refStr.split('/').pop());
234
241
  const target = this.discriminatorTargets.get(schemaName);
235
242
  if (target)
236
243
  return target;
@@ -268,8 +275,8 @@ export class SchemaMapper {
268
275
  })
269
276
  .join(' | ');
270
277
  }
271
- mapCombinator(schemas, kind, context, visited) {
272
- const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited));
278
+ mapCombinator(schemas, kind, context, visited, indent) {
279
+ const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent));
273
280
  const allImports = [];
274
281
  for (const r of results) {
275
282
  allImports.push(...r.imports);
@@ -283,14 +290,14 @@ export class SchemaMapper {
283
290
  });
284
291
  return { tsType: parts.join(separator), imports: allImports };
285
292
  }
286
- mapDiscriminatedUnion(schemas, discriminator, context, visited) {
293
+ mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
287
294
  const propertyName = discriminator.propertyName;
288
295
  const mapping = discriminator.mapping;
289
296
  const allImports = [];
290
297
  const parts = [];
291
298
  for (const schema of schemas) {
292
299
  const discriminantValue = this.resolveDiscriminantValue(schema, propertyName, mapping);
293
- const variantResult = this.mapInternal(schema, undefined, context, visited);
300
+ const variantResult = this.mapInternal(schema, undefined, context, visited, indent);
294
301
  allImports.push(...variantResult.imports);
295
302
  const quotedProp = quoteKey(propertyName);
296
303
  parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
@@ -321,14 +328,14 @@ export class SchemaMapper {
321
328
  }
322
329
  return 'unknown';
323
330
  }
324
- mapArray(schema, context, visited) {
331
+ mapArray(schema, context, visited, indent) {
325
332
  if (!schema.items) {
326
333
  return {
327
334
  tsType: schema.nullable === true ? 'unknown[] | null' : 'unknown[]',
328
335
  imports: [],
329
336
  };
330
337
  }
331
- const itemResult = this.mapInternal(schema.items, undefined, context, visited);
338
+ const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
332
339
  const tsType = isComplexType(itemResult.tsType)
333
340
  ? `Array<${itemResult.tsType}>`
334
341
  : `${itemResult.tsType}[]`;
@@ -337,7 +344,33 @@ export class SchemaMapper {
337
344
  imports: itemResult.imports,
338
345
  };
339
346
  }
340
- mapObject(schema, name, context, visited) {
347
+ /**
348
+ * Render the per-property JSDoc comment lines for an object property.
349
+ *
350
+ * Metadata comes from the property's own schema node; for a `$ref` property
351
+ * the resolved target's own node metadata is used (one level only — never
352
+ * recursed), so self-referential schemas cannot loop here.
353
+ */
354
+ renderPropertyJsDoc(propSchema, indentLevel) {
355
+ let metaSchema;
356
+ if (isRefObject(propSchema)) {
357
+ try {
358
+ metaSchema = this.resolver.resolveSchema(propSchema);
359
+ }
360
+ catch {
361
+ return [];
362
+ }
363
+ }
364
+ else {
365
+ metaSchema = propSchema;
366
+ }
367
+ const block = buildTypeJsDoc(metaSchema);
368
+ if (block === '')
369
+ return [];
370
+ const ind = indentBy(indentLevel);
371
+ return block.split('\n').map((line) => `${ind}${line}`);
372
+ }
373
+ mapObject(schema, name, context, visited, indent) {
341
374
  const properties = schema.properties ?? {};
342
375
  const requiredSet = new Set(schema.required ?? []);
343
376
  const propNames = Object.keys(properties);
@@ -352,7 +385,9 @@ export class SchemaMapper {
352
385
  return false;
353
386
  return true;
354
387
  });
355
- const propEntries = [];
388
+ const ownIndent = indentBy(indent);
389
+ const memberIndent = indentBy(indent + 1);
390
+ const memberLines = [];
356
391
  const allImports = [];
357
392
  if (schema.discriminator) {
358
393
  const discPropName = schema.discriminator.propertyName;
@@ -360,15 +395,16 @@ export class SchemaMapper {
360
395
  if (discIndex !== -1) {
361
396
  filteredPropNames.splice(discIndex, 1);
362
397
  }
363
- propEntries.push(`"${discPropName}": string`);
398
+ memberLines.push(`${memberIndent}"${discPropName}": string;`);
364
399
  }
365
400
  for (const propName of filteredPropNames) {
366
401
  const propSchema = properties[propName];
367
- const propResult = this.mapInternal(propSchema, undefined, context, visited);
402
+ const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
368
403
  allImports.push(...propResult.imports);
404
+ memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
369
405
  const optional = requiredSet.has(propName) ? '' : '?';
370
406
  const quotedName = quoteKey(propName);
371
- propEntries.push(`${quotedName}${optional}: ${propResult.tsType}`);
407
+ memberLines.push(`${memberIndent}${quotedName}${optional}: ${propResult.tsType};`);
372
408
  }
373
409
  const additionalProps = schema.additionalProperties;
374
410
  let indexSignature = null;
@@ -379,11 +415,11 @@ export class SchemaMapper {
379
415
  indexSignature = '[key: string]: unknown';
380
416
  }
381
417
  else if (additionalProps !== undefined && typeof additionalProps === 'object') {
382
- const addPropResult = this.mapInternal(additionalProps, undefined, context, visited);
418
+ const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
383
419
  allImports.push(...addPropResult.imports);
384
420
  indexSignature = `[key: string]: ${addPropResult.tsType}`;
385
421
  }
386
- const hasProps = propEntries.length > 0;
422
+ const hasProps = memberLines.length > 0;
387
423
  const hasIndex = indexSignature !== null;
388
424
  let result;
389
425
  if (!hasProps && !hasIndex) {
@@ -398,7 +434,7 @@ export class SchemaMapper {
398
434
  const valueType = indexSignature.replace('[key: string]: ', '');
399
435
  if (name) {
400
436
  result = {
401
- tsType: `{ ${indexSignature}; }`,
437
+ tsType: `{\n${memberIndent}${indexSignature};\n${ownIndent}}`,
402
438
  imports: allImports,
403
439
  };
404
440
  }
@@ -410,16 +446,17 @@ export class SchemaMapper {
410
446
  }
411
447
  }
412
448
  else {
413
- const propsBody = propEntries.join('; ') + ';';
449
+ const propsBlock = `{\n${memberLines.join('\n')}\n${ownIndent}}`;
414
450
  if (hasIndex) {
451
+ const indexBlock = `{\n${memberIndent}${indexSignature};\n${ownIndent}}`;
415
452
  result = {
416
- tsType: `{ ${propsBody} } & { ${indexSignature}; }`,
453
+ tsType: `${propsBlock} & ${indexBlock}`,
417
454
  imports: allImports,
418
455
  };
419
456
  }
420
457
  else {
421
458
  result = {
422
- tsType: `{ ${propsBody} }`,
459
+ tsType: propsBlock,
423
460
  imports: allImports,
424
461
  };
425
462
  }
package/dist/cli/app.d.ts CHANGED
@@ -3,6 +3,7 @@ interface Flags {
3
3
  methodNameStrategy: 'path-based' | 'operationId' | 'operationId-with-fallback';
4
4
  specVersion?: string;
5
5
  strictVersion: boolean;
6
+ runtimeImportPath?: string;
6
7
  }
7
8
  export type AppFlags = Flags;
8
9
  export declare const app: import("@stricli/core").Application<import("@stricli/core").CommandContext>;
package/dist/cli/app.js CHANGED
@@ -29,6 +29,13 @@ const command = buildCommand({
29
29
  default: true,
30
30
  brief: 'Enable strict version checking',
31
31
  },
32
+ runtimeImportPath: {
33
+ kind: 'parsed',
34
+ parse: String,
35
+ brief: 'Module specifier for runtime imports (default: genoc/runtime)',
36
+ optional: true,
37
+ placeholder: 'module',
38
+ },
32
39
  },
33
40
  positional: {
34
41
  kind: 'tuple',
package/dist/cli/impl.js CHANGED
@@ -27,6 +27,7 @@ export default async function (flags, spec) {
27
27
  methodNameStrategy: flags.methodNameStrategy || 'path-based',
28
28
  specVersion: flags.specVersion,
29
29
  strictVersion: flags.strictVersion,
30
+ runtimeImportPath: flags.runtimeImportPath,
30
31
  };
31
32
  const preserveRefSiblings = strategy.version() === '3.1';
32
33
  this.process.stdout.write('Generating client...\n');
@@ -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;