genoc 0.1.1 → 0.1.3

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
@@ -127,7 +127,7 @@ if (options.expectStream === true) {
127
127
  class StreamResponse {
128
128
  data: ReadableStream<Uint8Array>;
129
129
  filename?: string;
130
- headers: Headers;
130
+ headers: Record<string, string>;
131
131
  }
132
132
  ```
133
133
 
@@ -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
  }
@@ -1,5 +1,6 @@
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
4
  import { quoteKey } from '../utils/string.js';
4
5
  /**
5
6
  * Default: extracts the last segment of the JSON pointer.
@@ -7,7 +8,8 @@ import { quoteKey } from '../utils/string.js';
7
8
  */
8
9
  function defaultTypeNameGenerator(refString) {
9
10
  const segments = refString.split('/');
10
- return segments[segments.length - 1] || 'unknown';
11
+ const rawSegment = segments[segments.length - 1] || 'unknown';
12
+ return sanitizeTypeName(rawSegment);
11
13
  }
12
14
  function isRefObject(obj) {
13
15
  return (obj !== null &&
@@ -230,7 +232,7 @@ export class SchemaMapper {
230
232
  }
231
233
  }
232
234
  resolveDiscriminatorInfo(schema, refStr) {
233
- const schemaName = refStr.split('/').pop();
235
+ const schemaName = sanitizeTypeName(refStr.split('/').pop());
234
236
  const target = this.discriminatorTargets.get(schemaName);
235
237
  if (target)
236
238
  return target;
@@ -89,12 +89,12 @@ function buildClientMethodBody(op) {
89
89
  if (op.queryParams.length > 0) {
90
90
  opts.push('query');
91
91
  }
92
- if (op.headerParams.length > 0) {
93
- opts.push('headers');
94
- }
95
92
  if (op.requestBody) {
96
93
  opts.push('body');
97
94
  }
95
+ if (op.headerParams.length > 0) {
96
+ opts.push('headers');
97
+ }
98
98
  const lines = [];
99
99
  if (op.requestBody?.isMultipart && op.requestBody.schema) {
100
100
  const schema = op.requestBody.schema;
@@ -1,7 +1,7 @@
1
1
  import { analyzePaths } from '../analyzer/path-analyzer.js';
2
2
  import { SchemaMapper } from '../analyzer/schema-mapper.js';
3
3
  import { RefResolver } from '../parser/ref-resolver.js';
4
- import { toPascalCase, getOperationTypePrefix, makeHeader } from '../utils/generator-helpers.js';
4
+ import { RESERVED_TYPE_NAMES, buildSchemaRenameMap, sanitizeTypeName, toPascalCase, getOperationTypePrefix, makeHeader, } from '../utils/generator-helpers.js';
5
5
  function isBinaryContentType(ct) {
6
6
  if (ct === 'application/octet-stream')
7
7
  return true;
@@ -17,22 +17,24 @@ function isBinaryContentType(ct) {
17
17
  * If the schema is a $ref to a discriminated base type (or an array whose items
18
18
  * are), replace the type name with the {Base}Variant union type.
19
19
  */
20
- function substituteDiscriminatedType(tsType, schema, discriminatorInfo) {
20
+ function substituteDiscriminatedType(tsType, schema, discriminatorInfo, renameMap) {
21
21
  const refSchema = schema;
22
22
  if (!refSchema || typeof refSchema !== 'object')
23
23
  return tsType;
24
24
  if (typeof refSchema.$ref === 'string') {
25
- const schemaName = refSchema.$ref.split('/').pop();
25
+ const schemaName = sanitizeTypeName(refSchema.$ref.split('/').pop());
26
26
  if (schemaName && discriminatorInfo.has(schemaName)) {
27
- return tsType.replace(new RegExp(`\\b${schemaName}\\b`, 'g'), `${schemaName}Variant`);
27
+ const renamed = renameMap.get(schemaName) ?? schemaName;
28
+ return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
28
29
  }
29
30
  }
30
31
  if (refSchema.items && typeof refSchema.items === 'object') {
31
32
  const items = refSchema.items;
32
33
  if (typeof items.$ref === 'string') {
33
- const schemaName = items.$ref.split('/').pop();
34
+ const schemaName = sanitizeTypeName(items.$ref.split('/').pop());
34
35
  if (schemaName && discriminatorInfo.has(schemaName)) {
35
- return tsType.replace(new RegExp(`\\b${schemaName}\\b`, 'g'), `${schemaName}Variant`);
36
+ const renamed = renameMap.get(schemaName) ?? schemaName;
37
+ return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
36
38
  }
37
39
  }
38
40
  }
@@ -162,6 +164,19 @@ function topologicalSort(entries, allNames) {
162
164
  * 7b. UnspecifiedApiError class
163
165
  */
164
166
  export function generateContracts(doc, resolver) {
167
+ const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
168
+ const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
169
+ for (const [original, renamed] of renameMap) {
170
+ // TODO: Replace with structured logging solution
171
+ // oxlint-disable-next-line no-console
172
+ console.warn(`Warning: Schema "${original}" collides with a built-in type and was renamed to "${renamed}".`);
173
+ }
174
+ const renamingTypeGenerator = (refString) => {
175
+ const segments = refString.split('/');
176
+ const rawSegment = segments[segments.length - 1] || 'unknown';
177
+ const lastSegment = sanitizeTypeName(rawSegment);
178
+ return renameMap.get(lastSegment) ?? lastSegment;
179
+ };
165
180
  const discriminatorInfo = new Map();
166
181
  if (doc.components?.schemas) {
167
182
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
@@ -170,11 +185,12 @@ export function generateContracts(doc, resolver) {
170
185
  const mapping = new Map();
171
186
  if (resolved.discriminator.mapping) {
172
187
  for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
173
- const targetName = ref.split('/').pop() || key;
174
- mapping.set(key, targetName);
188
+ const targetName = sanitizeTypeName(ref.split('/').pop() || key);
189
+ const renamedTarget = renameMap.get(targetName) ?? targetName;
190
+ mapping.set(key, renamedTarget);
175
191
  }
176
192
  }
177
- discriminatorInfo.set(name, {
193
+ discriminatorInfo.set(sanitizeTypeName(name), {
178
194
  propertyName: resolved.discriminator.propertyName,
179
195
  mapping,
180
196
  });
@@ -190,25 +206,26 @@ export function generateContracts(doc, resolver) {
190
206
  });
191
207
  }
192
208
  }
193
- // reservedNames: prevent branded types from colliding with user-defined schema names
194
209
  const allSchemaNames = new Set();
195
210
  if (doc.components?.schemas) {
196
211
  for (const name of Object.keys(doc.components.schemas)) {
197
- allSchemaNames.add(name);
212
+ allSchemaNames.add(sanitizeTypeName(name));
198
213
  }
199
214
  }
200
- const mapper = new SchemaMapper(resolver, undefined, discriminatorTargets, allSchemaNames);
215
+ const mapper = new SchemaMapper(resolver, renamingTypeGenerator, discriminatorTargets, allSchemaNames);
201
216
  const lines = [];
202
217
  lines.push(makeHeader(doc.openapi));
203
218
  // Section 1: Schema types
204
219
  const schemaEntries = [];
205
220
  if (doc.components?.schemas) {
206
221
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
207
- const result = mapper.mapSchema(schema, name);
222
+ const sanitizedName = sanitizeTypeName(name);
223
+ const renamedName = renameMap.get(sanitizedName) ?? sanitizedName;
224
+ const result = mapper.mapSchema(schema, sanitizedName);
208
225
  const resolved = resolver.resolve(schema);
209
- const definition = `export type ${name} = ${result.tsType};`;
226
+ const definition = `export type ${renamedName} = ${result.tsType};`;
210
227
  schemaEntries.push({
211
- name,
228
+ name: renamedName,
212
229
  kind: 'type',
213
230
  definition,
214
231
  jsDoc: buildJsDoc(resolved.description),
@@ -228,9 +245,10 @@ export function generateContracts(doc, resolver) {
228
245
  if (subtypeNames.length === 0)
229
246
  continue;
230
247
  const unionType = subtypeNames.join(' | ');
248
+ const renamedBase = renameMap.get(sanitizeTypeName(baseName)) ?? sanitizeTypeName(baseName);
231
249
  lines.push('');
232
- lines.push(`export type ${baseName}Variant = ${unionType};`);
233
- allSchemaNames.add(`${baseName}Variant`);
250
+ lines.push(`export type ${renamedBase}Variant = ${unionType};`);
251
+ allSchemaNames.add(`${renamedBase}Variant`);
234
252
  }
235
253
  // Section 1b: Security scheme types
236
254
  const securitySchemes = doc.components?.securitySchemes;
@@ -376,7 +394,7 @@ export function generateContracts(doc, resolver) {
376
394
  return 'StreamResponse';
377
395
  if (r.schema) {
378
396
  const result = mapper.mapSchema(r.schema, undefined, 'response').tsType;
379
- return substituteDiscriminatedType(result, r.schema, discriminatorInfo);
397
+ return substituteDiscriminatedType(result, r.schema, discriminatorInfo, renameMap);
380
398
  }
381
399
  return r.tsType;
382
400
  });
@@ -442,16 +460,16 @@ export function generateContracts(doc, resolver) {
442
460
  lines.push(' constructor(');
443
461
  lines.push(' public readonly data: ReadableStream<Uint8Array>,');
444
462
  lines.push(' public readonly filename?: string,');
445
- lines.push(' public readonly headers: Headers = new Headers(),');
463
+ lines.push(' public readonly headers: Record<string, string> = {},');
446
464
  lines.push(' ) {}');
447
465
  lines.push('}');
448
466
  lines.push('');
449
467
  lines.push(`export function streamResponse(
450
468
  data: ReadableStream<Uint8Array>,
451
469
  filename?: string,
452
- headers?: Headers,
470
+ headers?: Record<string, string>,
453
471
  ): StreamResponse {
454
- return new StreamResponse(data, filename, headers ?? new Headers());
472
+ return new StreamResponse(data, filename, headers ?? {});
455
473
  }`);
456
474
  // ErrorResponse class
457
475
  lines.push('');
@@ -459,7 +477,7 @@ export function generateContracts(doc, resolver) {
459
477
  constructor(
460
478
  public readonly status: number,
461
479
  public readonly data: unknown,
462
- public readonly headers: Headers,
480
+ public readonly headers: Record<string, string>,
463
481
  public readonly message?: string,
464
482
  ) {}
465
483
  }`);
