@workos/oagen-emitters 0.3.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.
Files changed (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -50,8 +50,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
50
50
  lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
51
51
  lines.push('');
52
52
 
53
+ // Build resolved lookup early — used by both imports and method generation
54
+ const resolvedLookup = buildResolvedLookup(ctx);
55
+
53
56
  // Collect imports
54
- const imports = collectImports(mergedService, ctx);
57
+ const imports = collectImports(mergedService, ctx, resolvedLookup);
55
58
  for (const imp of imports) {
56
59
  lines.push(`use ${imp};`);
57
60
  }
@@ -66,7 +69,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
66
69
 
67
70
  // Track emitted method names to avoid duplicates
68
71
  const emittedMethods = new Set<string>();
69
- const resolvedLookup = buildResolvedLookup(ctx);
70
72
  for (const op of operations) {
71
73
  const method = resolveMethodName(op, mergedService, ctx);
72
74
  if (emittedMethods.has(method)) continue;
@@ -94,6 +96,29 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
94
96
  return files;
95
97
  }
96
98
 
99
+ /**
100
+ * Check if an operation is a redirect endpoint that should construct a URL
101
+ * instead of making an HTTP request.
102
+ *
103
+ * Detection: GET endpoints with no response body (primitive unknown) and query
104
+ * params are redirect endpoints (e.g., SSO/OAuth authorize and logout flows).
105
+ * Also respects an explicit urlBuilder flag on the resolved operation and
106
+ * catches endpoints with 302 success responses.
107
+ */
108
+ export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation): boolean {
109
+ if ((resolvedOp as any)?.urlBuilder) return true;
110
+ if ((op as any).successResponses?.some((r: any) => r.statusCode >= 300 && r.statusCode < 400)) return true;
111
+ if (
112
+ op.httpMethod === 'get' &&
113
+ op.response.kind === 'primitive' &&
114
+ (op.response as any).type === 'unknown' &&
115
+ op.queryParams.length > 0
116
+ ) {
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+
97
122
  function generateMethod(
98
123
  lines: string[],
99
124
  op: Operation,
@@ -112,8 +137,9 @@ function generateMethod(
112
137
  ...getOpInferFromClient(resolvedOp),
113
138
  ]);
114
139
 
140
+ const isRedirect = isRedirectEndpoint(op, resolvedOp);
115
141
  const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
116
- const returnType = getReturnType(plan, ctx);
142
+ const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
117
143
 
118
144
  // PHPDoc block
119
145
  const docParts: string[] = [];
@@ -183,6 +209,16 @@ function generateMethod(
183
209
  docParts.push(`@return ${returnType}`);
184
210
  }
185
211
 
212
+ // @throws — scope to what the method actually calls
213
+ if (!isRedirect) {
214
+ // HTTP methods can throw any WorkOSException (config, transport, API response)
215
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\WorkOSException`);
216
+ } else if (getOpInferFromClient(resolvedOp).length > 0) {
217
+ // Redirect endpoints that inject client fields can throw ConfigurationException
218
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\ConfigurationException`);
219
+ }
220
+ // Redirect endpoints with no inferFromClient: buildUrl() is pure, no @throws
221
+
186
222
  if (op.deprecated) docParts.push('@deprecated');
187
223
  lines.push(...phpDocComment(docParts.join('\n'), 4));
188
224
 
@@ -198,7 +234,41 @@ function generateMethod(
198
234
  const httpMethod = op.httpMethod.toUpperCase();
199
235
  const path = buildPathString(op);
200
236
 
201
- if (plan.isPaginated) {
237
+ if (isRedirect) {
238
+ // Redirect endpoint: construct URL client-side instead of making HTTP request
239
+ const queryLines = buildQueryArray(op, hiddenParams);
240
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
241
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
242
+ const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
243
+
244
+ if (needsQuery) {
245
+ const hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
246
+ if (hasOptionalQuery) {
247
+ lines.push(' $query = array_filter([');
248
+ } else {
249
+ lines.push(' $query = [');
250
+ }
251
+ for (const q of queryLines) {
252
+ lines.push(` ${q}`);
253
+ }
254
+ // Inject constant defaults
255
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
256
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
257
+ }
258
+ if (hasOptionalQuery) {
259
+ lines.push(' ], fn ($v) => $v !== null);');
260
+ } else {
261
+ lines.push(' ];');
262
+ }
263
+ // Inject fields from client config
264
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
265
+ lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
266
+ }
267
+ lines.push(` return $this->client->buildUrl(${path}, $query, $options);`);
268
+ } else {
269
+ lines.push(` return $this->client->buildUrl(${path}, [], $options);`);
270
+ }
271
+ } else if (plan.isPaginated) {
202
272
  const queryLines = buildQueryArray(op);
203
273
  if (queryLines.length > 0) {
204
274
  lines.push(' $query = array_filter([');
@@ -534,13 +604,18 @@ function clientFieldExpression(field: string): string {
534
604
  }
535
605
  }
536
606
 
537
- function collectImports(service: Service, ctx: EmitterContext): string[] {
607
+ function collectImports(
608
+ service: Service,
609
+ ctx: EmitterContext,
610
+ resolvedLookup?: Map<string, ResolvedOperation>,
611
+ ): string[] {
538
612
  const imports = new Set<string>();
539
613
  const ns = ctx.namespacePascal;
540
614
 
541
615
  for (const op of service.operations) {
542
616
  const plan = planOperation(op);
543
- if (plan.responseModelName && !plan.isPaginated) {
617
+ const resolved = resolvedLookup ? lookupResolved(op, resolvedLookup) : undefined;
618
+ if (plan.responseModelName && !plan.isPaginated && !isRedirectEndpoint(op, resolved)) {
544
619
  imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
545
620
  }
546
621
  if (op.pagination?.itemType.kind === 'model') {
package/src/php/tests.ts CHANGED
@@ -7,12 +7,19 @@ import type {
7
7
  Model,
8
8
  ResolvedOperation,
9
9
  } from '@workos/oagen';
10
- import { planOperation, toCamelCase } from '@workos/oagen';
10
+ import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
11
11
  import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
12
12
  import { isListWrapperModel } from './models.js';
13
13
  import { generateFixtures } from './fixtures.js';
14
- import { getMountTarget, groupByMount, buildHiddenParams } from '../shared/resolved-ops.js';
14
+ import {
15
+ getMountTarget,
16
+ groupByMount,
17
+ buildHiddenParams,
18
+ getOpDefaults,
19
+ getOpInferFromClient,
20
+ } from '../shared/resolved-ops.js';
15
21
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
22
+ import { isRedirectEndpoint } from './resources.js';
16
23
 
17
24
  /**
18
25
  * Generate PHPUnit test files and fixture JSON files.
@@ -20,16 +27,6 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
20
27
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
21
28
  const files: GeneratedFile[] = [];
22
29
 
23
- // Generate fixture JSON files
24
- const fixtures = generateFixtures(spec);
25
- for (const fixture of fixtures) {
26
- files.push({
27
- path: fixture.path,
28
- content: fixture.content,
29
- headerPlacement: 'skip',
30
- });
31
- }
32
-
33
30
  // TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
34
31
 
35
32
  // Collect all operations per mount target using resolved per-operation mounts.
@@ -72,6 +69,16 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
72
69
  overwriteExisting: true,
73
70
  });
74
71
 
72
+ // Generate fixture JSON files
73
+ const fixtures = generateFixtures(spec);
74
+ for (const fixture of fixtures) {
75
+ files.push({
76
+ path: fixture.path,
77
+ content: fixture.content,
78
+ headerPlacement: 'skip',
79
+ });
80
+ }
81
+
75
82
  return files;
76
83
  }
77
84
 
@@ -146,6 +153,17 @@ function generateMountGroupTest(
146
153
  lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
147
154
  // Query string serialization assertions
148
155
  emitQueryAssertions(lines, op, ctx, hidden);
156
+ } else if (isRedirectEndpoint(op, resolvedOp)) {
157
+ // Redirect endpoint: URL is built locally, no HTTP request made.
158
+ // Pass all params (including optional) to verify they appear in the URL.
159
+ lines.push(' $client = $this->createMockClient([]);');
160
+ lines.push(
161
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
162
+ );
163
+ lines.push(' $this->assertIsString($result);');
164
+ lines.push(` $this->assertStringContainsString('${expectedPath}', $result);`);
165
+ // Query param assertions for the generated URL
166
+ emitRedirectQueryAssertions(lines, op, ctx, hidden, resolvedOp);
149
167
  } else if (plan.responseModelName) {
150
168
  const modelName = className(plan.responseModelName);
151
169
  const fixtureName = `${snakeName(plan.responseModelName)}`;
@@ -363,11 +381,8 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
363
381
  const e = ctx.spec.enums.find((en) => en.name === ref.name);
364
382
  if (e && e.values.length > 0) {
365
383
  const enumClass = enumClassName(ref.name);
366
- const caseName = String(e.values[0].name)
367
- .split(/[_\s-]+/)
368
- .filter(Boolean)
369
- .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
370
- .join('');
384
+ // Must match the case-name logic in enums.ts
385
+ const caseName = toPascalCase(String(e.values[0].name).toLowerCase());
371
386
  return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
372
387
  }
373
388
  }
@@ -375,6 +390,13 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
375
390
  }
376
391
  case 'array':
377
392
  return '[]';
393
+ case 'map':
394
+ return '[]';
395
+ case 'nullable':
396
+ return generateTestValue(
397
+ (ref as unknown as { inner: { kind: string; type?: string; name?: string } }).inner,
398
+ ctx,
399
+ );
378
400
  case 'model': {
379
401
  if (ref.name) {
380
402
  const modelClass = className(ref.name);
@@ -499,6 +521,60 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
499
521
  }
500
522
  }
501
523
 
524
+ /**
525
+ * Emit query param assertions for redirect endpoint URLs.
526
+ * Parses the query string from the built URL and asserts visible params,
527
+ * hidden defaults (e.g., response_type), and inferred client fields (e.g., client_id).
528
+ */
529
+ function emitRedirectQueryAssertions(
530
+ lines: string[],
531
+ op: Operation,
532
+ ctx: EmitterContext,
533
+ hidden: Set<string>,
534
+ resolvedOp?: ResolvedOperation,
535
+ ): void {
536
+ const hasVisibleQueryParams = op.queryParams.some((q) => !hidden.has(q.name));
537
+ const defaults = getOpDefaults(resolvedOp);
538
+ const inferred = getOpInferFromClient(resolvedOp);
539
+ if (!hasVisibleQueryParams && Object.keys(defaults).length === 0 && inferred.length === 0) return;
540
+
541
+ lines.push(" parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);");
542
+
543
+ // Assert visible query params (same logic as emitQueryAssertions but reading from $query parsed from URL)
544
+ for (const q of op.queryParams) {
545
+ if (hidden.has(q.name)) continue;
546
+ const innerType =
547
+ q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
548
+ if (innerType.kind === 'enum' && innerType.name) {
549
+ const e = ctx.spec.enums.find((en) => en.name === innerType.name);
550
+ if (e && e.values.length > 0) {
551
+ lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
552
+ }
553
+ } else if (innerType.kind === 'primitive') {
554
+ switch (innerType.type) {
555
+ case 'string':
556
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
557
+ break;
558
+ case 'integer':
559
+ case 'number':
560
+ case 'boolean':
561
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
562
+ break;
563
+ }
564
+ }
565
+ }
566
+
567
+ // Assert hidden defaults (e.g., response_type => 'code')
568
+ for (const [key, value] of Object.entries(defaults)) {
569
+ lines.push(` $this->assertSame('${value}', $query['${key}']);`);
570
+ }
571
+
572
+ // Assert inferred client fields are present (e.g., client_id)
573
+ for (const key of inferred) {
574
+ lines.push(` $this->assertArrayHasKey('${key}', $query);`);
575
+ }
576
+ }
577
+
502
578
  /**
503
579
  * Emit body field assertions for POST/PUT/PATCH operations.
504
580
  * Only asserts primitive required fields (strings, numbers, booleans).
@@ -38,6 +38,7 @@ function emitWrapperMethod(
38
38
  const op2 = resolvedOp.operation;
39
39
  const returnDocType = op2.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op2.response.name)}` : 'mixed';
40
40
  docParts.push(`@return ${returnDocType}`);
41
+ docParts.push(`@throws \\${ns}\\Exception\\WorkOSException`);
41
42
  lines.push(...phpDocComment(docParts.join('\n'), 4));
42
43
 
43
44
  lines.push(` public function ${method}(`);
@@ -7,17 +7,11 @@ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
7
 
8
8
  /** Python-specific wiring for each non-spec service. */
9
9
  interface PythonNonSpecWiring {
10
- /** Python import line (e.g. "from .vault import Vault, AsyncVault") */
11
10
  importLine: string;
12
- /** Property name on the client class */
13
11
  prop: string;
14
- /** Sync class name */
15
12
  syncClass: string;
16
- /** Async class name, or null if no async variant */
17
13
  asyncClass: string | null;
18
- /** Constructor expression — 'self' if the class takes the client, '' if stateless */
19
14
  ctorArg: 'self' | '';
20
- /** One-line docstring for the client property */
21
15
  docstring?: string;
22
16
  }
23
17
 
@@ -159,13 +153,25 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
159
153
  const resolvedName = resolveResourceClassName(service, ctx);
160
154
  const clsName = className(resolvedName);
161
155
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
162
- lines.push(`from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`);
156
+ const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
157
+ if (importLine.length > 88) {
158
+ lines.push(`from .${dirToModule(dirName)}._resource import (`);
159
+ lines.push(` ${clsName},`);
160
+ lines.push(` Async${clsName},`);
161
+ lines.push(')');
162
+ } else {
163
+ lines.push(importLine);
164
+ }
163
165
  }
164
- // Non-spec service imports (driven by shared/non-spec-services.ts)
166
+ // Non-spec service imports wrapped in ignore markers so the merger
167
+ // matches them positionally and doesn't displace them.
168
+ lines.push('');
169
+ lines.push('# @oagen-ignore-start — non-spec service imports (hand-maintained)');
165
170
  for (const s of NON_SPEC_SERVICES) {
166
171
  const w = PYTHON_NON_SPEC_WIRING[s.id];
167
172
  if (w) lines.push(w.importLine);
168
173
  }
174
+ lines.push('# @oagen-ignore-end');
169
175
  lines.push('');
170
176
  lines.push('');
171
177
 
@@ -188,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
188
194
  generatedProps.add(prop);
189
195
  }
190
196
  emitCompatClientPropertyAliases(lines, generatedProps, false);
191
- emitCompatClientAccessors(lines, false);
197
+ emitNonSpecClientAccessors(lines, false);
192
198
 
193
199
  lines.push('');
194
200
  lines.push('');
@@ -211,18 +217,38 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
211
217
  asyncGeneratedProps.add(prop);
212
218
  }
213
219
  emitCompatClientPropertyAliases(lines, asyncGeneratedProps, true);
214
- emitCompatClientAccessors(lines, true);
220
+ emitNonSpecClientAccessors(lines, true);
215
221
 
216
222
  return [
217
223
  {
218
224
  path: `src/${ctx.namespace}/_client.py`,
219
225
  content: lines.join('\n'),
220
- integrateTarget: true,
221
226
  overwriteExisting: true,
222
227
  },
223
228
  ];
224
229
  }
225
230
 
231
+ function emitNonSpecClientAccessors(lines: string[], isAsync: boolean): void {
232
+ lines.push('');
233
+ lines.push(' # @oagen-ignore-start — non-spec service accessors (hand-maintained)');
234
+ for (const s of NON_SPEC_SERVICES) {
235
+ const w = PYTHON_NON_SPEC_WIRING[s.id];
236
+ if (!w) continue;
237
+ const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
238
+ const arg = w.ctorArg === 'self' ? 'self' : '';
239
+
240
+ lines.push('');
241
+ lines.push(' @functools.cached_property');
242
+ lines.push(` def ${w.prop}(self) -> ${typeName}:`);
243
+ if (w.docstring) {
244
+ lines.push(` """${w.docstring}"""`);
245
+ }
246
+ lines.push(` return ${typeName}(${arg})`);
247
+ }
248
+ lines.push('');
249
+ lines.push(' # @oagen-ignore-end');
250
+ }
251
+
226
252
  function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
227
253
  const files: GeneratedFile[] = [];
228
254
  const topLevel = deduplicateByMount(spec.services, ctx);
@@ -274,24 +300,6 @@ function emitCompatClientPropertyAliases(lines: string[], generatedProps: Set<st
274
300
  }
275
301
  }
276
302
 
277
- function emitCompatClientAccessors(lines: string[], isAsync: boolean): void {
278
- for (const s of NON_SPEC_SERVICES) {
279
- const w = PYTHON_NON_SPEC_WIRING[s.id];
280
- if (!w) continue;
281
- // Skip async-only services when emitting the sync client, and vice versa
282
- const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
283
- const arg = w.ctorArg === 'self' ? 'self' : '';
284
-
285
- lines.push('');
286
- lines.push(' @functools.cached_property');
287
- lines.push(` def ${w.prop}(self) -> ${typeName}:`);
288
- if (w.docstring) {
289
- lines.push(` """${w.docstring}"""`);
290
- }
291
- lines.push(` return ${typeName}(${arg})`);
292
- }
293
- }
294
-
295
303
  /**
296
304
  * Generate types/<service>/__init__.py re-export barrels so that
297
305
  * `from workos.types.<service> import Model` continues to work.
@@ -42,7 +42,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
42
42
  const canonicalCls = className(canonicalName);
43
43
  const aliasCls = className(enumDef.name);
44
44
  const lines: string[] = [];
45
- lines.push('from typing_extensions import TypeAlias');
45
+ lines.push('from typing import TypeAlias');
46
46
  // Use explicit __all__ to prevent ruff F401 from stripping the re-export
47
47
  // Always use direct file import to avoid barrel dependency on the canonical
48
48
  if (canonicalDir === dirName) {
@@ -71,7 +71,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
71
71
  files.push({
72
72
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
73
73
  content: [
74
- 'from typing_extensions import TypeAlias',
74
+ 'from typing import TypeAlias',
75
75
  importLine,
76
76
  '',
77
77
  `${aliasName}: TypeAlias = ${canonicalCls}`,
@@ -96,7 +96,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
96
 
97
97
  if (enumDef.values.length === 0) {
98
98
  lines.push('from typing import Union');
99
- lines.push('from typing_extensions import TypeAlias');
99
+ lines.push('from typing import TypeAlias');
100
100
  lines.push('');
101
101
  lines.push(`${cls}: TypeAlias = str`);
102
102
  } else {
@@ -118,7 +118,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
118
118
  if (allStrings) {
119
119
  lines.push('from enum import Enum');
120
120
  lines.push('from typing import Optional');
121
- lines.push('from typing_extensions import Literal, TypeAlias');
121
+ lines.push('from typing import Literal, TypeAlias');
122
122
  lines.push('');
123
123
  lines.push('');
124
124
  lines.push(`class ${cls}(str, Enum):`);
@@ -126,7 +126,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
126
126
  lines.push('');
127
127
  } else if (allIntegers) {
128
128
  lines.push('from enum import IntEnum');
129
- lines.push('from typing_extensions import Literal, TypeAlias');
129
+ lines.push('from typing import Literal, TypeAlias');
130
130
  lines.push('');
131
131
  lines.push('');
132
132
  lines.push(`class ${cls}(IntEnum):`);
@@ -135,7 +135,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
135
135
  } else {
136
136
  // Mixed types — fall back to Union[Literal[...], str]
137
137
  lines.push('from typing import Union');
138
- lines.push('from typing_extensions import Literal, TypeAlias');
138
+ lines.push('from typing import Literal, TypeAlias');
139
139
  lines.push('');
140
140
  const literals = uniqueValues.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)));
141
141
  lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
@@ -196,7 +196,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
196
196
  files.push({
197
197
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
198
198
  content: [
199
- 'from typing_extensions import TypeAlias',
199
+ 'from typing import TypeAlias',
200
200
  `from .${fileName(enumDef.name)} import ${cls}`,
201
201
  '',
202
202
  `${aliasName}: TypeAlias = ${cls}`,
@@ -62,7 +62,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
62
62
  const canonicalDir = resolveDir(canonicalService);
63
63
  const canonicalClassName = className(canonicalName);
64
64
  const lines: string[] = [];
65
- lines.push('from typing_extensions import TypeAlias');
65
+ lines.push('from typing import TypeAlias');
66
66
  // Always use direct file import to avoid barrel dependency on the canonical
67
67
  if (canonicalDir === dirName) {
68
68
  lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
@@ -1,24 +1,7 @@
1
1
  import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
- import { stripUrnPrefix } from '../shared/naming-utils.js';
5
-
6
- /**
7
- * Map of lowercase acronym forms to their correct casing.
8
- * Applied as a post-processing step after toPascalCase.
9
- */
10
- const ACRONYM_FIXES: [RegExp, string][] = [
11
- [/Workos/g, 'WorkOS'],
12
- [/Sso/g, 'SSO'],
13
- [/Mfa/g, 'MFA'],
14
- [/Jwt/g, 'JWT'],
15
- [/Cors/g, 'CORS'],
16
- [/Saml/g, 'SAML'],
17
- [/Scim/g, 'SCIM'],
18
- [/Rbac/g, 'RBAC'],
19
- [/Oauth/g, 'OAuth'],
20
- [/Oidc/g, 'OIDC'],
21
- ];
4
+ import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
22
5
 
23
6
  /**
24
7
  * Python class names that collide with builtins or typing imports.
@@ -41,10 +24,7 @@ const PYTHON_RESERVED_CLASS_NAMES = new Set([
41
24
 
42
25
  /** PascalCase class name with acronym preservation. */
43
26
  export function className(name: string): string {
44
- let result = toPascalCase(stripUrnPrefix(name));
45
- for (const [pattern, replacement] of ACRONYM_FIXES) {
46
- result = result.replace(pattern, replacement);
47
- }
27
+ let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
48
28
  if (PYTHON_RESERVED_CLASS_NAMES.has(result)) {
49
29
  result += 'Model';
50
30
  }