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
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,14 @@ 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 |
180
+ | `--proxy` | (none) | HTTP(S) proxy URL for fetching specs from URLs; overrides HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars |
159
181
 
160
182
  ## Method Naming Strategies
161
183
 
@@ -168,6 +190,36 @@ genoc <spec> [flags]
168
190
  - **`operationId-with-fallback`** — Use `operationId` if present, otherwise
169
191
  fall back to path-based naming.
170
192
 
193
+ ## Proxy Support
194
+
195
+ When fetching a spec from a URL, `genoc` can route the request through an
196
+ HTTP(S) proxy:
197
+
198
+ ```bash
199
+ genoc https://api.example.com/openapi.yaml --output-dir ./src/api \
200
+ --proxy http://user:pass@proxy.example.com:8080
201
+ ```
202
+
203
+ - Credentials are supported in the proxy URL (`user:pass@host:port`).
204
+ - Without `--proxy`, the `HTTP_PROXY` / `HTTPS_PROXY` environment variables
205
+ (either casing) are respected automatically, minus `NO_PROXY` exclusions.
206
+ - An explicit `--proxy` flag overrides environment detection entirely.
207
+ - Programmatic equivalent: `proxy: 'http://proxy.example.com:8080'` in the
208
+ `generateClient` config, or an optional second argument `loadSpec(url, { proxy })`.
209
+ - SOCKS proxies and `ALL_PROXY` are not supported.
210
+
211
+ Note: if you already have `HTTP_PROXY` set in your environment, spec fetches
212
+ now go through it (previously ignored). Use `NO_PROXY` to opt out.
213
+
214
+ ## JSDoc Documentation Output
215
+
216
+ `description`, `deprecated`, `default`, `example`/`examples`, and `title`
217
+ fields from your spec become JSDoc comments in the generated code — on object
218
+ type properties, named schemas, and client methods. Object types are always
219
+ emitted multi-line, so per-property JSDoc stays aligned at each nesting depth.
220
+ `@deprecated` tags give you editor strikethrough on deprecated fields and
221
+ methods for free.
222
+
171
223
  ## Programmatic API
172
224
 
173
225
  ```typescript
@@ -179,6 +231,7 @@ await generateClient({
179
231
  methodNameStrategy: 'path-based',
180
232
  specVersion: '3.1',
181
233
  strictVersion: true,
234
+ proxy: 'http://proxy.example.com:8080',
182
235
  });
183
236
  ```
184
237
 
@@ -194,7 +247,7 @@ union, and `isDefinedError` narrows a caught error to that union:
194
247
 
