@workos/oagen-emitters 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BoTAX4nl.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DOE0FqrZ.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.16.0"
57
+ "@workos/oagen": "^0.17.0"
58
58
  }
59
59
  }
@@ -20,7 +20,7 @@ import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
22
  import { groupByMount } from '../shared/resolved-ops.js';
23
- import { discriminatedUnions } from './type-map.js';
23
+ import { discriminatedUnions, resolveModelName } from './type-map.js';
24
24
  import { modelClassName } from './naming.js';
25
25
 
26
26
  /**
@@ -133,8 +133,11 @@ export const dotnetEmitter: Emitter = {
133
133
  lines.push(' {');
134
134
  lines.push(' public override bool CanConvert(Type objectType) => objectType == typeof(object);');
135
135
  lines.push('');
136
+ // Override returns `object?` to match Newtonsoft.Json 13+'s nullable
137
+ // signature; `JToken.ToObject<T>` is itself `T?`, so a non-nullable
138
+ // override would trigger CS8603 under <Nullable>enable</Nullable>.
136
139
  lines.push(
137
- ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
140
+ ' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
138
141
  );
139
142
  lines.push(' {');
140
143
  lines.push(' var jObject = JObject.Load(reader);');
@@ -142,7 +145,7 @@ export const dotnetEmitter: Emitter = {
142
145
  lines.push(' switch (discriminatorValue)');
143
146
  lines.push(' {');
144
147
  for (const [value, modelName] of Object.entries(disc.mapping)) {
145
- const csName = modelClassName(modelName);
148
+ const csName = modelClassName(resolveModelName(modelName));
146
149
  lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
147
150
  }
148
151
  lines.push(' default: return jObject.ToObject<object>(serializer);');
@@ -194,8 +197,9 @@ export const dotnetEmitter: Emitter = {
194
197
  ` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
195
198
  );
196
199
  lines.push('');
200
+ // See first converter — `object?` matches Newtonsoft 13+ to avoid CS8603.
197
201
  lines.push(
198
- ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
202
+ ' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
199
203
  );
200
204
  lines.push(' {');
201
205
  lines.push(' var jObject = JObject.Load(reader);');
@@ -1,4 +1,5 @@
1
- import type { Model, EmitterContext, GeneratedFile, TypeRef } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
2
3
  import {
3
4
  mapTypeRef,
4
5
  isValueTypeRef,
@@ -6,6 +7,7 @@ import {
6
7
  emitJsonPropertyAttributes,
7
8
  setModelAliases,
8
9
  isModelAlias,
10
+ resolveModelName,
9
11
  } from './type-map.js';
10
12
  import {
11
13
  articleFor,
@@ -56,8 +58,23 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
56
58
  const files: GeneratedFile[] = [];
57
59
 
58
60
  // Compute and publish model aliases so mapTypeRef rewrites references.
61
+ // Must run BEFORE collectRequestBodyOnlyModelNames so the body/non-body
62
+ // tally collapses aliased pairs onto their canonical name — otherwise a
63
+ // model that's only a request body in name (e.g. `AddRolePermissionDto`)
64
+ // but is the canonical for a field-referenced alias (e.g. `SlimRole`)
65
+ // would be wrongly classified as body-only and skipped from emission,
66
+ // leaving every alias-rewritten field reference dangling.
59
67
  primeModelAliases(models);
60
68
 
69
+ // Models that are referenced ONLY as an operation request body (not by any
70
+ // response, field, or other operation type) are dead surface in .NET because
71
+ // the wrapper generator emits a per-operation `*Options` class containing
72
+ // the same fields. The method signature consumes the Options class — the
73
+ // named entity is never instantiated by callers and just clutters the SDK
74
+ // (see workos-dotnet#248 with `CreateUserApiKey` /
75
+ // `UserManagementCreateApiKeyOptions`). Skip emission for those.
76
+ const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
77
+
61
78
  // Build a lookup of base model field C# names → C# types for inheritance.
62
79
  // Variant models skip inherited fields and use `new` for type-divergent ones.
63
80
  const baseFieldLookup = new Map<string, Map<string, string>>();
@@ -75,6 +92,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
75
92
 
76
93
  for (const model of models) {
77
94
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
95
+ if (requestBodyOnlyNames.has(model.name)) continue;
78
96
 
79
97
  const csClassName = modelClassName(model.name);
80
98
 
@@ -217,6 +235,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
217
235
 
218
236
  const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
219
237
  lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
238
+ // Discriminated-union-typed field: attach the variant-dispatching converter
239
+ // so Newtonsoft picks the right subtype on deserialization. The converter
240
+ // name is keyed off the first IR variant model name (matches how
241
+ // `joinUnionVariants` registered it in `discriminatedUnions`).
242
+ const discriminatedUnionConverter = discriminatedUnionConverterName(field.type);
243
+ if (discriminatedUnionConverter) {
244
+ lines.push(` [Newtonsoft.Json.JsonConverter(typeof(${discriminatedUnionConverter}))]`);
245
+ }
220
246
  const newMod = useNewModifier ? 'new ' : '';
221
247
  lines.push(` public ${newMod}${csType} ${csFieldName} { get; ${setterModifier}set; }${initializer}`);
222
248
 
@@ -289,6 +315,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
289
315
  return files;
290
316
  }
291
317
 
318
+ /**
319
+ * Compute the name of the discriminator converter class for a field whose
320
+ * type is a discriminated union, mirroring the keying used in
321
+ * `joinUnionVariants` (first IR model variant name + "DiscriminatorConverter").
322
+ * Returns null when the type isn't a discriminated union with a populated
323
+ * mapping. Also walks through `nullable` so an optional discriminated field
324
+ * still gets the converter applied.
325
+ */
326
+ function discriminatedUnionConverterName(ref: TypeRef): string | null {
327
+ const inner = ref.kind === 'nullable' ? ref.inner : ref;
328
+ if (inner.kind !== 'union') return null;
329
+ if (!inner.discriminator || !inner.discriminator.mapping) return null;
330
+ if (Object.keys(inner.discriminator.mapping).length === 0) return null;
331
+ const firstModel = inner.variants.find((v) => v.kind === 'model');
332
+ if (!firstModel || firstModel.kind !== 'model') return null;
333
+ return `${modelClassName(firstModel.name)}DiscriminatorConverter`;
334
+ }
335
+
292
336
  /**
293
337
  * Whether the emitted C# type is `Dictionary<string, object>` or its
294
338
  * nullable variant — the usual shape of metadata / additional-properties
@@ -403,3 +447,59 @@ function structuralHash(model: Model, aliasOf: Map<string, string> = new Map()):
403
447
  .sort()
404
448
  .join('|');
405
449
  }
450
+
451
+ /**
452
+ * Names of models referenced **only** as a named operation request body —
453
+ * i.e. never appearing in a response, an error, a paginated item type, or as
454
+ * a field type on another model. The .NET wrapper generator emits a
455
+ * per-operation `*Options` class containing the same fields, so the named
456
+ * entity is never instantiated by callers and just clutters the SDK
457
+ * (workos-dotnet#248: `CreateUserApiKey` vs `UserManagementCreateApiKeyOptions`).
458
+ */
459
+ function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]): Set<string> {
460
+ const requestBodyNames = new Set<string>();
461
+ const otherReferences = new Set<string>();
462
+
463
+ // Resolve every reference through the alias map so structurally-identical
464
+ // models share a body/non-body classification. Without this, an alias being
465
+ // used as a field would only mark the alias name as non-body — leaving its
466
+ // canonical (which carries the same shape and gets emitted) wrongly tagged
467
+ // as body-only and skipped.
468
+ const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
469
+ if (!ref) return;
470
+ walkTypeRef(ref, {
471
+ model: (r) => into.add(resolveModelName(r.name)),
472
+ });
473
+ };
474
+
475
+ for (const service of services) {
476
+ for (const op of service.operations) {
477
+ if (op.requestBody?.kind === 'model') {
478
+ requestBodyNames.add(resolveModelName(op.requestBody.name));
479
+ }
480
+ collect(op.response, otherReferences);
481
+ if (op.pagination) collect(op.pagination.itemType, otherReferences);
482
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
483
+ collect(p.type, otherReferences);
484
+ }
485
+ if (op.successResponses) {
486
+ for (const sr of op.successResponses) collect(sr.type, otherReferences);
487
+ }
488
+ for (const err of op.errors) {
489
+ if (err.type) collect(err.type, otherReferences);
490
+ }
491
+ }
492
+ }
493
+
494
+ for (const model of models) {
495
+ for (const field of model.fields) {
496
+ collect(field.type, otherReferences);
497
+ }
498
+ }
499
+
500
+ const result = new Set<string>();
501
+ for (const name of requestBodyNames) {
502
+ if (!otherReferences.has(name)) result.add(name);
503
+ }
504
+ return result;
505
+ }
@@ -200,11 +200,18 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
200
200
  return variants[0] ?? 'object';
