@workos/oagen 0.2.0 → 0.4.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 +72 -0
- package/dist/cli/index.mjs +1076 -73
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +1 -1
- package/dist/compat/index.mjs +9 -4
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-DyQF4sel.mjs → diff-DcY93sii.mjs} +619 -18
- package/dist/diff-DcY93sii.mjs.map +1 -0
- package/dist/{differ-BwelBJN0.mjs → differ-CG5ZErKl.mjs} +2 -2
- package/dist/{differ-BwelBJN0.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
- package/dist/index.d.mts +136 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +337 -29
- package/dist/index.mjs.map +1 -1
- package/dist/scripts/smoke/baseline.mjs +351 -34
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +138 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +351 -34
- package/dist/smoke.mjs.map +1 -1
- package/dist/{tree-sitter-BPNfbEzY.mjs → tree-sitter-C53w_0WE.mjs} +42 -9
- package/dist/tree-sitter-C53w_0WE.mjs.map +1 -0
- package/dist/{types-O25qZxMl.d.mts → types-D1tMqLVB.d.mts} +165 -2
- package/dist/types-D1tMqLVB.d.mts.map +1 -0
- package/dist/verify/index.d.mts +1 -1
- package/dist/verify/index.mjs +5 -5
- package/dist/verify/index.mjs.map +1 -1
- package/docs/architecture/ir-types.md +73 -1
- package/docs/cli.md +29 -8
- package/package.json +4 -4
- package/skills/review-operations/SKILL.md +83 -0
- package/dist/diff-DyQF4sel.mjs.map +0 -1
- package/dist/tree-sitter-BPNfbEzY.mjs.map +0 -1
- package/dist/types-O25qZxMl.d.mts.map +0 -1
package/dist/smoke.mjs
CHANGED
|
@@ -3,18 +3,52 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
-
import { BaseResolver, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
|
|
6
|
+
import { BaseResolver, Oas3_1Types, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
|
|
7
7
|
//#region src/utils/naming.ts
|
|
8
8
|
/**
|
|
9
|
+
* Known compound tokens that the regex-based splitter over-splits.
|
|
10
|
+
* Each entry is [lowercase-word-sequence, canonical-form].
|
|
11
|
+
* Sorted longest-first so greedy matching works correctly.
|
|
12
|
+
*/
|
|
13
|
+
const COMPOUND_WORDS = [[[
|
|
14
|
+
"m",
|
|
15
|
+
"2",
|
|
16
|
+
"m"
|
|
17
|
+
], "M2M"], [["o", "auth"], "OAuth"]];
|
|
18
|
+
/**
|
|
19
|
+
* Recombine adjacent words that form a known compound token.
|
|
20
|
+
*/
|
|
21
|
+
function recombineCompounds(words) {
|
|
22
|
+
const result = [];
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < words.length) {
|
|
25
|
+
let matched = false;
|
|
26
|
+
for (const [pattern, canonical] of COMPOUND_WORDS) if (i + pattern.length <= words.length) {
|
|
27
|
+
if (pattern.every((p, j) => words[i + j].toLowerCase() === p)) {
|
|
28
|
+
result.push(canonical);
|
|
29
|
+
i += pattern.length;
|
|
30
|
+
matched = true;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!matched) {
|
|
35
|
+
result.push(words[i]);
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
9
42
|
* Split a string into words, handling:
|
|
10
43
|
* - camelCase / PascalCase boundaries
|
|
11
44
|
* - snake_case / kebab-case separators
|
|
12
45
|
* - Consecutive capitals (e.g., "HTTPClient" → ["HTTP", "Client"])
|
|
13
46
|
* - Numbers as word boundaries (e.g., "OAuth2Token" → ["OAuth", "2", "Token"])
|
|
47
|
+
* - Known compounds are recombined (e.g., "M2M" stays as one word)
|
|
14
48
|
*/
|
|
15
49
|
function splitWords(s) {
|
|
16
50
|
if (!s) return [];
|
|
17
|
-
return s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0);
|
|
51
|
+
return recombineCompounds(s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0));
|
|
18
52
|
}
|
|
19
53
|
const ACRONYM_SET = new Set([
|
|
20
54
|
"SSO",
|
|
@@ -22,7 +56,8 @@ const ACRONYM_SET = new Set([
|
|
|
22
56
|
"SAML",
|
|
23
57
|
"SCIM",
|
|
24
58
|
"JWT",
|
|
25
|
-
"HMAC"
|
|
59
|
+
"HMAC",
|
|
60
|
+
"M2M"
|
|
26
61
|
]);
|
|
27
62
|
function toPascalCase(s, acronyms) {
|
|
28
63
|
const merged = acronyms ? new Set([...ACRONYM_SET, ...acronyms]) : ACRONYM_SET;
|
|
@@ -51,11 +86,7 @@ function toSnakeCase(s) {
|
|
|
51
86
|
function toUpperSnakeCase(s) {
|
|
52
87
|
return splitWords(s).map((w) => w.toUpperCase()).join("_");
|
|
53
88
|
}
|
|
54
|
-
const BACKEND_SUFFIXES = [
|
|
55
|
-
"Dto",
|
|
56
|
-
"DTO",
|
|
57
|
-
"Controller"
|
|
58
|
-
];
|
|
89
|
+
const BACKEND_SUFFIXES = ["Controller"];
|
|
59
90
|
function stripBackendSuffixes(name) {
|
|
60
91
|
for (const suffix of BACKEND_SUFFIXES) if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length);
|
|
61
92
|
return name;
|
|
@@ -82,7 +113,9 @@ const SINGULAR_SAFE_LIST = new Set([
|
|
|
82
113
|
"Canvas",
|
|
83
114
|
"Alias",
|
|
84
115
|
"Basis",
|
|
85
|
-
"Bus"
|
|
116
|
+
"Bus",
|
|
117
|
+
"Species",
|
|
118
|
+
"Series"
|
|
86
119
|
]);
|
|
87
120
|
/**
|
|
88
121
|
* Conservative singularize for the leading PascalCase word.
|
|
@@ -110,6 +143,120 @@ function cleanSchemaName(name) {
|
|
|
110
143
|
return result;
|
|
111
144
|
}
|
|
112
145
|
//#endregion
|
|
146
|
+
//#region src/ir/sdk-behavior.ts
|
|
147
|
+
/**
|
|
148
|
+
* Canonical SDK behavior defaults. These match the PHP emitter's current
|
|
149
|
+
* implementation (the most complete). Per-language overrides can be applied
|
|
150
|
+
* via `mergeSdkBehavior()` in each SDK's `oagen.config.ts`.
|
|
151
|
+
*
|
|
152
|
+
* - Retry: exponential backoff with jitter, retries on 429/5xx
|
|
153
|
+
* - Errors: standard HTTP status → exception kind mapping
|
|
154
|
+
* - Telemetry: enabled by default, tracks request ID + latency
|
|
155
|
+
* - Pagination: no inter-page delay (Node overrides to 350ms)
|
|
156
|
+
* - Idempotency: auto-generate UUID v4 for retryable POSTs
|
|
157
|
+
* - Logging: all lifecycle events enabled
|
|
158
|
+
* - User-Agent: SDK identifier + runtime version + app info + AI agent detection
|
|
159
|
+
* - Request guards: detect misplaced RequestOptions keys in params
|
|
160
|
+
* - Timeout: 60s default (Python overrides to 30s)
|
|
161
|
+
*/
|
|
162
|
+
function defaultSdkBehavior() {
|
|
163
|
+
return {
|
|
164
|
+
retry: {
|
|
165
|
+
retryableStatusCodes: [
|
|
166
|
+
429,
|
|
167
|
+
500,
|
|
168
|
+
502,
|
|
169
|
+
503,
|
|
170
|
+
504
|
|
171
|
+
],
|
|
172
|
+
maxRetries: 3,
|
|
173
|
+
retryOnConnectionError: true,
|
|
174
|
+
retryOnTimeout: true,
|
|
175
|
+
backoff: {
|
|
176
|
+
initialDelay: 1,
|
|
177
|
+
multiplier: 2,
|
|
178
|
+
maxDelay: 30,
|
|
179
|
+
jitterFactor: .5
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
errors: {
|
|
183
|
+
statusCodeMap: {
|
|
184
|
+
400: "BadRequest",
|
|
185
|
+
401: "Authentication",
|
|
186
|
+
403: "Authorization",
|
|
187
|
+
404: "NotFound",
|
|
188
|
+
409: "Conflict",
|
|
189
|
+
422: "UnprocessableEntity",
|
|
190
|
+
429: "RateLimitExceeded"
|
|
191
|
+
},
|
|
192
|
+
serverErrorKind: "Server",
|
|
193
|
+
clientErrorKind: "Api",
|
|
194
|
+
errorDocUrlTemplate: "https://workos.com/docs/errors/{code}"
|
|
195
|
+
},
|
|
196
|
+
telemetry: {
|
|
197
|
+
enabledByDefault: true,
|
|
198
|
+
headerName: "X-WorkOS-Client-Telemetry",
|
|
199
|
+
requestIdHeader: "X-Request-ID"
|
|
200
|
+
},
|
|
201
|
+
pagination: { autoPageDelayMs: 0 },
|
|
202
|
+
idempotency: {
|
|
203
|
+
headerName: "Idempotency-Key",
|
|
204
|
+
autoGenerateForPost: true
|
|
205
|
+
},
|
|
206
|
+
logging: {
|
|
207
|
+
enabled: true,
|
|
208
|
+
events: [
|
|
209
|
+
"request.start",
|
|
210
|
+
"request.success",
|
|
211
|
+
"request.retry",
|
|
212
|
+
"request.rate_limited",
|
|
213
|
+
"request.error",
|
|
214
|
+
"request.connection_error"
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
userAgent: {
|
|
218
|
+
sdkIdentifierTemplate: "{name} {lang}/{version}",
|
|
219
|
+
includeRuntimeVersion: true,
|
|
220
|
+
allowAppInfo: true,
|
|
221
|
+
aiAgentEnvVars: [
|
|
222
|
+
{
|
|
223
|
+
envVar: "CLAUDE_CODE",
|
|
224
|
+
agentName: "ClaudeCode"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
envVar: "CURSOR_AGENT",
|
|
228
|
+
agentName: "Cursor"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
envVar: "CLINE_ACTIVE",
|
|
232
|
+
agentName: "Cline"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
envVar: "WINDSURF_ACTIVE",
|
|
236
|
+
agentName: "Windsurf"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
envVar: "COPILOT_AGENT",
|
|
240
|
+
agentName: "Copilot"
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
requestGuard: { optionKeys: [
|
|
245
|
+
"api_key",
|
|
246
|
+
"apiKey",
|
|
247
|
+
"idempotency_key",
|
|
248
|
+
"idempotencyKey",
|
|
249
|
+
"extra_headers",
|
|
250
|
+
"extraHeaders",
|
|
251
|
+
"max_retries",
|
|
252
|
+
"maxRetries",
|
|
253
|
+
"base_url",
|
|
254
|
+
"baseUrl"
|
|
255
|
+
] },
|
|
256
|
+
timeout: { defaultTimeoutSeconds: 60 }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
113
260
|
//#region src/errors.ts
|
|
114
261
|
/**
|
|
115
262
|
* Structured error hierarchy for oagen.
|
|
@@ -138,13 +285,11 @@ var SpecParseError = class extends OagenError {
|
|
|
138
285
|
//#region src/parser/refs.ts
|
|
139
286
|
async function loadAndBundleSpec(specPath) {
|
|
140
287
|
const absolutePath = resolve(specPath);
|
|
141
|
-
const document = makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href);
|
|
142
|
-
const config = await createConfig({});
|
|
143
|
-
const resolver = new BaseResolver();
|
|
144
288
|
const result = await bundleDocument({
|
|
145
|
-
document,
|
|
146
|
-
config:
|
|
147
|
-
|
|
289
|
+
document: makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href),
|
|
290
|
+
config: await createConfig({}),
|
|
291
|
+
types: Oas3_1Types,
|
|
292
|
+
externalRefResolver: new BaseResolver(),
|
|
148
293
|
dereference: false
|
|
149
294
|
});
|
|
150
295
|
if (result.problems.some((p) => p.severity === "error")) throw new SpecParseError(`Failed to parse spec: ${result.problems.filter((p) => p.severity === "error").map((p) => p.message).join("\n")}`, `Check the OpenAPI spec at "${absolutePath}" for syntax errors. Run a linter such as \`npx @redocly/cli lint ${specPath}\` to identify issues.`);
|
|
@@ -286,15 +431,39 @@ function buildInlineModel(name, schema) {
|
|
|
286
431
|
}
|
|
287
432
|
//#endregion
|
|
288
433
|
//#region src/parser/schemas.ts
|
|
289
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Module-level transform set during extractSchemas(). Used by schemaToTypeRef()
|
|
436
|
+
* to apply the same name transform to $ref model/enum references.
|
|
437
|
+
*/
|
|
438
|
+
let activeSchemaNameTransform = null;
|
|
439
|
+
/** Apply cleanSchemaName + the active transform (if any) to a raw schema name. */
|
|
440
|
+
function resolveSchemaName(rawName) {
|
|
441
|
+
let name = cleanSchemaName(toPascalCase(rawName));
|
|
442
|
+
if (activeSchemaNameTransform) name = activeSchemaNameTransform(name);
|
|
443
|
+
return name;
|
|
444
|
+
}
|
|
445
|
+
function extractSchemas(schemas, options) {
|
|
290
446
|
const enums = [];
|
|
291
447
|
if (!schemas) return {
|
|
292
448
|
models: [],
|
|
293
449
|
enums
|
|
294
450
|
};
|
|
451
|
+
if (options?.schemaNameTransform) {
|
|
452
|
+
const transform = options.schemaNameTransform;
|
|
453
|
+
const cleanedNames = Object.keys(schemas).map((n) => cleanSchemaName(toPascalCase(n)));
|
|
454
|
+
const transformedToOriginals = /* @__PURE__ */ new Map();
|
|
455
|
+
for (const cleaned of cleanedNames) {
|
|
456
|
+
const transformed = transform(cleaned);
|
|
457
|
+
if (!transformedToOriginals.has(transformed)) transformedToOriginals.set(transformed, []);
|
|
458
|
+
transformedToOriginals.get(transformed).push(cleaned);
|
|
459
|
+
}
|
|
460
|
+
const unsafeToTransform = /* @__PURE__ */ new Set();
|
|
461
|
+
for (const [, originals] of transformedToOriginals) if (originals.length > 1) for (const n of originals) unsafeToTransform.add(n);
|
|
462
|
+
activeSchemaNameTransform = (name) => unsafeToTransform.has(name) ? name : transform(name);
|
|
463
|
+
} else activeSchemaNameTransform = null;
|
|
295
464
|
const modelsByCleanName = /* @__PURE__ */ new Map();
|
|
296
465
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
297
|
-
const pascalName =
|
|
466
|
+
const pascalName = resolveSchemaName(name);
|
|
298
467
|
if (schema.enum) enums.push(extractEnum(pascalName, schema));
|
|
299
468
|
else {
|
|
300
469
|
const model = extractModel(pascalName, schema, schemas);
|
|
@@ -302,6 +471,12 @@ function extractSchemas(schemas) {
|
|
|
302
471
|
if (existing) {
|
|
303
472
|
if (model.fields.length > existing.fields.length) modelsByCleanName.set(pascalName, model);
|
|
304
473
|
} else modelsByCleanName.set(pascalName, model);
|
|
474
|
+
for (const inlineModel of extractDiscriminatedAllOfVariantModels(schema, pascalName)) {
|
|
475
|
+
const existingInline = modelsByCleanName.get(inlineModel.name);
|
|
476
|
+
if (existingInline) {
|
|
477
|
+
if (inlineModel.fields.length > existingInline.fields.length) modelsByCleanName.set(inlineModel.name, inlineModel);
|
|
478
|
+
} else modelsByCleanName.set(inlineModel.name, inlineModel);
|
|
479
|
+
}
|
|
305
480
|
}
|
|
306
481
|
}
|
|
307
482
|
const models = [...modelsByCleanName.values()];
|
|
@@ -317,6 +492,10 @@ function extractSchemas(schemas) {
|
|
|
317
492
|
enums
|
|
318
493
|
};
|
|
319
494
|
}
|
|
495
|
+
/** Clear the module-level schemaNameTransform. Called by parseSpec() after all extraction phases. */
|
|
496
|
+
function clearSchemaNameTransform() {
|
|
497
|
+
activeSchemaNameTransform = null;
|
|
498
|
+
}
|
|
320
499
|
/**
|
|
321
500
|
* Walk a single TypeRef and extract inline enum refs as top-level enum definitions.
|
|
322
501
|
* Shared by both schemas.ts (field-level) and parse.ts (model-level) collection passes.
|
|
@@ -353,7 +532,7 @@ function collectInlineEnums(fields, enums) {
|
|
|
353
532
|
function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, models, modelNames, queue) {
|
|
354
533
|
walkTypeRef(ref, { model: (modelRef) => {
|
|
355
534
|
if (modelNames.has(modelRef.name)) return;
|
|
356
|
-
if (Object.keys(schemas).some((k) =>
|
|
535
|
+
if (Object.keys(schemas).some((k) => resolveSchemaName(k) === modelRef.name)) return;
|
|
357
536
|
const parentSchema = findSchemaByName(parentModelName, schemas);
|
|
358
537
|
if (!parentSchema) return;
|
|
359
538
|
const fieldSchema = findNestedFieldSchema(fieldName, parentSchema, schemas);
|
|
@@ -368,7 +547,7 @@ function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, mod
|
|
|
368
547
|
}
|
|
369
548
|
/** Find a component schema by PascalCase name. */
|
|
370
549
|
function findSchemaByName(pascalName, schemas) {
|
|
371
|
-
for (const [k, v] of Object.entries(schemas)) if (
|
|
550
|
+
for (const [k, v] of Object.entries(schemas)) if (resolveSchemaName(k) === pascalName) return v;
|
|
372
551
|
return null;
|
|
373
552
|
}
|
|
374
553
|
/** Walk into a schema to find a field's sub-schema, resolving allOf. */
|
|
@@ -526,13 +705,88 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
526
705
|
fields
|
|
527
706
|
};
|
|
528
707
|
}
|
|
708
|
+
function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
|
|
709
|
+
const models = [];
|
|
710
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
711
|
+
for (const subSchema of schema.allOf ?? []) for (const variant of subSchema.oneOf ?? []) {
|
|
712
|
+
if (!variant.properties || variant.type !== void 0 && variant.type !== "object") continue;
|
|
713
|
+
const variantName = deriveDiscriminatedVariantName(variant, fallbackName);
|
|
714
|
+
for (const model of extractInlineModelDeep(variantName, variant)) {
|
|
715
|
+
if (seenNames.has(model.name)) continue;
|
|
716
|
+
seenNames.add(model.name);
|
|
717
|
+
models.push(model);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return models;
|
|
721
|
+
}
|
|
722
|
+
function deriveDiscriminatedVariantName(schema, fallbackName) {
|
|
723
|
+
if (!schema.properties) return fallbackName;
|
|
724
|
+
for (const name of [
|
|
725
|
+
"event",
|
|
726
|
+
"type",
|
|
727
|
+
"object"
|
|
728
|
+
]) {
|
|
729
|
+
const field = schema.properties[name];
|
|
730
|
+
if (!field) continue;
|
|
731
|
+
if (typeof field.const === "string") return toPascalCase(field.const);
|
|
732
|
+
if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
|
|
733
|
+
}
|
|
734
|
+
for (const field of Object.values(schema.properties)) {
|
|
735
|
+
if (!field) continue;
|
|
736
|
+
if (typeof field.const === "string") return toPascalCase(field.const);
|
|
737
|
+
if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
|
|
738
|
+
}
|
|
739
|
+
return fallbackName;
|
|
740
|
+
}
|
|
741
|
+
function extractInlineModelDeep(name, schema) {
|
|
742
|
+
const requiredSet = new Set(schema.required ?? []);
|
|
743
|
+
const fields = [];
|
|
744
|
+
const properties = schema.properties ?? {};
|
|
745
|
+
const nestedModels = [];
|
|
746
|
+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
|
747
|
+
if (!fieldSchema) continue;
|
|
748
|
+
fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, requiredSet));
|
|
749
|
+
if (fieldSchema.type === "object" && fieldSchema.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema));
|
|
750
|
+
if (fieldSchema.type === "array" && fieldSchema.items?.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema.items));
|
|
751
|
+
if (fieldSchema.allOf) {
|
|
752
|
+
const mergedProperties = {};
|
|
753
|
+
const mergedRequired = [];
|
|
754
|
+
for (const sub of fieldSchema.allOf) {
|
|
755
|
+
if (sub.properties) Object.assign(mergedProperties, sub.properties);
|
|
756
|
+
if (sub.required) mergedRequired.push(...sub.required);
|
|
757
|
+
}
|
|
758
|
+
if (Object.keys(mergedProperties).length > 0) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), {
|
|
759
|
+
type: "object",
|
|
760
|
+
properties: mergedProperties,
|
|
761
|
+
required: mergedRequired
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
if (fieldSchema.oneOf) {
|
|
765
|
+
const objectVariant = fieldSchema.oneOf.find((v) => v.properties && (v.type === "object" || !v.type));
|
|
766
|
+
if (objectVariant) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), objectVariant));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return [{
|
|
770
|
+
name,
|
|
771
|
+
description: schema.description,
|
|
772
|
+
fields
|
|
773
|
+
}, ...nestedModels];
|
|
774
|
+
}
|
|
775
|
+
function qualifyNestedInlineName(parentName, fieldName) {
|
|
776
|
+
const pascalField = toPascalCase(fieldName);
|
|
777
|
+
if (pascalField.startsWith(parentName)) return pascalField;
|
|
778
|
+
const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
|
|
779
|
+
const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
|
|
780
|
+
if (match && match[2]) return match[1] + singularize(match[2]);
|
|
781
|
+
return qualified;
|
|
782
|
+
}
|
|
529
783
|
function schemaToTypeRef(schema, contextName, parentModelName) {
|
|
530
784
|
if (schema.$ref) {
|
|
531
785
|
const segments = schema.$ref.split("/");
|
|
532
786
|
const rawName = segments[segments.length - 1];
|
|
533
787
|
return {
|
|
534
788
|
kind: "model",
|
|
535
|
-
name:
|
|
789
|
+
name: resolveSchemaName(rawName)
|
|
536
790
|
};
|
|
537
791
|
}
|
|
538
792
|
if (Array.isArray(schema.type)) {
|
|
@@ -796,6 +1050,7 @@ const KNOWN_DATA_PATHS = new Set([
|
|
|
796
1050
|
"edges"
|
|
797
1051
|
]);
|
|
798
1052
|
const PAGINATION_METADATA_PATTERNS = [
|
|
1053
|
+
"meta",
|
|
799
1054
|
"metadata",
|
|
800
1055
|
"pagination",
|
|
801
1056
|
"cursor",
|
|
@@ -827,11 +1082,12 @@ function detectListEnvelope(schema) {
|
|
|
827
1082
|
}
|
|
828
1083
|
if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
|
|
829
1084
|
const arrayPropName = arrayProps[0];
|
|
830
|
-
|
|
1085
|
+
const hasPaginationCompanion = nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)));
|
|
1086
|
+
if (KNOWN_DATA_PATHS.has(arrayPropName) && hasPaginationCompanion) return {
|
|
831
1087
|
isEnvelope: true,
|
|
832
1088
|
dataPath: arrayPropName
|
|
833
1089
|
};
|
|
834
|
-
if (
|
|
1090
|
+
if (hasPaginationCompanion) return {
|
|
835
1091
|
isEnvelope: true,
|
|
836
1092
|
dataPath: arrayPropName
|
|
837
1093
|
};
|
|
@@ -1135,13 +1391,33 @@ function extractOperations(paths, operationIdTransform) {
|
|
|
1135
1391
|
const op = pathItem[method];
|
|
1136
1392
|
if (!op) continue;
|
|
1137
1393
|
const serviceName = inferServiceName(path, op.tags?.[0]);
|
|
1138
|
-
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform);
|
|
1394
|
+
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName);
|
|
1139
1395
|
inlineModels.push(...opModels);
|
|
1140
1396
|
const ops = serviceMap.get(serviceName) ?? [];
|
|
1141
1397
|
ops.push(operation);
|
|
1142
1398
|
serviceMap.set(serviceName, ops);
|
|
1143
1399
|
}
|
|
1144
1400
|
}
|
|
1401
|
+
for (const [serviceName, operations] of serviceMap) {
|
|
1402
|
+
const pathGroups = /* @__PURE__ */ new Map();
|
|
1403
|
+
for (const op of operations) {
|
|
1404
|
+
const firstSeg = op.path.split("/").filter(Boolean)[0] ?? "";
|
|
1405
|
+
const groupKey = firstSeg.startsWith("{") ? serviceName : toPascalCase(firstSeg);
|
|
1406
|
+
const group = pathGroups.get(groupKey) ?? [];
|
|
1407
|
+
group.push(op);
|
|
1408
|
+
pathGroups.set(groupKey, group);
|
|
1409
|
+
}
|
|
1410
|
+
if (pathGroups.size > 1) {
|
|
1411
|
+
if ([...pathGroups.keys()].some((k) => k !== serviceName)) {
|
|
1412
|
+
serviceMap.delete(serviceName);
|
|
1413
|
+
for (const [groupName, groupOps] of pathGroups) {
|
|
1414
|
+
const existing = serviceMap.get(groupName) ?? [];
|
|
1415
|
+
existing.push(...groupOps);
|
|
1416
|
+
serviceMap.set(groupName, existing);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1145
1421
|
for (const [, operations] of serviceMap) disambiguateOperationNames(operations);
|
|
1146
1422
|
return {
|
|
1147
1423
|
services: Array.from(serviceMap.entries()).map(([name, operations]) => ({
|
|
@@ -1257,13 +1533,14 @@ function inferDeeperContext(path) {
|
|
|
1257
1533
|
if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
|
|
1258
1534
|
return null;
|
|
1259
1535
|
}
|
|
1260
|
-
function buildOperation(method, path, op, pathLevelParams, operationIdTransform) {
|
|
1536
|
+
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
|
|
1261
1537
|
const allParams = [...pathLevelParams, ...op.parameters ?? []];
|
|
1262
1538
|
const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
|
|
1263
|
-
const
|
|
1264
|
-
const
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1539
|
+
const opContext = serviceName;
|
|
1540
|
+
const pathParams = extractParams(allParams, "path", opContext);
|
|
1541
|
+
const queryParams = extractParams(allParams, "query", opContext);
|
|
1542
|
+
const headerParams = extractParams(allParams, "header", opContext).filter((p) => p.name.toLowerCase() !== "idempotency-key");
|
|
1543
|
+
const cookieParams = extractParams(allParams, "cookie", opContext);
|
|
1267
1544
|
const reqBodyModels = [];
|
|
1268
1545
|
const { body: requestBody, encoding: requestBodyEncoding } = extractRequestBody(op.requestBody, op, reqBodyModels);
|
|
1269
1546
|
const { response, successResponses, errors, inlineModels, isPaginated, dataPath: responseDataPath, itemType: responseItemType } = extractResponses(op.responses, op, path, method);
|
|
@@ -1280,6 +1557,7 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1280
1557
|
itemType
|
|
1281
1558
|
};
|
|
1282
1559
|
} else if (paginationFromParams) pagination = paginationFromParams;
|
|
1560
|
+
const security = extractOperationSecurity(op.security);
|
|
1283
1561
|
return {
|
|
1284
1562
|
operation: {
|
|
1285
1563
|
name: inferOperationName(method, path, op.operationId, operationIdTransform),
|
|
@@ -1298,19 +1576,35 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1298
1576
|
pagination,
|
|
1299
1577
|
injectIdempotencyKey: hasIdempotencyHeader,
|
|
1300
1578
|
deprecated: op.deprecated || void 0,
|
|
1301
|
-
async: op["x-oagen-async"]
|
|
1579
|
+
async: op["x-oagen-async"],
|
|
1580
|
+
security
|
|
1302
1581
|
},
|
|
1303
1582
|
inlineModels
|
|
1304
1583
|
};
|
|
1305
1584
|
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Extract per-operation security requirements from an OpenAPI security directive.
|
|
1587
|
+
* Returns undefined when the operation uses the spec-level default security.
|
|
1588
|
+
*
|
|
1589
|
+
* OpenAPI security format: `security: [{ schemeName: [scope1, scope2] }]`
|
|
1590
|
+
*/
|
|
1591
|
+
function extractOperationSecurity(security) {
|
|
1592
|
+
if (!security || security.length === 0) return void 0;
|
|
1593
|
+
const requirements = [];
|
|
1594
|
+
for (const entry of security) for (const [schemeName, scopes] of Object.entries(entry)) requirements.push({
|
|
1595
|
+
schemeName,
|
|
1596
|
+
scopes: scopes ?? []
|
|
1597
|
+
});
|
|
1598
|
+
return requirements.length > 0 ? requirements : void 0;
|
|
1599
|
+
}
|
|
1306
1600
|
function buildDescription(summary, description) {
|
|
1307
1601
|
if (summary && description && summary !== description) return `${summary}\n\n${description}`;
|
|
1308
1602
|
return description ?? summary;
|
|
1309
1603
|
}
|
|
1310
|
-
function extractParams(params, location) {
|
|
1604
|
+
function extractParams(params, location, operationContext) {
|
|
1311
1605
|
return params.filter((p) => p.in === location).map((p) => ({
|
|
1312
1606
|
name: p.name,
|
|
1313
|
-
type: p.schema ? schemaToTypeRef(p.schema, p.name) : {
|
|
1607
|
+
type: p.schema ? schemaToTypeRef(p.schema, p.name, operationContext ? toPascalCase(operationContext) : void 0) : {
|
|
1314
1608
|
kind: "primitive",
|
|
1315
1609
|
type: "string"
|
|
1316
1610
|
},
|
|
@@ -1496,7 +1790,14 @@ function extractResponses(responses, op, path, method) {
|
|
|
1496
1790
|
statusCode: code,
|
|
1497
1791
|
type: extractedType
|
|
1498
1792
|
});
|
|
1499
|
-
} else if (code >= 400) {
|
|
1793
|
+
} else if (code >= 300 && code < 400) allSuccessResponses.push({
|
|
1794
|
+
statusCode: code,
|
|
1795
|
+
type: {
|
|
1796
|
+
kind: "primitive",
|
|
1797
|
+
type: "unknown"
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
else if (code >= 400) {
|
|
1500
1801
|
const jsonContent = resp.content?.["application/json"];
|
|
1501
1802
|
const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
|
|
1502
1803
|
errors.push({
|
|
@@ -1623,6 +1924,19 @@ function collectInlineEnumsFromModels(models, enums) {
|
|
|
1623
1924
|
const enumNames = new Set(enums.map((e) => e.name));
|
|
1624
1925
|
for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
|
|
1625
1926
|
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Collect inline enum definitions from operation parameters (query, path, header)
|
|
1929
|
+
* and promote them to top-level enums so emitters can generate typed enum files.
|
|
1930
|
+
*/
|
|
1931
|
+
function collectInlineEnumsFromOperations(services, enums) {
|
|
1932
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
1933
|
+
for (const service of services) for (const op of service.operations) for (const param of [
|
|
1934
|
+
...op.pathParams,
|
|
1935
|
+
...op.queryParams,
|
|
1936
|
+
...op.headerParams,
|
|
1937
|
+
...op.cookieParams ?? []
|
|
1938
|
+
]) collectInlineEnumFromRef(param.type, enums, enumNames);
|
|
1939
|
+
}
|
|
1626
1940
|
//#endregion
|
|
1627
1941
|
//#region src/parser/normalize-model-refs.ts
|
|
1628
1942
|
function validateModelRefs(spec) {
|
|
@@ -1652,10 +1966,12 @@ async function parseSpec(specPath, options) {
|
|
|
1652
1966
|
const spec = parsed;
|
|
1653
1967
|
const version = spec.openapi ?? "";
|
|
1654
1968
|
if (!version.startsWith("3.")) throw new SpecParseError(`Unsupported OpenAPI version: ${version}. oagen requires OpenAPI 3.x`, `Update the spec to OpenAPI 3.0 or 3.1. If you are using Swagger 2.x, convert it first with \`npx swagger2openapi ${specPath}\`.`);
|
|
1655
|
-
const { models, enums } = extractSchemas(spec.components?.schemas);
|
|
1969
|
+
const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
|
|
1656
1970
|
const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
|
|
1971
|
+
clearSchemaNameTransform();
|
|
1657
1972
|
const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
|
|
1658
1973
|
collectInlineEnumsFromModels(finalModels, enums);
|
|
1974
|
+
collectInlineEnumsFromOperations(services, enums);
|
|
1659
1975
|
const auth = extractAuthSchemes(spec.components?.securitySchemes);
|
|
1660
1976
|
const serverEntries = (spec.servers ?? []).map((s) => ({
|
|
1661
1977
|
url: s.url ?? "",
|
|
@@ -1670,7 +1986,8 @@ async function parseSpec(specPath, options) {
|
|
|
1670
1986
|
services,
|
|
1671
1987
|
models: finalModels,
|
|
1672
1988
|
enums,
|
|
1673
|
-
auth
|
|
1989
|
+
auth,
|
|
1990
|
+
sdk: defaultSdkBehavior()
|
|
1674
1991
|
};
|
|
1675
1992
|
validateModelRefs(result);
|
|
1676
1993
|
return result;
|