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 +2 -1
- package/dist/analyzer/analyze.js +139 -28
- package/dist/analyzer/path-analyzer.d.ts +21 -1
- package/dist/analyzer/path-analyzer.js +3 -1
- package/dist/analyzer/types.d.ts +25 -0
- package/dist/cli/impl.js +3 -1
- package/dist/generator/client-generator.js +65 -5
- package/dist/generator/contracts-generator.js +7 -0
- package/dist/generator/method-generator.d.ts +9 -1
- package/dist/generator/method-generator.js +38 -1
- package/dist/parser/spec-reader.js +6 -25
- package/dist/parser/version/spec-links.d.ts +55 -0
- package/dist/parser/version/spec-links.js +65 -0
- package/dist/parser/version/v3.0/validator.d.ts +2 -0
- package/dist/parser/version/v3.0/validator.js +23 -13
- package/dist/parser/version/v3.1/validator.d.ts +2 -0
- package/dist/parser/version/v3.1/validator.js +25 -13
- package/dist/parser/version/validate-security.d.ts +17 -0
- package/dist/parser/version/validate-security.js +218 -0
- package/dist/parser/version/version-detector.js +27 -8
- package/dist/runtime/defined-errors.d.ts +3 -2
- package/dist/runtime/defined-errors.js +6 -3
- package/dist/runtime/errors.d.ts +1 -3
- package/dist/runtime/errors.js +2 -6
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/requester.d.ts +67 -0
- package/dist/types/openapi.d.ts +5 -4
- package/dist/utils/http-methods.d.ts +8 -0
- package/dist/utils/http-methods.js +17 -0
- package/dist/utils/operation-naming.js +6 -0
- package/package.json +1 -1
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
|
package/dist/analyzer/analyze.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
127
|
-
|
|
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:
|
|
163
|
+
parts.push(`description: '${escapeStringLiteral(scheme.description)}'`);
|
|
130
164
|
}
|
|
131
165
|
if (scheme.type === 'apiKey') {
|
|
132
166
|
if (scheme.name)
|
|
133
|
-
parts.push(`name:
|
|
167
|
+
parts.push(`name: '${escapeStringLiteral(scheme.name)}'`);
|
|
134
168
|
if (scheme.in)
|
|
135
|
-
parts.push(`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:
|
|
140
|
-
if (scheme.bearerFormat)
|
|
141
|
-
parts.push(`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:
|
|
196
|
+
parts.push(`openIdConnectUrl: '${escapeStringLiteral(scheme.openIdConnectUrl)}'`);
|
|
162
197
|
}
|
|
163
|
-
return
|
|
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:
|
|
210
|
+
entries.push(`authorizationUrl: '${escapeStringLiteral(flow.authorizationUrl)}'`);
|
|
169
211
|
}
|
|
170
212
|
if (flow.tokenUrl) {
|
|
171
|
-
entries.push(`tokenUrl:
|
|
213
|
+
entries.push(`tokenUrl: '${escapeStringLiteral(flow.tokenUrl)}'`);
|
|
172
214
|
}
|
|
173
215
|
if (flow.refreshUrl) {
|
|
174
|
-
entries.push(`refreshUrl:
|
|
216
|
+
entries.push(`refreshUrl: '${escapeStringLiteral(flow.refreshUrl)}'`);
|
|
175
217
|
}
|
|
176
218
|
const scopeEntries = Object.entries(flow.scopes)
|
|
177
|
-
.map(([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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
}
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
*
|