201
201
  }
202
202
  const unique = [...new Set(variants)];
203
- if (unique.length === 1) return unique[0];
204
203
 
205
- // Discriminated union: register for converter generation and return first variant as base
204
+ // Discriminated union: register for converter generation BEFORE collapsing
205
+ // single-unique to avoid losing the discriminator when all variants alias
206
+ // to the same canonical model (structural dedup). The base type used for
207
+ // the converter name comes from the discriminator's IR variant names so
208
+ // it stays stable even when the C# class names get rewritten by aliases.
206
209
  if (_ref.discriminator && _ref.discriminator.mapping) {
207
- const baseName = unique[0];
210
+ const irVariantNames = _ref.variants
211
+ .filter((v) => v.kind === 'model')
212
+ .map((v) => (v.kind === 'model' ? v.name : ''))
213
+ .filter(Boolean);
214
+ const baseName = irVariantNames[0] ?? unique[0];
208
215
  discriminatedUnions.set(baseName, {
209
216
  property: _ref.discriminator.property,
210
217
  mapping: _ref.discriminator.mapping,
@@ -215,6 +222,8 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
215
222
  return 'object';
216
223
  }
217
224
 
225
+ if (unique.length === 1) return unique[0];
226
+
218
227
  if (unique.length >= 2 && unique.length <= 9) return `OneOf.OneOf<${unique.join(', ')}>`;
219
228
  // OneOf supports arity 2-9. Higher-arity unions collapse to `object`,
220
229
  // losing type information. Warn so the author knows the spec outgrew the
@@ -46,7 +46,12 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
46
46
  });