195
248
  ```typescript
196
249
  import { UnspecifiedApiError, RequesterFailError } from './contracts.js';
197
- import { isDefinedError } from './client.js';
250
+ import { isDefinedError } from 'genoc/runtime';
198
251
 
199
252
  try {
200
253
  const result = await client.getPets();
@@ -1,4 +1,5 @@
1
1
  import { toPascalCaseSegment } from '../utils/case.js';
2
+ import { sanitizeTypeName } from '../utils/generator-helpers.js';
2
3
  import { sanitizeIdentifier } from '../utils/string.js';
3
4
  import { isPathParam, extractParamName } from '../utils/url.js';
4
5
  /**
@@ -51,9 +52,9 @@ export function generateMethodName(method, path) {
51
52
  }
52
53
  const transformedSegments = segments.map((segment, index) => {
53
54
  if (isParam[index]) {
54
- return `By${toPascalCaseSegment(segment)}`;
55
+ return `By${sanitizeTypeName(toPascalCaseSegment(segment))}`;
55
56
  }
56
- return toPascalCaseSegment(segment);
57
+ return sanitizeTypeName(toPascalCaseSegment(segment));
57
58
  });
58
59
  return lowerMethod + transformedSegments.join('');
59
60
  }
@@ -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;
@@ -30,6 +32,8 @@ export interface AnalyzedOperation {
30
32
  path: string;
31
33
  operationId: string | undefined;
32
34
  methodName: string;
35
+ /** Deduped by analyzePaths; computed from method + path when absent. */
36
+ typePrefix?: string;
33
37
  summary: string | undefined;
34
38
  description: string | undefined;
35
39
  deprecated: boolean;
@@ -1,5 +1,5 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
- import { sanitizeTypeName } from '../utils/generator-helpers.js';
2
+ import { sanitizeTypeName, getOperationTypePrefix } from '../utils/generator-helpers.js';
3
3
  import { getMethodName } from './naming.js';
4
4
  const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
5
5
  function isRef(obj) {
@@ -78,6 +78,7 @@ function analyzeParameter(param, resolver) {
78
78
  tsType: schemaToTsType(param.schema, resolver),
79
79
  description: param.description,
80
80
  deprecated: param.deprecated,
81
+ example: param.example,
81
82
  };
82
83
  }
83
84
  function mergeParameters(pathItemParams, operationParams, resolver) {
@@ -120,6 +121,7 @@ function analyzeRequestBody(body, resolver) {
120
121
  schema,
121
122
  tsType,
122
123
  isMultipart,
124
+ description: resolved.description,
123
125
  };
124
126
  }
125
127
  function analyzeResponses(responses, resolver) {
@@ -193,6 +195,8 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
193
195
  const operations = [];
194
196
  if (!doc.paths)
195
197
  return operations;
198
+ const usedTypePrefixes = new Set();
199
+ const usedMethodNames = new Set();
196
200
  for (const [urlPath, pathItem] of Object.entries(doc.paths)) {
197
201
  for (const method of HTTP_METHODS) {
198
202
  const operation = pathItem[method];
@@ -219,5 +223,31 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
219
223
  });
220
224
  }
221
225
  }
226
+ // Dedupe type prefixes and method names so that distinct routes folding to
227
+ // the same identifier (e.g. "/weird" and "/weird-", or "/Weird" and
228
+ // "/weird") cannot produce duplicate exported types or client methods.
229
+ // NOTE: analyzePaths runs twice per generation (contracts pass + client
230
+ // pass); this assignment must stay deterministic - it relies on insertion
231
+ // order and the fixed HTTP_METHODS loop - so both passes agree.
232
+ for (const op of operations) {
233
+ const baseTypePrefix = getOperationTypePrefix(op);
234
+ let typePrefix = baseTypePrefix;
235
+ let n = 2;
236
+ while (usedTypePrefixes.has(typePrefix)) {
237
+ typePrefix = `${baseTypePrefix}${n}`;
238
+ n += 1;
239
+ }
240
+ usedTypePrefixes.add(typePrefix);
241
+ op.typePrefix = typePrefix;
242
+ const baseMethodName = op.methodName;
243
+ let methodName = baseMethodName;
244
+ n = 2;
245
+ while (usedMethodNames.has(methodName)) {
246
+ methodName = `${baseMethodName}${n}`;
247
+ n += 1;
248
+ }
249
+ usedMethodNames.add(methodName);
250
+ op.methodName = methodName;
251
+ }
222
252
  return operations;
223
253
  }
@@ -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,7 +1,12 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import { formatToBrandTypeName } from '../utils/case.js';
3
- import { sanitizeTypeName } from '../utils/generator-helpers.js';
3
+ import { buildTypeJsDoc, sanitizeTypeName } from '../utils/generator-helpers.js';
4
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
+ }
5
10
  /**
6
11
  * Default: extracts the last segment of the JSON pointer.
7
12
  * "#/components/schemas/User" -> "User"
@@ -37,6 +42,10 @@ export class SchemaMapper {
37
42
  brandedTypes = new Map();
38
43
  static nullableWarned = false;
39
44
  constructor(resolver, typeNameGenerator, discriminatorTargets, reservedNames) {
45
+ // NOTE: discriminatorTargets must be keyed by names produced by the SAME
46
+ // (rename-aware) typeNameGenerator that resolves $refs. Passing targets
47
+ // keyed by raw schema names means renamed subtypes silently lose their
48
+ // `& { prop: 'literal' }` intersection in the generated output.
40
49
  this.resolver = resolver;
41
50
  this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
42
51
  this.discriminatorTargets = discriminatorTargets ?? new Map();
@@ -58,7 +67,7 @@ export class SchemaMapper {
58
67
  return { tsType: schema ? 'unknown' : 'never', imports: [] };
59
68
  }
60
69
  const visited = new Set();
61
- const result = this.mapInternal(schema, name, context, visited);
70
+ const result = this.mapInternal(schema, name, context, visited, 0);
62
71
  if (name && this.discriminatorTargets.has(name)) {
63
72
  const target = this.discriminatorTargets.get(name);
64
73
  result.tsType += ` & { ${quoteKey(target.propertyName)}: '${target.literalValue}' }`;
@@ -75,7 +84,7 @@ export class SchemaMapper {
75
84
  return null;
76
85
  return brandName;
77
86
  }
78
- mapInternal(schema, name, context, visited) {
87
+ mapInternal(schema, name, context, visited, indent) {
79
88
  if (isRefObject(schema)) {
80
89
  const refStr = schema.$ref;
81
90
  const refName = this.typeNameGenerator(refStr);
@@ -89,7 +98,7 @@ export class SchemaMapper {
89
98
  if (resolved) {
90
99
  const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
91
100
  if (discInfo) {
92
- const expanded = this.mapInternal(resolved, undefined, context, visited);
101
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
93
102
  expanded.tsType += ` & { ${quoteKey(discInfo.propertyName)}: '${discInfo.literalValue}' }`;
94
103
  return expanded;
95
104
  }
@@ -121,7 +130,7 @@ export class SchemaMapper {
121
130
  return { tsType, imports: [] };
122
131
  }
123
132
  if (s.allOf !== undefined && s.allOf.length > 0) {
124
- const result = this.mapCombinator(s.allOf, '&', context, visited);
133
+ const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
125
134
  if (s.nullable === true) {
126
135
  return {
127
136
  tsType: needsParens(result.tsType)
@@ -134,8 +143,8 @@ export class SchemaMapper {
134
143
  }
135
144
  if (s.oneOf !== undefined && s.oneOf.length > 0) {
136
145
  const result = s.discriminator
137
- ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited)
138
- : this.mapCombinator(s.oneOf, '|', context, visited);
146
+ ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
147
+ : this.mapCombinator(s.oneOf, '|', context, visited, indent);
139
148
  if (s.nullable === true) {
140
149
  return {
141
150
  tsType: needsParens(result.tsType)
@@ -148,8 +157,8 @@ export class SchemaMapper {
148
157
  }
149
158
  if (s.anyOf !== undefined && s.anyOf.length > 0) {
150
159
  const result = s.discriminator
151
- ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited)
152
- : this.mapCombinator(s.anyOf, '|', context, visited);
160
+ ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
161
+ : this.mapCombinator(s.anyOf, '|', context, visited, indent);
153
162
  if (s.nullable === true) {
154
163
  return {
155
164
  tsType: needsParens(result.tsType)
@@ -169,7 +178,7 @@ export class SchemaMapper {
169
178
  if (nonNull.length === 0) {
170
179
  return { tsType: 'null', imports: [] };
171
180
  }
172
- const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited));
181
+ const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent);
173
182
  if (hasNull) {
174
183
  return {
175
184
  tsType: `${baseResult.tsType} | null`,
@@ -224,15 +233,15 @@ export class SchemaMapper {
224
233
  case 'null':
225
234
  return { tsType: 'null', imports: [] };
226
235
  case 'array':
227
- return this.mapArray(s, context, visited);
236
+ return this.mapArray(s, context, visited, indent);
228
237
  case 'object':
229
- return this.mapObject(s, name, context, visited);
238
+ return this.mapObject(s, name, context, visited, indent);
230
239
  default:
231
240
  return { tsType: 'unknown', imports: [] };
232
241
  }
233
242
  }
234
243
  resolveDiscriminatorInfo(schema, refStr) {
235
- const schemaName = sanitizeTypeName(refStr.split('/').pop());
244
+ const schemaName = this.typeNameGenerator(refStr);
236
245
  const target = this.discriminatorTargets.get(schemaName);
237
246
  if (target)
238
247
  return target;
@@ -270,8 +279,8 @@ export class SchemaMapper {
270
279
  })
271
280
  .join(' | ');
272
281
  }
273
- mapCombinator(schemas, kind, context, visited) {
274
- const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited));
282
+ mapCombinator(schemas, kind, context, visited, indent) {
283
+ const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent));
275
284
  const allImports = [];
276
285
  for (const r of results) {
277
286
  allImports.push(...r.imports);
@@ -285,14 +294,14 @@ export class SchemaMapper {
285
294
  });
286
295
  return { tsType: parts.join(separator), imports: allImports };
287
296
  }
288
- mapDiscriminatedUnion(schemas, discriminator, context, visited) {
297
+ mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
289
298
  const propertyName = discriminator.propertyName;
290
299
  const mapping = discriminator.mapping;
291
300
  const allImports = [];
292
301
  const parts = [];
293
302
  for (const schema of schemas) {
294
303
  const discriminantValue = this.resolveDiscriminantValue(schema, propertyName, mapping);
295
- const variantResult = this.mapInternal(schema, undefined, context, visited);
304
+ const variantResult = this.mapInternal(schema, undefined, context, visited, indent);
296
305
  allImports.push(...variantResult.imports);
297
306
  const quotedProp = quoteKey(propertyName);
298
307
  parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
@@ -323,14 +332,14 @@ export class SchemaMapper {
323
332
  }
324
333
  return 'unknown';
325
334
  }
326
- mapArray(schema, context, visited) {
335
+ mapArray(schema, context, visited, indent) {
327
336
  if (!schema.items) {
328
337
  return {
329
338
  tsType: schema.nullable === true ? 'unknown[] | null' : 'unknown[]',
330
339
  imports: [],
331
340
  };
332
341
  }
333
- const itemResult = this.mapInternal(schema.items, undefined, context, visited);
342
+ const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
334
343
  const tsType = isComplexType(itemResult.tsType)
335
344
  ? `Array<${itemResult.tsType}>`
336
345
  : `${itemResult.tsType}[]`;
@@ -339,7 +348,33 @@ export class SchemaMapper {
339
348
  imports: itemResult.imports,
340
349
  };
341
350
  }
342
- mapObject(schema, name, context, visited) {
351
+ /**
352
+ * Render the per-property JSDoc comment lines for an object property.
353
+ *
354
+ * Metadata comes from the property's own schema node; for a `$ref` property
355
+ * the resolved target's own node metadata is used (one level only — never
356
+ * recursed), so self-referential schemas cannot loop here.
357
+ */
358
+ renderPropertyJsDoc(propSchema, indentLevel) {
359
+ let metaSchema;
360
+ if (isRefObject(propSchema)) {
361
+ try {
362
+ metaSchema = this.resolver.resolveSchema(propSchema);
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ }
368
+ else {
369
+ metaSchema = propSchema;
370
+ }
371
+ const block = buildTypeJsDoc(metaSchema);
372
+ if (block === '')
373
+ return [];
374
+ const ind = indentBy(indentLevel);
375
+ return block.split('\n').map((line) => `${ind}${line}`);
376
+ }
377
+ mapObject(schema, name, context, visited, indent) {
343
378
  const properties = schema.properties ?? {};
344
379
  const requiredSet = new Set(schema.required ?? []);
345
380
  const propNames = Object.keys(properties);
@@ -354,7 +389,9 @@ export class SchemaMapper {
354
389
  return false;
355
390
  return true;
356
391
  });
357
- const propEntries = [];
392
+ const ownIndent = indentBy(indent);
393
+ const memberIndent = indentBy(indent + 1);
394
+ const memberLines = [];
358
395
  const allImports = [];
359
396
  if (schema.discriminator) {
360
397
  const discPropName = schema.discriminator.propertyName;
@@ -362,15 +399,16 @@ export class SchemaMapper {
362
399
  if (discIndex !== -1) {
363
400
  filteredPropNames.splice(discIndex, 1);
364
401
  }
365
- propEntries.push(`"${discPropName}": string`);
402
+ memberLines.push(`${memberIndent}"${discPropName}": string;`);
366
403
  }
367
404
  for (const propName of filteredPropNames) {
368
405
  const propSchema = properties[propName];
369
- const propResult = this.mapInternal(propSchema, undefined, context, visited);
406
+ const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
370
407
  allImports.push(...propResult.imports);
408
+ memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
371
409
  const optional = requiredSet.has(propName) ? '' : '?';
372
410
  const quotedName = quoteKey(propName);
373
- propEntries.push(`${quotedName}${optional}: ${propResult.tsType}`);
411
+ memberLines.push(`${memberIndent}${quotedName}${optional}: ${propResult.tsType};`);
374
412
  }
375
413
  const additionalProps = schema.additionalProperties;
376
414
  let indexSignature = null;
@@ -381,11 +419,11 @@ export class SchemaMapper {
381
419
  indexSignature = '[key: string]: unknown';
382
420
  }
383
421
  else if (additionalProps !== undefined && typeof additionalProps === 'object') {
384
- const addPropResult = this.mapInternal(additionalProps, undefined, context, visited);
422
+ const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
385
423
  allImports.push(...addPropResult.imports);
386
424
  indexSignature = `[key: string]: ${addPropResult.tsType}`;
387
425
  }
388
- const hasProps = propEntries.length > 0;
426
+ const hasProps = memberLines.length > 0;
389
427
  const hasIndex = indexSignature !== null;
390
428
  let result;
391
429
  if (!hasProps && !hasIndex) {
@@ -400,7 +438,7 @@ export class SchemaMapper {
400
438
  const valueType = indexSignature.replace('[key: string]: ', '');
401
439
  if (name) {
402
440
  result = {
403
- tsType: `{ ${indexSignature}; }`,
441
+ tsType: `{\n${memberIndent}${indexSignature};\n${ownIndent}}`,
404
442
  imports: allImports,
405
443
  };
406
444
  }
@@ -412,16 +450,17 @@ export class SchemaMapper {
412
450
  }
413
451
  }
414
452
  else {
415
- const propsBody = propEntries.join('; ') + ';';
453
+ const propsBlock = `{\n${memberLines.join('\n')}\n${ownIndent}}`;
416
454
  if (hasIndex) {
455
+ const indexBlock = `{\n${memberIndent}${indexSignature};\n${ownIndent}}`;
417
456
  result = {
418
- tsType: `{ ${propsBody} } & { ${indexSignature}; }`,
457
+ tsType: `${propsBlock} & ${indexBlock}`,
419
458
  imports: allImports,
420
459
  };
421
460
  }
422
461
  else {
423
462
  result = {
424
- tsType: `{ ${propsBody} }`,
463
+ tsType: propsBlock,
425
464
  imports: allImports,
426
465
  };
427
466
  }
package/dist/cli/app.d.ts CHANGED
@@ -3,6 +3,8 @@ interface Flags {
3
3
  methodNameStrategy: 'path-based' | 'operationId' | 'operationId-with-fallback';
4
4
  specVersion?: string;
5
5
  strictVersion: boolean;
6
+ runtimeImportPath?: string;
7
+ proxy?: string;
6
8
  }
7
9
  export type AppFlags = Flags;
8
10
  export declare const app: import("@stricli/core").Application<import("@stricli/core").CommandContext>;
package/dist/cli/app.js CHANGED
@@ -29,6 +29,20 @@ 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
+ },
39
+ proxy: {
40
+ kind: 'parsed',
41
+ parse: String,
42
+ brief: 'HTTP(S) proxy URL for fetching specs (overrides HTTP_PROXY/HTTPS_PROXY env vars)',
43
+ optional: true,
44
+ placeholder: 'url',
45
+ },
32
46
  },
33
47
  positional: {
34
48
  kind: 'tuple',
package/dist/cli/impl.js CHANGED
@@ -2,11 +2,20 @@ import { generateFullOutput } from '../generator/client-generator.js';
2
2
  import { load } from '../parser/spec-reader.js';
3
3
  import { validateSpec } from '../parser/validators.js';
4
4
  import { defaultRegistry } from '../parser/version/index.js';
5
+ import { assertValidProxyUrl } from '../utils/proxy.js';
5
6
  import { UserError } from './errors.js';
6
7
  export default async function (flags, spec) {
7
8
  try {
9
+ if (flags.proxy) {
10
+ try {
11
+ assertValidProxyUrl(flags.proxy);
12
+ }
13
+ catch (err) {
14
+ throw new UserError(err.message);
15
+ }
16
+ }
8
17
  this.process.stdout.write(`Loading spec from ${spec}...\n`);
9
- const doc = await load(spec);
18
+ const doc = await load(spec, { proxy: flags.proxy });
10
19
  this.process.stdout.write(`Loaded OpenAPI ${doc.openapi} spec\n`);
11
20
  const strategy = flags.specVersion
12
21
  ? defaultRegistry.get(flags.specVersion)
@@ -27,6 +36,7 @@ export default async function (flags, spec) {
27
36
  methodNameStrategy: flags.methodNameStrategy || 'path-based',
28
37
  specVersion: flags.specVersion,
29
38
  strictVersion: flags.strictVersion,
39
+ runtimeImportPath: flags.runtimeImportPath,
30
40
  };
31
41
  const preserveRefSiblings = strategy.version() === '3.1';
32
42
  this.process.stdout.write('Generating client...\n');