@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.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -9,16 +9,60 @@ import type {
9
9
  ResolvedOperation,
10
10
  } from '@workos/oagen';
11
11
  import { planOperation, toSnakeCase, assignModelsToServices } from '@workos/oagen';
12
- import { className, fileName, fieldName, resolveMethodName, buildMountDirMap, dirToModule } from './naming.js';
12
+ import {
13
+ className,
14
+ fileName,
15
+ fieldName,
16
+ moduleName,
17
+ resolveMethodName,
18
+ buildMountDirMap,
19
+ dirToModule,
20
+ } from './naming.js';
13
21
  import { resolveResourceClassName, bodyParamName } from './resources.js';
14
22
  import { buildServiceAccessPaths } from './client.js';
15
23
  import { generateFixtures, generateModelFixture } from './fixtures.js';
16
24
  import { isListWrapperModel, isListMetadataModel } from './models.js';
17
25
  import { assignEnumsToServices } from './enums.js';
18
- import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
26
+ import {
27
+ groupByMount,
28
+ buildResolvedLookup,
29
+ lookupResolved,
30
+ buildHiddenParams,
31
+ collectGroupedParamNames,
32
+ } from '../shared/resolved-ops.js';
19
33
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
20
34
  import { pythonLiteral } from './wrappers.js';
21
35
 
36
+ /**
37
+ * Resolve the Python class name to use for isinstance checks on paginated items.
38
+ * For discriminated unions, generates the fixture and determines which variant
39
+ * the discriminator value maps to. For regular models, returns the model class.
40
+ */
41
+ function resolvePaginatedItemClass(itemName: string | null, spec: ApiSpec): string | null {
42
+ if (!itemName) return null;
43
+ const itemModel = spec.models.find((m) => m.name === itemName);
44
+ if (!itemModel) return className(itemName);
45
+
46
+ const disc = (itemModel as any).discriminator as { property: string; mapping: Record<string, string> } | undefined;
47
+ if (!disc) return className(itemName);
48
+
49
+ // Generate the fixture to determine which discriminator value appears
50
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
51
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
52
+ const fixture = generateModelFixture(itemModel, modelMap, enumMap);
53
+ const discValue = fixture[disc.property];
54
+
55
+ if (typeof discValue === 'string' && disc.mapping[discValue]) {
56
+ return className(disc.mapping[discValue]);
57
+ }
58
+
59
+ // Fallback: first variant alphabetically
60
+ const sortedEntries = Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b));
61
+ if (sortedEntries.length > 0) return className(sortedEntries[0][1]);
62
+
63
+ return className(itemName);
64
+ }
65
+
22
66
  /** Check if an operation is a redirect endpoint (same logic as resources.ts). */