47
47
  }
48
48
 
49
- // Generate list fixtures for paginated responses
49
+ // Generate list fixtures for paginated responses. Multiple operations may
50
+ // share the same item model (e.g. several role-assignment list endpoints all
51
+ // returning UserRoleAssignmentList) — emit each fixture path once so the
52
+ // content-dedup pass below doesn't see N copies of the same path and drop
53
+ // the file entirely.
54
+ const seenListPaths = new Set<string>();
50
55
  for (const service of spec.services) {
51
56
  for (const op of service.operations) {
52
57
  if (op.pagination) {
@@ -55,6 +60,9 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
55
60
  const unwrapped = unwrapListModel(itemModel, modelMap);
56
61
  if (unwrapped) itemModel = unwrapped;
57
62
  if (itemModel.fields.length === 0) continue;
63
+ const path = `testdata/list_${fileName(itemModel.name)}.json`;
64
+ if (seenListPaths.has(path)) continue;
65
+ seenListPaths.add(path);
58
66
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
59
67
  const listFixture = {
60
68
  data: [fixture],
@@ -64,7 +72,7 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
64
72
  },
65
73
  };
66
74
  files.push({
67
- path: `testdata/list_${fileName(itemModel.name)}.json`,
75
+ path,
68
76
  content: JSON.stringify(listFixture, null, 2),
69
77
  });
70
78
  }
package/src/go/models.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
2
3
  import { mapTypeRef } from './type-map.js';
3
4
  import { className, fieldName } from './naming.js';
4
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
@@ -7,6 +8,67 @@ import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-
7
8
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
8
9
  export { isListWrapperModel, isListMetadataModel };
9
10
 
11
+ /**
12
+ * Collect names of models that are referenced **only** as a named request body
13
+ * model on an operation, with no other consumers (response types, pagination
14
+ * item types, error types, or fields on other models).
15
+ *
16
+ * The Go emitter synthesizes a `{Service}{Method}Params` struct from those
17
+ * request bodies (see `resources.ts:392`), and the method signature uses the
18
+ * synthesized struct — never the named model. So emitting the named model in
19
+ * `models.go` would leave callers with a duplicate, unused struct (the bug
20
+ * surfaced in workos-go#544 with `CreateUserAPIKey` /
21
+ * `UserManagementCreateAPIKeyParams`).
22
+ *
23
+ * Models in this set are skipped during model emission. Callers parameterize
24
+ * the API surface through `*Params` exclusively, which is the sole consumer
25
+ * of the spec's named request body schema.
26
+ */
27
+ function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]): Set<string> {
28
+ const requestBodyNames = new Set<string>();
29
+ const otherReferences = new Set<string>();
30
+
31
+ const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
32
+ if (!ref) return;
33
+ walkTypeRef(ref, {
34
+ model: (r) => into.add(r.name),
35
+ });
36
+ };
37
+
38
+ for (const service of services) {
39
+ for (const op of service.operations) {
40
+ if (op.requestBody?.kind === 'model') {
41
+ requestBodyNames.add(op.requestBody.name);
42
+ }
43
+ collect(op.response, otherReferences);
44
+ if (op.pagination) collect(op.pagination.itemType, otherReferences);
45
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
46
+ collect(p.type, otherReferences);
47
+ }
48
+ if (op.successResponses) {
49
+ for (const sr of op.successResponses) collect(sr.type, otherReferences);
50
+ }
51
+ for (const err of op.errors) {
52
+ if (err.type) collect(err.type, otherReferences);
53
+ }
54
+ }
55
+ }
56
+
57
+ // Field references — a request body model that's also a field type elsewhere
58
+ // is a reusable schema (e.g. consumed by webhook event data), so we keep it.
59
+ for (const model of models) {
60
+ for (const field of model.fields) {
61
+ collect(field.type, otherReferences);
62
+ }
63
+ }
64
+
65
+ const result = new Set<string>();
66
+ for (const name of requestBodyNames) {
67
+ if (!otherReferences.has(name)) result.add(name);
68
+ }
69
+ return result;
70
+ }
71
+
10
72
  /**
11
73
  * Generate Go struct definitions from IR Models.
12
74
  * All models go into a single models.go file for the flat package.
@@ -20,11 +82,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
20
82
  lines.push(`package ${ctx.namespace}`);
21
83
  lines.push('');
22
84
 
85
+ const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
86
+
23
87
  // Build structural hash for deduplication
24
88
  const modelHashMap = new Map<string, string>();
25
89
  const hashGroups = new Map<string, string[]>();
26
90
  for (const model of models) {
27
91
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
92
+ if (requestBodyOnly.has(model.name)) continue;
28
93
  const hash = structuralHash(model);
29
94
  modelHashMap.set(model.name, hash);
30
95
  if (!hashGroups.has(hash)) hashGroups.set(hash, []);
@@ -48,6 +113,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
48
113
  const batchedAliases = new Set<string>();
49
114
  for (const model of models) {
50
115
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
116
+ if (requestBodyOnly.has(model.name)) continue;
51
117
 
52
118
  const structName = className(model.name);
53
119
 
@@ -410,9 +410,11 @@ function generateParamsStruct(
410
410
  const isOptional = !field.required;
411
411
  const goType = isOptional ? makeOptional(mapTypeRef(field.type)) : mapTypeRef(field.type);
412
412
  const jsonTag = field.required ? `json:"${field.name}"` : `json:"${field.name},omitempty"`;
413
- // If this field also appears in query params, emit a url tag too
414
- const isAlsoQueryParam = op.queryParams.some((qp) => !hidden.has(qp.name) && fieldName(qp.name) === goField);
415
- const urlTag = isAlsoQueryParam ? ` url:"${field.name}${field.required ? '' : ',omitempty'}"` : '';
413
+ // Body fields are JSON-marshaled into the request body. Emit `url:"-"`
414
+ // so go-querystring's default field-name fallback doesn't also place
415
+ // them in the URL important when the spec duplicates a field as both
416
+ // body and query param (e.g. /sso/token's `code`).
417
+ const urlTag = ' url:"-"';
416
418
  if (field.description) {
417
419
  const fdLines = field.description.split('\n').filter((l) => l.trim());
418
420
  lines.push(`\t// ${fieldDocComment(goField, fdLines[0])}`);
@@ -82,6 +82,29 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
82
82
  }
83
83
  const unique = [...new Set(variants)];
84
84
  if (unique.length === 1) return unique[0];
85
- // Go doesn't have union types; use interface{}
85
+ // Discriminated unions: Go has no sum types, so widen to the discriminator-
86
+ // resolver type when one is registered downstream (see resolveDiscriminatedUnionType).
87
+ // Fall back to interface{} for non-discriminated heterogeneous unions.
88
+ if (_ref.discriminator && _ref.discriminator.mapping) {
89
+ const resolverName = unionResolverName(_ref);
90
+ if (resolverName) return resolverName;
91
+ }
86
92
  return 'interface{}';
87
93
  }
94
+
95
+ /**
96
+ * Pick a stable type name for a discriminated union's runtime resolver. Today
97
+ * we emit no resolver struct, so we treat the union's first model variant as
98
+ * the public type — matching the pre-discriminator behavior where Go just
99
+ * referenced one variant directly. The Owner field stays typed, callers
100
+ * can still inspect Type to detect the user variant (data loss on
101
+ * non-overlapping fields like organization_id is documented in the SDK
102
+ * compat report rather than fixed in the emitter — Go callers who need the
103
+ * user-only fields can json.Unmarshal the raw payload manually).
104
+ */
105
+ function unionResolverName(ref: UnionType): string | null {
106
+ for (const v of ref.variants) {
107
+ if (v.kind === 'model') return `*${className(v.name)}`;
108
+ }
109
+ return null;
110
+ }
@@ -95,10 +95,17 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
95
95
  return variants[0] ?? 'Any';
