genoc 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,13 +170,14 @@ genoc <spec> [flags]
170
170
 
171
171
  `<spec>` — Path or URL to an OpenAPI 3.0 / 3.1 spec (JSON or YAML).
172
172
 
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 |
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 |
180
181
 
181
182
  ## Method Naming Strategies
182
183
 
@@ -189,6 +190,27 @@ genoc <spec> [flags]
189
190
  - **`operationId-with-fallback`** — Use `operationId` if present, otherwise
190
191
  fall back to path-based naming.
191
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
+
192
214
  ## JSDoc Documentation Output
193
215
 
194
216
  `description`, `deprecated`, `default`, `example`/`examples`, and `title`
@@ -209,6 +231,7 @@ await generateClient({
209
231
  methodNameStrategy: 'path-based',
210
232
  specVersion: '3.1',
211
233
  strictVersion: true,
234
+ proxy: 'http://proxy.example.com:8080',
212
235
  });
213
236
  ```
214
237
 
@@ -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
  }
@@ -32,6 +32,8 @@ export interface AnalyzedOperation {
32
32
  path: string;
33
33
  operationId: string | undefined;
34
34
  methodName: string;
35
+ /** Deduped by analyzePaths; computed from method + path when absent. */
36
+ typePrefix?: string;
35
37
  summary: string | undefined;
36
38
  description: string | undefined;
37
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) {
@@ -195,6 +195,8 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
195
195
  const operations = [];
196
196
  if (!doc.paths)
197
197
  return operations;
198
+ const usedTypePrefixes = new Set();
199
+ const usedMethodNames = new Set();
198
200
  for (const [urlPath, pathItem] of Object.entries(doc.paths)) {
199
201
  for (const method of HTTP_METHODS) {
200
202
  const operation = pathItem[method];
@@ -221,5 +223,31 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
221
223
  });
222
224
  }
223
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
+ }
224
252
  return operations;
225
253
  }
@@ -42,6 +42,10 @@ export class SchemaMapper {
42
42
  brandedTypes = new Map();
43
43
  static nullableWarned = false;
44
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.
45
49
  this.resolver = resolver;
46
50
  this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
47
51
  this.discriminatorTargets = discriminatorTargets ?? new Map();
@@ -237,7 +241,7 @@ export class SchemaMapper {
237
241
  }
238
242
  }
239
243
  resolveDiscriminatorInfo(schema, refStr) {
240
- const schemaName = sanitizeTypeName(refStr.split('/').pop());
244
+ const schemaName = this.typeNameGenerator(refStr);
241
245
  const target = this.discriminatorTargets.get(schemaName);
242
246
  if (target)
243
247
  return target;
package/dist/cli/app.d.ts CHANGED
@@ -4,6 +4,7 @@ interface Flags {
4
4
  specVersion?: string;
5
5
  strictVersion: boolean;
6
6
  runtimeImportPath?: string;
7
+ proxy?: string;
7
8
  }
8
9
  export type AppFlags = Flags;
9
10
  export declare const app: import("@stricli/core").Application<import("@stricli/core").CommandContext>;
package/dist/cli/app.js CHANGED
@@ -36,6 +36,13 @@ const command = buildCommand({
36
36
  optional: true,
37
37
  placeholder: 'module',
38
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
+ },
39
46
  },
40
47
  positional: {
41
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)
@@ -22,18 +22,20 @@ 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
  }
@@ -245,13 +247,12 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
245
247
  for (const [original, renamed] of renameMap) {
246
248
  // TODO: Replace with structured logging solution
247
249
  // oxlint-disable-next-line no-console
248
- 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}".`);
249
251
  }
250
252
  const renamingTypeGenerator = (refString) => {
251
253
  const segments = refString.split('/');
252
254
  const rawSegment = segments[segments.length - 1] || 'unknown';
253
- const lastSegment = sanitizeTypeName(rawSegment);
254
- return renameMap.get(lastSegment) ?? lastSegment;
255
+ return renameMap.get(rawSegment) ?? sanitizeTypeName(rawSegment);
255
256
  };
256
257
  const discriminatorInfo = new Map();
