@workos/oagen-emitters 0.3.0 → 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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { EmitterContext, Operation, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { className, fieldName, safeParamName } from './naming.js';
|
|
3
|
+
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
|
|
4
|
+
import { mapTypeRefForYard } from './type-map.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate Ruby wrapper method lines for union split operations.
|
|
8
|
+
*
|
|
9
|
+
* Each wrapper is a typed convenience method that:
|
|
10
|
+
* - Accepts only the exposed params (keyword args)
|
|
11
|
+
* - Injects constant defaults (e.g., grant_type)
|
|
12
|
+
* - Reads inferred fields from client config (e.g., client_id)
|
|
13
|
+
* - Delegates to the same HTTP runtime as the main method
|
|
14
|
+
*/
|
|
15
|
+
export function generateWrapperMethods(
|
|
16
|
+
resolvedOp: ResolvedOperation,
|
|
17
|
+
ctx: EmitterContext,
|
|
18
|
+
modelNames: Set<string>,
|
|
19
|
+
requires: Set<string>,
|
|
20
|
+
): string[] {
|
|
21
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
24
|
+
const body = emitWrapperMethod(resolvedOp.operation, wrapper, ctx, modelNames, requires);
|
|
25
|
+
out.push(body);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Collect response model filenames needed for wrapper imports. */
|
|
31
|
+
export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
|
|
32
|
+
const models = new Set<string>();
|
|
33
|
+
for (const w of resolvedOp.wrappers ?? []) {
|
|
34
|
+
if (w.responseModelName) models.add(w.responseModelName);
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emitWrapperMethod(
|
|
40
|
+
op: Operation,
|
|
41
|
+
wrapper: ResolvedWrapper,
|
|
42
|
+
ctx: EmitterContext,
|
|
43
|
+
modelNames: Set<string>,
|
|
44
|
+
requires: Set<string>,
|
|
45
|
+
): string {
|
|
46
|
+
void requires;
|
|
47
|
+
const method = wrapper.name; // already snake_case
|
|
48
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
|
|
51
|
+
// YARD doc
|
|
52
|
+
lines.push(` # ${formatWrapperDescription(wrapper.name)}.`);
|
|
53
|
+
for (const p of op.pathParams ?? []) {
|
|
54
|
+
const pyType = mapTypeRefForYard(p.type);
|
|
55
|
+
lines.push(` # @param ${safeParamName(p.name)} [${pyType}]`);
|
|
56
|
+
}
|
|
57
|
+
for (const wp of wrapperParams) {
|
|
58
|
+
const type = wp.field ? mapTypeRefForYard(wp.field.type) : 'String';
|
|
59
|
+
const alreadyNilable = type.split(', ').includes('nil');
|
|
60
|
+
const suffix = wp.isOptional && !alreadyNilable ? ', nil' : '';
|
|
61
|
+
lines.push(` # @param ${fieldName(wp.paramName)} [${type}${suffix}]`);
|
|
62
|
+
}
|
|
63
|
+
lines.push(` # @param request_options [Hash] Per-request overrides.`);
|
|
64
|
+
if (wrapper.responseModelName) {
|
|
65
|
+
lines.push(` # @return [WorkOS::${className(wrapper.responseModelName)}]`);
|
|
66
|
+
} else {
|
|
67
|
+
lines.push(` # @return [nil]`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Signature
|
|
71
|
+
const sigParts: string[] = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const p of op.pathParams ?? []) {
|
|
74
|
+
const n = safeParamName(p.name);
|
|
75
|
+
if (seen.has(n)) continue;
|
|
76
|
+
seen.add(n);
|
|
77
|
+
sigParts.push(`${n}:`);
|
|
78
|
+
}
|
|
79
|
+
// Required first, then optional
|
|
80
|
+
for (const wp of wrapperParams) {
|
|
81
|
+
if (wp.isOptional) continue;
|
|
82
|
+
const n = fieldName(wp.paramName);
|
|
83
|
+
if (seen.has(n)) continue;
|
|
84
|
+
seen.add(n);
|
|
85
|
+
sigParts.push(`${n}:`);
|
|
86
|
+
}
|
|
87
|
+
for (const wp of wrapperParams) {
|
|
88
|
+
if (!wp.isOptional) continue;
|
|
89
|
+
const n = fieldName(wp.paramName);
|
|
90
|
+
if (seen.has(n)) continue;
|
|
91
|
+
seen.add(n);
|
|
92
|
+
sigParts.push(`${n}: nil`);
|
|
93
|
+
}
|
|
94
|
+
sigParts.push('request_options: {}');
|
|
95
|
+
|
|
96
|
+
if (sigParts.length === 1 && sigParts[0].length < 60) {
|
|
97
|
+
lines.push(` def ${method}(${sigParts[0]})`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(` def ${method}(`);
|
|
100
|
+
for (let i = 0; i < sigParts.length; i++) {
|
|
101
|
+
const sep = i === sigParts.length - 1 ? '' : ',';
|
|
102
|
+
lines.push(` ${sigParts[i]}${sep}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push(' )');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Body hash
|
|
108
|
+
const bodyEntries: string[] = [];
|
|
109
|
+
for (const [k, v] of Object.entries(wrapper.defaults)) {
|
|
110
|
+
const lit = typeof v === 'string' ? rubyStringLit(v) : String(v);
|
|
111
|
+
bodyEntries.push(`${rubyStringLit(k)} => ${lit}`);
|
|
112
|
+
}
|
|
113
|
+
for (const fc of wrapper.inferFromClient) {
|
|
114
|
+
const clientProp = fc === 'client_secret' ? 'api_key' : fc;
|
|
115
|
+
bodyEntries.push(`${rubyStringLit(fc)} => @client.${clientProp}`);
|
|
116
|
+
}
|
|
117
|
+
for (const wp of wrapperParams) {
|
|
118
|
+
bodyEntries.push(`${rubyStringLit(wp.paramName)} => ${fieldName(wp.paramName)}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(' body = {');
|
|
121
|
+
for (let i = 0; i < bodyEntries.length; i++) {
|
|
122
|
+
const sep = i === bodyEntries.length - 1 ? '' : ',';
|
|
123
|
+
lines.push(` ${bodyEntries[i]}${sep}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push(' }.compact');
|
|
126
|
+
|
|
127
|
+
// Path string — use the unified @client.request helper.
|
|
128
|
+
const rubyPath = interpolateRubyPath(op.path, op.pathParams ?? []);
|
|
129
|
+
const verbSym = op.httpMethod.toLowerCase();
|
|
130
|
+
lines.push(' response = @client.request(');
|
|
131
|
+
lines.push(` method: :${verbSym},`);
|
|
132
|
+
lines.push(` path: ${rubyPath},`);
|
|
133
|
+
lines.push(' auth: true,');
|
|
134
|
+
lines.push(' body: body,');
|
|
135
|
+
lines.push(' request_options: request_options');
|
|
136
|
+
lines.push(' )');
|
|
137
|
+
|
|
138
|
+
// Response handling
|
|
139
|
+
if (wrapper.responseModelName && modelNames.has(wrapper.responseModelName)) {
|
|
140
|
+
lines.push(` WorkOS::${className(wrapper.responseModelName)}.new(response.body)`);
|
|
141
|
+
} else {
|
|
142
|
+
lines.push(' JSON.parse(response.body)');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push(' end');
|
|
146
|
+
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function interpolateRubyPath(path: string, pathParams: Operation['pathParams']): string {
|
|
151
|
+
if (!pathParams || pathParams.length === 0) return `'${path}'`;
|
|
152
|
+
let result = path;
|
|
153
|
+
for (const p of pathParams) {
|
|
154
|
+
result = result.split(`{${p.name}}`).join(`#{WorkOS::Util.encode_path(${safeParamName(p.name)})}`);
|
|
155
|
+
}
|
|
156
|
+
return `"${result}"`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rubyStringLit(s: string): string {
|
|
160
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
161
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Model, Field, TypeRef } from '@workos/oagen';
|
|
1
|
+
import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
import { toSnakeCase } from '@workos/oagen';
|
|
2
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
// @ts-ignore -- js-yaml has no type declarations in this project
|
|
@@ -103,13 +104,80 @@ function lookupRawSchema(name: string): Record<string, any> | null {
|
|
|
103
104
|
return spec?.components?.schemas?.[name] ?? null;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Synthetic model / enum collection
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Accumulator for synthetic models and enums generated from inline
|
|
113
|
+
* definitions encountered during oneOf flattening.
|
|
114
|
+
*/
|
|
115
|
+
interface SyntheticCollector {
|
|
116
|
+
models: Model[];
|
|
117
|
+
enums: Array<{ name: string; values: Array<{ value: string; description?: string }> }>;
|
|
118
|
+
/** Track names already used to avoid duplicates. */
|
|
119
|
+
usedNames: Set<string>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createCollector(): SyntheticCollector {
|
|
123
|
+
return { models: [], enums: [], usedNames: new Set() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Singularize a snake_case name for use as an array-item model name.
|
|
128
|
+
* `redirect_uris` -> `redirect_uri`, `scopes` -> `scope`.
|
|
129
|
+
*/
|
|
130
|
+
function singularizeSnake(name: string): string {
|
|
131
|
+
if (name.endsWith('ies') && name.length > 3) {
|
|
132
|
+
return `${name.slice(0, -3)}y`;
|
|
133
|
+
}
|
|
134
|
+
if (name.endsWith('s') && !name.endsWith('ss')) {
|
|
135
|
+
return name.slice(0, -1);
|
|
136
|
+
}
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// rawSchemaToTypeRef -- with synthetic model/enum generation
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Convert a raw OpenAPI type+format to an IR TypeRef.
|
|
146
|
+
*
|
|
147
|
+
* When `parentModelName` and `fieldName` are provided, inline objects and
|
|
148
|
+
* enums generate synthetic models/enums instead of degrading to `unknown`
|
|
149
|
+
* or `string`.
|
|
150
|
+
*/
|
|
151
|
+
function rawSchemaToTypeRef(
|
|
152
|
+
schema: Record<string, any>,
|
|
153
|
+
parentModelName?: string,
|
|
154
|
+
fName?: string,
|
|
155
|
+
collector?: SyntheticCollector,
|
|
156
|
+
): TypeRef {
|
|
108
157
|
if (schema.const !== undefined) {
|
|
109
158
|
return { kind: 'literal', value: schema.const };
|
|
110
159
|
}
|
|
160
|
+
if (schema.enum && collector && parentModelName && fName) {
|
|
161
|
+
// Generate a synthetic enum
|
|
162
|
+
const syntheticName = `${parentModelName}_${fName}`;
|
|
163
|
+
if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
|
|
164
|
+
collector.usedNames.add(syntheticName);
|
|
165
|
+
collector.enums.push({
|
|
166
|
+
name: syntheticName,
|
|
167
|
+
values: (schema.enum as string[]).map((v: string) => ({
|
|
168
|
+
value: v,
|
|
169
|
+
description: undefined,
|
|
170
|
+
})),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
kind: 'enum',
|
|
175
|
+
name: syntheticName,
|
|
176
|
+
values: schema.enum as string[],
|
|
177
|
+
} as TypeRef;
|
|
178
|
+
}
|
|
111
179
|
if (schema.enum) {
|
|
112
|
-
// Simple string enum -- represent as primitive string
|
|
180
|
+
// Simple string enum -- represent as primitive string (no collector)
|
|
113
181
|
return { kind: 'primitive', type: 'string' } as TypeRef;
|
|
114
182
|
}
|
|
115
183
|
if (schema.$ref) {
|
|
@@ -127,11 +195,39 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
|
|
|
127
195
|
}
|
|
128
196
|
|
|
129
197
|
let ref: TypeRef;
|
|
130
|
-
if (baseType === 'object' && schema.properties) {
|
|
131
|
-
// Inline object --
|
|
198
|
+
if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
|
|
199
|
+
// Inline object -- generate a synthetic model
|
|
200
|
+
const syntheticName = `${parentModelName}_${fName}`;
|
|
201
|
+
if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
|
|
202
|
+
collector.usedNames.add(syntheticName);
|
|
203
|
+
const fields: Field[] = [];
|
|
204
|
+
const requiredSet = new Set<string>(schema.required ?? []);
|
|
205
|
+
for (const [propName, propSchema] of Object.entries(schema.properties) as [string, Record<string, any>][]) {
|
|
206
|
+
fields.push({
|
|
207
|
+
name: propName,
|
|
208
|
+
type: rawSchemaToTypeRef(propSchema, syntheticName, propName, collector),
|
|
209
|
+
required: requiredSet.has(propName),
|
|
210
|
+
description: propSchema.description,
|
|
211
|
+
deprecated: propSchema.deprecated,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
collector.models.push({
|
|
215
|
+
name: syntheticName,
|
|
216
|
+
fields,
|
|
217
|
+
description: schema.description,
|
|
218
|
+
} as Model);
|
|
219
|
+
}
|
|
220
|
+
ref = { kind: 'model', name: syntheticName } as TypeRef;
|
|
221
|
+
} else if (baseType === 'object' && schema.properties) {
|
|
222
|
+
// Inline object -- treat as unknown (no collector)
|
|
132
223
|
ref = { kind: 'primitive', type: 'unknown' } as TypeRef;
|
|
133
224
|
} else if (baseType === 'array' && schema.items) {
|
|
134
|
-
|
|
225
|
+
// For array items that are inline objects, use the singular field name
|
|
226
|
+
const itemFieldName = fName ? singularizeSnake(fName) : undefined;
|
|
227
|
+
ref = {
|
|
228
|
+
kind: 'array',
|
|
229
|
+
items: rawSchemaToTypeRef(schema.items, parentModelName, itemFieldName, collector),
|
|
230
|
+
} as TypeRef;
|
|
135
231
|
} else if (baseType === 'boolean') {
|
|
136
232
|
ref = { kind: 'primitive', type: 'boolean' } as TypeRef;
|
|
137
233
|
} else if (baseType === 'integer' || baseType === 'number') {
|
|
@@ -151,13 +247,17 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
|
|
|
151
247
|
* All fields are returned as optional (not required) since they come from
|
|
152
248
|
* oneOf variants where only one variant is active at a time.
|
|
153
249
|
*/
|
|
154
|
-
function extractFieldsFromRawSchema(
|
|
250
|
+
function extractFieldsFromRawSchema(
|
|
251
|
+
schema: Record<string, any>,
|
|
252
|
+
parentModelName?: string,
|
|
253
|
+
collector?: SyntheticCollector,
|
|
254
|
+
): Field[] {
|
|
155
255
|
const fields: Field[] = [];
|
|
156
256
|
const props = schema.properties ?? {};
|
|
157
257
|
for (const [name, propSchema] of Object.entries(props) as [string, Record<string, any>][]) {
|
|
158
258
|
fields.push({
|
|
159
259
|
name,
|
|
160
|
-
type: rawSchemaToTypeRef(propSchema),
|
|
260
|
+
type: rawSchemaToTypeRef(propSchema, parentModelName, name, collector),
|
|
161
261
|
required: false, // All oneOf variant fields are optional
|
|
162
262
|
description: propSchema.description,
|
|
163
263
|
deprecated: propSchema.deprecated,
|
|
@@ -170,14 +270,18 @@ function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
|
|
|
170
270
|
* Recursively collect all fields from a oneOf schema, flattening nested
|
|
171
271
|
* allOf+oneOf compositions. All fields are marked optional.
|
|
172
272
|
*/
|
|
173
|
-
function collectOneOfFields(
|
|
273
|
+
function collectOneOfFields(
|
|
274
|
+
schema: Record<string, any>,
|
|
275
|
+
parentModelName?: string,
|
|
276
|
+
collector?: SyntheticCollector,
|
|
277
|
+
): Field[] {
|
|
174
278
|
const allFields: Field[] = [];
|
|
175
279
|
const seenFieldNames = new Set<string>();
|
|
176
280
|
|
|
177
281
|
function walkSchema(s: Record<string, any>): void {
|
|
178
282
|
// Direct properties
|
|
179
283
|
if (s.properties) {
|
|
180
|
-
for (const f of extractFieldsFromRawSchema(s)) {
|
|
284
|
+
for (const f of extractFieldsFromRawSchema(s, parentModelName, collector)) {
|
|
181
285
|
if (!seenFieldNames.has(f.name)) {
|
|
182
286
|
seenFieldNames.add(f.name);
|
|
183
287
|
allFields.push(f);
|
|
@@ -208,6 +312,179 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
|
|
|
208
312
|
return allFields;
|
|
209
313
|
}
|
|
210
314
|
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Array-item type upgrade
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check if a TypeRef is `unknown` (the degraded type for inline objects).
|
|
321
|
+
*/
|
|
322
|
+
function isUnknownType(ref: TypeRef): boolean {
|
|
323
|
+
return ref.kind === 'primitive' && (ref as any).type === 'unknown';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* If a field is an `array<unknown>` (or `nullable<array<unknown>>`) and the
|
|
328
|
+
* raw spec defines inline object/enum items, replace the item type with a
|
|
329
|
+
* synthetic model/enum. Returns the original field unchanged when no upgrade
|
|
330
|
+
* is needed.
|
|
331
|
+
*/
|
|
332
|
+
function upgradeArrayItemType(
|
|
333
|
+
field: Field,
|
|
334
|
+
rawSchema: Record<string, any>,
|
|
335
|
+
parentModelName: string,
|
|
336
|
+
collector: SyntheticCollector,
|
|
337
|
+
): Field {
|
|
338
|
+
// Unwrap nullable to find the array
|
|
339
|
+
let arrayRef: TypeRef | null = null;
|
|
340
|
+
let isNullableWrapper = false;
|
|
341
|
+
if (field.type.kind === 'array') {
|
|
342
|
+
arrayRef = field.type;
|
|
343
|
+
} else if (field.type.kind === 'nullable' && field.type.inner.kind === 'array') {
|
|
344
|
+
arrayRef = field.type.inner;
|
|
345
|
+
isNullableWrapper = true;
|
|
346
|
+
}
|
|
347
|
+
if (!arrayRef || arrayRef.kind !== 'array') return field;
|
|
348
|
+
if (!isUnknownType(arrayRef.items)) return field;
|
|
349
|
+
|
|
350
|
+
// Look up the raw spec for this field
|
|
351
|
+
const rawProp = rawSchema.properties?.[field.name];
|
|
352
|
+
if (!rawProp) return field;
|
|
353
|
+
|
|
354
|
+
// Handle the case where the raw property is inside a nullable type array
|
|
355
|
+
let rawArraySchema = rawProp;
|
|
356
|
+
if (Array.isArray(rawProp.type)) {
|
|
357
|
+
const nonNull = rawProp.type.filter((t: string) => t !== 'null');
|
|
358
|
+
if (nonNull[0] === 'array') {
|
|
359
|
+
rawArraySchema = rawProp;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (rawArraySchema.type !== 'array' && !(Array.isArray(rawArraySchema.type) && rawArraySchema.type.includes('array')))
|
|
363
|
+
return field;
|
|
364
|
+
if (!rawArraySchema.items) return field;
|
|
365
|
+
|
|
366
|
+
// Generate a proper TypeRef from the raw items schema
|
|
367
|
+
const itemFieldName = singularizeSnake(field.name);
|
|
368
|
+
const newItemRef = rawSchemaToTypeRef(rawArraySchema.items, parentModelName, itemFieldName, collector);
|
|
369
|
+
if (isUnknownType(newItemRef)) return field;
|
|
370
|
+
|
|
371
|
+
const newArrayRef: TypeRef = { kind: 'array', items: newItemRef } as TypeRef;
|
|
372
|
+
const newType: TypeRef = isNullableWrapper ? ({ kind: 'nullable', inner: newArrayRef } as TypeRef) : newArrayRef;
|
|
373
|
+
|
|
374
|
+
return { ...field, type: newType };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Module-level store for synthetic enums produced during enrichment.
|
|
379
|
+
// Consumed by `getSyntheticEnums()` after `enrichModelsFromSpec` runs.
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
let _lastSyntheticEnums: Enum[] = [];
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Return the synthetic enums generated during the last call to
|
|
385
|
+
* `enrichModelsFromSpec`. Call this after enrichment to merge them into the
|
|
386
|
+
* enum generation phase.
|
|
387
|
+
*/
|
|
388
|
+
export function getSyntheticEnums(): Enum[] {
|
|
389
|
+
return _lastSyntheticEnums;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Implicit discriminator detection
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Find a property name that has a `const` value in ALL oneOf variants.
|
|
398
|
+
* Returns null if no shared const property is found.
|
|
399
|
+
*/
|
|
400
|
+
function findSharedConstProperty(oneOfSchemas: Record<string, any>[]): string | null {
|
|
401
|
+
if (oneOfSchemas.length === 0) return null;
|
|
402
|
+
|
|
403
|
+
const first = oneOfSchemas[0];
|
|
404
|
+
if (!first.properties) return null;
|
|
405
|
+
|
|
406
|
+
// Candidate properties from the first variant that have const values
|
|
407
|
+
const candidates = Object.keys(first.properties).filter((name) => first.properties[name].const !== undefined);
|
|
408
|
+
|
|
409
|
+
// Return the first candidate that has const values in ALL variants
|
|
410
|
+
for (const candidate of candidates) {
|
|
411
|
+
const allHaveConst = oneOfSchemas.every((variant) => variant.properties?.[candidate]?.const !== undefined);
|
|
412
|
+
if (allHaveConst) return candidate;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Build a discriminator mapping from const values to IR model names.
|
|
420
|
+
* For each oneOf variant's const value on `discProperty`, find the IR model
|
|
421
|
+
* whose field with the same name is a Literal type with that value.
|
|
422
|
+
*/
|
|
423
|
+
function buildDiscriminatorMapping(
|
|
424
|
+
discProperty: string,
|
|
425
|
+
oneOfSchemas: Record<string, any>[],
|
|
426
|
+
models: Model[],
|
|
427
|
+
parentModelName: string,
|
|
428
|
+
): Record<string, string> {
|
|
429
|
+
const mapping: Record<string, string> = {};
|
|
430
|
+
|
|
431
|
+
for (const variant of oneOfSchemas) {
|
|
432
|
+
const constValue = variant.properties?.[discProperty]?.const;
|
|
433
|
+
if (constValue === undefined) continue;
|
|
434
|
+
|
|
435
|
+
const variantModel = models.find(
|
|
436
|
+
(m) =>
|
|
437
|
+
m.name !== parentModelName &&
|
|
438
|
+
m.fields.some(
|
|
439
|
+
(f) => f.name === discProperty && f.type.kind === 'literal' && (f.type as any).value === constValue,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
if (variantModel) {
|
|
443
|
+
mapping[String(constValue)] = variantModel.name;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return mapping;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Detect implicit discriminators on models without full oneOf flattening.
|
|
452
|
+
* Returns a new array with discriminator annotations; models without
|
|
453
|
+
* discriminators are returned as-is. Use this when you need discriminator
|
|
454
|
+
* info but don't want the side-effects of full enrichment (synthetic
|
|
455
|
+
* models/enums, field flattening).
|
|
456
|
+
*/
|
|
457
|
+
export function detectDiscriminators(models: Model[]): Model[] {
|
|
458
|
+
const spec = loadRawSpec();
|
|
459
|
+
if (!spec) return models;
|
|
460
|
+
|
|
461
|
+
let changed = false;
|
|
462
|
+
const result = models.map((model) => {
|
|
463
|
+
if ((model as any).discriminator) return model;
|
|
464
|
+
|
|
465
|
+
const rawSchema = lookupRawSchema(model.name);
|
|
466
|
+
if (!rawSchema) return model;
|
|
467
|
+
|
|
468
|
+
const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
|
|
469
|
+
if (!oneOfContainer?.oneOf || oneOfContainer.oneOf.length === 0) return model;
|
|
470
|
+
|
|
471
|
+
const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
|
|
472
|
+
if (!discProperty) return model;
|
|
473
|
+
|
|
474
|
+
const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
|
|
475
|
+
if (Object.keys(mapping).length === 0) return model;
|
|
476
|
+
|
|
477
|
+
changed = true;
|
|
478
|
+
return {
|
|
479
|
+
...model,
|
|
480
|
+
fields: [],
|
|
481
|
+
discriminator: { property: discProperty, mapping },
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return changed ? result : models;
|
|
486
|
+
}
|
|
487
|
+
|
|
211
488
|
/**
|
|
212
489
|
* Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
|
|
213
490
|
*
|
|
@@ -217,28 +494,64 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
|
|
|
217
494
|
* For models whose raw spec schema has allOf containing a oneOf:
|
|
218
495
|
* - Collect the missing variant fields and add them as optional.
|
|
219
496
|
*
|
|
497
|
+
* Inline objects and enums in oneOf branches are promoted to synthetic
|
|
498
|
+
* models/enums instead of degrading to `object` / `string`.
|
|
499
|
+
*
|
|
220
500
|
* Returns a new array of enriched models (original models are not mutated).
|
|
501
|
+
* Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
|
|
221
502
|
*/
|
|
222
503
|
export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
223
504
|
const spec = loadRawSpec();
|
|
224
|
-
if (!spec)
|
|
505
|
+
if (!spec) {
|
|
506
|
+
_lastSyntheticEnums = [];
|
|
507
|
+
return models;
|
|
508
|
+
}
|
|
225
509
|
|
|
226
|
-
|
|
510
|
+
const collector = createCollector();
|
|
511
|
+
// Avoid name collisions with existing models (check both PascalCase and
|
|
512
|
+
// snake_case to prevent synthetic models from shadowing existing ones when
|
|
513
|
+
// they share a file name, e.g. FooBar vs Foo_bar -> foo_bar).
|
|
514
|
+
for (const m of models) {
|
|
515
|
+
collector.usedNames.add(m.name);
|
|
516
|
+
collector.usedNames.add(toSnakeCase(m.name));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const enriched = models.map((model) => {
|
|
227
520
|
const rawSchema = lookupRawSchema(model.name);
|
|
228
521
|
if (!rawSchema) return model;
|
|
229
522
|
|
|
230
523
|
const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
|
|
231
524
|
if (!hasOneOf) return model;
|
|
232
525
|
|
|
233
|
-
// Skip schemas with discriminator -- those are intentional unions
|
|
526
|
+
// Skip schemas with explicit discriminator -- those are intentional unions
|
|
234
527
|
const hasDiscriminator =
|
|
235
528
|
rawSchema.discriminator ||
|
|
236
529
|
rawSchema.oneOf?.some((v: any) => v.discriminator) ||
|
|
237
530
|
rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
|
|
238
531
|
if (hasDiscriminator) return model;
|
|
239
532
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
533
|
+
// Detect implicit discriminators: allOf+oneOf where all variants share a
|
|
534
|
+
// property with const values (e.g., EventSchema with event: const: "user.created").
|
|
535
|
+
// When found, attach a discriminator mapping and clear fields so the emitter
|
|
536
|
+
// generates a dispatcher class instead of a flat dataclass.
|
|
537
|
+
const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
|
|
538
|
+
if (oneOfContainer?.oneOf && oneOfContainer.oneOf.length > 0) {
|
|
539
|
+
const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
|
|
540
|
+
if (discProperty) {
|
|
541
|
+
const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
|
|
542
|
+
if (Object.keys(mapping).length > 0) {
|
|
543
|
+
return {
|
|
544
|
+
...model,
|
|
545
|
+
fields: [],
|
|
546
|
+
discriminator: { property: discProperty, mapping },
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Collect all variant fields from the raw schema, generating synthetic
|
|
553
|
+
// models/enums for inline definitions along the way.
|
|
554
|
+
const variantFields = collectOneOfFields(rawSchema, model.name, collector);
|
|
242
555
|
if (variantFields.length === 0) return model;
|
|
243
556
|
|
|
244
557
|
// Merge variant fields into the existing model, skipping duplicates
|
|
@@ -252,4 +565,32 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
252
565
|
fields: [...model.fields, ...newFields],
|
|
253
566
|
};
|
|
254
567
|
});
|
|
568
|
+
|
|
569
|
+
// Second pass: fix array fields whose items degraded to `unknown` in the
|
|
570
|
+
// IR but are actually inline objects or enums in the raw spec.
|
|
571
|
+
const enriched2 = enriched.map((model) => {
|
|
572
|
+
const rawSchema = lookupRawSchema(model.name);
|
|
573
|
+
if (!rawSchema?.properties) return model;
|
|
574
|
+
|
|
575
|
+
let modified = false;
|
|
576
|
+
const newFields = model.fields.map((field) => {
|
|
577
|
+
const upgraded = upgradeArrayItemType(field, rawSchema, model.name, collector);
|
|
578
|
+
if (upgraded !== field) modified = true;
|
|
579
|
+
return upgraded;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return modified ? { ...model, fields: newFields } : model;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Convert synthetic enum collector entries to proper Enum objects
|
|
586
|
+
_lastSyntheticEnums = collector.enums.map((e) => ({
|
|
587
|
+
name: e.name,
|
|
588
|
+
values: e.values.map((v) => ({ value: v.value, description: v.description })),
|
|
589
|
+
})) as Enum[];
|
|
590
|
+
|
|
591
|
+
// Append synthetic models, skipping those whose snake_case name collides
|
|
592
|
+
// with an existing model (prevents broken TypeAlias self-imports).
|
|
593
|
+
const existingSnakeNames = new Set(enriched2.map((m) => toSnakeCase(m.name)));
|
|
594
|
+
const filteredSynthetic = collector.models.filter((m) => !existingSnakeNames.has(toSnakeCase(m.name)));
|
|
595
|
+
return [...enriched2, ...filteredSynthetic];
|
|
255
596
|
}
|