@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
@@ -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', () => {
@@ -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',