257
258
  if (doc.components?.schemas) {
@@ -261,12 +262,13 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
261
262
  const mapping = new Map();
262
263
  if (resolved.discriminator.mapping) {
263
264
  for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
264
- const targetName = sanitizeTypeName(ref.split('/').pop() || key);
265
- const renamedTarget = renameMap.get(targetName) ?? targetName;
265
+ const rawTarget = ref.split('/').pop() || key;
266
+ const renamedTarget = renameMap.get(rawTarget) ?? sanitizeTypeName(rawTarget);
266
267
  mapping.set(key, renamedTarget);
267
268
  }
268
269
  }
269
270
  discriminatorInfo.set(sanitizeTypeName(name), {
271
+ rawName: name,
270
272
  propertyName: resolved.discriminator.propertyName,
271
273
  mapping,
272
274
  });
@@ -285,7 +287,7 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
285
287
  const allSchemaNames = new Set();
286
288
  if (doc.components?.schemas) {
287
289
  for (const name of Object.keys(doc.components.schemas)) {
288
- allSchemaNames.add(sanitizeTypeName(name));
290
+ allSchemaNames.add(renameMap.get(name) ?? sanitizeTypeName(name));
289
291
  }
290
292
  }
291
293
  const mapper = new SchemaMapper(resolver, renamingTypeGenerator, discriminatorTargets, allSchemaNames);
@@ -297,9 +299,8 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
297
299
  const schemaEntries = [];