@@ -468,10 +486,10 @@ export function generateContracts(doc, resolver) {
468
486
  lines.push(`export function errorResponse(
469
487
  status: number,
470
488
  data: unknown,
471
- headers?: Headers,
489
+ headers?: Record<string, string>,
472
490
  message?: string,
473
491
  ): ErrorResponse {
474
- return new ErrorResponse(status, data, headers ?? new Headers(), message);
492
+ return new ErrorResponse(status, data, headers ?? {}, message);
475
493
  }`);
476
494
  // RequesterFailError class
477
495
  lines.push('');
@@ -15,10 +15,9 @@ export declare class DefaultApiError<TData> extends Error {
15
15
  * Produces:
16
16
  * 1. `ApiError<TStatus, TData>` class
17
17
  * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
18
- * 3. `isError` type guard function
19
- * 4. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
20
- * 5. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
21
- * 6. `DefaultErrorBody` type for `default` responses
22
- * 7. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
18
+ * 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
19
+ * 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
20
+ * 5. `DefaultErrorBody` type for `default` responses
21
+ * 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
23
22
  */
24
23
  export declare function generateErrorTypes(operations: AnalyzedOperation[]): string;
@@ -24,11 +24,10 @@ export class DefaultApiError extends Error {
24
24
  * Produces:
25
25
  * 1. `ApiError<TStatus, TData>` class
26
26
  * 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
27
- * 3. `isError` type guard function
28
- * 4. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
29
- * 5. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
30
- * 6. `DefaultErrorBody` type for `default` responses
31
- * 7. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
27
+ * 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
28
+ * 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
29
+ * 5. `DefaultErrorBody` type for `default` responses
30
+ * 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
32
31
  */
33
32
  export function generateErrorTypes(operations) {
34
33
  const lines = [];
@@ -54,12 +53,6 @@ export function generateErrorTypes(operations) {
54
53
  }
55
54
  }`);
56
55
  lines.push('');
57
- lines.push(`export function isError<T extends { status: number }, S extends number>(
58
- response: T,
59
- status: S,
60
- ): response is Extract<T, { status: S }> {
61
- return response.status === status;
62
- }`);
63
56
  let needsDefaultErrorBody = false;
64
57
  for (const op of operations) {
65
58
  const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
@@ -7,7 +7,7 @@ function buildParameters(op) {
7
7
  if (op.queryParams.length > 0) {
8
8
  const prefix = getOperationTypePrefix(op);
9
9
  const allOptional = op.queryParams.every((p) => !p.required);
10
- const hasRequiredAfter = op.headerParams.some((p) => p.required) || !!op.requestBody?.required;
10
+ const hasRequiredAfter = !!op.requestBody?.required || op.headerParams.some((p) => p.required);
11
11
  if (allOptional && hasRequiredAfter) {
12
12
  // All optional query params + required param after: use explicit undefined to avoid "required param cannot follow optional" error
13
13
  params.push(`query: ${prefix}Query | undefined`);
@@ -18,22 +18,17 @@ function buildParameters(op) {
18
18
  params.push(`query${optional}: ${prefix}Query`);
19
19
  }
20
20
  }
21
- if (op.headerParams.length > 0) {
22
- const prefix = getOperationTypePrefix(op);
23
- const allOptional = op.headerParams.every((p) => !p.required);
24
- if (allOptional && op.requestBody?.required) {
25
- params.push(`headers: ${prefix}Headers | undefined`);
26
- }
27
- else {
28
- const optional = allOptional ? '?' : '';
29
- params.push(`headers${optional}: ${prefix}Headers`);
30
- }
31
- }
32
21
  if (op.requestBody) {
33
22
  const prefix = getOperationTypePrefix(op);
34
23
  const optional = op.requestBody.required ? '' : '?';
35
24
  params.push(`body${optional}: ${prefix}Body`);
36
25
  }
26
+ if (op.headerParams.length > 0) {
27
+ const prefix = getOperationTypePrefix(op);
28
+ const allOptional = op.headerParams.every((p) => !p.required);
29
+ const optional = allOptional ? '?' : '';
30
+ params.push(`headers${optional}: ${prefix}Headers`);
31
+ }
37
32
  return params.join(', ');
38
33
  }
39
34
  function buildJsDoc(op) {
@@ -117,12 +112,12 @@ function buildImplementation(op) {
117
112
  if (op.queryParams.length > 0) {
118
113
  requesterOpts.push('query');
119
114
  }
120
- if (op.headerParams.length > 0) {
121
- requesterOpts.push('headers');
122
- }
123
115
  if (op.requestBody) {
124
116
  requesterOpts.push('body');
125
117
  }
118
+ if (op.headerParams.length > 0) {
119
+ requesterOpts.push('headers');
120
+ }
126
121
  // Error responses for status-specific checks
127
122
  const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
128
123
  // Lines for inside `if (result instanceof ErrorResponse) { ... }`
@@ -1,4 +1,22 @@
1
1
  import type { AnalyzedOperation } from '../analyzer/path-analyzer.js';
2
+ /**
3
+ * Reserved type names used by the generated output's built-in classes,
4
+ * functions, and types. User-defined schema names that collide with these
5
+ * are automatically renamed with a suffix to prevent duplicate identifiers.
6
+ */
7
+ export declare const RESERVED_TYPE_NAMES: ReadonlySet<string>;
8
+ /**
9
+ * Sanitize a schema type name by converting dot-separated segments into
10
+ * PascalCase. Names without dots pass through unchanged.
11
+ *
12
+ * "Api.Error" → "ApiError", "Simple" → "Simple"
13
+ */
14
+ export declare function sanitizeTypeName(name: string): string;
15
+ /**
16
+ * Build a mapping of sanitized schema names → renamed names for any that
17
+ * collide with reserved type names.
18
+ */
19
+ export declare function buildSchemaRenameMap(schemaNames: Iterable<string>, reserved: ReadonlySet<string>, suffix?: string): Map<string, string>;
2
20
  /**
3
21
  * Convert a string to PascalCase, handling camelCase, kebab-case, snake_case,
4
22
  * and colon-separated segments.
@@ -1,3 +1,55 @@
1
+ /**
2
+ * Reserved type names used by the generated output's built-in classes,
3
+ * functions, and types. User-defined schema names that collide with these
4
+ * are automatically renamed with a suffix to prevent duplicate identifiers.
5
+ */
6
+ export const RESERVED_TYPE_NAMES = new Set([
7
+ // Classes in contracts.ts
8
+ 'StreamResponse',
9
+ 'ErrorResponse',
10
+ 'ApiError',
11
+ 'UnspecifiedApiError',
12
+ 'DefaultApiError',
13
+ 'RequesterFailError',
14
+ // Functions in contracts.ts
15
+ 'streamResponse',
16
+ 'errorResponse',
17
+ // Types / functions in client.ts
18
+ 'Requester',
19
+ 'isDefinedError',
20
+ 'decorateWithErrors',
21
+ 'ApiClient',
22
+ // Interface in contracts.ts (when file uploads present)
23
+ 'FileInput',
24
+ ]);
25
+ /**
26
+ * Sanitize a schema type name by converting dot-separated segments into
27
+ * PascalCase. Names without dots pass through unchanged.
28
+ *
29
+ * "Api.Error" → "ApiError", "Simple" → "Simple"
30
+ */
31
+ export function sanitizeTypeName(name) {
32
+ if (!name.includes('.'))
33
+ return name;
34
+ return name
35
+ .split('.')
36
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
37
+ .join('');
38
+ }
39
+ /**
40
+ * Build a mapping of sanitized schema names → renamed names for any that
41
+ * collide with reserved type names.
42
+ */
43
+ export function buildSchemaRenameMap(schemaNames, reserved, suffix = 'Model') {
44
+ const renameMap = new Map();
45
+ for (const name of schemaNames) {
46
+ const sanitized = sanitizeTypeName(name);
47
+ if (reserved.has(sanitized)) {
48
+ renameMap.set(sanitized, `${sanitized}${suffix}`);
49
+ }
50
+ }
51
+ return renameMap;
52
+ }
1
53
  /**
2
54
  * Convert a string to PascalCase, handling camelCase, kebab-case, snake_case,
3
55
  * and colon-separated segments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genoc",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
5
5
  "keywords": [
6
6
  "client",