96
96
  }
97
97
  const unique = [...new Set(variants)];
98
- if (unique.length === 1) return unique[0];
99
98
 
99
+ // Register discriminated unions BEFORE the single-unique collapse so we
100
+ // don't lose the dispatcher when every variant aliases to the same canonical
101
+ // type. The base type used at the call site is the IR variant name, which
102
+ // matches how the dispatcher infrastructure looks up registered unions.
100
103
  if (ref.discriminator && ref.discriminator.mapping) {
101
- const baseName = unique[0];
104
+ const irVariantNames = ref.variants
105
+ .filter((v) => v.kind === 'model')
106
+ .map((v) => (v.kind === 'model' ? v.name : ''))
107
+ .filter(Boolean);
108
+ const baseName = irVariantNames[0] ?? unique[0];
102
109
  discriminatedUnions.set(baseName, {
103
110
  property: ref.discriminator.property,
104
111
  mapping: ref.discriminator.mapping,
@@ -108,6 +115,8 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
108
115
  return baseName;
109
116
  }
110
117
 
118
+ if (unique.length === 1) return unique[0];
119
+
111
120
  // Non-discriminated unions fall back to the Kotlin top type. A generic
112
121
  // AnyOf<> is planned for a future phase if emitter tests prove it necessary.
113
122
  return 'Any';
package/src/php/models.ts CHANGED
@@ -220,6 +220,18 @@ function generateFromArrayValue(ref: TypeRef, accessor: string): string {
220
220
  case 'nullable':
221
221
  return generateFromArrayValue(ref.inner, accessor);
222
222
  case 'union': {
223
+ // Discriminated union: dispatch via match() on the discriminator
224
+ // property to call the matching variant's fromArray. Unknown values
225
+ // pass through as raw arrays so callers can introspect.
226
+ if (ref.discriminator && ref.discriminator.mapping) {
227
+ const entries = Object.entries(ref.discriminator.mapping);
228
+ if (entries.length > 0) {
229
+ const arms = entries
230
+ .map(([value, modelName]) => `'${value}' => ${className(modelName)}::fromArray(${accessor})`)
231
+ .join(', ');
232
+ return `match (${accessor}['${ref.discriminator.property}'] ?? null) { ${arms}, default => ${accessor} }`;
233
+ }
234
+ }
223
235
  const resolved = resolveDegenerateUnion(ref);
224
236
  if (resolved) return generateFromArrayValue(resolved, accessor);
225
237
  return accessor;
@@ -286,12 +286,10 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
286
286
  overwriteExisting: true,
287
287
  });
288
288
 
289
- // Ensure models/__init__.py exists even if no models are assigned to this service
290
- files.push({
291
- path: `src/${ctx.namespace}/${dirName}/models/__init__.py`,
292
- content: '',
293
- skipIfExists: true,
294
- });
289
+ // models/__init__.py is emitted unconditionally by `models.ts` including
290
+ // an empty barrel for services with no models — so we don't need a safety
291
+ // net here. (A `skipIfExists` safety net previously caused stale imports
292
+ // to survive when the underlying module was pruned.)
295
293
  }
296
294
 
297
295
  return files;
@@ -447,6 +447,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
447
447
  serviceDirModelPaths.add(`src/${ctx.namespace}/${dirName}/models`);
448
448
  }
