genoc 0.4.2 → 0.4.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
@@ -12,6 +12,7 @@ Generated code depends only on the tiny `genoc/runtime` module. Full type safety
12
12
 
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
+ - Typed path parameters: values are typed from their schema and accept `T | string` — pass a pre-formatted string for custom wire formatting; serialization is inline `String()`
15
16
  - Works with any HTTP client: plug in fetch, axios, or anything else
16
17
  - Shared runtime contract: `genoc/runtime` exports the `Requester` type and response and error classes, so one requester implementation works with every generated client
17
18
  - Error types with per-status-code narrowing and type guards
@@ -115,7 +116,7 @@ For the full example of catching and narrowing errors, see [Error handling](./do
115
116
  ## Documentation
116
117
 
117
118
  - [Configuration](./docs/configuration.md): CLI flags, `.genocrc` config files, method naming strategies, and proxy support
118
- - [Usage](./docs/usage.md): the `Requester` contract, shared runtime, binary and file responses, and JSDoc output
119
+ - [Usage](./docs/usage.md): the `Requester` contract, `createClient` options, shared runtime, binary and file responses, and JSDoc output
119
120
  - [Error handling](./docs/error-handling.md): typed errors, `isDefinedError`, and the catching example
120
121
  - [OpenAPI 3.0 support](./docs/openapi-3.0-support.md): data types, schema keywords, parameters, bodies, uploads, responses, errors, `$ref`, components, security, servers, and operations
121
122
  - [OpenAPI 3.1 support](./docs/openapi-3.1-support.md): all 3.0 features plus webhooks and JSON Schema 2020-12 alignment, with a diff against 3.0
@@ -41,6 +41,34 @@ function attachTypeJsDoc(jsDoc, definition) {
41
41
  function indentJsDocBlock(jsDoc) {
42
42
  return jsDoc.split('\n').map((line) => `${INDENT_UNIT}${line}`);
43
43
  }
44
+ /** Schema used for parameters declared without a `schema` (typed as `string`). */
45
+ function defaultedParamSchema(param) {
46
+ return param.schema ?? { type: 'string' };
47
+ }
48
+ const STRING_SERIALIZABLE_MEMBER = /^(string|number|boolean|null|true|false|'[^']*'|-?\d+(?:\.\d+)?)$/;
49
+ /**
50
+ * Whether a finished path-param type can be serialized inline with `String()`:
51
+ * every union member must be a primitive with a defined string form, or a
52
+ * string/number literal (literal unions like `'fast' | 'slow'` are kept).
53
+ * Named component types count when their own mapped definition resolves
54
+ * (transitively, cycle-safe) to such members — so `$ref` path params to
55
+ * primitive aliases keep their named type. Anything else — objects, arrays,
56
+ * named non-primitive `$ref` types — falls back to a `string` signature (the
57
+ * pre-typed-path-params behavior), because there is no defined string
58
+ * serialization for such values.
59
+ */
60
+ function isInlineSerializableType(tsType, namedMappedTypes, seen = new Set()) {
61
+ return tsType.split('|').every((member) => {
62
+ const token = member.trim();
63
+ if (STRING_SERIALIZABLE_MEMBER.test(token))
64
+ return true;
65
+ const namedDefinition = namedMappedTypes.get(token);
66
+ if (namedDefinition === undefined || seen.has(token))
67
+ return false;
68
+ const nextSeen = new Set(seen).add(token);
69
+ return isInlineSerializableType(namedDefinition, namedMappedTypes, nextSeen);
70
+ });
71
+ }
44
72
  /**
45
73
  * Build the JSDoc block for a query/header parameter property using the
46
74
  * pinned parameter merge rule: parameter-level description / deprecated /
@@ -62,7 +90,7 @@ function buildParamPropertyJsDoc(param, schema) {
62
90
  function buildParamTypeBody(params, mapper) {
63
91
  const lines = [];
64
92
  for (const param of params) {
65
- const paramSchema = param.schema ?? { type: 'string' };
93
+ const paramSchema = defaultedParamSchema(param);
66
94
  const result = mapper.mapSchema(paramSchema);
67
95
  const optional = param.required ? '' : '?';
68
96
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
@@ -301,10 +329,14 @@ export function analyze(doc, opts = {}) {
301
329
  });
302
330
  // Section 1: Schema types
303
331
  const schemaEntries = [];
332
+ // Mapped definition text per renamed component name — lets the path-param
333
+ // inline-serializability check resolve named types to their primitives.
334
+ const namedMappedTypes = new Map();
304
335
  if (doc.components?.schemas) {
305
336
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
306
337
  const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
307
338
  const result = mapper.mapSchema(schema, renamedName);
339
+ namedMappedTypes.set(renamedName, result.tsType);
308
340
  const resolved = resolver.resolve(schema);
309
341
  const definition = `export type ${renamedName} = ${result.tsType};`;
310
342
  schemaEntries.push({
@@ -337,6 +369,43 @@ export function analyze(doc, opts = {}) {
337
369
  const { operations: analyzedOperations, requestBodySchemas } = analyzePathsDetailed(doc, resolver, strategy);
338
370
  const operations = [];
339
371
  for (const op of analyzedOperations) {
372
+ // Path params render as flat method arguments, so each carries its
373
+ // finished signature type. A raw `$ref` schema maps to the named contract
374
+ // type (the same raw-schema travel pattern as request bodies and
375
+ // responses — the contracts renderer emits the named type from the
376
+ // component table); the mapper's import facts feed the client file's
377
+ // `import type` list. Inline schemas map the resolved schema with
378
+ // `format` stripped: path params are typed by their base primitive
379
+ // (boolean, number, literal unions) and format brands don't flow into
380
+ // signatures.
381
+ const pathParamTypeNames = new Set();
382
+ for (const param of op.pathParams) {
383
+ const raw = param.rawSchema;
384
+ if (raw !== undefined && '$ref' in raw) {
385
+ const result = mapper.mapSchema(raw);
386
+ if (isInlineSerializableType(result.tsType, namedMappedTypes)) {
387
+ param.finishedType = result.tsType;
388
+ for (const name of result.imports) {
389
+ pathParamTypeNames.add(name);
390
+ }
391
+ }
392
+ else {
393
+ // Inline-serialization constraint: a `$ref` to an object/array
394
+ // schema (or any non-primitive named type) has no defined string
395
+ // form, so the parameter falls back to `string` and the named
396
+ // type is not imported into the client file.
397
+ param.finishedType = 'string';
398
+ }
399
+ continue;
400
+ }
401
+ const schema = defaultedParamSchema(param);
402
+ const tracerSchema = schema.format === undefined ? schema : { ...schema, format: undefined };
403
+ const mapped = mapper.mapSchema(tracerSchema).tsType;
404
+ param.finishedType = isInlineSerializableType(mapped, namedMappedTypes) ? mapped : 'string';
405
+ }
406
+ if (pathParamTypeNames.size > 0) {
407
+ op.pathParamTypeNames = [...pathParamTypeNames];
408
+ }
340
409
  operations.push({
341
410
  ...op,
342
411
  contractsLines: [],
@@ -7,9 +7,21 @@ export interface AnalyzedParameter {
7
7
  required: boolean;
8
8
  /** Resolved param schema — analyzer-internal translation input for `analyze()`; renderers must not read it. */
9
9
  schema: SchemaObject | undefined;
10
+ /**
11
+ * Raw (possibly `$ref`) param schema — analyzer-internal translation input
12
+ * for `analyze()`; renderers must not read it. Kept beside the resolved
13
+ * schema so `$ref` sites translate to the named contract type (the same
14
+ * raw-schema travel pattern as request bodies and responses).
15
+ */
16
+ rawSchema: SchemaObject | ReferenceObject | undefined;
10
17
  description?: string;
11
18
  deprecated?: boolean;
12
19
  example?: unknown;
20
+ /**
21
+ * Finished (real-mapper) TS type text for the method signature — set by
22
+ * `analyze()` for path params, the only params rendered as flat args.
23
+ */
24
+ finishedType?: string;
13
25
  }
14
26
  export interface AnalyzedRequestBody {
15
27
  required: boolean;
@@ -59,6 +71,12 @@ export interface AnalyzedOperation {
59
71
  queryParams: AnalyzedParameter[];
60
72
  headerParams: AnalyzedParameter[];
61
73
  cookieParams: AnalyzedParameter[];
74
+ /**
75
+ * Named contract types referenced by path-param finished types (`$ref`
76
+ * path params). Set by `analyze()` from the mapper's import facts; drives
77
+ * the client file's `import type` list for method signatures.
78
+ */
79
+ pathParamTypeNames?: string[];
62
80
  requestBody: AnalyzedRequestBody | undefined;
63
81
  responses: AnalyzedResponse[];
64
82
  }
@@ -28,6 +28,7 @@ function analyzeParameter(param, resolver) {
28
28
  in: param.in,
29
29
  required: param.required ?? param.in === 'path',
30
30
  schema,
31
+ rawSchema: param.schema,
31
32
  description: param.description,
32
33
  deprecated: param.deprecated,
33
34
  example: param.example,
@@ -17,7 +17,7 @@ function buildClientMethodBody(op) {
17
17
  const emissions = operationEmissions(op);
18
18
  let urlTemplate = op.path;
19
19
  for (const param of op.pathParams) {
20
- urlTemplate = urlTemplate.replace(`{${param.name}}`, `\${encodeURIComponent(${param.name})}`);
20
+ urlTemplate = urlTemplate.replace(`{${param.name}}`, `\${encodeURIComponent(String(${param.name}))}`);
21
21
  }
22
22
  const urlExpr = `\`${urlTemplate}\``;
23
23
  const errorCheckLines = [];
@@ -25,7 +25,18 @@ function buildParameters(op) {
25
25
  const emissions = operationEmissions(op);
26
26
  const specs = [];
27
27
  for (const param of op.pathParams) {
28
- specs.push({ name: param.name, type: 'string', isOptional: false });
28
+ // Finished signature type text arrives from the analyzer's translation
29
+ // layer; `?? 'string'` only guards hand-built `AnalyzedOperation` inputs.
30
+ const finished = param.finishedType ?? 'string';
31
+ // `T | string` is the caller escape hatch for custom wire formatting:
32
+ // pass a pre-formatted string instead of the schema type. When T is
33
+ // already `string` the union is skipped, and string-literal unions
34
+ // deliberately widen to `string` (flexibility over strictness here).
35
+ specs.push({
36
+ name: param.name,
37
+ type: finished === 'string' ? 'string' : `${finished} | string`,
38
+ isOptional: false,
39
+ });
29
40
  }
30
41
  if (emissions.query !== undefined) {
31
42
  const allOptional = op.queryParams.every((p) => !p.required);
@@ -151,6 +151,11 @@ export function clientImportedNames(op) {
151
151
  names.push(emissions.headers);
152
152
  if (emissions.body !== undefined)
153
153
  names.push(emissions.body);
154
+ // `$ref` path params reference named contract types directly in the method
155
+ // signature, so the client file must import them (fact collected by
156
+ // `analyze()` from the mapper's import list).
157
+ if (op.pathParamTypeNames !== undefined)
158
+ names.push(...op.pathParamTypeNames);
154
159
  if (emissions.response !== undefined) {
155
160
  const successType = getSuccessType(op);
156
161
  if (successType !== 'void' && successType !== 'unknown' && /^[A-Z]/.test(successType)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genoc",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
5
5
  "keywords": [
6
6
  "client",