@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.
- 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 +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.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 +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- 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 +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- 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/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- 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 +35 -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 +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- 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 +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- 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/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
|
@@ -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
|
{
|
|
@@ -470,6 +535,44 @@ describe('generateResources', () => {
|
|
|
470
535
|
// Should inject default and inferred values into query
|
|
471
536
|
expect(content).toContain("'response_type' => 'code'");
|
|
472
537
|
expect(content).toContain("$query['client_id'] = $this->client->requireClientId()");
|
|
538
|
+
|
|
539
|
+
// Redirect endpoint: should return string and build URL, not make HTTP request
|
|
540
|
+
expect(content).toContain('): string {');
|
|
541
|
+
expect(content).toContain('$this->client->buildUrl(');
|
|
542
|
+
expect(content).not.toContain('$this->client->request(');
|
|
543
|
+
expect(content).toContain('@return string');
|
|
544
|
+
// Should pass $options to buildUrl for base URL overrides
|
|
545
|
+
expect(content).toContain('$options);');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('generates redirect endpoint that builds URL for GET with primitive unknown response', () => {
|
|
549
|
+
const logoutServices: Service[] = [
|
|
550
|
+
{
|
|
551
|
+
name: 'SSO',
|
|
552
|
+
operations: [
|
|
553
|
+
{
|
|
554
|
+
name: 'getLogoutUrl',
|
|
555
|
+
httpMethod: 'get',
|
|
556
|
+
path: '/sso/logout',
|
|
557
|
+
pathParams: [],
|
|
558
|
+
queryParams: [{ name: 'token', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
559
|
+
headerParams: [],
|
|
560
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
561
|
+
errors: [],
|
|
562
|
+
injectIdempotencyKey: false,
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
|
|
568
|
+
const spec = { ...emptySpec, services: logoutServices };
|
|
569
|
+
const result = generateResources(logoutServices, { ...ctx, spec });
|
|
570
|
+
const content = result[0].content;
|
|
571
|
+
|
|
572
|
+
expect(content).toContain('): string {');
|
|
573
|
+
expect(content).toContain("return $this->client->buildUrl(path: 'sso/logout', query: $query, options: $options);");
|
|
574
|
+
expect(content).not.toContain('$this->client->request(');
|
|
575
|
+
expect(content).toContain('@return string');
|
|
473
576
|
});
|
|
474
577
|
|
|
475
578
|
it('skips base method when wrappers exist', () => {
|
package/test/php/tests.test.ts
CHANGED
|
@@ -106,6 +106,73 @@ describe('generateTests', () => {
|
|
|
106
106
|
expect(resourceTest!.content).toContain('foreach ($result as $item)');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('generates redirect endpoint test with query param assertions', () => {
|
|
110
|
+
const ssoServices: Service[] = [
|
|
111
|
+
{
|
|
112
|
+
name: 'SSO',
|
|
113
|
+
operations: [
|
|
114
|
+
{
|
|
115
|
+
name: 'getAuthorizationUrl',
|
|
116
|
+
httpMethod: 'get',
|
|
117
|
+
path: '/sso/authorize',
|
|
118
|
+
pathParams: [],
|
|
119
|
+
queryParams: [
|
|
120
|
+
{ name: 'client_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
121
|
+
{ name: 'response_type', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
122
|
+
{ name: 'redirect_uri', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
123
|
+
{ name: 'state', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
124
|
+
],
|
|
125
|
+
headerParams: [],
|
|
126
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
127
|
+
errors: [],
|
|
128
|
+
injectIdempotencyKey: false,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const ssoSpec = { ...spec, services: ssoServices };
|
|
135
|
+
const ssoCtx: EmitterContext = {
|
|
136
|
+
...ctx,
|
|
137
|
+
spec: ssoSpec,
|
|
138
|
+
resolvedOperations: [
|
|
139
|
+
{
|
|
140
|
+
operation: ssoServices[0].operations[0],
|
|
141
|
+
service: ssoServices[0],
|
|
142
|
+
methodName: 'get_authorization_url',
|
|
143
|
+
mountOn: 'SSO',
|
|
144
|
+
defaults: { response_type: 'code' },
|
|
145
|
+
inferFromClient: ['client_id'],
|
|
146
|
+
} as any,
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = generateTests(ssoSpec, ssoCtx);
|
|
151
|
+
const testFile = result.find((f) => f.path === 'tests/Service/SSOTest.php');
|
|
152
|
+
expect(testFile).toBeDefined();
|
|
153
|
+
const content = testFile!.content;
|
|
154
|
+
|
|
155
|
+
// Should be a redirect endpoint test (no mock responses, returns string)
|
|
156
|
+
expect(content).toContain('$this->assertIsString($result)');
|
|
157
|
+
expect(content).toContain("assertStringContainsString('sso/authorize'");
|
|
158
|
+
|
|
159
|
+
// Should parse query params from URL
|
|
160
|
+
expect(content).toContain('parse_str(parse_url($result, PHP_URL_QUERY)');
|
|
161
|
+
|
|
162
|
+
// Should assert visible query params (required and optional)
|
|
163
|
+
expect(content).toContain("assertSame('test_value', $query['redirect_uri'])");
|
|
164
|
+
expect(content).toContain("assertSame('test_value', $query['state'])");
|
|
165
|
+
|
|
166
|
+
// Should pass optional params in the method call
|
|
167
|
+
expect(content).toContain("state: 'test_value'");
|
|
168
|
+
|
|
169
|
+
// Should assert hidden defaults
|
|
170
|
+
expect(content).toContain("assertSame('code', $query['response_type'])");
|
|
171
|
+
|
|
172
|
+
// Should assert inferred client fields
|
|
173
|
+
expect(content).toContain("assertArrayHasKey('client_id', $query)");
|
|
174
|
+
});
|
|
175
|
+
|
|
109
176
|
it('generates fixture JSON files', () => {
|
|
110
177
|
const result = generateTests(spec, ctx);
|
|
111
178
|
|
|
@@ -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
|
|
|
@@ -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',
|