449
449
 
450
+ // Emit an empty barrel for every service-models dir that has no symbols of
451
+ // its own (e.g. a service whose models live in another package via
452
+ // cross-domain aliases). Otherwise the live SDK can keep a stale
453
+ // `__init__.py` from a previous spec revision — when the underlying module
454
+ // gets pruned the dangling re-export survives and breaks pyright. Done here
455
+ // (not in client.ts) so a subsequent emission for the same path with real
456
+ // content always wins last-write-wins.
457
+ for (const dirPath of serviceDirModelPaths) {
458
+ if (!symbolsByDir.has(dirPath)) {
459
+ files.push({
460
+ path: `${dirPath}/__init__.py`,
461
+ content: '',
462
+ integrateTarget: true,
463
+ overwriteExisting: true,
464
+ });
465
+ }
466
+ }
467
+
450
468
  for (const [dirPath, names] of symbolsByDir) {
451
469
  // Use `import X as X` syntax for explicit re-exports (required by pyright strict)
452
470
  const uniqueNames = [...new Set(names)].sort();
@@ -709,7 +727,27 @@ function deserializeField(ref: any, accessor: string, isRequired: boolean, walru
709
727
  return deserializeField(ref.inner, accessor, false, walrusVar);
710
728
  case 'union': {
711
729
  const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
712
- const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))];
730
+ const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))] as string[];
731
+ // Discriminated union: dispatch on the discriminator property to call
732
+ // the matching variant's from_dict. Unknown discriminator values fall
733
+ // back to the raw payload so callers can introspect.
734
+ if (ref.discriminator && ref.discriminator.mapping) {
735
+ const entries = Object.entries(ref.discriminator.mapping as Record<string, string>);
736
+ if (entries.length > 0) {
737
+ const dispatchMap = entries.map(([value, modelName]) => `"${value}": ${className(modelName)}`).join(', ');
738
+ const dataExpr = isRequired ? accessor : walrusVar;
739
+ const dataCast = `cast(Dict[str, Any], ${dataExpr})`;
740
+ // The dispatch dict has `str` keys, so pyright (strict) rejects the
741
+ // raw `Any | None` returned by `.get(prop)` even though `dict.get`
742
+ // accepts any hashable. Cast through `str` to satisfy the parameter
743
+ // type — runtime semantics are unchanged because a missing/`None`
744
+ // discriminator simply misses the dispatch and falls through.
745
+ const lookupExpr = `{${dispatchMap}}.get(cast(str, ${dataCast}.get("${ref.discriminator.property}")))`;
746
+ const branch = `(_disc.from_dict(${dataCast}) if (_disc := ${lookupExpr}) is not None else ${dataExpr})`;
747
+ if (isRequired) return branch;
748
+ return `(${branch}) if (${walrusVar} := ${accessor}) is not None else None`;
749
+ }
750
+ }
713
751
  if (uniqueModels.length === 1) {
714
752
  return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
715
753
  }
