@workos/oagen-emitters 0.4.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 (105) 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 +8 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  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 +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. 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
+ });
@@ -436,7 +436,7 @@ describe('generateModels', () => {
436
436
  expect(file!.content).not.toContain('instanceof \\BackedEnum');
437
437
  });
438
438
 
439
- it('deduplicates structurally identical models', () => {
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
- // Only the trait + one canonical model file should be emitted (not 3)
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(1);
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
 
@@ -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',