@workos/oagen-emitters 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-Dws9b6T7.mjs +21441 -0
- package/dist/plugin-Dws9b6T7.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +17 -41
- package/smoke/sdk-dotnet.ts +11 -5
- package/smoke/sdk-elixir.ts +11 -5
- package/smoke/sdk-go.ts +10 -4
- package/smoke/sdk-kotlin.ts +11 -5
- package/smoke/sdk-node.ts +11 -5
- package/smoke/sdk-php.ts +9 -4
- package/smoke/sdk-python.ts +10 -4
- package/smoke/sdk-ruby.ts +10 -4
- package/smoke/sdk-rust.ts +11 -5
- package/src/dotnet/index.ts +9 -7
- package/src/dotnet/manifest.ts +5 -11
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +13 -8
- package/src/go/manifest.ts +5 -11
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/index.ts +3 -3
- package/src/kotlin/manifest.ts +9 -15
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +3 -3
- package/src/node/manifest.ts +4 -11
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/index.ts +3 -3
- package/src/php/manifest.ts +5 -11
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +38 -30
- package/src/python/manifest.ts +5 -12
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +28 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/manifest.test.ts +13 -12
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/manifest.test.ts +7 -7
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsconfig.json +1 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/python/tests.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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(
|
|
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
|
-
|
|
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 [
|
|
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
|
|
1251
|
-
//
|
|
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'),
|
package/src/python/type-map.ts
CHANGED
|
@@ -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'
|
|
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'
|
|
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) {
|