@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { modelHasNewFields } from '../../src/node/utils.js';
|
|
3
|
+
import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
|
+
|
|
6
|
+
const emptySpec: ApiSpec = {
|
|
7
|
+
name: 'Test',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
baseUrl: '',
|
|
10
|
+
services: [],
|
|
11
|
+
models: [],
|
|
12
|
+
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ctx: EmitterContext = {
|
|
17
|
+
namespace: 'workos',
|
|
18
|
+
namespacePascal: 'WorkOS',
|
|
19
|
+
spec: emptySpec,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('modelHasNewFields', () => {
|
|
23
|
+
const model: Model = {
|
|
24
|
+
name: 'Organization',
|
|
25
|
+
fields: [
|
|
26
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
27
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
it('returns true when no apiSurface exists (Scenario B)', () => {
|
|
32
|
+
expect(modelHasNewFields(model, ctx)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns true when model has no baseline entry (new model)', () => {
|
|
36
|
+
const ctxWithSurface: EmitterContext = {
|
|
37
|
+
...ctx,
|
|
38
|
+
apiSurface: {
|
|
39
|
+
language: 'node',
|
|
40
|
+
extractedFrom: 'test',
|
|
41
|
+
extractedAt: '2024-01-01',
|
|
42
|
+
classes: {},
|
|
43
|
+
interfaces: {},
|
|
44
|
+
typeAliases: {},
|
|
45
|
+
enums: {},
|
|
46
|
+
exports: {},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
expect(modelHasNewFields(model, ctxWithSurface)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false when all fields exist in baseline', () => {
|
|
53
|
+
const ctxWithBaseline: EmitterContext = {
|
|
54
|
+
...ctx,
|
|
55
|
+
apiSurface: {
|
|
56
|
+
language: 'node',
|
|
57
|
+
extractedFrom: 'test',
|
|
58
|
+
extractedAt: '2024-01-01',
|
|
59
|
+
classes: {},
|
|
60
|
+
typeAliases: {},
|
|
61
|
+
enums: {},
|
|
62
|
+
exports: {},
|
|
63
|
+
interfaces: {
|
|
64
|
+
Organization: {
|
|
65
|
+
name: 'Organization',
|
|
66
|
+
fields: {
|
|
67
|
+
id: { name: 'id', type: 'string', optional: false },
|
|
68
|
+
name: { name: 'name', type: 'string', optional: false },
|
|
69
|
+
},
|
|
70
|
+
extends: [],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
expect(modelHasNewFields(model, ctxWithBaseline)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns true when model has one new field not in baseline', () => {
|
|
79
|
+
const modelWithNewField: Model = {
|
|
80
|
+
name: 'Organization',
|
|
81
|
+
fields: [
|
|
82
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
83
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
84
|
+
{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const ctxWithBaseline: EmitterContext = {
|
|
88
|
+
...ctx,
|
|
89
|
+
apiSurface: {
|
|
90
|
+
language: 'node',
|
|
91
|
+
extractedFrom: 'test',
|
|
92
|
+
extractedAt: '2024-01-01',
|
|
93
|
+
classes: {},
|
|
94
|
+
typeAliases: {},
|
|
95
|
+
enums: {},
|
|
96
|
+
exports: {},
|
|
97
|
+
interfaces: {
|
|
98
|
+
Organization: {
|
|
99
|
+
name: 'Organization',
|
|
100
|
+
fields: {
|
|
101
|
+
id: { name: 'id', type: 'string', optional: false },
|
|
102
|
+
name: { name: 'name', type: 'string', optional: false },
|
|
103
|
+
},
|
|
104
|
+
extends: [],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
expect(modelHasNewFields(modelWithNewField, ctxWithBaseline)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('converts snake_case IR field names to camelCase for baseline comparison', () => {
|
|
113
|
+
const snakeModel: Model = {
|
|
114
|
+
name: 'Organization',
|
|
115
|
+
fields: [{ name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
116
|
+
};
|
|
117
|
+
const ctxWithBaseline: EmitterContext = {
|
|
118
|
+
...ctx,
|
|
119
|
+
apiSurface: {
|
|
120
|
+
language: 'node',
|
|
121
|
+
extractedFrom: 'test',
|
|
122
|
+
extractedAt: '2024-01-01',
|
|
123
|
+
classes: {},
|
|
124
|
+
typeAliases: {},
|
|
125
|
+
enums: {},
|
|
126
|
+
exports: {},
|
|
127
|
+
interfaces: {
|
|
128
|
+
Organization: {
|
|
129
|
+
name: 'Organization',
|
|
130
|
+
fields: {
|
|
131
|
+
organizationId: { name: 'organizationId', type: 'string', optional: false },
|
|
132
|
+
},
|
|
133
|
+
extends: [],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
expect(modelHasNewFields(snakeModel, ctxWithBaseline)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
package/test/php/models.test.ts
CHANGED
|
@@ -436,7 +436,7 @@ describe('generateModels', () => {
|
|
|
436
436
|
expect(file!.content).not.toContain('instanceof \\BackedEnum');
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
-
it('
|
|
439
|
+
it('emits all structurally identical models as full classes', () => {
|
|
440
440
|
const models: Model[] = [
|
|
441
441
|
{
|
|
442
442
|
name: 'FlagCreatedContextActor',
|
|
@@ -464,11 +464,12 @@ describe('generateModels', () => {
|
|
|
464
464
|
const specWithModels = { ...emptySpec, models };
|
|
465
465
|
const result = generateModels(models, { ...ctx, spec: specWithModels });
|
|
466
466
|
|
|
467
|
-
//
|
|
467
|
+
// PHP readonly classes cannot be aliased, so all models are emitted as full classes
|
|
468
468
|
const modelFiles = result.filter((f) => !f.path.includes('Trait'));
|
|
469
|
-
expect(modelFiles).toHaveLength(
|
|
470
|
-
// Shortest class name wins as canonical
|
|
469
|
+
expect(modelFiles).toHaveLength(3);
|
|
471
470
|
expect(modelFiles[0].path).toContain('FlagCreatedContextActor');
|
|
471
|
+
expect(modelFiles[1].path).toContain('FlagUpdatedContextActor');
|
|
472
|
+
expect(modelFiles[2].path).toContain('FlagDeletedContextActor');
|
|
472
473
|
});
|
|
473
474
|
|
|
474
475
|
it('does not produce double |null in @var for nullable optional arrays', () => {
|
|
@@ -379,6 +379,71 @@ describe('generateResources', () => {
|
|
|
379
379
|
expect(result[0].content).toContain('(deprecated) The organization ID');
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
it('uses body model field types for parameter group variant classes', () => {
|
|
383
|
+
const membershipModels: Model[] = [
|
|
384
|
+
{
|
|
385
|
+
name: 'OrganizationMembership',
|
|
386
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: 'CreateOrganizationMembershipRequest',
|
|
390
|
+
fields: [
|
|
391
|
+
{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
392
|
+
{ name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
393
|
+
{ name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
394
|
+
{
|
|
395
|
+
name: 'role_slugs',
|
|
396
|
+
type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
|
|
397
|
+
required: false,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
const membershipServices: Service[] = [
|
|
404
|
+
{
|
|
405
|
+
name: 'UserManagement',
|
|
406
|
+
operations: [
|
|
407
|
+
{
|
|
408
|
+
name: 'createOrganizationMembership',
|
|
409
|
+
httpMethod: 'post',
|
|
410
|
+
path: '/user_management/organization_memberships',
|
|
411
|
+
pathParams: [],
|
|
412
|
+
queryParams: [],
|
|
413
|
+
headerParams: [],
|
|
414
|
+
requestBody: { kind: 'model', name: 'CreateOrganizationMembershipRequest' },
|
|
415
|
+
response: { kind: 'model', name: 'OrganizationMembership' },
|
|
416
|
+
errors: [],
|
|
417
|
+
injectIdempotencyKey: false,
|
|
418
|
+
parameterGroups: [
|
|
419
|
+
{
|
|
420
|
+
name: 'role',
|
|
421
|
+
optional: true,
|
|
422
|
+
variants: [
|
|
423
|
+
{
|
|
424
|
+
name: 'single',
|
|
425
|
+
parameters: [{ name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: 'multiple',
|
|
429
|
+
parameters: [{ name: 'role_slugs', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
const spec = { ...emptySpec, services: membershipServices, models: membershipModels };
|
|
440
|
+
const result = generateResources(membershipServices, { ...ctx, spec });
|
|
441
|
+
const roleMultiple = result.find((file) => file.path === 'lib/Service/RoleMultiple.php');
|
|
442
|
+
|
|
443
|
+
expect(roleMultiple).toBeDefined();
|
|
444
|
+
expect(roleMultiple!.content).toContain('public readonly array $slugs');
|
|
445
|
+
});
|
|
446
|
+
|
|
382
447
|
it('requires inferred client credentials in wrapper methods', () => {
|
|
383
448
|
const lines = generateWrapperMethods(
|
|
384
449
|
{
|
|
@@ -505,7 +570,7 @@ describe('generateResources', () => {
|
|
|
505
570
|
const content = result[0].content;
|
|
506
571
|
|
|
507
572
|
expect(content).toContain('): string {');
|
|
508
|
-
expect(content).toContain("return $this->client->buildUrl('sso/logout', $query, $options);");
|
|
573
|
+
expect(content).toContain("return $this->client->buildUrl(path: 'sso/logout', query: $query, options: $options);");
|
|
509
574
|
expect(content).not.toContain('$this->client->request(');
|
|
510
575
|
expect(content).toContain('@return string');
|
|
511
576
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { workosEmittersPlugin } from '../src/plugin.js';
|
|
3
|
+
|
|
4
|
+
describe('workosEmittersPlugin', () => {
|
|
5
|
+
it('exports emitters for all supported languages', () => {
|
|
6
|
+
const languages = workosEmittersPlugin.emitters!.map((e) => e.language);
|
|
7
|
+
expect(languages).toContain('node');
|
|
8
|
+
expect(languages).toContain('python');
|
|
9
|
+
expect(languages).toContain('php');
|
|
10
|
+
expect(languages).toContain('go');
|
|
11
|
+
expect(languages).toContain('dotnet');
|
|
12
|
+
expect(languages).toContain('kotlin');
|
|
13
|
+
expect(languages).toContain('ruby');
|
|
14
|
+
expect(languages).toHaveLength(7);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('exports extractors for all supported languages', () => {
|
|
18
|
+
const languages = workosEmittersPlugin.extractors!.map((e) => e.language);
|
|
19
|
+
expect(languages).toContain('node');
|
|
20
|
+
expect(languages).toContain('python');
|
|
21
|
+
expect(languages).toContain('php');
|
|
22
|
+
expect(languages).toContain('go');
|
|
23
|
+
expect(languages).toContain('ruby');
|
|
24
|
+
expect(languages).toContain('rust');
|
|
25
|
+
expect(languages).toContain('kotlin');
|
|
26
|
+
expect(languages).toContain('dotnet');
|
|
27
|
+
expect(languages).toContain('elixir');
|
|
28
|
+
expect(languages).toHaveLength(9);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('exports smoke runners for all supported languages', () => {
|
|
32
|
+
const runners = Object.keys(workosEmittersPlugin.smokeRunners!);
|
|
33
|
+
expect(runners).toContain('node');
|
|
34
|
+
expect(runners).toContain('python');
|
|
35
|
+
expect(runners).toContain('php');
|
|
36
|
+
expect(runners).toContain('go');
|
|
37
|
+
expect(runners).toContain('ruby');
|
|
38
|
+
expect(runners).toContain('rust');
|
|
39
|
+
expect(runners).toContain('kotlin');
|
|
40
|
+
expect(runners).toContain('dotnet');
|
|
41
|
+
expect(runners).toContain('elixir');
|
|
42
|
+
expect(runners).toHaveLength(9);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('smoke runner paths are absolute', () => {
|
|
46
|
+
for (const [, runnerPath] of Object.entries(workosEmittersPlugin.smokeRunners!)) {
|
|
47
|
+
expect(runnerPath).toMatch(/^\//);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -141,6 +141,62 @@ describe('generateClient', () => {
|
|
|
141
141
|
expect(clientFile!.content).toContain('OrganizationsApiKeys');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it('re-exports parameter group classes from service __init__.py', () => {
|
|
145
|
+
const groupSpec: ApiSpec = {
|
|
146
|
+
...spec,
|
|
147
|
+
services: [
|
|
148
|
+
{
|
|
149
|
+
name: 'UserManagement',
|
|
150
|
+
operations: [
|
|
151
|
+
{
|
|
152
|
+
name: 'createOrganizationMembership',
|
|
153
|
+
httpMethod: 'post',
|
|
154
|
+
path: '/user_management/organization_memberships',
|
|
155
|
+
pathParams: [],
|
|
156
|
+
queryParams: [],
|
|
157
|
+
headerParams: [],
|
|
158
|
+
requestBody: { kind: 'model', name: 'CreateOrganizationMembershipRequest' },
|
|
159
|
+
parameterGroups: [
|
|
160
|
+
{
|
|
161
|
+
name: 'role',
|
|
162
|
+
optional: false,
|
|
163
|
+
variants: [
|
|
164
|
+
{
|
|
165
|
+
name: 'single',
|
|
166
|
+
parameters: [{ name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'multiple',
|
|
170
|
+
parameters: [
|
|
171
|
+
{
|
|
172
|
+
name: 'role_slugs',
|
|
173
|
+
type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
|
|
174
|
+
required: true,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
response: { kind: 'model', name: 'Organization' },
|
|
182
|
+
errors: [],
|
|
183
|
+
injectIdempotencyKey: false,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const files = generateClient(groupSpec, { ...ctx, spec: groupSpec });
|
|
191
|
+
const serviceInit = files.find((f) => f.path === 'src/workos/user_management/__init__.py');
|
|
192
|
+
expect(serviceInit).toBeDefined();
|
|
193
|
+
expect(serviceInit!.content).toContain('RoleSingle');
|
|
194
|
+
expect(serviceInit!.content).toContain('RoleMultiple');
|
|
195
|
+
expect(serviceInit!.content).toContain(
|
|
196
|
+
'from ._resource import UserManagement, AsyncUserManagement, RoleSingle, RoleMultiple',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
144
200
|
it('does not generate compat shim modules', () => {
|
|
145
201
|
const files = generateClient(spec, ctx);
|
|
146
202
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { buildOperationsMap } from '../../src/python/manifest.js';
|
|
3
3
|
import type { ApiSpec, EmitterContext, Service, Model } from '@workos/oagen';
|
|
4
4
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
5
|
|
|
@@ -61,14 +61,14 @@ const ctx: EmitterContext = {
|
|
|
61
61
|
spec,
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
describe('
|
|
64
|
+
describe('buildOperationsMap', () => {
|
|
65
65
|
it('uses flat client access paths (no dotted namespaces)', () => {
|
|
66
|
-
const
|
|
67
|
-
expect(files).toHaveLength(1);
|
|
66
|
+
const ops = buildOperationsMap(spec, ctx);
|
|
68
67
|
|
|
69
|
-
const
|
|
70
|
-
expect(
|
|
68
|
+
const orgEntry = ops['GET /organizations'] as { sdkMethod: string; service: string };
|
|
69
|
+
expect(orgEntry.service).toBe('organizations');
|
|
71
70
|
// Flat: no dotted access, each service has its own accessor
|
|
72
|
-
|
|
71
|
+
const apiKeysEntry = ops['GET /organizations/api_keys'] as { sdkMethod: string; service: string };
|
|
72
|
+
expect(apiKeysEntry.service).toBe('organizations_api_keys');
|
|
73
73
|
});
|
|
74
74
|
});
|
|
@@ -610,6 +610,105 @@ describe('generateModels', () => {
|
|
|
610
610
|
expect(modelFile.content).toContain('""".. deprecated:: This field is deprecated."""');
|
|
611
611
|
});
|
|
612
612
|
|
|
613
|
+
it('generates discriminator dispatcher with unknown fallback variant', () => {
|
|
614
|
+
const service: Service = {
|
|
615
|
+
name: 'Events',
|
|
616
|
+
operations: [
|
|
617
|
+
{
|
|
618
|
+
name: 'listEvents',
|
|
619
|
+
httpMethod: 'get',
|
|
620
|
+
path: '/events',
|
|
621
|
+
pathParams: [],
|
|
622
|
+
queryParams: [],
|
|
623
|
+
headerParams: [],
|
|
624
|
+
response: { kind: 'model', name: 'EventSchema' },
|
|
625
|
+
errors: [],
|
|
626
|
+
injectIdempotencyKey: false,
|
|
627
|
+
pagination: {
|
|
628
|
+
strategy: 'cursor',
|
|
629
|
+
param: 'after',
|
|
630
|
+
dataPath: 'data',
|
|
631
|
+
itemType: { kind: 'model', name: 'EventSchema' },
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const discriminatorModel: any = {
|
|
638
|
+
name: 'EventSchema',
|
|
639
|
+
fields: [],
|
|
640
|
+
discriminator: {
|
|
641
|
+
property: 'event',
|
|
642
|
+
mapping: {
|
|
643
|
+
'user.created': 'UserCreated',
|
|
644
|
+
'dsync.user.created': 'DsyncUserCreated',
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const models: Model[] = [
|
|
650
|
+
discriminatorModel,
|
|
651
|
+
{
|
|
652
|
+
name: 'UserCreated',
|
|
653
|
+
fields: [
|
|
654
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
655
|
+
{ name: 'event', type: { kind: 'literal', value: 'user.created' }, required: true },
|
|
656
|
+
{ name: 'data', type: { kind: 'primitive', type: 'unknown' }, required: true },
|
|
657
|
+
],
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
name: 'DsyncUserCreated',
|
|
661
|
+
fields: [
|
|
662
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
663
|
+
{ name: 'event', type: { kind: 'literal', value: 'dsync.user.created' }, required: true },
|
|
664
|
+
{ name: 'data', type: { kind: 'primitive', type: 'unknown' }, required: true },
|
|
665
|
+
],
|
|
666
|
+
},
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
const ctxWithServices: EmitterContext = {
|
|
670
|
+
...ctx,
|
|
671
|
+
spec: { ...emptySpec, services: [service], models },
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const files = generateModels(models, ctxWithServices);
|
|
675
|
+
const dispatcherFile = files.find((f) => f.path.includes('event_schema.py'))!;
|
|
676
|
+
expect(dispatcherFile).toBeDefined();
|
|
677
|
+
|
|
678
|
+
// Has Unknown variant dataclass
|
|
679
|
+
expect(dispatcherFile.content).toContain('@dataclass(slots=True)');
|
|
680
|
+
expect(dispatcherFile.content).toContain('class EventSchemaUnknown:');
|
|
681
|
+
expect(dispatcherFile.content).toContain('raw_data: Dict[str, Any]');
|
|
682
|
+
expect(dispatcherFile.content).toContain('def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaUnknown"');
|
|
683
|
+
expect(dispatcherFile.content).toContain('def to_dict(self) -> Dict[str, Any]:');
|
|
684
|
+
|
|
685
|
+
// Union includes Unknown variant
|
|
686
|
+
expect(dispatcherFile.content).toContain('EventSchemaVariant = Union[');
|
|
687
|
+
expect(dispatcherFile.content).toContain(' EventSchemaUnknown,');
|
|
688
|
+
|
|
689
|
+
// Dispatcher class
|
|
690
|
+
expect(dispatcherFile.content).toContain('class EventSchema:');
|
|
691
|
+
expect(dispatcherFile.content).toContain('_DISPATCH: ClassVar[Dict[str, type]]');
|
|
692
|
+
|
|
693
|
+
// from_dict falls back to Unknown instead of raising
|
|
694
|
+
expect(dispatcherFile.content).toContain('return EventSchemaUnknown.from_dict(data)');
|
|
695
|
+
expect(dispatcherFile.content).not.toContain('Unknown event');
|
|
696
|
+
|
|
697
|
+
// No str() coercion on discriminator value
|
|
698
|
+
expect(dispatcherFile.content).toContain('cls._DISPATCH.get(disc_value)');
|
|
699
|
+
expect(dispatcherFile.content).not.toContain('str(');
|
|
700
|
+
|
|
701
|
+
// Still raises on missing key and None value
|
|
702
|
+
expect(dispatcherFile.content).toContain("Missing required field 'event'");
|
|
703
|
+
expect(dispatcherFile.content).toContain('event must not be None');
|
|
704
|
+
|
|
705
|
+
// Barrel exports include Unknown variant
|
|
706
|
+
const barrel = files.find((f) => f.path.endsWith('__init__.py') && f.path.includes('models/'));
|
|
707
|
+
expect(barrel).toBeDefined();
|
|
708
|
+
expect(barrel!.content).toContain('EventSchemaUnknown');
|
|
709
|
+
expect(barrel!.content).toContain('EventSchemaVariant');
|
|
710
|
+
});
|
|
711
|
+
|
|
613
712
|
it('deduplicates models with recursively identical sub-model references', () => {
|
|
614
713
|
const service: Service = {
|
|
615
714
|
name: 'Events',
|