298
300
  if (doc.components?.schemas) {
299
301
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
300
- const sanitizedName = sanitizeTypeName(name);
301
- const renamedName = renameMap.get(sanitizedName) ?? sanitizedName;
302
- const result = mapper.mapSchema(schema, sanitizedName);
302
+ const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
303
+ const result = mapper.mapSchema(schema, renamedName);
303
304
  const resolved = resolver.resolve(schema);
304
305
  const definition = `export type ${renamedName} = ${result.tsType};`;
305
306
  schemaEntries.push({
@@ -318,12 +319,12 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
318
319
  }
319
320
  lines.push(entry.definition);
320
321
  }
321
- for (const [baseName, info] of discriminatorInfo) {
322
+ for (const [, info] of discriminatorInfo) {
322
323
  const subtypeNames = Array.from(info.mapping.values());
323
324
  if (subtypeNames.length === 0)
324
325
  continue;
325
326
  const unionType = subtypeNames.join(' | ');
326
- const renamedBase = renameMap.get(sanitizeTypeName(baseName)) ?? sanitizeTypeName(baseName);
327
+ const renamedBase = renameMap.get(info.rawName) ?? sanitizeTypeName(info.rawName);
327
328
  lines.push('');
328
329
  lines.push(`export type ${renamedBase}Variant = ${unionType};`);
329
330
  allSchemaNames.add(`${renamedBase}Variant`);
@@ -332,8 +333,16 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
332
333
  const securitySchemes = doc.components?.securitySchemes;
333
334
  if (securitySchemes && Object.keys(securitySchemes).length > 0) {
334
335
  const securityTypeNames = [];
336
+ const usedSecurityTypeNames = new Set();
335
337
  for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
336
- 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);
337
346
  const tsType = securitySchemeToTsType(scheme);
338
347
  const schemeJsDoc = buildDescriptionJsDoc(scheme.description);
339
348
  if (schemeJsDoc !== '') {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type ApiClient } from './generator/client-generator.js';
2
2
  import type { GeneratorConfig } from './types/client.js';
3
+ import { type LoadOptions } from './utils/proxy.js';
3
4
  export declare function generateClient(config: GeneratorConfig): Promise<void>;
4
5
  export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
5
6
  export type { Requester } from './runtime/index.js';
6
- export type { GeneratorConfig, ApiClient };
7
+ export type { GeneratorConfig, ApiClient, LoadOptions };
7
8
  export type { GenerationOptions } from './generator/client-generator.js';
8
9
  export { load as loadSpec } from './parser/spec-reader.js';
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { generateFullOutput } from './generator/client-generator.js';
2
2
  import { load } from './parser/spec-reader.js';
3
+ import { assertValidProxyUrl } from './utils/proxy.js';
3
4
  export async function generateClient(config) {
4
- const doc = await load(config.input);
5
+ if (config.proxy)
6
+ assertValidProxyUrl(config.proxy);
7
+ const doc = await load(config.input, { proxy: config.proxy });
5
8
  await generateFullOutput(doc, config);
6
9
  }
7
10
  export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.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
  }
@@ -7,6 +7,7 @@ export type GeneratorConfig = {
7
7
  specVersion?: string;
8
8
  strictVersion?: boolean;
9
9
  runtimeImportPath?: string;
10
+ proxy?: string;
10
11
  };
11
12
  export type RequesterFunction = <TResponse>(method: string, path: string, options: {
12
13
  query?: Record<string, unknown>;
@@ -9,15 +9,32 @@ export declare const RESERVED_TYPE_NAMES: ReadonlySet<string>;
9
9
  /** Default module specifier generated code imports shared runtime classes from. */
10
10
  export declare const DEFAULT_RUNTIME_IMPORT_PATH = "genoc/runtime";
11
11
  /**
12
- * Sanitize a schema type name by converting dot-separated segments into
13
- * PascalCase. Names without dots pass through unchanged.
12
+ * Sanitize a schema type name into a valid TypeScript identifier.
14
13
  *
15
- * "Api.Error" → "ApiError", "Simple" → "Simple"
14
+ * Any run of non-identifier characters (dots, brackets, backticks, hyphens,
15
+ * spaces, slashes, ...) splits the name into segments that are re-joined in
16
+ * PascalCase:
17
+ *
18
+ * "Api.Error" → "ApiError"
19
+ * "User[Dto]" → "UserDto"
20
+ * "my-schema" → "MySchema"
21
+ * "payment id" → "PaymentId"
22
+ *
23
+ * Names that are already valid identifiers pass through unchanged
24
+ * ("User", "my_schema", "mySchema", "$Foo", "Данные"). Results that would
25
+ * start with a digit ("2FA") get an "_" prefix, as do exact reserved words
26
+ * ("class" → "_class"). Names with no usable characters collapse to "_".
16
27
  */
17
28
  export declare function sanitizeTypeName(name: string): string;
18
29
  /**
19
- * Build a mapping of sanitized schema names → renamed names for any that
20
- * collide with reserved type names.
30
+ * Build a mapping of raw schema names → renamed names for any whose
31
+ * sanitized name collides with a reserved type name or with another
32
+ * schema's sanitized name (e.g. "User-Dto" and "User[Dto]" both → "UserDto").
33
+ *
34
+ * The map is keyed by the raw schema name (component keys are unique by
35
+ * construction), so callers resolve raw $ref segments / schema keys first
36
+ * and fall back to sanitizeTypeName for the common no-collision case.
37
+ * Numbered suffixes are used when the first renamed candidate also collides.
21
38
  */
22
39
  export declare function buildSchemaRenameMap(schemaNames: Iterable<string>, reserved: ReadonlySet<string>, suffix?: string): Map<string, string>;
23
40
  /**
@@ -28,6 +45,10 @@ export declare function toPascalCase(str: string): string;
28
45
  /**
29
46
  * Build a PascalCase type-name prefix from an operation's method + path.
30
47
  * get + /api/v1/products → "GetApiV1Products"
48
+ *
49
+ * Returns the deduped prefix assigned by analyzePaths when available
50
+ * (distinct routes folding to the same identifier get numbered suffixes),
51
+ * otherwise computes it from the operation directly.
31
52
  */
32
53
  export declare function getOperationTypePrefix(op: AnalyzedOperation): string;
33
54
  /**
@@ -1,3 +1,87 @@
1
+ /**
2
+ * TypeScript/JavaScript reserved words, matched case-sensitively: a type
3
+ * named "Null" or "Class" is legal, only the exact lowercase keywords are.
4
+ */
5
+ const RESERVED_WORDS = new Set([
6
+ 'abstract',
7
+ 'any',
8
+ 'as',
9
+ 'asserts',
10
+ 'assert',
11
+ 'async',
12
+ 'await',
13
+ 'boolean',
14
+ 'break',
15
+ 'case',
16
+ 'catch',
17
+ 'class',
18
+ 'continue',
19
+ 'const',
20
+ 'constructor',
21
+ 'debugger',
22
+ 'declare',
23
+ 'default',
24
+ 'delete',
25
+ 'do',
26
+ 'else',
27
+ 'enum',
28
+ 'export',
29
+ 'extends',
30
+ 'false',
31
+ 'finally',
32
+ 'for',
33
+ 'from',
34
+ 'function',
35
+ 'get',
36
+ 'if',
37
+ 'implements',
38
+ 'import',
39
+ 'in',
40
+ 'infer',
41
+ 'instanceof',
42
+ 'interface',
43
+ 'is',
44
+ 'keyof',
45
+ 'let',
46
+ 'module',
47
+ 'namespace',
48
+ 'never',
49
+ 'new',
50
+ 'null',
51
+ 'number',
52
+ 'object',
53
+ 'package',
54
+ 'private',
55
+ 'protected',
56
+ 'public',
57
+ 'readonly',
58
+ 'require',
59
+ 'return',
60
+ 'satisfies',
61
+ 'set',
62
+ 'static',
63
+ 'string',
64
+ 'super',
65
+ 'switch',
66
+ 'symbol',
67
+ 'this',
68
+ 'throw',
69
+ 'true',
70
+ 'try',
71
+ 'type',
72
+ 'typeof',
73
+ 'undefined',
74
+ 'unique',
75
+ 'unknown',
76
+ 'var',
77
+ 'void',
78
+ 'while',
79
+ 'with',
80
+ 'yield',
81
+ ]);
82
+ function isReservedTypeName(name) {
83
+ return RESERVED_WORDS.has(name);
84
+ }
1
85
  /**
2
86
  * Reserved type names used by the generated output's built-in classes,
3
87
  * functions, and types. User-defined schema names that collide with these
@@ -22,30 +106,72 @@ export const RESERVED_TYPE_NAMES = new Set([
22
106
  /** Default module specifier generated code imports shared runtime classes from. */
23
107
  export const DEFAULT_RUNTIME_IMPORT_PATH = 'genoc/runtime';
24
108
  /**
25
- * Sanitize a schema type name by converting dot-separated segments into
26
- * PascalCase. Names without dots pass through unchanged.
109
+ * Sanitize a schema type name into a valid TypeScript identifier.
110
+ *
111
+ * Any run of non-identifier characters (dots, brackets, backticks, hyphens,
112
+ * spaces, slashes, ...) splits the name into segments that are re-joined in
113
+ * PascalCase:
27
114
  *
28
- * "Api.Error" → "ApiError", "Simple" → "Simple"
115
+ * "Api.Error" → "ApiError"
116
+ * "User[Dto]" → "UserDto"
117
+ * "my-schema" → "MySchema"
118
+ * "payment id" → "PaymentId"
119
+ *
120
+ * Names that are already valid identifiers pass through unchanged
121
+ * ("User", "my_schema", "mySchema", "$Foo", "Данные"). Results that would
122
+ * start with a digit ("2FA") get an "_" prefix, as do exact reserved words
123
+ * ("class" → "_class"). Names with no usable characters collapse to "_".
29
124
  */
30
125
  export function sanitizeTypeName(name) {
31
- if (!name.includes('.'))
126
+ if (!name)
127
+ return '_';
128
+ // Exact reserved words keep their casing and get an "_" prefix
129
+ // ("class" → "_class"), while "Null" / "Class" are legal and pass through.
130
+ if (isReservedTypeName(name))
131
+ return `_${name}`;
132
+ const isValidIdentifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name);
133
+ if (isValidIdentifier)
32
134
  return name;
33
- return name
34
- .split('.')
135
+ const segments = name.match(/[\p{ID_Continue}$]+/gu) ?? [];
136
+ const result = segments
35
137
  .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
36
138
  .join('');
139
+ // Covers empty results, ASCII digits, and non-ASCII digits (Nd but not
140
+ // ID_Start, e.g. Arabic-Indic) alike: anything not starting with a valid
141
+ // identifier start character gets the "_" prefix.
142
+ if (!/^[\p{ID_Start}$_]/u.test(result))
143
+ return `_${result}`;
144
+ if (isReservedTypeName(result))
145
+ return `_${result}`;
146
+ return result;
37
147
  }
38
148
  /**
39
- * Build a mapping of sanitized schema names → renamed names for any that
40
- * collide with reserved type names.
149
+ * Build a mapping of raw schema names → renamed names for any whose
150
+ * sanitized name collides with a reserved type name or with another
151
+ * schema's sanitized name (e.g. "User-Dto" and "User[Dto]" both → "UserDto").
152
+ *
153
+ * The map is keyed by the raw schema name (component keys are unique by
154
+ * construction), so callers resolve raw $ref segments / schema keys first
155
+ * and fall back to sanitizeTypeName for the common no-collision case.
156
+ * Numbered suffixes are used when the first renamed candidate also collides.
41
157
  */
42
158
  export function buildSchemaRenameMap(schemaNames, reserved, suffix = 'Model') {
43
159
  const renameMap = new Map();
160
+ const used = new Set(reserved);
44
161
  for (const name of schemaNames) {
45
162
  const sanitized = sanitizeTypeName(name);
46
- if (reserved.has(sanitized)) {
47
- renameMap.set(sanitized, `${sanitized}${suffix}`);
163
+ if (!used.has(sanitized)) {
164
+ used.add(sanitized);
165
+ continue;
166
+ }
167
+ let candidate = `${sanitized}${suffix}`;
168
+ let counter = 2;
169
+ while (used.has(candidate)) {
170
+ candidate = `${sanitized}${suffix}${counter}`;
171
+ counter += 1;
48
172
  }
173
+ used.add(candidate);
174
+ renameMap.set(name, candidate);
49
175
  }
50
176
  return renameMap;
51
177
  }
@@ -64,15 +190,24 @@ export function toPascalCase(str) {
64
190
  /**
65
191
  * Build a PascalCase type-name prefix from an operation's method + path.
66
192
  * get + /api/v1/products → "GetApiV1Products"
193
+ *
194
+ * Returns the deduped prefix assigned by analyzePaths when available
195
+ * (distinct routes folding to the same identifier get numbered suffixes),
196
+ * otherwise computes it from the operation directly.
67
197
  */
68
198
  export function getOperationTypePrefix(op) {
199
+ if (op.typePrefix)
200
+ return op.typePrefix;
201
+ return computeOperationTypePrefix(op);
202
+ }
203
+ function computeOperationTypePrefix(op) {
69
204
  const methodPascal = op.method.charAt(0).toUpperCase() + op.method.slice(1).toLowerCase();
70
205
  const segments = op.path
71
206
  .split('/')
72
207
  .filter((s) => s.length > 0)
73
208
  .map((s) => {
74
209
  const cleaned = s.replace(/[{}]/g, '');
75
- return toPascalCase(cleaned);
210
+ return sanitizeTypeName(toPascalCase(cleaned));
76
211
  });
77
212
  return methodPascal + segments.join('');
78
213
  }
@@ -0,0 +1,6 @@
1
+ export type LoadOptions = {
2
+ proxy?: string;
3
+ };
4
+ export declare function assertValidProxyUrl(url: string): void;
5
+ export declare function hasProxyEnv(): boolean;
6
+ export declare function fetchSpec(url: string, opts?: LoadOptions): Promise<Response>;
@@ -0,0 +1,81 @@
1
+ const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']);
2
+ /** Security: never echo credentials — user:pass@ becomes ***@ in any echoed value. */
3
+ function redactCredentials(proxyUrl) {
4
+ try {
5
+ const url = new URL(proxyUrl);
6
+ if (url.username || url.password) {
7
+ url.username = '***';
8
+ url.password = '';
9
+ }
10
+ return url.toString();
11
+ }
12
+ catch {
13
+ return proxyUrl;
14
+ }
15
+ }
16
+ const MAX_CAUSE_DEPTH = 5;
17
+ /**
18
+ * undici's fetch() nests the meaningful error up to 3 levels deep via `cause`
19
+ * (e.g. "fetch failed" → "Request was cancelled." → "Proxy response (502) !== 200...").
20
+ * Flatten the chain so the deepest message (proxy status, ECONNREFUSED, ...) is user-visible.
21
+ */
22
+ function describeFetchError(err) {
23
+ const messages = [];
24
+ let current = err;
25
+ for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
26
+ if (!messages.includes(current.message)) {
27
+ messages.push(current.message);
28
+ }
29
+ current = current.cause;
30
+ }
31
+ return messages.join(': ');
32
+ }
33
+ export function assertValidProxyUrl(url) {
34
+ let parsed;
35
+ try {
36
+ parsed = new URL(url);
37
+ }
38
+ catch {
39
+ throw new Error(`Invalid proxy URL "${redactCredentials(url)}": only http:// and https:// proxies are supported`);
40
+ }
41
+ if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol)) {
42
+ throw new Error(`Invalid proxy URL "${redactCredentials(url)}": only http:// and https:// proxies are supported (got scheme "${parsed.protocol.replace(/:$/, '')}:")`);
43
+ }
44
+ }
45
+ export function hasProxyEnv() {
46
+ return Boolean(process.env.HTTP_PROXY ||
47
+ process.env.http_proxy ||
48
+ process.env.HTTPS_PROXY ||
49
+ process.env.https_proxy);
50
+ }
51
+ export async function fetchSpec(url, opts) {
52
+ if (opts?.proxy) {
53
+ assertValidProxyUrl(opts.proxy);
54
+ const redacted = redactCredentials(opts.proxy);
55
+ try {
56
+ const { fetch: undiciFetch, ProxyAgent } = await import('undici');
57
+ return (await undiciFetch(url, {
58
+ dispatcher: new ProxyAgent(opts.proxy),
59
+ }));
60
+ }
61
+ catch (err) {
62
+ throw new Error(`Failed to fetch spec from URL via proxy ${redacted}: ${describeFetchError(err)}`, {
63
+ cause: err,
64
+ });
65
+ }
66
+ }
67
+ if (hasProxyEnv()) {
68
+ try {
69
+ const { fetch: undiciFetch, EnvHttpProxyAgent } = await import('undici');
70
+ return (await undiciFetch(url, {
71
+ dispatcher: new EnvHttpProxyAgent(),
72
+ }));
73
+ }
74
+ catch (err) {
75
+ throw new Error(`Failed to fetch spec from URL via proxy env: ${describeFetchError(err)}`, {
76
+ cause: err,
77
+ });
78
+ }
79
+ }
80
+ return fetch(url);
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genoc",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
5
5
  "keywords": [
6
6
  "client",
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@stricli/core": "^1.2.6",
51
+ "undici": "^6.13.0",
51
52
  "yaml": "^2.8.4"
52
53
  },
53
54
  "devDependencies": {
@@ -1,13 +0,0 @@
1
- import type { AnalyzedOperation } from '../analyzer/path-analyzer.js';
2
- /**
3
- * Generate error types for a set of analyzed operations.
4
- *
5
- * Produces:
6
- * 1. `ApiError<TStatus, TData>` class
7
- * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
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
12
- */
13
- export declare function generateErrorTypes(operations: AnalyzedOperation[]): string;
@@ -1,67 +0,0 @@
1
- /**
2
- * Generate error types for a set of analyzed operations.
3
- *
4
- * Produces:
5
- * 1. `ApiError<TStatus, TData>` class
6
- * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
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
11
- */
12
- export function generateErrorTypes(operations) {
13
- const lines = [];
14
- lines.push(`export class ApiError<TStatus extends number, TData> extends Error {
15
- constructor(
16
- public readonly status: TStatus,
17
- public readonly data: TData,
18
- message: string,
19
- ) {
20
- super(message);
21
- this.name = "ApiError";
22
- }
23
- }`);
24
- lines.push('');
25
- lines.push(`export class UnspecifiedApiError extends ApiError<number, unknown> {
26
- constructor(
27
- status: number,
28
- data: unknown,
29
- message: string,
30
- ) {
31
- super(status, data, message);
32
- this.name = "UnspecifiedApiError";
33
- }
34
- }`);
35
- lines.push('');
36
- let needsDefaultErrorBody = false;
37
- for (const op of operations) {
38
- const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
39
- const defaultResponse = op.responses.find((r) => !r.isSuccess && r.statusCode === 'default');
40
- if (errorResponses.length === 0 && !defaultResponse) {
41
- continue;
42
- }
43
- const methodName = op.methodName;
44
- const errorTypeName = (status) => `${methodName}Error${status}`;
45
- lines.push('');
46
- for (const err of errorResponses) {
47
- const tsType = err.tsType || 'unknown';
48
- lines.push(`export type ${errorTypeName(err.statusCode)} = ${tsType};`);
49
- }
50
- const unionParts = [];
51
- for (const err of errorResponses) {
52
- const status = Number(err.statusCode);
53
- unionParts.push(`ApiError<${status}, ${errorTypeName(err.statusCode)}>`);
54
- }
55
- if (defaultResponse) {
56
- needsDefaultErrorBody = true;
57
- unionParts.push(`ApiError<number, DefaultErrorBody>`);
58
- }
59
- unionParts.push(`UnspecifiedApiError`);
60
- lines.push(`export type ${methodName}Errors = ${unionParts.join('\n | ')};`);
61
- }
62
- if (needsDefaultErrorBody) {
63
- lines.push('');
64
- lines.push('export type DefaultErrorBody = unknown;');
65
- }
66
- return lines.join('\n');
67
- }