@@ -745,6 +783,12 @@ function serializeField(ref: any, accessor: string): string {
745
783
  if (uniqueModels.length === 1) {
746
784
  return `${accessor}.to_dict()`;
747
785
  }
786
+ // Discriminated union: deserialize produced a dataclass instance for
787
+ // known discriminator values and the raw dict for unknowns. Round-trip
788
+ // both — call `.to_dict()` if it exists, otherwise pass through.
789
+ if (ref.discriminator && ref.discriminator.mapping && modelVariants.length > 0) {
790
+ return `${accessor}.to_dict() if hasattr(${accessor}, "to_dict") else ${accessor}`;
791
+ }
748
792
  return accessor;
749
793
  }
750
794
  default:
@@ -269,11 +269,30 @@ function deserializeExpression(
269
269
  }
270
270
 
271
271
  if (ref.kind === 'union') {
272
- // Unions: if all variants are models, try each; otherwise return raw value.
273
- const modelVariants = ref.variants.filter((v) => v.kind === 'model' && modelNames.has(v.name));
274
- if (modelVariants.length > 0 && modelVariants.length === ref.variants.length) {
275
- // Multiple model variants — default to first successful parse or raw value.
276
- return accessor; // simplification: return raw, let user inspect
272
+ // Discriminated union: dispatch on the discriminator property to construct
273
+ // the matching variant. Unknown values fall back to the raw hash so callers
274
+ // can still introspect (rather than crashing on an unmapped discriminator).
275
+ if (ref.discriminator && ref.discriminator.mapping) {
276
+ const entries = Object.entries(ref.discriminator.mapping).filter(([, name]) => modelNames.has(name));
277
+ if (entries.length > 0) {
278
+ const propAccess = rubyHashAccessor(accessor, ref.discriminator.property);
279
+ const branches = entries
280
+ .map(([value, modelName]) => {
281
+ const cls = `WorkOS::${className(modelName)}`;
282
+ return `when ${JSON.stringify(value)} then ${cls}.new(${accessor})`;
283
+ })
284
+ .join(' ');
285
+ const dispatcher = `(case ${propAccess} ${branches} else ${accessor} end)`;
286
+ return `${accessor} ? ${dispatcher} : nil`;
287
+ }
288
+ }
289
+ // Non-discriminated union of models: pick the first variant as a best-
290
+ // effort typed wrapper. Preserves the pre-discriminator behavior of
291
+ // wrapping inline-variant unions whose owner used to be a single model.
292
+ const firstModelVariant = ref.variants.find((v) => v.kind === 'model' && modelNames.has(v.name));
293
+ if (firstModelVariant && firstModelVariant.kind === 'model') {
294
+ const cls = `WorkOS::${className(firstModelVariant.name)}`;
295
+ return `${accessor} ? ${cls}.new(${accessor}) : nil`;
277
296
  }
278
297
  return accessor;
279
298
  }
@@ -220,11 +220,47 @@ describe('go/resources', () => {
220
220
  const files = generateResources(services, makeCtx(spec));
221
221
  const content = files[0].content;
222
222
  expect(content).toContain('type UsersCreateParams struct {');
223
- expect(content).toContain('Email string `json:"email"`');
223
+ expect(content).toContain('Email string `json:"email" url:"-"`');
224
224
  expect(content).toContain('Notify *bool `url:"notify,omitempty" json:"-"`');
225
225
  expect(content).toContain('request(ctx, "POST", "/users", params, params, &result, opts)');
226
226
  });
227
227
 
228
+ it('does not emit url tag for body fields shadowed by a same-named query param', () => {
229
+ // Spec quirk: an operation lists the same field name in both `parameters`
230
+ // (in: query) and `requestBody`. The body field must not also leak into
231
+ // the URL query string (e.g. /sso/token's `code` carries an auth code).
232
+ const services: Service[] = [
233
+ {
234
+ name: 'Sso',
235
+ operations: [
236
+ makeOp({
237
+ name: 'getProfileAndToken',
238
+ httpMethod: 'post',
239
+ path: '/sso/token',
240
+ requestBody: { kind: 'model', name: 'TokenQueryDto' },
241
+ queryParams: [
242
+ {
243
+ name: 'code',
244
+ type: { kind: 'primitive', type: 'string' },
245
+ required: true,
246
+ },
247
+ ],
248
+ }),
249
+ ],
250
+ },
251
+ ];
252
+ const spec = makeSpec(services, [
253
+ {
254
+ name: 'TokenQueryDto',
255
+ fields: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
256
+ },
257
+ ]);
258
+ const files = generateResources(services, makeCtx(spec));
259
+ const content = files[0].content;
260
+ expect(content).toContain('Code string `json:"code" url:"-"`');
261
+ expect(content).not.toContain('json:"code" url:"code"');
262
+ });
263
+
228
264
  it('emits Deprecated comment for deprecated body field in params struct', () => {
229
265
  const services: Service[] = [
230
266
  {