23
67
  function isRedirectEndpoint(op: Operation): boolean {
24
68
  if (op.successResponses?.some((r) => r.statusCode >= 300 && r.statusCode < 400)) return true;
@@ -39,6 +83,14 @@ function pushAsyncTestDef(lines: string[], def: string): void {
39
83
  lines.push(def);
40
84
  }
41
85
 
86
+ function buildDeleteSuccessResponseSetup(op: Operation): string {
87
+ const statusCode = op.successResponses?.[0]?.statusCode ?? 204;
88
+ if (statusCode === 204) {
89
+ return 'httpx_mock.add_response(status_code=204)';
90
+ }
91
+ return `httpx_mock.add_response(status_code=${statusCode}, content=b"\\n")`;
92
+ }
93
+
42
94
  /**
43
95
  * Generate pytest test files and JSON fixtures for the Python SDK.
44
96
  */
@@ -117,12 +169,41 @@ function generateServiceTest(
117
169
  if (plan.responseModelName) modelImports.add(plan.responseModelName);
118
170
  if (op.pagination?.itemType.kind === 'model') {
119
171
  modelImports.add(op.pagination.itemType.name);
172
+ // Unwrap list wrapper to find the inner item model (may be a discriminated union)
173
+ let paginationItemName = op.pagination.itemType.name;
174
+ const wrapperModel = spec.models.find((m) => m.name === paginationItemName);
175
+ if (wrapperModel && isListWrapperModel(wrapperModel)) {
176
+ const dataField = wrapperModel.fields.find((f) => f.name === 'data');
177
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
178
+ paginationItemName = dataField.type.items.name;
179
+ modelImports.add(paginationItemName);
180
+ }
181
+ }
182
+ // For discriminated union pagination items, import the variant that the fixture resolves to
183
+ const resolvedVariantClass = resolvePaginatedItemClass(paginationItemName, spec);
184
+ if (resolvedVariantClass && resolvedVariantClass !== className(paginationItemName)) {
185
+ // Find the model name from the class name — reverse-lookup through the discriminator mapping
186
+ const paginationModel = spec.models.find((m) => m.name === paginationItemName);
187
+ const disc =
188
+ paginationModel &&
189
+ ((paginationModel as any).discriminator as { property: string; mapping: Record<string, string> } | undefined);
190
+ if (disc) {
191
+ for (const variantName of Object.values(disc.mapping)) {
192
+ if (className(variantName) === resolvedVariantClass) {
193
+ modelImports.add(variantName);
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ }
120
199
  }
121
200
  // Collect model-typed and enum-typed body fields (used as method arguments)
122
201
  if (plan.hasBody && op.requestBody?.kind === 'model') {
123
202
  const bodyModel = spec.models.find((m) => m.name === (op.requestBody as any).name);
124
203
  if (bodyModel) {
204
+ const testGroupedParams = collectGroupedParamNames(op);
125
205
  for (const f of bodyModel.fields) {
206
+ if (testGroupedParams.has(f.name)) continue;
126
207
  if (f.type.kind === 'model') modelImports.add(f.type.name);
127
208
  if (f.type.kind === 'nullable' && f.type.inner.kind === 'model') modelImports.add(f.type.inner.name);
128
209
  if (f.type.kind === 'array' && f.type.items.kind === 'model') modelImports.add(f.type.items.name);
@@ -186,6 +267,20 @@ function generateServiceTest(
186
267
  `from ${ctx.namespace}._errors import AuthenticationError, BadRequestError, NotFoundError, RateLimitExceededError, ServerError, UnprocessableEntityError`,
187
268
  );
188
269
 
270
+ // Import parameter group variant classes
271
+ const groupVariantImports = new Set<string>();
272
+ for (const op of service.operations) {
273
+ for (const group of op.parameterGroups ?? []) {
274
+ for (const variant of group.variants) {
275
+ groupVariantImports.add(className(`${group.name}_${variant.name}`));
276
+ }
277
+ }
278
+ }
279
+ if (groupVariantImports.size > 0) {
280
+ const mountDir = dirToModule(buildMountDirMap(ctx).get(service.name) ?? moduleName(service.name));
281
+ lines.push(`from ${ctx.namespace}.${mountDir}._resource import ${[...groupVariantImports].join(', ')}`);
282
+ }
283
+
189
284
  lines.push('');
190
285
  lines.push('');
191
286
  lines.push(`class Test${resolvedName}:`);
@@ -229,12 +324,19 @@ function generateServiceTest(
229
324
  }
230
325
  }
231
326
  // Skip fixture-based testing for models with no fields (discriminated unions)
327
+ // Save the unwrapped name before nulling — needed for discriminator check below
328
+ const unwrappedItemName = itemName;
232
329
  if (itemName) {
233
330
  const itemModel = spec.models.find((m) => m.name === itemName);
234
331
  if (itemModel && itemModel.fields.length === 0) itemName = null;
235
332
  }
236
333
  const fixtureName = itemName ? `list_${fileName(itemName)}.json` : null;
237
334
 
335
+ // Determine the class name to use for isinstance checks on paginated items.
336
+ // If the item model is a discriminated union (has a discriminator), the fixture
337
+ // will deserialize to a concrete variant, so assert on that variant class.
338
+ const paginatedItemClass = resolvePaginatedItemClass(itemName, spec);
339
+
238
340
  const paginatedArgs = buildTestArgs(op, spec, hiddenParams);
239
341
  lines.push(` def test_${method}(self, workos, httpx_mock):`);
240
342
  if (fixtureName) {
@@ -243,22 +345,44 @@ function generateServiceTest(
243
345
  lines.push(' )');
244
346
  lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
245
347
  lines.push(' assert isinstance(page, SyncPage)');
246
- lines.push(' assert isinstance(page.data, list)');
348
+ lines.push(' assert len(page.data) == 1');
349
+ lines.push(` assert isinstance(page.data[0], ${paginatedItemClass})`);
247
350
 
248
351
  lines.push('');
249
352
  lines.push(` def test_${method}_empty_page(self, workos, httpx_mock):`);
250
353
  lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
251
354
  lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
355
+
252
356
  lines.push(' assert isinstance(page, SyncPage)');
253
357
  lines.push(' assert page.data == []');
254
358
  } else {
255
- lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
256
- lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
257
- lines.push(' assert isinstance(page, SyncPage)');
359
+ // Check if the unwrapped item is a discriminated union — test dispatch through pagination
360
+ const discModel = unwrappedItemName ? spec.models.find((m) => m.name === unwrappedItemName) : null;
361
+ const disc =
362
+ discModel && (discModel as any).discriminator
363
+ ? ((discModel as any).discriminator as { property: string; mapping: Record<string, string> })
364
+ : null;
365
+ const discEntries = disc ? Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b)) : [];
366
+ if (disc && discEntries.length > 0) {
367
+ const [, firstVariantName] = discEntries[0];
368
+ const variantFixture = `${fileName(firstVariantName)}.json`;
369
+ const variantClass = className(firstVariantName);
370
+ lines.push(' httpx_mock.add_response(');
371
+ lines.push(` json={"data": [load_fixture("${variantFixture}")], "list_metadata": {}},`);
372
+ lines.push(' )');
373
+ lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
374
+ lines.push(' assert isinstance(page, SyncPage)');
375
+ lines.push(' assert len(page.data) == 1');
376
+ lines.push(` assert isinstance(page.data[0], ${variantClass})`);
377
+ } else {
378
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
379
+ lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
380
+ lines.push(' assert isinstance(page, SyncPage)');
381
+ }
258
382
  }
259
383
  } else if (isDelete) {
260
384
  lines.push(` def test_${method}(self, workos, httpx_mock):`);
261
- lines.push(' httpx_mock.add_response(status_code=204)');
385
+ lines.push(` ${buildDeleteSuccessResponseSetup(op)}`);
262
386
  const args = buildTestArgs(op, spec, hiddenParams);
263
387
  lines.push(` result = workos.${propName}.${method}(${args})`);
264
388
  lines.push(' assert result is None');
@@ -505,17 +629,23 @@ function generateServiceTest(
505
629
  }
506
630
  }
507
631
  // Skip fixture-based testing for models with no fields (discriminated unions)
632
+ // Save the unwrapped name before nulling — needed for discriminator check below
633
+ const unwrappedItemName = itemName;
508
634
  if (itemName) {
509
635
  const itemModel = spec.models.find((m) => m.name === itemName);
510
636
  if (itemModel && itemModel.fields.length === 0) itemName = null;
511
637
  }
512
638
  const fixtureName = itemName ? `list_${fileName(itemName)}.json` : null;
639
+
640
+ const asyncPaginatedItemClass = resolvePaginatedItemClass(itemName, spec);
641
+
513
642
  pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
514
643
  if (fixtureName) {
515
644
  lines.push(` httpx_mock.add_response(json=load_fixture("${fixtureName}"))`);
516
645
  lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
517
646
  lines.push(' assert isinstance(page, AsyncPage)');
518
- lines.push(' assert isinstance(page.data, list)');
647
+ lines.push(' assert len(page.data) == 1');
648
+ lines.push(` assert isinstance(page.data[0], ${asyncPaginatedItemClass})`);
519
649
 
520
650
  lines.push('');
521
651
  pushAsyncTestDef(lines, ` async def test_${method}_empty_page(self, async_workos, httpx_mock):`);
@@ -524,14 +654,34 @@ function generateServiceTest(
524
654
  lines.push(' assert isinstance(page, AsyncPage)');
525
655
  lines.push(' assert page.data == []');
526
656
  } else {
527
- lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
528
- lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
529
- lines.push(' assert isinstance(page, AsyncPage)');
657
+ // Check if the unwrapped item is a discriminated union — test dispatch through pagination
658
+ const discModel = unwrappedItemName ? spec.models.find((m) => m.name === unwrappedItemName) : null;
659
+ const disc =
660
+ discModel && (discModel as any).discriminator
661
+ ? ((discModel as any).discriminator as { property: string; mapping: Record<string, string> })
662
+ : null;
663
+ const discEntries = disc ? Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b)) : [];
664
+ if (disc && discEntries.length > 0) {
665
+ const [, firstVariantName] = discEntries[0];
666
+ const variantFixture = `${fileName(firstVariantName)}.json`;
667
+ const variantClass = className(firstVariantName);
668
+ lines.push(' httpx_mock.add_response(');
669
+ lines.push(` json={"data": [load_fixture("${variantFixture}")], "list_metadata": {}},`);
670
+ lines.push(' )');
671
+ lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
672
+ lines.push(' assert isinstance(page, AsyncPage)');
673
+ lines.push(' assert len(page.data) == 1');
674
+ lines.push(` assert isinstance(page.data[0], ${variantClass})`);
675
+ } else {
676
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
677
+ lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
678
+ lines.push(' assert isinstance(page, AsyncPage)');
679
+ }
530
680
  }
531
681
  } else if (isDelete) {
532
682
  const deletePath = buildExpectedPath(op);
533
683
  pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
534
- lines.push(' httpx_mock.add_response(status_code=204)');
684
+ lines.push(` ${buildDeleteSuccessResponseSetup(op)}`);
535
685
  lines.push(` result = await async_workos.${propName}.${method}(${asyncArgs})`);
536
686
  lines.push(' assert result is None');
537
687
  lines.push(' request = httpx_mock.get_request()');
@@ -884,11 +1034,22 @@ function buildTestArgs(op: Operation, spec: ApiSpec, hiddenParams?: Set<string>)
884
1034
  args.push(`${tokenParamName}="test_${tokenParamName}"`);
885
1035
  }
886
1036
 
1037
+ // Parameter group args — emit first variant constructor
1038
+ const groupedParamNames = collectGroupedParamNames(op);
1039
+ for (const group of op.parameterGroups ?? []) {
1040
+ const variant = group.variants[0];
1041
+ const variantClass = className(`${group.name}_${variant.name}`);
1042
+ const variantArgs = variant.parameters.map((p) => `${fieldName(p.name)}="test_value"`).join(', ');
1043
+ args.push(`${fieldName(group.name)}=${variantClass}(${variantArgs})`);
1044
+ }
1045
+
887
1046
  // Required query params (for all methods, including paginated)
888
1047
  if (plan.hasQueryParams) {
889
1048
  for (const param of op.queryParams) {
890
1049
  // Skip hidden/injected params
891
1050
  if (hiddenParams?.has(param.name)) continue;
1051
+ // Skip params that belong to parameter groups
1052
+ if (groupedParamNames.has(param.name)) continue;
892
1053
  // Skip pagination params (they're optional)
893
1054
  if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
894
1055
  // Skip params already covered by body fields
@@ -908,6 +1069,7 @@ function buildTestArgs(op: Operation, spec: ApiSpec, hiddenParams?: Set<string>)
908
1069
 
909
1070
  function buildQueryEncodingTestArgs(op: Operation, spec: ApiSpec): string {
910
1071
  const args: string[] = [];
1072
+ const groupedParamNames = collectGroupedParamNames(op);
911
1073
 
912
1074
  for (const param of op.pathParams) {
913
1075
  args.push(`"test_${param.name}"`);
@@ -918,7 +1080,8 @@ function buildQueryEncodingTestArgs(op: Operation, spec: ApiSpec): string {
918
1080
 
919
1081
  if (plan.hasBody && op.requestBody?.kind === 'model') {
920
1082
  const bodyModel = spec.models.find((m) => m.name === (op.requestBody as { kind: string; name: string }).name);
921
- for (const field of bodyModel?.fields.filter((f) => f.required) ?? []) {
1083
+ const bodyArgGrouped = collectGroupedParamNames(op);
1084
+ for (const field of bodyModel?.fields.filter((f) => f.required && !bodyArgGrouped.has(f.name)) ?? []) {
922
1085
  args.push(`${bodyParamName(field, pathParamNames)}=${generateTestValue(field.type, field.name)}`);
923
1086
  }
924
1087
  } else if (plan.hasBody && op.requestBody?.kind === 'union') {
@@ -927,6 +1090,16 @@ function buildQueryEncodingTestArgs(op: Operation, spec: ApiSpec): string {
927
1090
  args.push(firstModelVariant ? `body=load_fixture("${fileName(firstModelVariant.name)}.json")` : 'body={}');
928
1091
  }
929
1092
 
1093
+ // Parameter group args — emit first variant constructor
1094
+ for (const group of op.parameterGroups ?? []) {
1095
+ const variant = group.variants[0];
1096
+ const variantClass = className(`${group.name}_${variant.name}`);
1097
+ const variantArgs = variant.parameters
1098
+ .map((p) => `${fieldName(p.name)}=${generateQueryEncodingValue(p.type, p.name)}`)
1099
+ .join(', ');
1100
+ args.push(`${fieldName(group.name)}=${variantClass}(${variantArgs})`);
1101
+ }
1102
+
930
1103
  if (plan.isPaginated) {
931
1104
  args.push('limit=10');
932
1105
  args.push('before="cursor before"');
@@ -939,6 +1112,7 @@ function buildQueryEncodingTestArgs(op: Operation, spec: ApiSpec): string {
939
1112
 
940
1113
  for (const param of op.queryParams) {
941
1114
  if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
1115
+ if (groupedParamNames.has(param.name)) continue;
942
1116
  // Include explode=false array params; skip other array params (complex serialization)
943
1117
  if (param.type.kind === 'array' && (param as any).explode !== false) continue;
944
1118
  const paramName = fieldName(param.name);
@@ -962,7 +1136,7 @@ function buildQueryEncodingResponseSetup(op: Operation, plan: ReturnType<typeof
962
1136
  return ['httpx_mock.add_response(json={"data": [], "list_metadata": {}})'];
963
1137
  }
964
1138
  if (plan.isDelete) {
965
- return ['httpx_mock.add_response(status_code=204)'];
1139
+ return [buildDeleteSuccessResponseSetup(op)];
966
1140
  }
967
1141
  if (op.response.kind === 'array') {
968
1142
  if (op.response.items.kind === 'model') {
@@ -980,6 +1154,17 @@ function buildQueryEncodingAssertions(op: Operation, spec: ApiSpec): string[] {
980
1154
  const assertions: string[] = [];
981
1155
  const plan = planOperation(op);
982
1156
  const pathParamNames = new Set(op.pathParams.map((param) => fieldName(param.name)));
1157
+ const groupedParamNames = collectGroupedParamNames(op);
1158
+
1159
+ // Assert first variant's params from parameter groups
1160
+ for (const group of op.parameterGroups ?? []) {
1161
+ const variant = group.variants[0];
1162
+ for (const param of variant.parameters) {
1163
+ assertions.push(
1164
+ `assert request.url.params["${param.name}"] == ${toPythonLiteral(expectedQueryEncodingValue(param.type, param.name))}`,
1165
+ );
1166
+ }
1167
+ }
983
1168
 
984
1169
  if (plan.isPaginated) {
985
1170
  assertions.push('assert request.url.params["limit"] == "10"');
@@ -995,6 +1180,7 @@ function buildQueryEncodingAssertions(op: Operation, spec: ApiSpec): string[] {
995
1180
 
996
1181
  for (const param of op.queryParams) {
997
1182
  if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
1183
+ if (groupedParamNames.has(param.name)) continue;
998
1184
  // Include explode=false array params; skip other array params (complex serialization)
999
1185
  if (param.type.kind === 'array' && (param as any).explode !== false) continue;
1000
1186
  const paramName = fieldName(param.name);
@@ -1237,6 +1423,14 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1237
1423
  if (!importsByDir.has(dirName)) importsByDir.set(dirName, []);
1238
1424
  importsByDir.get(dirName)!.push(className(model.name));
1239
1425
  }
1426
+ // Add discriminator Unknown variant classes to imports for dispatch tests
1427
+ for (const model of models) {
1428
+ if (!(model as any).discriminator) continue;
1429
+ const service = modelToService.get(model.name);
1430
+ const dirName = resolveDir(service);
1431
+ if (!importsByDir.has(dirName)) importsByDir.set(dirName, []);
1432
+ importsByDir.get(dirName)!.push(`${className(model.name)}Unknown`);
1433
+ }
1240
1434
 
1241
1435
  for (const [dirName, names] of [...importsByDir].sort()) {
1242
1436
  lines.push(`from ${ctx.namespace}.${dirToModule(dirName)}.models import ${names.sort().join(', ')}`);
@@ -1247,9 +1441,10 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1247
1441
  lines.push('class TestModelRoundTrip:');
1248
1442
 
1249
1443
  for (const model of models) {
1250
- // Skip models with no fields these are typically discriminated unions
1251
- // with hand-maintained @oagen-ignore overrides whose fixtures would not match.
1444
+ // Skip models with no fields or discriminated union dispatchers these
1445
+ // don't have a to_dict() and their round-trip semantics differ.
1252
1446
  if (model.fields.length === 0) continue;
1447
+ if ((model as any).discriminator) continue;
1253
1448
  // Deduplicate fields that map to the same snake_case name (mirrors models.ts)
1254
1449
  const seenFieldNames = new Set<string>();
1255
1450
  const dedupFields = model.fields.filter((f) => {
@@ -1326,6 +1521,55 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1326
1521
  }
1327
1522
  }
1328
1523
 
1524
+ // Discriminator dispatch tests — targeted coverage for from_dict routing
1525
+ const discriminatorModels = models.filter((m) => (m as any).discriminator);
1526
+ if (discriminatorModels.length > 0) {
1527
+ lines.push('');
1528
+ lines.push('');
1529
+ lines.push('class TestDiscriminatorDispatch:');
1530
+
1531
+ for (const model of discriminatorModels) {
1532
+ const disc = (model as any).discriminator as { property: string; mapping: Record<string, string> };
1533
+ const modelClass = className(model.name);
1534
+ const unknownClass = `${modelClass}Unknown`;
1535
+
1536
+ // Pick the first variant (alphabetically by discriminator value) for tests
1537
+ const sortedEntries = Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b));
1538
+ if (sortedEntries.length === 0) continue;
1539
+ const [, firstVariantName] = sortedEntries[0];
1540
+ const firstVariantClass = className(firstVariantName);
1541
+ const firstVariantFixture = `${fileName(firstVariantName)}.json`;
1542
+
1543
+ lines.push('');
1544
+ lines.push(` def test_${fileName(model.name)}_dispatches_known_variant(self):`);
1545
+ lines.push(` data = load_fixture("${firstVariantFixture}")`);
1546
+ lines.push(` result = ${modelClass}.from_dict(data)`);
1547
+ lines.push(` assert isinstance(result, ${firstVariantClass})`);
1548
+
1549
+ lines.push('');
1550
+ lines.push(` def test_${fileName(model.name)}_returns_unknown_for_unrecognized_type(self):`);
1551
+ lines.push(` data = load_fixture("${firstVariantFixture}")`);
1552
+ lines.push(` data = {**data, "${disc.property}": "future.unrecognized.type"}`);
1553
+ lines.push(` result = ${modelClass}.from_dict(data)`);
1554
+ lines.push(` assert isinstance(result, ${unknownClass})`);
1555
+ lines.push(' assert result.raw_data == data');
1556
+
1557
+ lines.push('');
1558
+ lines.push(` def test_${fileName(model.name)}_raises_on_missing_discriminator(self):`);
1559
+ lines.push(` data = load_fixture("${firstVariantFixture}")`);
1560
+ lines.push(` data = {k: v for k, v in data.items() if k != "${disc.property}"}`);
1561
+ lines.push(' with pytest.raises(Exception):');
1562
+ lines.push(` ${modelClass}.from_dict(data)`);
1563
+
1564
+ lines.push('');
1565
+ lines.push(` def test_${fileName(model.name)}_raises_on_none_discriminator(self):`);
1566
+ lines.push(` data = load_fixture("${firstVariantFixture}")`);
1567
+ lines.push(` data = {**data, "${disc.property}": None}`);
1568
+ lines.push(' with pytest.raises(Exception):');
1569
+ lines.push(` ${modelClass}.from_dict(data)`);
1570
+ }
1571
+ }
1572
+
1329
1573
  return {
1330
1574
  path: 'tests/test_models_round_trip.py',
1331
1575
  content: lines.join('\n'),
@@ -21,7 +21,11 @@ export function mapTypeRef(ref: TypeRef): string {
21
21
  return `Optional[${inner}]`;
22
22
  },
23
23
  literal: (r) =>
24
- typeof r.value === 'string' ? `Literal["${r.value}"]` : r.value === null ? 'None' : `Literal[${String(r.value)}]`,
24
+ typeof r.value === 'string'
25
+ ? `Literal["${r.value}"]`
26
+ : r.value === null
27
+ ? 'None'
28
+ : `Literal[${toPythonLiteral(r.value)}]`,
25
29
  map: (ref, value) => {
26
30
  void ref;
27
31
  return `Dict[str, ${value}]`;
@@ -52,7 +56,11 @@ export function mapTypeRefUnquoted(ref: TypeRef, knownEnums?: Set<string>, allow
52
56
  return `Optional[${inner}]`;
53
57
  },
54
58
  literal: (r) =>
55
- typeof r.value === 'string' ? `Literal["${r.value}"]` : r.value === null ? 'None' : `Literal[${String(r.value)}]`,
59
+ typeof r.value === 'string'
60
+ ? `Literal["${r.value}"]`
61
+ : r.value === null
62
+ ? 'None'
63
+ : `Literal[${toPythonLiteral(r.value)}]`,
56
64
  map: (ref, value) => {
57
65
  void ref;
58
66
  return `Dict[str, ${value}]`;
@@ -60,6 +68,12 @@ export function mapTypeRefUnquoted(ref: TypeRef, knownEnums?: Set<string>, allow
60
68
  });
61
69
  }
62
70
 
71
+ /** Convert a JS value to a Python literal string (capitalizes booleans). */
72
+ function toPythonLiteral(value: unknown): string {
73
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
74
+ return String(value);
75
+ }
76
+
63
77
  function mapPrimitive(ref: PrimitiveType): string {
64
78
  if (ref.format) {
65
79
  switch (ref.format) {