@workos/oagen-emitters 0.2.1 → 0.3.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/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a PascalCase class name to a human-readable lowercase string,
|
|
7
|
+
* preserving known acronyms instead of splitting them character-by-character.
|
|
8
|
+
*/
|
|
9
|
+
function humanizeClassName(name: string): string {
|
|
10
|
+
// Insert spaces before uppercase runs, but keep acronyms together
|
|
11
|
+
let result = name.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
12
|
+
// Split consecutive uppercase letters from following lowercase: "SSOProvider" -> "SSO Provider"
|
|
13
|
+
result = result.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
14
|
+
return result.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate Python enum class files from IR Enum definitions.
|
|
19
|
+
* Uses `(str, Enum)` for type-safe enum values (Python 3.10+).
|
|
20
|
+
*/
|
|
21
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
22
|
+
if (enums.length === 0) return [];
|
|
23
|
+
|
|
24
|
+
const enumToService = assignEnumsToServices(enums, ctx.spec.services);
|
|
25
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
26
|
+
const resolveDir = (irService: string | undefined) =>
|
|
27
|
+
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
28
|
+
const files: GeneratedFile[] = [];
|
|
29
|
+
const compatAliases = collectCompatEnumAliases(enums, ctx);
|
|
30
|
+
|
|
31
|
+
const aliasOf = collectEnumAliasOf(enums);
|
|
32
|
+
|
|
33
|
+
for (const enumDef of enums) {
|
|
34
|
+
const service = enumToService.get(enumDef.name);
|
|
35
|
+
const dirName = resolveDir(service);
|
|
36
|
+
|
|
37
|
+
// If this enum is an alias for a canonical enum, generate a type alias file
|
|
38
|
+
const canonicalName = aliasOf.get(enumDef.name);
|
|
39
|
+
if (canonicalName) {
|
|
40
|
+
const canonicalService = enumToService.get(canonicalName);
|
|
41
|
+
const canonicalDir = resolveDir(canonicalService);
|
|
42
|
+
const canonicalCls = className(canonicalName);
|
|
43
|
+
const aliasCls = className(enumDef.name);
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
lines.push('from typing_extensions import TypeAlias');
|
|
46
|
+
// Use explicit __all__ to prevent ruff F401 from stripping the re-export
|
|
47
|
+
// Always use direct file import to avoid barrel dependency on the canonical
|
|
48
|
+
if (canonicalDir === dirName) {
|
|
49
|
+
lines.push(`from .${fileName(canonicalName)} import ${canonicalCls}`);
|
|
50
|
+
} else {
|
|
51
|
+
lines.push(
|
|
52
|
+
`from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalCls}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(`${aliasCls}: TypeAlias = ${canonicalCls}`);
|
|
57
|
+
lines.push(`__all__ = ["${aliasCls}"]`);
|
|
58
|
+
files.push({
|
|
59
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
60
|
+
content: lines.join('\n'),
|
|
61
|
+
integrateTarget: true,
|
|
62
|
+
overwriteExisting: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Also generate compat alias files for dedup aliases (they may have compat aliases too)
|
|
66
|
+
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
67
|
+
const importLine =
|
|
68
|
+
canonicalDir === dirName
|
|
69
|
+
? `from .${fileName(canonicalName)} import ${canonicalCls}`
|
|
70
|
+
: `from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalCls}`;
|
|
71
|
+
files.push({
|
|
72
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
73
|
+
content: [
|
|
74
|
+
'from typing_extensions import TypeAlias',
|
|
75
|
+
importLine,
|
|
76
|
+
'',
|
|
77
|
+
`${aliasName}: TypeAlias = ${canonicalCls}`,
|
|
78
|
+
`__all__ = ["${aliasName}"]`,
|
|
79
|
+
].join('\n'),
|
|
80
|
+
integrateTarget: true,
|
|
81
|
+
overwriteExisting: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cls = className(enumDef.name);
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
|
|
91
|
+
const readable = humanizeClassName(enumDef.name);
|
|
92
|
+
lines.push(`"""Enumeration of ${readable} values."""`);
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('from __future__ import annotations');
|
|
95
|
+
lines.push('');
|
|
96
|
+
|
|
97
|
+
if (enumDef.values.length === 0) {
|
|
98
|
+
lines.push('from typing import Union');
|
|
99
|
+
lines.push('from typing_extensions import TypeAlias');
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(`${cls}: TypeAlias = str`);
|
|
102
|
+
} else {
|
|
103
|
+
// Deduplicate values that produce the same string
|
|
104
|
+
const seenValues = new Set<string>();
|
|
105
|
+
const uniqueValues: typeof enumDef.values = [];
|
|
106
|
+
for (const value of enumDef.values) {
|
|
107
|
+
const valueStr = String(value.value);
|
|
108
|
+
if (!seenValues.has(valueStr)) {
|
|
109
|
+
seenValues.add(valueStr);
|
|
110
|
+
uniqueValues.push({ ...value, value: valueStr });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Determine if all values are strings or all integers
|
|
115
|
+
const allStrings = uniqueValues.every((v) => typeof v.value === 'string');
|
|
116
|
+
const allIntegers = uniqueValues.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
|
|
117
|
+
|
|
118
|
+
if (allStrings) {
|
|
119
|
+
lines.push('from enum import Enum');
|
|
120
|
+
lines.push('from typing import Optional');
|
|
121
|
+
lines.push('from typing_extensions import Literal, TypeAlias');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(`class ${cls}(str, Enum):`);
|
|
125
|
+
lines.push(` """Known values for ${cls}."""`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
} else if (allIntegers) {
|
|
128
|
+
lines.push('from enum import IntEnum');
|
|
129
|
+
lines.push('from typing_extensions import Literal, TypeAlias');
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(`class ${cls}(IntEnum):`);
|
|
133
|
+
lines.push(` """Known values for ${cls}."""`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
} else {
|
|
136
|
+
// Mixed types — fall back to Union[Literal[...], str]
|
|
137
|
+
lines.push('from typing import Union');
|
|
138
|
+
lines.push('from typing_extensions import Literal, TypeAlias');
|
|
139
|
+
lines.push('');
|
|
140
|
+
const literals = uniqueValues.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)));
|
|
141
|
+
lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
|
|
142
|
+
files.push({
|
|
143
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
144
|
+
content: lines.join('\n'),
|
|
145
|
+
integrateTarget: true,
|
|
146
|
+
overwriteExisting: true,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const usedNames = new Set<string>();
|
|
152
|
+
for (const v of uniqueValues) {
|
|
153
|
+
let memberName = toUpperSnakeCase(String(v.value));
|
|
154
|
+
if (usedNames.has(memberName)) {
|
|
155
|
+
let suffix = 2;
|
|
156
|
+
while (usedNames.has(`${memberName}_${suffix}`)) suffix++;
|
|
157
|
+
memberName = `${memberName}_${suffix}`;
|
|
158
|
+
}
|
|
159
|
+
usedNames.add(memberName);
|
|
160
|
+
const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
|
|
161
|
+
lines.push(` ${memberName} = ${valueStr}`);
|
|
162
|
+
if (v.description || v.deprecated) {
|
|
163
|
+
const parts: string[] = [];
|
|
164
|
+
if (v.description) parts.push(v.description);
|
|
165
|
+
if (v.deprecated) parts.push('.. deprecated::');
|
|
166
|
+
lines.push(` """${parts.join('\n\n ')}"""`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (allStrings) {
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(' @classmethod');
|
|
172
|
+
lines.push(` def _missing_(cls, value: object) -> Optional["${cls}"]:`);
|
|
173
|
+
lines.push(' if not isinstance(value, str):');
|
|
174
|
+
lines.push(' return None');
|
|
175
|
+
lines.push(' unknown = str.__new__(cls, value)');
|
|
176
|
+
lines.push(' unknown._name_ = value.upper()');
|
|
177
|
+
lines.push(' unknown._value_ = value');
|
|
178
|
+
lines.push(' return unknown');
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push(
|
|
182
|
+
`${cls}Literal: TypeAlias = Literal[${uniqueValues
|
|
183
|
+
.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)))
|
|
184
|
+
.join(', ')}]`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
files.push({
|
|
189
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
190
|
+
content: lines.join('\n'),
|
|
191
|
+
integrateTarget: true,
|
|
192
|
+
overwriteExisting: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
196
|
+
files.push({
|
|
197
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
198
|
+
content: [
|
|
199
|
+
'from typing_extensions import TypeAlias',
|
|
200
|
+
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
201
|
+
'',
|
|
202
|
+
`${aliasName}: TypeAlias = ${cls}`,
|
|
203
|
+
`__all__ = ["${aliasName}"]`,
|
|
204
|
+
].join('\n'),
|
|
205
|
+
integrateTarget: true,
|
|
206
|
+
overwriteExisting: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return files;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function collectCompatEnumAliases(enums: Enum[], ctx: EmitterContext): Map<string, string[]> {
|
|
215
|
+
const aliases = new Map<string, string[]>();
|
|
216
|
+
const irEnumNames = new Set(enums.map((enumDef) => enumDef.name));
|
|
217
|
+
const normalizedHashToEnum = new Map<string, string>();
|
|
218
|
+
|
|
219
|
+
for (const enumDef of enums) {
|
|
220
|
+
normalizedHashToEnum.set(enumValueHash(enumDef), enumDef.name);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const baselineEnum of Object.values(ctx.apiSurface?.enums ?? {})) {
|
|
224
|
+
if (irEnumNames.has(baselineEnum.name)) continue;
|
|
225
|
+
const hash = Object.values(baselineEnum.members)
|
|
226
|
+
.map((value) => String(value))
|
|
227
|
+
.sort()
|
|
228
|
+
.join('|');
|
|
229
|
+
const target = normalizedHashToEnum.get(hash);
|
|
230
|
+
if (!target) continue;
|
|
231
|
+
if (!aliases.has(target)) aliases.set(target, []);
|
|
232
|
+
aliases.get(target)!.push(baselineEnum.name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return aliases;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
|
|
239
|
+
const hashGroups = new Map<string, string[]>();
|
|
240
|
+
for (const enumDef of enums) {
|
|
241
|
+
const hash = [...enumDef.values]
|
|
242
|
+
.map((v) => String(v.value))
|
|
243
|
+
.sort()
|
|
244
|
+
.join('|');
|
|
245
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
246
|
+
hashGroups.get(hash)!.push(enumDef.name);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const aliasOf = new Map<string, string>();
|
|
250
|
+
for (const [, names] of hashGroups) {
|
|
251
|
+
if (names.length <= 1) continue;
|
|
252
|
+
const sorted = [...names].sort();
|
|
253
|
+
const canonical = sorted[0];
|
|
254
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
255
|
+
aliasOf.set(sorted[i], canonical);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return aliasOf;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function collectGeneratedEnumSymbolsByDir(enums: Enum[], ctx: EmitterContext): Map<string, string[]> {
|
|
262
|
+
const enumToService = assignEnumsToServices(enums, ctx.spec.services);
|
|
263
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
264
|
+
const resolveDir = (irService: string | undefined) =>
|
|
265
|
+
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
266
|
+
const compatAliases = collectCompatEnumAliases(enums, ctx);
|
|
267
|
+
const symbolsByDir = new Map<string, string[]>();
|
|
268
|
+
|
|
269
|
+
for (const enumDef of enums) {
|
|
270
|
+
const service = enumToService.get(enumDef.name);
|
|
271
|
+
const dirName = resolveDir(service);
|
|
272
|
+
if (!symbolsByDir.has(dirName)) symbolsByDir.set(dirName, []);
|
|
273
|
+
symbolsByDir.get(dirName)!.push(enumDef.name);
|
|
274
|
+
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
275
|
+
symbolsByDir.get(dirName)!.push(aliasName);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return symbolsByDir;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function enumValueHash(enumDef: Enum): string {
|
|
283
|
+
return [...enumDef.values]
|
|
284
|
+
.map((value) => String(value.value))
|
|
285
|
+
.sort()
|
|
286
|
+
.join('|');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
|
|
290
|
+
const enumToService = new Map<string, string>();
|
|
291
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
292
|
+
|
|
293
|
+
for (const service of services) {
|
|
294
|
+
for (const op of service.operations) {
|
|
295
|
+
const refs = new Set<string>();
|
|
296
|
+
const collect = (ref: any) => {
|
|
297
|
+
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
298
|
+
};
|
|
299
|
+
if (op.requestBody) collect(op.requestBody);
|
|
300
|
+
collect(op.response);
|
|
301
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
302
|
+
collect(p.type);
|
|
303
|
+
}
|
|
304
|
+
for (const name of refs) {
|
|
305
|
+
if (enumNames.has(name) && !enumToService.has(name)) {
|
|
306
|
+
enumToService.set(name, service.name);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return enumToService;
|
|
313
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
import { fileName, fieldName } from './naming.js';
|
|
4
|
+
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prefix mapping for generating realistic ID fixture values.
|
|
8
|
+
*/
|
|
9
|
+
export const ID_PREFIXES: Record<string, string> = {
|
|
10
|
+
Connection: 'conn_',
|
|
11
|
+
Organization: 'org_',
|
|
12
|
+
OrganizationMembership: 'om_',
|
|
13
|
+
User: 'user_',
|
|
14
|
+
Directory: 'directory_',
|
|
15
|
+
DirectoryGroup: 'dir_grp_',
|
|
16
|
+
DirectoryUser: 'dir_usr_',
|
|
17
|
+
Invitation: 'inv_',
|
|
18
|
+
Session: 'session_',
|
|
19
|
+
AuthenticationFactor: 'auth_factor_',
|
|
20
|
+
EmailVerification: 'email_verification_',
|
|
21
|
+
MagicAuth: 'magic_auth_',
|
|
22
|
+
PasswordReset: 'password_reset_',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate JSON fixture files for test data.
|
|
27
|
+
*/
|
|
28
|
+
export function generateFixtures(spec: {
|
|
29
|
+
models: Model[];
|
|
30
|
+
enums: Enum[];
|
|
31
|
+
services: any[];
|
|
32
|
+
}): { path: string; content: string }[] {
|
|
33
|
+
if (spec.models.length === 0) return [];
|
|
34
|
+
|
|
35
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
36
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
37
|
+
const files: { path: string; content: string }[] = [];
|
|
38
|
+
|
|
39
|
+
for (const model of spec.models) {
|
|
40
|
+
if (isListMetadataModel(model)) continue;
|
|
41
|
+
if (isListWrapperModel(model)) continue;
|
|
42
|
+
// Skip models with no fields — these are typically discriminated unions
|
|
43
|
+
// with hand-maintained @oagen-ignore overrides; generated empty fixtures
|
|
44
|
+
// would not match the override's required fields.
|
|
45
|
+
if (model.fields.length === 0) continue;
|
|
46
|
+
|
|
47
|
+
const fixture = generateModelFixture(model, modelMap, enumMap);
|
|
48
|
+
|
|
49
|
+
files.push({
|
|
50
|
+
path: `tests/fixtures/${fileName(model.name)}.json`,
|
|
51
|
+
content: JSON.stringify(fixture, null, 2),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Generate list fixtures for paginated responses
|
|
56
|
+
for (const service of spec.services) {
|
|
57
|
+
for (const op of service.operations) {
|
|
58
|
+
if (op.pagination) {
|
|
59
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
60
|
+
if (itemModel) {
|
|
61
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
62
|
+
if (unwrapped) itemModel = unwrapped;
|
|
63
|
+
if (itemModel.fields.length === 0) continue;
|
|
64
|
+
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
65
|
+
const listFixture = {
|
|
66
|
+
data: [fixture],
|
|
67
|
+
list_metadata: {
|
|
68
|
+
before: null,
|
|
69
|
+
after: null,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
files.push({
|
|
73
|
+
path: `tests/fixtures/list_${fileName(itemModel.name)}.json`,
|
|
74
|
+
content: JSON.stringify(listFixture, null, 2),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
85
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
86
|
+
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
87
|
+
if (dataField && hasListMetadata && dataField.type.kind === 'array') {
|
|
88
|
+
const itemType = dataField.type.items;
|
|
89
|
+
if (itemType.kind === 'model') {
|
|
90
|
+
return modelMap.get(itemType.name) ?? null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function generateModelFixture(
|
|
97
|
+
model: Model,
|
|
98
|
+
modelMap: Map<string, Model>,
|
|
99
|
+
enumMap: Map<string, Enum>,
|
|
100
|
+
): Record<string, any> {
|
|
101
|
+
const fixture: Record<string, any> = {};
|
|
102
|
+
|
|
103
|
+
// Deduplicate fields by snake_case name (matching model generation in models.ts)
|
|
104
|
+
const seenFieldNames = new Set<string>();
|
|
105
|
+
const deduplicatedFields = model.fields.filter((f) => {
|
|
106
|
+
const pyName = fieldName(f.name);
|
|
107
|
+
if (seenFieldNames.has(pyName)) return false;
|
|
108
|
+
seenFieldNames.add(pyName);
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
for (const field of deduplicatedFields) {
|
|
113
|
+
// Use the original field name as the wire key (matches from_dict access patterns)
|
|
114
|
+
const wireName = field.name;
|
|
115
|
+
if (field.example !== undefined) {
|
|
116
|
+
fixture[wireName] = field.example;
|
|
117
|
+
} else {
|
|
118
|
+
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return fixture;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function generateFieldValue(
|
|
126
|
+
ref: TypeRef,
|
|
127
|
+
fieldName: string,
|
|
128
|
+
modelName: string,
|
|
129
|
+
modelMap: Map<string, Model>,
|
|
130
|
+
enumMap: Map<string, Enum>,
|
|
131
|
+
): any {
|
|
132
|
+
switch (ref.kind) {
|
|
133
|
+
case 'primitive':
|
|
134
|
+
return generatePrimitiveValue(ref.type, ref.format, fieldName, modelName);
|
|
135
|
+
case 'literal':
|
|
136
|
+
return ref.value;
|
|
137
|
+
case 'enum': {
|
|
138
|
+
const e = enumMap.get(ref.name);
|
|
139
|
+
return e?.values[0]?.value ?? 'unknown';
|
|
140
|
+
}
|
|
141
|
+
case 'model': {
|
|
142
|
+
const nested = modelMap.get(ref.name);
|
|
143
|
+
if (nested) return generateModelFixture(nested, modelMap, enumMap);
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
case 'array': {
|
|
147
|
+
if (ref.items.kind === 'enum') {
|
|
148
|
+
const e = enumMap.get(ref.items.name);
|
|
149
|
+
if (e && e.values.length > 0) {
|
|
150
|
+
return e.values.map((v) => v.value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const item = generateFieldValue(ref.items, fieldName, modelName, modelMap, enumMap);
|
|
154
|
+
return [item];
|
|
155
|
+
}
|
|
156
|
+
case 'nullable':
|
|
157
|
+
return generateFieldValue(ref.inner, fieldName, modelName, modelMap, enumMap);
|
|
158
|
+
case 'union':
|
|
159
|
+
if (ref.variants.length > 0) {
|
|
160
|
+
return generateFieldValue(ref.variants[0], fieldName, modelName, modelMap, enumMap);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
case 'map':
|
|
164
|
+
return {
|
|
165
|
+
key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
171
|
+
switch (type) {
|
|
172
|
+
case 'string':
|
|
173
|
+
if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
174
|
+
if (format === 'date') return '2023-01-01';
|
|
175
|
+
if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
176
|
+
if (name === 'id') {
|
|
177
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
178
|
+
return `${prefix}01234`;
|
|
179
|
+
}
|
|
180
|
+
if (name.includes('id')) return `${name}_01234`;
|
|
181
|
+
if (name.includes('email')) return 'test@example.com';
|
|
182
|
+
if (name.includes('url') || name.includes('uri')) return 'https://example.com';
|
|
183
|
+
if (name.includes('name')) return 'Test';
|
|
184
|
+
return `test_${name}`;
|
|
185
|
+
case 'integer':
|
|
186
|
+
return 1;
|
|
187
|
+
case 'number':
|
|
188
|
+
return 1.0;
|
|
189
|
+
case 'boolean':
|
|
190
|
+
return true;
|
|
191
|
+
case 'unknown':
|
|
192
|
+
return {};
|
|
193
|
+
default:
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { generateModels } from './models.js';
|
|
15
|
+
import { generateEnums } from './enums.js';
|
|
16
|
+
import { generateResources } from './resources.js';
|
|
17
|
+
import { generateClient } from './client.js';
|
|
18
|
+
import { generateTests } from './tests.js';
|
|
19
|
+
import { generateManifest } from './manifest.js';
|
|
20
|
+
/** Ensure every generated file's content ends with a trailing newline. */
|
|
21
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
22
|
+
for (const f of files) {
|
|
23
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
24
|
+
f.content += '\n';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return files;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const pythonEmitter: Emitter = {
|
|
31
|
+
language: 'python',
|
|
32
|
+
|
|
33
|
+
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
+
return ensureTrailingNewlines(generateModels(models, ctx));
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
38
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
42
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
generateErrors(): GeneratedFile[] {
|
|
50
|
+
// _errors.py is now hand-maintained in the target SDK (@oagen-ignore-file)
|
|
51
|
+
return [];
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
generateTypeSignatures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
55
|
+
void spec;
|
|
56
|
+
void ctx;
|
|
57
|
+
// Python uses inline type annotations — no separate type signature files needed
|
|
58
|
+
return [];
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
62
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
66
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
fileHeader(): string {
|
|
70
|
+
return '# This file is auto-generated by oagen. Do not edit.';
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
formatCommand(targetDir: string): FormatCommand | null {
|
|
74
|
+
const hasRuff =
|
|
75
|
+
fs.existsSync(path.join(targetDir, 'ruff.toml')) || fs.existsSync(path.join(targetDir, '.ruff.toml'));
|
|
76
|
+
// Also check pyproject.toml for [tool.ruff] section
|
|
77
|
+
const pyproject = path.join(targetDir, 'pyproject.toml');
|
|
78
|
+
const hasRuffInPyproject = fs.existsSync(pyproject) && fs.readFileSync(pyproject, 'utf8').includes('[tool.ruff]');
|
|
79
|
+
// Check for noxfile that uses ruff
|
|
80
|
+
const noxfile = path.join(targetDir, 'noxfile.py');
|
|
81
|
+
const hasRuffInNox = fs.existsSync(noxfile) && fs.readFileSync(noxfile, 'utf8').includes('ruff');
|
|
82
|
+
|
|
83
|
+
if (hasRuff || hasRuffInPyproject || hasRuffInNox) {
|
|
84
|
+
return {
|
|
85
|
+
cmd: 'bash',
|
|
86
|
+
args: [
|
|
87
|
+
'-c',
|
|
88
|
+
'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$"); [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff format --quiet',
|
|
89
|
+
'--',
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { resolveMethodName } from './naming.js';
|
|
3
|
+
import { buildServiceAccessPaths } from './client.js';
|
|
4
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate smoke test manifest mapping HTTP operations to SDK methods.
|
|
8
|
+
*/
|
|
9
|
+
export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
10
|
+
const manifest: Record<string, { sdkMethod: string; service: string }> = {};
|
|
11
|
+
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
12
|
+
|
|
13
|
+
for (const service of spec.services) {
|
|
14
|
+
// For mounted services, look up the mount target's access path
|
|
15
|
+
let propName = accessPaths.get(service.name);
|
|
16
|
+
if (!propName) {
|
|
17
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
18
|
+
propName = accessPaths.get(mountTarget);
|
|
19
|
+
}
|
|
20
|
+
if (!propName) {
|
|
21
|
+
throw new Error(`Missing public client access path for service ${service.name}`);
|
|
22
|
+
}
|
|
23
|
+
for (const op of service.operations) {
|
|
24
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
25
|
+
const method = resolveMethodName(op, service, ctx);
|
|
26
|
+
manifest[httpKey] = { sdkMethod: method, service: propName };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
path: 'smoke-manifest.json',
|
|
33
|
+
content: JSON.stringify(manifest, null, 2),
|
|
34
|
+
integrateTarget: false,
|
|
35
|
+
overwriteExisting: true,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|