genoc 0.3.0 → 0.4.1
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 +31 -187
- package/dist/analyzer/analyze.d.ts +18 -0
- package/dist/analyzer/analyze.js +493 -0
- package/dist/analyzer/discriminator-registry.d.ts +108 -0
- package/dist/analyzer/discriminator-registry.js +209 -0
- package/dist/analyzer/path-analyzer.d.ts +43 -6
- package/dist/analyzer/path-analyzer.js +57 -84
- package/dist/analyzer/schema-mapper.d.ts +68 -6
- package/dist/analyzer/schema-mapper.js +299 -40
- package/dist/analyzer/types.d.ts +126 -0
- package/dist/cli/app.d.ts +4 -2
- package/dist/cli/app.js +23 -4
- package/dist/cli/config-loader.d.ts +20 -0
- package/dist/cli/config-loader.js +186 -0
- package/dist/cli/config-schema.d.ts +105 -0
- package/dist/cli/config-schema.js +183 -0
- package/dist/cli/impl.d.ts +9 -1
- package/dist/cli/impl.js +189 -36
- package/dist/generator/client-generator.d.ts +13 -14
- package/dist/generator/client-generator.js +45 -77
- package/dist/generator/contracts-generator.d.ts +11 -9
- package/dist/generator/contracts-generator.js +45 -491
- package/dist/generator/method-generator.d.ts +1 -1
- package/dist/generator/method-generator.js +10 -150
- package/dist/parser/ref-resolver.d.ts +1 -1
- package/dist/parser/ref-resolver.js +1 -2
- package/dist/parser/version/index.d.ts +4 -16
- package/dist/parser/version/index.js +3 -14
- package/dist/parser/version/v3.0/validator.d.ts +1 -1
- package/dist/parser/version/v3.1/validator.d.ts +17 -0
- package/dist/parser/version/v3.1/validator.js +126 -0
- package/dist/parser/version/validate.d.ts +9 -0
- package/dist/parser/version/validate.js +12 -0
- package/dist/parser/version/version-detector.d.ts +21 -0
- package/dist/parser/version/version-detector.js +19 -0
- package/dist/pipeline.d.ts +15 -0
- package/dist/pipeline.js +25 -0
- package/dist/types/client.d.ts +0 -12
- package/dist/utils/generator-helpers.d.ts +1 -24
- package/dist/utils/generator-helpers.js +11 -78
- package/dist/utils/operation-naming.d.ts +84 -0
- package/dist/utils/operation-naming.js +178 -0
- package/dist/utils/proxy.d.ts +2 -0
- package/dist/utils/proxy.js +16 -0
- package/dist/utils/string.d.ts +7 -0
- package/dist/utils/string.js +9 -0
- package/package.json +19 -15
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -11
- package/dist/parser/validators.d.ts +0 -7
- package/dist/parser/validators.js +0 -79
- package/dist/parser/version/normalized-spec.d.ts +0 -199
- package/dist/parser/version/registry.d.ts +0 -28
- package/dist/parser/version/registry.js +0 -44
- package/dist/parser/version/v3.0/index.d.ts +0 -3
- package/dist/parser/version/v3.0/index.js +0 -3
- package/dist/parser/version/v3.0/normalizer.d.ts +0 -15
- package/dist/parser/version/v3.0/normalizer.js +0 -389
- package/dist/parser/version/v3.0/strategy.d.ts +0 -27
- package/dist/parser/version/v3.0/strategy.js +0 -96
- package/dist/parser/version/v3.1/index.d.ts +0 -1
- package/dist/parser/version/v3.1/index.js +0 -1
- package/dist/parser/version/v3.1/strategy.d.ts +0 -42
- package/dist/parser/version/v3.1/strategy.js +0 -513
- package/dist/parser/version/v3.2/index.d.ts +0 -4
- package/dist/parser/version/v3.2/index.js +0 -4
- package/dist/parser/version/v3.2/strategy.d.ts +0 -39
- package/dist/parser/version/v3.2/strategy.js +0 -57
- package/dist/parser/version/version-strategy.d.ts +0 -31
- package/dist/parser/version/version-strategy.js +0 -1
- /package/dist/{parser/version/normalized-spec.js → analyzer/types.js} +0 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import { buildSchemaRenameMap, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeName, toPascalCase, } from '../utils/generator-helpers.js';
|
|
3
|
+
import { operationEmissions, RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
|
|
4
|
+
import { buildDiscriminatorRegistry, renameAwareTypeName } from './discriminator-registry.js';
|
|
5
|
+
import { analyzePathsDetailed } from './path-analyzer.js';
|
|
6
|
+
import { SchemaMapper } from './schema-mapper.js';
|
|
7
|
+
/**
|
|
8
|
+
* Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
|
|
9
|
+
* the mapper's golden tests). Kept local to avoid modifying the mapper module.
|
|
10
|
+
*/
|
|
11
|
+
const INDENT_UNIT = ' ';
|
|
12
|
+
/**
|
|
13
|
+
* Shared warning channel for analyze() and the discriminator registry —
|
|
14
|
+
* the same sink the mapper defaults to.
|
|
15
|
+
*/
|
|
16
|
+
function writeWarning(message) {
|
|
17
|
+
process.stderr.write(message);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build a description-only type-level JSDoc comment for operation-derived
|
|
21
|
+
* types (body / response). Returns '' when the description is absent or
|
|
22
|
+
* whitespace-only so callers can skip emission entirely.
|
|
23
|
+
*/
|
|
24
|
+
export function buildDescriptionJsDoc(description) {
|
|
25
|
+
if (description === undefined)
|
|
26
|
+
return '';
|
|
27
|
+
const sanitized = sanitizeJsDocText(description);
|
|
28
|
+
return sanitized === '' ? '' : `/** ${sanitized} */`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Prefix a type definition with its JSDoc block (single- or multi-line),
|
|
32
|
+
* keeping the comment directly above the `export type` line.
|
|
33
|
+
*/
|
|
34
|
+
function attachTypeJsDoc(jsDoc, definition) {
|
|
35
|
+
return jsDoc === '' ? definition : `${jsDoc}\n${definition}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Indent every line of a JSDoc block by one indent unit so property comments
|
|
39
|
+
* line up exactly with their property line (mapper indentation contract).
|
|
40
|
+
*/
|
|
41
|
+
function indentJsDocBlock(jsDoc) {
|
|
42
|
+
return jsDoc.split('\n').map((line) => `${INDENT_UNIT}${line}`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the JSDoc block for a query/header parameter property using the
|
|
46
|
+
* pinned parameter merge rule: parameter-level description / deprecated /
|
|
47
|
+
* example win over the schema's; default, title and examples come from the
|
|
48
|
+
* schema only (carried by the spread).
|
|
49
|
+
*/
|
|
50
|
+
function buildParamPropertyJsDoc(param, schema) {
|
|
51
|
+
return buildTypeJsDoc({
|
|
52
|
+
...schema,
|
|
53
|
+
description: param.description ?? schema.description,
|
|
54
|
+
deprecated: param.deprecated === true || schema.deprecated === true,
|
|
55
|
+
example: param.example ?? schema.example,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Render a `${prefix}Query` / `${prefix}Headers` object body in the mapper's
|
|
60
|
+
* multi-line format with per-property JSDoc from the parameter merge rule.
|
|
61
|
+
*/
|
|
62
|
+
function buildParamTypeBody(params, mapper) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
for (const param of params) {
|
|
65
|
+
const paramSchema = param.schema ?? { type: 'string' };
|
|
66
|
+
const result = mapper.mapSchema(paramSchema);
|
|
67
|
+
const optional = param.required ? '' : '?';
|
|
68
|
+
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(param.name) ? param.name : `"${param.name}"`;
|
|
69
|
+
const jsDoc = buildParamPropertyJsDoc(param, paramSchema);
|
|
70
|
+
if (jsDoc !== '') {
|
|
71
|
+
lines.push(...indentJsDocBlock(jsDoc));
|
|
72
|
+
}
|
|
73
|
+
lines.push(`${INDENT_UNIT}${key}${optional}: ${result.tsType};`);
|
|
74
|
+
}
|
|
75
|
+
return `{\n${lines.join('\n')}\n}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Sort ContractEntry list so that referenced types appear before referrers.
|
|
79
|
+
* Uses DFS-based topological sort; cycles are broken by skipping.
|
|
80
|
+
*/
|
|
81
|
+
function topologicalSort(entries, allNames) {
|
|
82
|
+
if (entries.length <= 1)
|
|
83
|
+
return entries;
|
|
84
|
+
const nameToIndex = new Map();
|
|
85
|
+
entries.forEach((e, i) => nameToIndex.set(e.name, i));
|
|
86
|
+
const graph = new Map();
|
|
87
|
+
for (let i = 0; i < entries.length; i++) {
|
|
88
|
+
const deps = new Set();
|
|
89
|
+
const def = entries[i].definition;
|
|
90
|
+
for (const name of allNames) {
|
|
91
|
+
if (name === entries[i].name)
|
|
92
|
+
continue;
|
|
93
|
+
if (new RegExp(`\\b${name}\\b`).test(def)) {
|
|
94
|
+
const depIdx = nameToIndex.get(name);
|
|
95
|
+
if (depIdx !== undefined) {
|
|
96
|
+
deps.add(depIdx);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
graph.set(i, deps);
|
|
101
|
+
}
|
|
102
|
+
const sorted = [];
|
|
103
|
+
const visited = new Set();
|
|
104
|
+
const inStack = new Set();
|
|
105
|
+
function visit(idx) {
|
|
106
|
+
if (visited.has(idx))
|
|
107
|
+
return;
|
|
108
|
+
if (inStack.has(idx))
|
|
109
|
+
return;
|
|
110
|
+
inStack.add(idx);
|
|
111
|
+
const deps = graph.get(idx);
|
|
112
|
+
if (deps) {
|
|
113
|
+
for (const dep of deps) {
|
|
114
|
+
visit(dep);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
inStack.delete(idx);
|
|
118
|
+
visited.add(idx);
|
|
119
|
+
sorted.push(entries[idx]);
|
|
120
|
+
}
|
|
121
|
+
for (let i = 0; i < entries.length; i++) {
|
|
122
|
+
visit(i);
|
|
123
|
+
}
|
|
124
|
+
return sorted;
|
|
125
|
+
}
|
|
126
|
+
function securitySchemeToTsType(scheme) {
|
|
127
|
+
const parts = [`type: "${scheme.type}"`];
|
|
128
|
+
if (scheme.description) {
|
|
129
|
+
parts.push(`description: "${scheme.description}"`);
|
|
130
|
+
}
|
|
131
|
+
if (scheme.type === 'apiKey') {
|
|
132
|
+
if (scheme.name)
|
|
133
|
+
parts.push(`name: "${scheme.name}"`);
|
|
134
|
+
if (scheme.in)
|
|
135
|
+
parts.push(`in: "${scheme.in}"`);
|
|
136
|
+
}
|
|
137
|
+
if (scheme.type === 'http') {
|
|
138
|
+
if (scheme.scheme)
|
|
139
|
+
parts.push(`scheme: "${scheme.scheme}"`);
|
|
140
|
+
if (scheme.bearerFormat)
|
|
141
|
+
parts.push(`bearerFormat: "${scheme.bearerFormat}"`);
|
|
142
|
+
}
|
|
143
|
+
if (scheme.type === 'oauth2' && scheme.flows) {
|
|
144
|
+
const flowParts = [];
|
|
145
|
+
const flows = scheme.flows;
|
|
146
|
+
if (flows.implicit) {
|
|
147
|
+
flowParts.push(`implicit: ${oAuth2FlowToTs(flows.implicit, true)}`);
|
|
148
|
+
}
|
|
149
|
+
if (flows.password) {
|
|
150
|
+
flowParts.push(`password: ${oAuth2FlowToTs(flows.password, false)}`);
|
|
151
|
+
}
|
|
152
|
+
if (flows.clientCredentials) {
|
|
153
|
+
flowParts.push(`clientCredentials: ${oAuth2FlowToTs(flows.clientCredentials, false)}`);
|
|
154
|
+
}
|
|
155
|
+
if (flows.authorizationCode) {
|
|
156
|
+
flowParts.push(`authorizationCode: ${oAuth2FlowToTs(flows.authorizationCode, true)}`);
|
|
157
|
+
}
|
|
158
|
+
parts.push(`flows: { ${flowParts.join('; ')} }`);
|
|
159
|
+
}
|
|
160
|
+
if (scheme.type === 'openIdConnect' && scheme.openIdConnectUrl) {
|
|
161
|
+
parts.push(`openIdConnectUrl: "${scheme.openIdConnectUrl}"`);
|
|
162
|
+
}
|
|
163
|
+
return `{ ${parts.join('; ')} }`;
|
|
164
|
+
}
|
|
165
|
+
function oAuth2FlowToTs(flow, hasAuthUrl) {
|
|
166
|
+
const entries = [];
|
|
167
|
+
if (hasAuthUrl && flow.authorizationUrl) {
|
|
168
|
+
entries.push(`authorizationUrl: "${flow.authorizationUrl}"`);
|
|
169
|
+
}
|
|
170
|
+
if (flow.tokenUrl) {
|
|
171
|
+
entries.push(`tokenUrl: "${flow.tokenUrl}"`);
|
|
172
|
+
}
|
|
173
|
+
if (flow.refreshUrl) {
|
|
174
|
+
entries.push(`refreshUrl: "${flow.refreshUrl}"`);
|
|
175
|
+
}
|
|
176
|
+
const scopeEntries = Object.entries(flow.scopes)
|
|
177
|
+
.map(([k, v]) => `"${k}": "${v}"`)
|
|
178
|
+
.join('; ');
|
|
179
|
+
entries.push(`scopes: { ${scopeEntries} }`);
|
|
180
|
+
return `{ ${entries.join('; ')} }`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Translate `components.securitySchemes` into finished `{Name}Auth` type
|
|
184
|
+
* declarations: sanitize + PascalCase the key, number collisions, render the
|
|
185
|
+
* object type text and the description JSDoc. Spec key order preserved.
|
|
186
|
+
*/
|
|
187
|
+
function analyzeSecuritySchemes(securitySchemes) {
|
|
188
|
+
const entries = [];
|
|
189
|
+
if (!securitySchemes || Object.keys(securitySchemes).length === 0) {
|
|
190
|
+
return entries;
|
|
191
|
+
}
|
|
192
|
+
const usedSecurityTypeNames = new Set();
|
|
193
|
+
for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
|
|
194
|
+
const baseTypeName = `${sanitizeTypeName(toPascalCase(schemeName))}Auth`;
|
|
195
|
+
let typeName = baseTypeName;
|
|
196
|
+
let n = 2;
|
|
197
|
+
while (usedSecurityTypeNames.has(typeName)) {
|
|
198
|
+
typeName = `${baseTypeName}${n}`;
|
|
199
|
+
n += 1;
|
|
200
|
+
}
|
|
201
|
+
usedSecurityTypeNames.add(typeName);
|
|
202
|
+
entries.push({
|
|
203
|
+
name: typeName,
|
|
204
|
+
tsType: securitySchemeToTsType(scheme),
|
|
205
|
+
jsDoc: buildDescriptionJsDoc(scheme.description),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return entries;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Translate `doc.servers` into finished server variable interface
|
|
212
|
+
* declarations, one per server that declares variables. Names are index-aware
|
|
213
|
+
* over ALL servers (`ServerParams` for a single-server document,
|
|
214
|
+
* `Server{i}Params` otherwise), so variable-less servers still occupy an
|
|
215
|
+
* index. Property lines carry their JSDoc and indentation.
|
|
216
|
+
*/
|
|
217
|
+
function analyzeServers(servers) {
|
|
218
|
+
const entries = [];
|
|
219
|
+
if (!servers)
|
|
220
|
+
return entries;
|
|
221
|
+
for (let serverIdx = 0; serverIdx < servers.length; serverIdx++) {
|
|
222
|
+
const server = servers[serverIdx];
|
|
223
|
+
if (!server.variables || Object.keys(server.variables).length === 0) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const typeName = servers.length === 1 ? 'ServerParams' : `Server${serverIdx + 1}Params`;
|
|
227
|
+
const props = [];
|
|
228
|
+
for (const [varName, variable] of Object.entries(server.variables)) {
|
|
229
|
+
const sv = variable;
|
|
230
|
+
const jsDocParts = [];
|
|
231
|
+
if (sv.description) {
|
|
232
|
+
const description = sanitizeJsDocText(sv.description);
|
|
233
|
+
if (description !== '') {
|
|
234
|
+
jsDocParts.push(description);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (sv.default !== undefined) {
|
|
238
|
+
jsDocParts.push(`@default ${sanitizeJsDocText(String(sv.default))}`);
|
|
239
|
+
}
|
|
240
|
+
let tsType;
|
|
241
|
+
if (sv.enum && sv.enum.length > 0) {
|
|
242
|
+
tsType = sv.enum.map((v) => `"${v}"`).join(' | ');
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
tsType = 'string';
|
|
246
|
+
}
|
|
247
|
+
const jsDoc = jsDocParts.length > 0 ? ` /** ${jsDocParts.join(' ')} */` : null;
|
|
248
|
+
if (jsDoc) {
|
|
249
|
+
props.push(jsDoc);
|
|
250
|
+
}
|
|
251
|
+
props.push(` ${varName}: ${tsType};`);
|
|
252
|
+
}
|
|
253
|
+
entries.push({
|
|
254
|
+
name: typeName,
|
|
255
|
+
url: server.url,
|
|
256
|
+
propertyLines: props,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return entries;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Analyze an OpenAPI document into the finished model every renderer consumes:
|
|
263
|
+
* schema→TS-type translation (rename machinery, discriminator discovery, the
|
|
264
|
+
* schema mapper) runs here exactly once, producing render-ready type text.
|
|
265
|
+
*
|
|
266
|
+
* @param doc - The parsed and validated OpenAPI document
|
|
267
|
+
* @param opts - Optional resolver override and method naming strategy
|
|
268
|
+
* @returns The AnalyzedSpec model consumed by all generators
|
|
269
|
+
*/
|
|
270
|
+
export function analyze(doc, opts = {}) {
|
|
271
|
+
const resolver = opts.resolver ?? new RefResolver(doc);
|
|
272
|
+
const strategy = opts.strategy ?? 'path-based';
|
|
273
|
+
const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
|
|
274
|
+
const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
|
|
275
|
+
for (const [original, renamed] of renameMap) {
|
|
276
|
+
// TODO: Replace with structured logging solution
|
|
277
|
+
// oxlint-disable-next-line no-console
|
|
278
|
+
console.warn(`Warning: Schema "${original}" collides with a built-in type or another schema and was renamed to "${renamed}".`);
|
|
279
|
+
}
|
|
280
|
+
const renamingTypeGenerator = (refString) => {
|
|
281
|
+
const segments = refString.split('/');
|
|
282
|
+
const rawSegment = segments[segments.length - 1] || 'unknown';
|
|
283
|
+
return renameAwareTypeName(renameMap, rawSegment);
|
|
284
|
+
};
|
|
285
|
+
const allSchemaNames = new Set();
|
|
286
|
+
if (doc.components?.schemas) {
|
|
287
|
+
for (const name of Object.keys(doc.components.schemas)) {
|
|
288
|
+
allSchemaNames.add(renameMap.get(name) ?? sanitizeTypeName(name));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Family-aware discriminator registry (plan D5) — the single knowledge
|
|
292
|
+
// source for discriminator translation: the mapper's ref-translation seam
|
|
293
|
+
// consumes it, and the always-emitted `{Base}Variant` unions below are
|
|
294
|
+
// emitted from it.
|
|
295
|
+
const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames, writeWarning);
|
|
296
|
+
const mapper = new SchemaMapper(resolver, {
|
|
297
|
+
typeNameGenerator: renamingTypeGenerator,
|
|
298
|
+
emittedNames: allSchemaNames,
|
|
299
|
+
discriminatorRegistry,
|
|
300
|
+
effectiveVersion: opts?.effectiveVersion,
|
|
301
|
+
});
|
|
302
|
+
// Section 1: Schema types
|
|
303
|
+
const schemaEntries = [];
|
|
304
|
+
if (doc.components?.schemas) {
|
|
305
|
+
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
306
|
+
const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
|
|
307
|
+
const result = mapper.mapSchema(schema, renamedName);
|
|
308
|
+
const resolved = resolver.resolve(schema);
|
|
309
|
+
const definition = `export type ${renamedName} = ${result.tsType};`;
|
|
310
|
+
schemaEntries.push({
|
|
311
|
+
name: renamedName,
|
|
312
|
+
kind: 'type',
|
|
313
|
+
definition,
|
|
314
|
+
jsDoc: buildTypeJsDoc(resolved) || undefined,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const sortedSchemas = topologicalSort(schemaEntries, allSchemaNames);
|
|
319
|
+
const schemaTypes = sortedSchemas.map((entry) => ({
|
|
320
|
+
name: entry.name,
|
|
321
|
+
definition: entry.definition,
|
|
322
|
+
jsDoc: entry.jsDoc,
|
|
323
|
+
}));
|
|
324
|
+
// Variant unions are emitted from the registry (D6): ALWAYS (mapping
|
|
325
|
+
// values ∪ implicit oneOf/anyOf refs, deduped by rename-aware name) and
|
|
326
|
+
// under collision-safe names so a user schema named `{Base}Variant`
|
|
327
|
+
// cannot collide with the generated union.
|
|
328
|
+
for (const family of discriminatorRegistry.families.values()) {
|
|
329
|
+
if (family.variantUnionMembers.length === 0)
|
|
330
|
+
continue;
|
|
331
|
+
schemaTypes.push({
|
|
332
|
+
name: family.variantUnionName,
|
|
333
|
+
definition: `export type ${family.variantUnionName} = ${family.variantUnionMembers.join(' | ')};`,
|
|
334
|
+
});
|
|
335
|
+
allSchemaNames.add(family.variantUnionName);
|
|
336
|
+
}
|
|
337
|
+
const { operations: analyzedOperations, requestBodySchemas } = analyzePathsDetailed(doc, resolver, strategy);
|
|
338
|
+
const operations = [];
|
|
339
|
+
for (const op of analyzedOperations) {
|
|
340
|
+
operations.push({
|
|
341
|
+
...op,
|
|
342
|
+
contractsLines: [],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const hasFileUpload = operations.some((op) => op.requestBody?.isMultipart);
|
|
346
|
+
// Sections 2-4: Operation-derived types. Presence decisions and type
|
|
347
|
+
// names come from the emission inventory (operationEmissions); the
|
|
348
|
+
// finished lines are assembled here so renderers never re-translate.
|
|
349
|
+
for (let opIdx = 0; opIdx < operations.length; opIdx++) {
|
|
350
|
+
const op = operations[opIdx];
|
|
351
|
+
// Raw request body schema, parallel to `operations` (analyzer-internal
|
|
352
|
+
// translation input; presence mirrors requestBody.hasSchema).
|
|
353
|
+
const rawBodySchema = requestBodySchemas[opIdx];
|
|
354
|
+
const emissions = operationEmissions(op);
|
|
355
|
+
const opLines = [];
|
|
356
|
+
// Section 2: Query parameter types
|
|
357
|
+
if (emissions.query) {
|
|
358
|
+
opLines.push(`export type ${emissions.query} = ${buildParamTypeBody(op.queryParams, mapper)};`);
|
|
359
|
+
}
|
|
360
|
+
// Section 2b: Header parameter types
|
|
361
|
+
if (emissions.headers) {
|
|
362
|
+
opLines.push(`export type ${emissions.headers} = ${buildParamTypeBody(op.headerParams, mapper)};`);
|
|
363
|
+
}
|
|
364
|
+
// Section 3: Request body types
|
|
365
|
+
const bodyJsDoc = op.requestBody ? buildDescriptionJsDoc(op.requestBody.description) : '';
|
|
366
|
+
if (op.requestBody?.isMultipart && rawBodySchema !== undefined) {
|
|
367
|
+
const schema = resolver.resolveSchema(rawBodySchema);
|
|
368
|
+
const requiredSet = new Set(schema.required ?? []);
|
|
369
|
+
const propLines = [];
|
|
370
|
+
const fileUploadProperties = [];
|
|
371
|
+
for (const [name, propSchema] of Object.entries(schema.properties ?? {})) {
|
|
372
|
+
const resolved = resolver.resolveSchema(propSchema);
|
|
373
|
+
const optional = requiredSet.has(name) ? '' : '?';
|
|
374
|
+
let tsType;
|
|
375
|
+
let kind;
|
|
376
|
+
if (resolved.format === 'binary') {
|
|
377
|
+
tsType = 'FileInput';
|
|
378
|
+
kind = 'file';
|
|
379
|
+
}
|
|
380
|
+
else if (resolved.type === 'array' && resolved.items?.format === 'binary') {
|
|
381
|
+
tsType = 'FileInput[]';
|
|
382
|
+
kind = 'file-array';
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
tsType = 'string';
|
|
386
|
+
kind = 'field';
|
|
387
|
+
}
|
|
388
|
+
fileUploadProperties.push({ name, required: requiredSet.has(name), kind });
|
|
389
|
+
const jsDoc = buildTypeJsDoc(resolved);
|
|
390
|
+
if (jsDoc !== '') {
|
|
391
|
+
propLines.push(...indentJsDocBlock(jsDoc));
|
|
392
|
+
}
|
|
393
|
+
propLines.push(`${INDENT_UNIT}${name}${optional}: ${tsType};`);
|
|
394
|
+
}
|
|
395
|
+
op.fileUploadProperties = fileUploadProperties;
|
|
396
|
+
if (propLines.length > 0) {
|
|
397
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${emissions.body} = {\n${propLines.join('\n')}\n};`));
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${emissions.body} = Record<string, never>;`));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (op.requestBody?.hasSchema && rawBodySchema !== undefined) {
|
|
404
|
+
if (op.requestBody.isBinary) {
|
|
405
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${emissions.body} = Blob;`));
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
const result = mapper.mapSchema(rawBodySchema, undefined, 'request');
|
|
409
|
+
opLines.push(attachTypeJsDoc(bodyJsDoc, `export type ${emissions.body} = ${result.tsType};`));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Section 4: Response types
|
|
413
|
+
const successResponses = op.responses.filter((r) => r.isSuccess);
|
|
414
|
+
const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
|
|
415
|
+
const defaultResponse = op.responses.find((r) => !r.isSuccess && r.statusCode === 'default');
|
|
416
|
+
// Success type
|
|
417
|
+
if (emissions.response) {
|
|
418
|
+
const types = successResponses.map((r) => {
|
|
419
|
+
if (r.isBinary) {
|
|
420
|
+
r.finishedType = 'StreamResponse';
|
|
421
|
+
return 'StreamResponse';
|
|
422
|
+
}
|
|
423
|
+
if (r.schema) {
|
|
424
|
+
const tsType = mapper.mapSchema(r.schema, undefined, 'response').tsType;
|
|
425
|
+
r.finishedType = tsType;
|
|
426
|
+
return tsType;
|
|
427
|
+
}
|
|
428
|
+
r.finishedType = r.isVoid ? 'void' : 'unknown';
|
|
429
|
+
return r.finishedType;
|
|
430
|
+
});
|
|
431
|
+
const successType = types.length === 1 ? types[0] : types.join(' | ');
|
|
432
|
+
// Type-level JSDoc from the lowest-numbered 2xx response (the analyzer
|
|
433
|
+
// returns success responses in ascending status order).
|
|
434
|
+
const responseJsDoc = buildDescriptionJsDoc(successResponses[0].description);
|
|
435
|
+
opLines.push(attachTypeJsDoc(responseJsDoc, `export type ${emissions.response} = ${successType};`));
|
|
436
|
+
}
|
|
437
|
+
// Error types per status (inventory pairs 1:1 with errorResponses by
|
|
438
|
+
// construction: same filter, same order)
|
|
439
|
+
for (let i = 0; i < errorResponses.length; i++) {
|
|
440
|
+
const err = errorResponses[i];
|
|
441
|
+
const errorTypeName = emissions.statusErrors[i].name;
|
|
442
|
+
let errorTsType;
|
|
443
|
+
if (err.isBinary) {
|
|
444
|
+
errorTsType = 'StreamResponse';
|
|
445
|
+
}
|
|
446
|
+
else if (err.schema) {
|
|
447
|
+
errorTsType = mapper.mapSchema(err.schema, undefined, 'response').tsType;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
errorTsType = 'unknown';
|
|
451
|
+
}
|
|
452
|
+
err.finishedType = errorTsType;
|
|
453
|
+
opLines.push(`export type ${errorTypeName} = ${errorTsType};`);
|
|
454
|
+
}
|
|
455
|
+
// Default response error type
|
|
456
|
+
if (emissions.defaultError) {
|
|
457
|
+
let defaultTsType;
|
|
458
|
+
if (defaultResponse?.isBinary) {
|
|
459
|
+
defaultTsType = 'StreamResponse';
|
|
460
|
+
}
|
|
461
|
+
else if (defaultResponse?.schema) {
|
|
462
|
+
defaultTsType = mapper.mapSchema(defaultResponse.schema, undefined, 'response').tsType;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
defaultTsType = 'unknown';
|
|
466
|
+
}
|
|
467
|
+
if (defaultResponse) {
|
|
468
|
+
defaultResponse.finishedType = defaultTsType;
|
|
469
|
+
}
|
|
470
|
+
opLines.push(`export type ${emissions.defaultError} = ${defaultTsType};`);
|
|
471
|
+
}
|
|
472
|
+
// Error union
|
|
473
|
+
if (emissions.errorsUnion) {
|
|
474
|
+
const unionParts = emissions.statusErrors.map((e) => `ApiError<${e.status}, ${e.name}>`);
|
|
475
|
+
opLines.push(`export type ${emissions.errorsUnion} = ${unionParts.join(' | ')};`);
|
|
476
|
+
}
|
|
477
|
+
op.contractsLines = opLines;
|
|
478
|
+
}
|
|
479
|
+
// Branded types snapshot after every mapSchema call has run, mirroring the
|
|
480
|
+
// pre-refactor read position at the end of generateContracts.
|
|
481
|
+
const brandedTypes = Array.from(mapper.getBrandedTypes().values());
|
|
482
|
+
const securitySchemeTypes = analyzeSecuritySchemes(doc.components?.securitySchemes);
|
|
483
|
+
const serverTypes = analyzeServers(doc.servers);
|
|
484
|
+
return {
|
|
485
|
+
specVersion: doc.openapi,
|
|
486
|
+
operations,
|
|
487
|
+
schemaTypes,
|
|
488
|
+
brandedTypes,
|
|
489
|
+
hasFileUpload,
|
|
490
|
+
securitySchemeTypes,
|
|
491
|
+
serverTypes,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import type { SchemaObject } from '../types/openapi.js';
|
|
3
|
+
/**
|
|
4
|
+
* Family-aware discriminator registry (plan D5) — the single knowledge source
|
|
5
|
+
* for discriminator families the mapper seam will translate through:
|
|
6
|
+
*
|
|
7
|
+
* - One record per discriminator family (a component schema declaring
|
|
8
|
+
* `discriminator`), keyed by `familyId` (the base's raw component name).
|
|
9
|
+
* - Variant entries keyed by the FULL `$ref` string (mapping values are exact
|
|
10
|
+
* ref strings) plus a derived rename-aware name index.
|
|
11
|
+
* - Literals are stored PRE-ESCAPED (D8: `'` and `\` only) so the mapper never
|
|
12
|
+
* interpolates raw values into string literals.
|
|
13
|
+
* - Implicit variants (oneOf/anyOf `$ref` members absent from `mapping`) carry
|
|
14
|
+
* the RAW last ref segment as their literal (D3 precedence: mapping key >
|
|
15
|
+
* own `const` > single-value `enum` > raw segment).
|
|
16
|
+
*
|
|
17
|
+
* Field budget (D5): audited — every remaining field has a live consumer in
|
|
18
|
+
* production (seam / Variant-union emission) or in the registry unit tests.
|
|
19
|
+
* Rename-aware keying is mandatory: the name index goes through the same
|
|
20
|
+
* `buildSchemaRenameMap` machinery `analyze()` uses (see the past-bug note at
|
|
21
|
+
* schema-mapper.ts:75–78 — raw-name keying silently drops literal injection
|
|
22
|
+
* for renamed subtypes).
|
|
23
|
+
*/
|
|
24
|
+
/** Rename-aware type-name derivation — the exact expression `analyze()` wires
|
|
25
|
+
* into the mapper's `typeNameGenerator` (`renameMap.get(raw) ?? sanitized`).
|
|
26
|
+
* Single source of truth so registry name keys can never diverge from the
|
|
27
|
+
* names the mapper emits for the same `$ref`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renameAwareTypeName(renameMap: Map<string, string>, rawName: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* One discriminator variant target inside a family. Consumers: `propertyName`
|
|
32
|
+
* → literal append + `Omit<M, quoteKey(P)>` key + union wrapper;
|
|
33
|
+
* `literalValue` → the appended literal (pre-escaped — render as
|
|
34
|
+
* `'${literalValue}'`); `isNamed` → bare-name vs inline-expansion decision;
|
|
35
|
+
* `isUnion`/`isNullable` → spine `Omit` fallback decision (D2); `familyId` →
|
|
36
|
+
* same-family spine detection (D1/D2); `refStr` → identity match at the
|
|
37
|
+
* ref-translation seam; `typeName` → bare-name emission and the name index.
|
|
38
|
+
*/
|
|
39
|
+
export interface DiscriminatorVariantEntry {
|
|
40
|
+
/** Discriminator property name declared by the family base (e.g. `'$type'`). */
|
|
41
|
+
propertyName: string;
|
|
42
|
+
/** Pre-escaped literal (D8) — the mapping key, own const/enum value, or raw ref segment. No surrounding quotes. */
|
|
43
|
+
literalValue: string;
|
|
44
|
+
/** The rename-aware derived name is an emitted component schema (`∈ allSchemaNames`). */
|
|
45
|
+
isNamed: boolean;
|
|
46
|
+
/** The target's own resolved schema declares `oneOf`/`anyOf` (spine `Omit` would drop variant props — D2 fallback). */
|
|
47
|
+
isUnion: boolean;
|
|
48
|
+
/** The target's own resolved schema is nullable (3.0 `nullable: true` or a 3.1 type array containing `'null'`). */
|
|
49
|
+
isNullable: boolean;
|
|
50
|
+
/** Owning family id — the discriminator base's raw component name. */
|
|
51
|
+
familyId: string;
|
|
52
|
+
/** The exact `$ref` string that produced this entry (mapping value or oneOf/anyOf member ref). */
|
|
53
|
+
refStr: string;
|
|
54
|
+
/** Rename-aware type name for the ref (same derivation as the mapper's `typeNameGenerator`). */
|
|
55
|
+
typeName: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One discriminator family: a component schema declaring `discriminator`
|
|
59
|
+
* (`familyId` = its raw component name). Consumers: `baseRefStr` → base-ref
|
|
60
|
+
* detection at the seam (D1: `$ref` to the base emits `variantUnionName`
|
|
61
|
+
* outside same-family spines); `variantUnionName` / `variantUnionMembers` →
|
|
62
|
+
* the always-emit `{Base}Variant` union (D6); `entriesByRef` → family
|
|
63
|
+
* construction substrate and per-family lookups.
|
|
64
|
+
*/
|
|
65
|
+
export interface DiscriminatorFamily {
|
|
66
|
+
/** Raw component name of the discriminator base — the family id. */
|
|
67
|
+
familyId: string;
|
|
68
|
+
/** Rename-aware emitted type name of the base. */
|
|
69
|
+
baseTypeName: string;
|
|
70
|
+
/** Canonical ref of the base component (`#/components/schemas/<encoded key>`). */
|
|
71
|
+
baseRefStr: string;
|
|
72
|
+
/** Discriminator property name (`discriminator.propertyName`). */
|
|
73
|
+
propertyName: string;
|
|
74
|
+
/** Collision-safe `{Base}Variant` union name (D6 — via the reserved-name machinery). */
|
|
75
|
+
variantUnionName: string;
|
|
76
|
+
/** Union member type names (rename-aware, deduped; mapping order, then unmapped oneOf/anyOf refs). */
|
|
77
|
+
variantUnionMembers: string[];
|
|
78
|
+
/** Family entries keyed by the FULL `$ref` string (mapping value or oneOf/anyOf member ref). */
|
|
79
|
+
entriesByRef: Map<string, DiscriminatorVariantEntry>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The discriminator registry built once per `analyze()` run. Consumers:
|
|
83
|
+
* `byRef`/`byName` for variant-target lookup at the ref-translation seam;
|
|
84
|
+
* `baseRefs` for D1 base-ref translation; `families` for same-family spine
|
|
85
|
+
* scoping (entry `familyId` → family) and the `{Base}Variant` union emission.
|
|
86
|
+
* Flat lookups are first-wins across families (spec order) — ambiguous
|
|
87
|
+
* cross-family targets stay reachable through their per-family maps.
|
|
88
|
+
*/
|
|
89
|
+
export interface DiscriminatorRegistry {
|
|
90
|
+
/** Families keyed by familyId (raw base component name), in spec order. */
|
|
91
|
+
families: Map<string, DiscriminatorFamily>;
|
|
92
|
+
/** Flat lookup: full refStr → entry (first-wins across families). */
|
|
93
|
+
byRef: Map<string, DiscriminatorVariantEntry>;
|
|
94
|
+
/** Flat lookup: rename-aware type name → entry (first-wins across families). */
|
|
95
|
+
byName: Map<string, DiscriminatorVariantEntry>;
|
|
96
|
+
/** Canonical base refStr → family, for D1 base-ref translation. */
|
|
97
|
+
baseRefs: Map<string, DiscriminatorFamily>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build the family-aware discriminator registry from the component schemas.
|
|
101
|
+
*
|
|
102
|
+
* Inputs must be the SAME `renameMap` / `allSchemaNames` `analyze()` derives
|
|
103
|
+
* (the rename-aware keying discipline) — pass `buildSchemaRenameMap`'s output
|
|
104
|
+
* and the emitted-name set, never a parallel sanitization. Pure with respect
|
|
105
|
+
* to the document: no mutation of the schemas; warnings (D3 mapping-key
|
|
106
|
+
* conflicts) go to `warnSink`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function buildDiscriminatorRegistry(schemas: Record<string, SchemaObject> | undefined, resolver: RefResolver, renameMap: Map<string, string>, allSchemaNames: ReadonlySet<string>, warnSink?: (message: string) => void): DiscriminatorRegistry;
|