genoc 0.4.2 → 0.5.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
@@ -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
@@ -1,6 +1,7 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import { buildSchemaRenameMap, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeName, toPascalCase, } from '../utils/generator-helpers.js';
3
3
  import { operationEmissions, RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
4
+ import { escapeStringLiteral } from '../utils/string.js';
4
5
  import { buildDiscriminatorRegistry, renameAwareTypeName } from './discriminator-registry.js';
5
6
  import { analyzePathsDetailed } from './path-analyzer.js';
6
7
  import { SchemaMapper } from './schema-mapper.js';
@@ -41,6 +42,34 @@ function attachTypeJsDoc(jsDoc, definition) {
41
42
  function indentJsDocBlock(jsDoc) {
42
43
  return jsDoc.split('\n').map((line) => `${INDENT_UNIT}${line}`);
43
44
  }
45
+ /** Schema used for parameters declared without a `schema` (typed as `string`). */
46
+ function defaultedParamSchema(param) {
47
+ return param.schema ?? { type: 'string' };
48
+ }
49
+ const STRING_SERIALIZABLE_MEMBER = /^(string|number|boolean|null|true|false|'[^']*'|-?\d+(?:\.\d+)?)$/;
50
+ /**
51
+ * Whether a finished path-param type can be serialized inline with `String()`:
52
+ * every union member must be a primitive with a defined string form, or a
53
+ * string/number literal (literal unions like `'fast' | 'slow'` are kept).
54
+ * Named component types count when their own mapped definition resolves
55
+ * (transitively, cycle-safe) to such members — so `$ref` path params to
56
+ * primitive aliases keep their named type. Anything else — objects, arrays,
57
+ * named non-primitive `$ref` types — falls back to a `string` signature (the
58
+ * pre-typed-path-params behavior), because there is no defined string
59
+ * serialization for such values.
60
+ */
61
+ function isInlineSerializableType(tsType, namedMappedTypes, seen = new Set()) {
62
+ return tsType.split('|').every((member) => {
63
+ const token = member.trim();
64
+ if (STRING_SERIALIZABLE_MEMBER.test(token))
65
+ return true;
66
+ const namedDefinition = namedMappedTypes.get(token);
67
+ if (namedDefinition === undefined || seen.has(token))
68
+ return false;
69
+ const nextSeen = new Set(seen).add(token);
70
+ return isInlineSerializableType(namedDefinition, namedMappedTypes, nextSeen);
71
+ });
72
+ }
44
73
  /**
45
74
  * Build the JSDoc block for a query/header parameter property using the
46
75
  * pinned parameter merge rule: parameter-level description / deprecated /
@@ -62,7 +91,7 @@ function buildParamPropertyJsDoc(param, schema) {
62
91
  function buildParamTypeBody(params, mapper) {
63
92
  const lines = [];
64
93
  for (const param of params) {
65
- const paramSchema = param.schema ?? { type: 'string' };
94
+ const paramSchema = defaultedParamSchema(param);
66
95
  const result = mapper.mapSchema(paramSchema);
67
96
  const optional = param.required ? '' : '?';
68
97
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
@@ -123,75 +152,92 @@ function topologicalSort(entries, allNames) {
123
152
  }
124
153
  return sorted;
125
154
  }
126
- function securitySchemeToTsType(scheme) {
127
- const parts = [`type: "${scheme.type}"`];
155
+ /**
156
+ * Build the `key: "value"` fragments of a security scheme object — shared by
157
+ * the contracts TYPE renderer (`'; '` separator) and the client CONST value
158
+ * renderer (`', '` separator).
159
+ */
160
+ function schemeParts(scheme, separator) {
161
+ const parts = [`type: '${escapeStringLiteral(scheme.type)}'`];
128
162
  if (scheme.description) {
129
- parts.push(`description: "${scheme.description}"`);
163
+ parts.push(`description: '${escapeStringLiteral(scheme.description)}'`);
130
164
  }
131
165
  if (scheme.type === 'apiKey') {
132
166
  if (scheme.name)
133
- parts.push(`name: "${scheme.name}"`);
167
+ parts.push(`name: '${escapeStringLiteral(scheme.name)}'`);
134
168
  if (scheme.in)
135
- parts.push(`in: "${scheme.in}"`);
169
+ parts.push(`in: '${escapeStringLiteral(scheme.in)}'`);
136
170
  }
137
171
  if (scheme.type === 'http') {
138
172
  if (scheme.scheme)
139
- parts.push(`scheme: "${scheme.scheme}"`);
140
- if (scheme.bearerFormat)
141
- parts.push(`bearerFormat: "${scheme.bearerFormat}"`);
173
+ parts.push(`scheme: '${escapeStringLiteral(scheme.scheme)}'`);
174
+ if (scheme.bearerFormat) {
175
+ parts.push(`bearerFormat: '${escapeStringLiteral(scheme.bearerFormat)}'`);
176
+ }
142
177
  }
143
178
  if (scheme.type === 'oauth2' && scheme.flows) {
144
179
  const flowParts = [];
145
180
  const flows = scheme.flows;
146
181
  if (flows.implicit) {
147
- flowParts.push(`implicit: ${oAuth2FlowToTs(flows.implicit, true)}`);
182
+ flowParts.push(`implicit: ${oAuth2FlowToTs(flows.implicit, true, separator)}`);
148
183
  }
149
184
  if (flows.password) {
150
- flowParts.push(`password: ${oAuth2FlowToTs(flows.password, false)}`);
185
+ flowParts.push(`password: ${oAuth2FlowToTs(flows.password, false, separator)}`);
151
186
  }
152
187
  if (flows.clientCredentials) {
153
- flowParts.push(`clientCredentials: ${oAuth2FlowToTs(flows.clientCredentials, false)}`);
188
+ flowParts.push(`clientCredentials: ${oAuth2FlowToTs(flows.clientCredentials, false, separator)}`);
154
189
  }
155
190
  if (flows.authorizationCode) {
156
- flowParts.push(`authorizationCode: ${oAuth2FlowToTs(flows.authorizationCode, true)}`);
191
+ flowParts.push(`authorizationCode: ${oAuth2FlowToTs(flows.authorizationCode, true, separator)}`);
157
192
  }
158
- parts.push(`flows: { ${flowParts.join('; ')} }`);
193
+ parts.push(`flows: { ${flowParts.join(separator)} }`);
159
194
  }
160
195
  if (scheme.type === 'openIdConnect' && scheme.openIdConnectUrl) {
161
- parts.push(`openIdConnectUrl: "${scheme.openIdConnectUrl}"`);
196
+ parts.push(`openIdConnectUrl: '${escapeStringLiteral(scheme.openIdConnectUrl)}'`);
162
197
  }
163
- return `{ ${parts.join('; ')} }`;
198
+ return parts;
199
+ }
200
+ function securitySchemeToTsType(scheme) {
201
+ return `{ ${schemeParts(scheme, '; ').join('; ')} }`;
202
+ }
203
+ /** The scheme object as a value expression — client-file consts (`as const`). */
204
+ function securitySchemeConstExpression(scheme) {
205
+ return `{ ${schemeParts(scheme, ', ').join(', ')} } as const`;
164
206
  }
165
- function oAuth2FlowToTs(flow, hasAuthUrl) {
207
+ function oAuth2FlowToTs(flow, hasAuthUrl, separator) {
166
208
  const entries = [];
167
209
  if (hasAuthUrl && flow.authorizationUrl) {
168
- entries.push(`authorizationUrl: "${flow.authorizationUrl}"`);
210
+ entries.push(`authorizationUrl: '${escapeStringLiteral(flow.authorizationUrl)}'`);
169
211
  }
170
212
  if (flow.tokenUrl) {
171
- entries.push(`tokenUrl: "${flow.tokenUrl}"`);
213
+ entries.push(`tokenUrl: '${escapeStringLiteral(flow.tokenUrl)}'`);
172
214
  }
173
215
  if (flow.refreshUrl) {
174
- entries.push(`refreshUrl: "${flow.refreshUrl}"`);
216
+ entries.push(`refreshUrl: '${escapeStringLiteral(flow.refreshUrl)}'`);
175
217
  }
176
218
  const scopeEntries = Object.entries(flow.scopes)
177
- .map(([k, v]) => `"${k}": "${v}"`)
178
- .join('; ');
219
+ .map(([k, v]) => `'${escapeStringLiteral(k)}': '${escapeStringLiteral(v)}'`)
220
+ .join(separator);
179
221
  entries.push(`scopes: { ${scopeEntries} }`);
180
- return `{ ${entries.join('; ')} }`;
222
+ return `{ ${entries.join(separator)} }`;
181
223
  }
182
224
  /**
183
225
  * Translate `components.securitySchemes` into finished `{Name}Auth` type
184
226
  * declarations: sanitize + PascalCase the key, number collisions, render the
185
- * object type text and the description JSDoc. Spec key order preserved.
227
+ * object type text, the client-file const, and the description JSDoc. Spec
228
+ * key order preserved; `$ref` entries resolved through the resolver.
186
229
  */
187
- function analyzeSecuritySchemes(securitySchemes) {
230
+ function analyzeSecuritySchemes(securitySchemes, resolver) {
188
231
  const entries = [];
189
232
  if (!securitySchemes || Object.keys(securitySchemes).length === 0) {
190
233
  return entries;
191
234
  }
192
235
  const usedSecurityTypeNames = new Set();
193
- for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
194
- const baseTypeName = `${sanitizeTypeName(toPascalCase(schemeName))}Auth`;
236
+ const usedConstNames = new Set();
237
+ for (const [schemeName, rawScheme] of Object.entries(securitySchemes)) {
238
+ const scheme = resolver.resolve(rawScheme);
239
+ const pascal = sanitizeTypeName(toPascalCase(schemeName));
240
+ const baseTypeName = `${pascal}Auth`;
195
241
  let typeName = baseTypeName;
196
242
  let n = 2;
197
243
  while (usedSecurityTypeNames.has(typeName)) {
@@ -199,14 +245,36 @@ function analyzeSecuritySchemes(securitySchemes) {
199
245
  n += 1;
200
246
  }
201
247
  usedSecurityTypeNames.add(typeName);
248
+ const baseConstName = `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}Scheme`;
249
+ let constName = baseConstName;
250
+ n = 2;
251
+ while (usedConstNames.has(constName)) {
252
+ constName = `${baseConstName}${n}`;
253
+ n += 1;
254
+ }
255
+ usedConstNames.add(constName);
202
256
  entries.push({
257
+ schemeName,
203
258
  name: typeName,
204
259
  tsType: securitySchemeToTsType(scheme),
205
260
  jsDoc: buildDescriptionJsDoc(scheme.description),
261
+ constName,
262
+ constExpression: securitySchemeConstExpression(scheme),
206
263
  });
207
264
  }
208
265
  return entries;
209
266
  }
267
+ /**
268
+ * Resolve effective `security` requirements into per-call facts: each spec
269
+ * requirement object becomes one AND-list of `{ schemeName, scopes }` calls,
270
+ * `{}` becoming the empty (anonymous-access) alternative. Scheme names are
271
+ * passed through unfiltered; the client generator degrades gracefully if a
272
+ * name has no scheme (unreachable in practice — validation rejects unknown
273
+ * names before analysis runs).
274
+ */
275
+ function analyzeSecurityRequirements(requirements) {
276
+ return requirements.map((requirement) => Object.entries(requirement).map(([schemeName, scopes]) => ({ schemeName, scopes })));
277
+ }
210
278
  /**
211
279
  * Translate `doc.servers` into finished server variable interface
212
280
  * declarations, one per server that declares variables. Names are index-aware
@@ -301,10 +369,14 @@ export function analyze(doc, opts = {}) {
301
369
  });
302
370
  // Section 1: Schema types
303
371
  const schemaEntries = [];
372
+ // Mapped definition text per renamed component name — lets the path-param
373
+ // inline-serializability check resolve named types to their primitives.
374
+ const namedMappedTypes = new Map();
304
375
  if (doc.components?.schemas) {
305
376
  for (const [name, schema] of Object.entries(doc.components.schemas)) {
306
377
  const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
307
378
  const result = mapper.mapSchema(schema, renamedName);
379
+ namedMappedTypes.set(renamedName, result.tsType);
308
380
  const resolved = resolver.resolve(schema);
309
381
  const definition = `export type ${renamedName} = ${result.tsType};`;
310
382
  schemaEntries.push({
@@ -337,9 +409,48 @@ export function analyze(doc, opts = {}) {
337
409
  const { operations: analyzedOperations, requestBodySchemas } = analyzePathsDetailed(doc, resolver, strategy);
338
410
  const operations = [];
339
411
  for (const op of analyzedOperations) {
412
+ // Path params render as flat method arguments, so each carries its
413
+ // finished signature type. A raw `$ref` schema maps to the named contract
414
+ // type (the same raw-schema travel pattern as request bodies and
415
+ // responses — the contracts renderer emits the named type from the
416
+ // component table); the mapper's import facts feed the client file's
417
+ // `import type` list. Inline schemas map the resolved schema with
418
+ // `format` stripped: path params are typed by their base primitive
419
+ // (boolean, number, literal unions) and format brands don't flow into
420
+ // signatures.
421
+ const pathParamTypeNames = new Set();
422
+ for (const param of op.pathParams) {
423
+ const raw = param.rawSchema;
424
+ if (raw !== undefined && '$ref' in raw) {
425
+ const result = mapper.mapSchema(raw);
426
+ if (isInlineSerializableType(result.tsType, namedMappedTypes)) {
427
+ param.finishedType = result.tsType;
428
+ for (const name of result.imports) {
429
+ pathParamTypeNames.add(name);
430
+ }
431
+ }
432
+ else {
433
+ // Inline-serialization constraint: a `$ref` to an object/array
434
+ // schema (or any non-primitive named type) has no defined string
435
+ // form, so the parameter falls back to `string` and the named
436
+ // type is not imported into the client file.
437
+ param.finishedType = 'string';
438
+ }
439
+ continue;
440
+ }
441
+ const schema = defaultedParamSchema(param);
442
+ const tracerSchema = schema.format === undefined ? schema : { ...schema, format: undefined };
443
+ const mapped = mapper.mapSchema(tracerSchema).tsType;
444
+ param.finishedType = isInlineSerializableType(mapped, namedMappedTypes) ? mapped : 'string';
445
+ }
446
+ if (pathParamTypeNames.size > 0) {
447
+ op.pathParamTypeNames = [...pathParamTypeNames];
448
+ }
449
+ const effectiveSecurity = op.security ?? doc.security;
340
450
  operations.push({
341
451
  ...op,
342
452
  contractsLines: [],
453
+ securityRequirements: effectiveSecurity === undefined ? [] : analyzeSecurityRequirements(effectiveSecurity),
343
454
  });
344
455
  }
345
456
  const hasFileUpload = operations.some((op) => op.requestBody?.isMultipart);
@@ -479,7 +590,7 @@ export function analyze(doc, opts = {}) {
479
590
  // Branded types snapshot after every mapSchema call has run, mirroring the
480
591
  // pre-refactor read position at the end of generateContracts.
481
592
  const brandedTypes = Array.from(mapper.getBrandedTypes().values());
482
- const securitySchemeTypes = analyzeSecuritySchemes(doc.components?.securitySchemes);
593
+ const securitySchemeTypes = analyzeSecuritySchemes(doc.components?.securitySchemes, resolver);
483
594
  const serverTypes = analyzeServers(doc.servers);
484
595
  return {
485
596
  specVersion: doc.openapi,
@@ -1,15 +1,27 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import type { MethodNameStrategy } from '../types/client.js';
3
- import type { OpenAPIDocument, ReferenceObject, SchemaObject } from '../types/openapi.js';
3
+ import type { OpenAPIDocument, ReferenceObject, SchemaObject, SecurityRequirementObject } from '../types/openapi.js';
4
4
  export interface AnalyzedParameter {
5
5
  name: string;
6
6
  in: 'path' | 'query' | 'header' | 'cookie';
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,8 +71,16 @@ 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[];
82
+ /** Raw operation-level `security`; `undefined` = inherit the document default. */
83
+ security?: SecurityRequirementObject[];
64
84
  }
65
85
  /**
66
86
  * Analyzer-internal analysis result: the analyzed operations plus the raw
@@ -1,8 +1,8 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import { isBinaryContentType } from '../utils/generator-helpers.js';
3
+ import { HTTP_METHODS } from '../utils/http-methods.js';
3
4
  import { getOperationTypePrefix } from '../utils/operation-naming.js';
4
5
  import { getMethodName } from './naming.js';
5
- const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
6
6
  /**
7
7
  * Resolve a possibly-$ref-ed schema and check for an exact top-level `format: binary`
8
8
  * match. Top-level only — intentionally no deep-walk into items/allOf/oneOf/anyOf.
@@ -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,
@@ -182,6 +183,7 @@ export function analyzePathsDetailed(doc, resolver, strategy = 'path-based') {
182
183
  ...categorized,
183
184
  requestBody: requestBodyResult?.requestBody,
184
185
  responses,
186
+ security: operation.security,
185
187
  });
186
188
  requestBodySchemas.push(requestBodyResult?.rawSchema);
187
189
  }
@@ -20,12 +20,29 @@ export interface TypeDeclaration {
20
20
  * and the description-only JSDoc block ('' when absent).
21
21
  */
22
22
  export interface SecuritySchemeTypeDeclaration {
23
+ /** Spec key under `components.securitySchemes` — the requirement lookup name. */
24
+ schemeName: string;
23
25
  /** Emitted `{Name}Auth` type name (sanitized, numbered on collision). */
24
26
  name: string;
25
27
  /** Rendered object type text — the `securitySchemeToTsType` output. */
26
28
  tsType: string;
27
29
  /** Description JSDoc block; '' when the scheme carries no description. */
28
30
  jsDoc: string;
31
+ /**
32
+ * Client-file const identifier holding the full scheme literal. Inline
33
+ * `scheme:` values in method bodies reference it — one const per scheme
34
+ * caps oauth2-flows bloat at a single site.
35
+ */
36
+ constName: string;
37
+ /** `{ … } as const` — the scheme object as a value expression. */
38
+ constExpression: string;
39
+ }
40
+ /** One scheme invocation inside an effective security requirement. */
41
+ export interface AnalyzedSecurityCall {
42
+ /** Scheme name as declared in `components.securitySchemes` (spec key). */
43
+ schemeName: string;
44
+ /** Requested scopes/roles; empty for non-oauth2/openIdConnect schemes. */
45
+ scopes: string[];
29
46
  }
30
47
  /**
31
48
  * Finished server variable type declaration (contracts Section 1c). Names
@@ -81,6 +98,14 @@ export interface FinishedOperation extends AnalyzedOperation {
81
98
  * emission). Sole fact source for the client generator's FormData appends.
82
99
  */
83
100
  fileUploadProperties?: FileUploadPropertyFact[];
101
+ /**
102
+ * Effective security requirements (operation override ?? document default
103
+ * ?? none): an OR-list of AND-lists. Each call names its scheme by spec
104
+ * key; the client generator resolves it to a `securitySchemeTypes` const.
105
+ * `[]` = public operation (explicitly or by absence). An empty AND-list is
106
+ * the spec's `{}` — "anonymous access acceptable" alternative.
107
+ */
108
+ securityRequirements: AnalyzedSecurityCall[][];
84
109
  }
85
110
  /**
86
111
  * Single-pass analysis model of an OpenAPI document — the ONLY input every
package/dist/cli/impl.js CHANGED
@@ -138,12 +138,14 @@ async function runOneTarget(target, context) {
138
138
  }
139
139
  context.process.stdout.write(`Loading spec from ${target.input}...\n`);
140
140
  const doc = await load(target.input, { proxy: target.proxy });
141
- context.process.stdout.write(`Loaded OpenAPI ${doc.openapi} spec\n`);
142
141
  const override = target.specVersion;
143
142
  if (override !== undefined && override !== '3.0' && override !== '3.1') {
144
143
  throw new Error(`No strategy registered for version: ${override}`);
145
144
  }
145
+ // Resolve the dialect before logging so a missing/malformed `openapi`
146
+ // field is reported (with its spec link) instead of logging "undefined".
146
147
  const profile = resolveVersion(doc, override);
148
+ context.process.stdout.write(`Loaded OpenAPI ${doc.openapi} spec\n`);
147
149
  if (override !== undefined &&
148
150
  target.strictVersion !== false &&
149
151
  profile.detected !== profile.effective) {
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_RUNTIME_IMPORT_PATH, makeHeader } from '../utils/generator-helpers.js';
2
2
  import { clientImportedNames, clientValueImports, getErrorType, getSuccessType, operationEmissions, } from '../utils/operation-naming.js';
3
+ import { escapeStringLiteral } from '../utils/string.js';
3
4
  import { renderContracts } from './contracts-generator.js';
4
5
  import { generateMethod } from './method-generator.js';
5
6
  function collectImportTypes(operations) {
@@ -12,12 +13,39 @@ function collectImportTypes(operations) {
12
13
  // oxlint-disable-next-line unicorn/no-array-sort
13
14
  return [...types].sort();
14
15
  }
15
- function buildClientMethodBody(op) {
16
+ /**
17
+ * Render the `security` option value for a call: `[[call, …], …]` — an
18
+ * OR-list of AND-lists, each call naming its scheme and pointing at the
19
+ * file-level scheme const. Returns `undefined` for public operations (option
20
+ * omitted) and when any scheme name is missing from the declared schemes
21
+ * (unreachable post-validation; degrades to omission rather than emitting a
22
+ * dangling reference).
23
+ */
24
+ function buildSecurityExpression(requirements, schemeConsts) {
25
+ if (requirements.length === 0) {
26
+ return undefined;
27
+ }
28
+ const alternatives = [];
29
+ for (const requirement of requirements) {
30
+ const calls = [];
31
+ for (const call of requirement) {
32
+ const scheme = schemeConsts.get(call.schemeName);
33
+ if (scheme === undefined) {
34
+ return undefined;
35
+ }
36
+ const scopes = call.scopes.map((scope) => `'${escapeStringLiteral(scope)}'`).join(', ');
37
+ calls.push(`{ schemeName: '${escapeStringLiteral(call.schemeName)}', scopes: [${scopes}], scheme: ${scheme.constName} }`);
38
+ }
39
+ alternatives.push(`[${calls.join(', ')}]`);
40
+ }
41
+ return `[${alternatives.join(', ')}]`;
42
+ }
43
+ function buildClientMethodBody(op, schemeConsts) {
16
44
  const successType = getSuccessType(op);
17
45
  const emissions = operationEmissions(op);
18
46
  let urlTemplate = op.path;
19
47
  for (const param of op.pathParams) {
20
- urlTemplate = urlTemplate.replace(`{${param.name}}`, `\${encodeURIComponent(${param.name})}`);
48
+ urlTemplate = urlTemplate.replace(`{${param.name}}`, `\${encodeURIComponent(String(${param.name}))}`);
21
49
  }
22
50
  const urlExpr = `\`${urlTemplate}\``;
23
51
  const errorCheckLines = [];
@@ -67,6 +95,10 @@ function buildClientMethodBody(op) {
67
95
  if (op.headerParams.length > 0) {
68
96
  opts.push('headers');
69
97
  }
98
+ const securityExpression = buildSecurityExpression(op.securityRequirements, schemeConsts);
99
+ if (securityExpression !== undefined) {
100
+ opts.push(`security: ${securityExpression}`);
101
+ }
70
102
  const lines = [];
71
103
  if (op.requestBody?.isMultipart && op.fileUploadProperties !== undefined) {
72
104
  const bodyRequired = op.requestBody.required;
@@ -123,7 +155,23 @@ function buildClientMethodBody(op) {
123
155
  }
124
156
  return tryCatchLines.join('\n');
125
157
  }
126
- function buildClientFile(operations, version, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
158
+ /**
159
+ * Collect the scheme consts actually referenced by some operation's effective
160
+ * security — spec declaration order preserved. Schemes no operation uses are
161
+ * not emitted (dead weight in the client file).
162
+ */
163
+ function collectUsedSchemeConsts(operations, securitySchemeTypes) {
164
+ const used = new Set();
165
+ for (const op of operations) {
166
+ for (const requirement of op.securityRequirements) {
167
+ for (const call of requirement) {
168
+ used.add(call.schemeName);
169
+ }
170
+ }
171
+ }
172
+ return securitySchemeTypes.filter((scheme) => used.has(scheme.schemeName));
173
+ }
174
+ function buildClientFile(operations, securitySchemeTypes, version, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
127
175
  const lines = [];
128
176
  lines.push(makeHeader(version));
129
177
  const importTypes = collectImportTypes(operations);
@@ -142,6 +190,18 @@ function buildClientFile(operations, version, runtimeImportPath = DEFAULT_RUNTIM
142
190
  lines.push('');
143
191
  lines.push(`export { isDefinedError } from '${runtimeImportPath}';`);
144
192
  lines.push(`export type { Requester } from '${runtimeImportPath}';`);
193
+ const schemeConsts = new Map(securitySchemeTypes.map((scheme) => [scheme.schemeName, scheme]));
194
+ const usedSchemes = collectUsedSchemeConsts(operations, securitySchemeTypes);
195
+ if (usedSchemes.length > 0) {
196
+ lines.push('');
197
+ lines.push('/**');
198
+ lines.push(' * Security schemes (from `components.securitySchemes`) referenced by operations below.');
199
+ lines.push(' * Passed to the requester via the `security` option — auth decisions stay in user land.');
200
+ lines.push(' */');
201
+ for (const scheme of usedSchemes) {
202
+ lines.push(`const ${scheme.constName} = ${scheme.constExpression};`);
203
+ }
204
+ }
145
205
  lines.push('');
146
206
  lines.push('export function createClient(requester: Requester) {');
147
207
  lines.push(' return {');
@@ -155,7 +215,7 @@ function buildClientFile(operations, version, runtimeImportPath = DEFAULT_RUNTIM
155
215
  .join('\n');
156
216
  lines.push(indentedJsDoc);
157
217
  }
158
- const body = buildClientMethodBody(op);
218
+ const body = buildClientMethodBody(op, schemeConsts);
159
219
  const sig = method.signature;
160
220
  const firstParen = sig.indexOf('(');
161
221
  const closeParenColon = sig.indexOf('): ');
@@ -202,7 +262,7 @@ function buildIndexFile(version) {
202
262
  */
203
263
  export function renderClient(analyzed, config) {
204
264
  const runtimeImportPath = config.runtimeImportPath ?? DEFAULT_RUNTIME_IMPORT_PATH;
205
- const client = buildClientFile(analyzed.operations, analyzed.specVersion, runtimeImportPath);
265
+ const client = buildClientFile(analyzed.operations, analyzed.securitySchemeTypes, analyzed.specVersion, runtimeImportPath);
206
266
  const index = buildIndexFile(analyzed.specVersion);
207
267
  return { client, index };
208
268
  }
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_RUNTIME_IMPORT_PATH, makeHeader } from '../utils/generator-helpers.js';
2
2
  import { RUNTIME_CLASS_NAMES } from '../utils/operation-naming.js';
3
+ import { escapeStringLiteral } from '../utils/string.js';
3
4
  /**
4
5
  * Build the import + re-export block for the shared runtime classes.
5
6
  * Generated code re-uses the genoc runtime as the single source of truth
@@ -53,6 +54,12 @@ export function renderContracts(analyzed, runtimeImportPath = DEFAULT_RUNTIME_IM
53
54
  lines.push('');
54
55
  lines.push(`export type ${entry.name} = ${entry.tsType};`);
55
56
  }
57
+ const nameMembers = analyzed.securitySchemeTypes
58
+ .map((entry) => `'${escapeStringLiteral(entry.schemeName)}'`)
59
+ .join(' | ');
60
+ lines.push('');
61
+ lines.push('/** Names of the security schemes declared in `components.securitySchemes` (spec keys). */');
62
+ lines.push(`export type SecuritySchemeName = ${nameMembers};`);
56
63
  if (analyzed.securitySchemeTypes.length > 1) {
57
64
  const unionMembers = analyzed.securitySchemeTypes.map((entry) => entry.name).join(' | ');
58
65
  lines.push('');
@@ -1,9 +1,17 @@
1
1
  import type { AnalyzedOperation } from '../analyzer/path-analyzer.js';
2
+ import type { FinishedOperation } from '../analyzer/types.js';
2
3
  import type { GeneratedMethod } from '../types/client.js';
4
+ /**
5
+ * Method rendering input: an analyzed operation plus the finished security
6
+ * facts when available (`analyze()` output; bare `AnalyzedOperation`s from
7
+ * path analysis render without the `@security` tag).
8
+ */
9
+ type MethodOperation = AnalyzedOperation & Partial<Pick<FinishedOperation, 'securityRequirements'>>;
3
10
  /**
4
11
  * Generate a client method from an analyzed OpenAPI operation.
5
12
  *
6
13
  * @param op - The analyzed operation
7
14
  * @returns Generated method with name, JSDoc, and signature
8
15
  */
9
- export declare function generateMethod(op: AnalyzedOperation): GeneratedMethod;
16
+ export declare function generateMethod(op: MethodOperation): GeneratedMethod;
17
+ export {};
@@ -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);
@@ -83,6 +94,13 @@ function buildJsDoc(op) {
83
94
  }
84
95
  lines.push(` * @param body — request body`);
85
96
  }
97
+ const securitySummary = renderSecuritySummary(op.securityRequirements);
98
+ if (securitySummary !== undefined) {
99
+ if (lines.length > 0) {
100
+ lines.push(' *');
101
+ }
102
+ lines.push(` * @security ${sanitizeJsDocText(securitySummary)}`);
103
+ }
86
104
  if (op.tags && op.tags.length > 0) {
87
105
  if (lines.length > 0) {
88
106
  lines.push(' *');
@@ -111,6 +129,25 @@ function buildJsDoc(op) {
111
129
  }
112
130
  return `/**\n${lines.join('\n')}\n */`;
113
131
  }
132
+ /**
133
+ * Render the `@security` tag text: OR-joined alternatives, AND-joined schemes
134
+ * within one alternative (parenthesized), scopes as `name[scope, …]`. An
135
+ * empty alternative is the spec's `{}` — rendered as `anonymous`. Returns
136
+ * `undefined` for public operations (no tag emitted).
137
+ */
138
+ function renderSecuritySummary(requirements) {
139
+ if (requirements === undefined || requirements.length === 0) {
140
+ return undefined;
141
+ }
142
+ const alternatives = requirements.map((requirement) => {
143
+ const calls = requirement.map((call) => call.scopes.length > 0 ? `${call.schemeName}[${call.scopes.join(', ')}]` : call.schemeName);
144
+ if (calls.length === 0) {
145
+ return 'anonymous';
146
+ }
147
+ return calls.length > 1 ? `(${calls.join(' AND ')})` : (calls[0] ?? '');
148
+ });
149
+ return alternatives.join(' OR ');
150
+ }
114
151
  /**
115
152
  * Generate a client method from an analyzed OpenAPI operation.
116
153
  *