@workos/oagen-emitters 0.10.0 → 0.12.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 (51) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +2 -2
  6. package/dist/{plugin-H0KhxbN7.mjs → plugin-C408Wh-o.mjs} +2632 -717
  7. package/dist/plugin-C408Wh-o.mjs.map +1 -0
  8. package/dist/plugin.d.mts.map +1 -1
  9. package/dist/plugin.mjs +1 -1
  10. package/docs/sdk-architecture/rust.md +323 -0
  11. package/package.json +2 -2
  12. package/src/go/models.ts +48 -3
  13. package/src/index.ts +1 -0
  14. package/src/php/models.ts +27 -3
  15. package/src/php/resources.ts +16 -16
  16. package/src/plugin.ts +2 -1
  17. package/src/python/enums.ts +11 -54
  18. package/src/python/models.ts +204 -219
  19. package/src/python/path-expression.ts +75 -26
  20. package/src/python/resources.ts +19 -44
  21. package/src/python/shared-schemas.ts +488 -0
  22. package/src/python/tests.ts +9 -7
  23. package/src/ruby/resources.ts +13 -1
  24. package/src/rust/client.ts +62 -0
  25. package/src/rust/enums.ts +201 -0
  26. package/src/rust/fixtures.ts +110 -0
  27. package/src/rust/index.ts +95 -0
  28. package/src/rust/manifest.ts +31 -0
  29. package/src/rust/models.ts +150 -0
  30. package/src/rust/naming.ts +131 -0
  31. package/src/rust/resources.ts +689 -0
  32. package/src/rust/secret.ts +59 -0
  33. package/src/rust/tests.ts +298 -0
  34. package/src/rust/type-map.ts +225 -0
  35. package/test/entrypoint.test.ts +1 -0
  36. package/test/go/models.test.ts +116 -1
  37. package/test/go/resources.test.ts +70 -0
  38. package/test/php/models.test.ts +77 -0
  39. package/test/php/resources.test.ts +95 -0
  40. package/test/plugin.test.ts +2 -1
  41. package/test/python/enums.test.ts +91 -0
  42. package/test/python/models.test.ts +225 -0
  43. package/test/python/resources.test.ts +47 -2
  44. package/test/ruby/resources.test.ts +58 -0
  45. package/test/rust/client.test.ts +62 -0
  46. package/test/rust/enums.test.ts +117 -0
  47. package/test/rust/manifest.test.ts +73 -0
  48. package/test/rust/models.test.ts +139 -0
  49. package/test/rust/resources.test.ts +245 -0
  50. package/test/rust/type-map.test.ts +83 -0
  51. package/dist/plugin-H0KhxbN7.mjs.map +0 -1
@@ -709,6 +709,86 @@ describe('generateModels', () => {
709
709
  expect(barrel!.content).toContain('EventSchemaVariant');
710
710
  });
711
711
 
712
+ it('emits strict dispatch (no raw-dict fallback, no hasattr) for fields typed as a discriminated union', () => {
713
+ const service: Service = {
714
+ name: 'ApiKeys',
715
+ operations: [
716
+ {
717
+ name: 'getApiKey',
718
+ httpMethod: 'get',
719
+ path: '/api_keys/{id}',
720
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
721
+ queryParams: [],
722
+ headerParams: [],
723
+ response: { kind: 'model', name: 'ApiKeyCreatedData' },
724
+ errors: [],
725
+ injectIdempotencyKey: false,
726
+ },
727
+ ],
728
+ };
729
+
730
+ const models: Model[] = [
731
+ {
732
+ name: 'ApiKeyCreatedData',
733
+ fields: [
734
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
735
+ {
736
+ name: 'owner',
737
+ type: {
738
+ kind: 'union',
739
+ variants: [
740
+ { kind: 'model', name: 'ApiKeyCreatedDataOwner' },
741
+ { kind: 'model', name: 'UserApiKeyCreatedDataOwner' },
742
+ ],
743
+ discriminator: {
744
+ property: 'type',
745
+ mapping: {
746
+ organization: 'ApiKeyCreatedDataOwner',
747
+ user: 'UserApiKeyCreatedDataOwner',
748
+ },
749
+ },
750
+ },
751
+ required: true,
752
+ },
753
+ ],
754
+ },
755
+ {
756
+ name: 'ApiKeyCreatedDataOwner',
757
+ fields: [
758
+ { name: 'type', type: { kind: 'literal', value: 'organization' }, required: true },
759
+ { name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true },
760
+ ],
761
+ },
762
+ {
763
+ name: 'UserApiKeyCreatedDataOwner',
764
+ fields: [
765
+ { name: 'type', type: { kind: 'literal', value: 'user' }, required: true },
766
+ { name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true },
767
+ ],
768
+ },
769
+ ];
770
+
771
+ const files = generateModels(models, {
772
+ ...ctx,
773
+ spec: { ...emptySpec, services: [service], models },
774
+ });
775
+
776
+ const parent = files.find((f) => f.path.endsWith('api_key_created_data.py'))!;
777
+ expect(parent).toBeDefined();
778
+
779
+ // from_dict performs strict dispatch and raises on unknown discriminator
780
+ expect(parent.content).toContain('"Unknown discriminator');
781
+ expect(parent.content).toContain('ApiKeyCreatedData.owner');
782
+ expect(parent.content).toContain('Expected one of {sorted(');
783
+ // Old lax fallback patterns are gone
784
+ expect(parent.content).not.toContain('else data["owner"]');
785
+ expect(parent.content).not.toContain('else data[');
786
+
787
+ // to_dict no longer needs the hasattr workaround
788
+ expect(parent.content).not.toContain('hasattr');
789
+ expect(parent.content).toContain('result["owner"] = self.owner.to_dict()');
790
+ });
791
+
712
792
  it('deduplicates models with recursively identical sub-model references', () => {
713
793
  const service: Service = {
714
794
  name: 'Events',
@@ -812,4 +892,149 @@ describe('generateModels', () => {
812
892
  expect(contextBFile.content).toContain('TypeAlias');
813
893
  expect(contextBFile.content).not.toContain('@dataclass');
814
894
  });
895
+
896
+ it('places a model in common/ when referenced by 2+ services', () => {
897
+ const sharedModel: Model = {
898
+ name: 'PageInfo',
899
+ fields: [
900
+ { name: 'page_number', type: { kind: 'primitive', type: 'string' }, required: true },
901
+ { name: 'page_size', type: { kind: 'primitive', type: 'string' }, required: true },
902
+ ],
903
+ };
904
+ const orgsService: Service = {
905
+ name: 'Authorization',
906
+ operations: [
907
+ {
908
+ name: 'listAuthz',
909
+ httpMethod: 'get',
910
+ path: '/authz',
911
+ pathParams: [],
912
+ queryParams: [],
913
+ headerParams: [],
914
+ response: { kind: 'model', name: 'PageInfo' },
915
+ errors: [],
916
+ injectIdempotencyKey: false,
917
+ },
918
+ ],
919
+ };
920
+ const usersService: Service = {
921
+ name: 'Organizations',
922
+ operations: [
923
+ {
924
+ name: 'listOrgs',
925
+ httpMethod: 'get',
926
+ path: '/orgs',
927
+ pathParams: [],
928
+ queryParams: [],
929
+ headerParams: [],
930
+ response: { kind: 'model', name: 'PageInfo' },
931
+ errors: [],
932
+ injectIdempotencyKey: false,
933
+ },
934
+ ],
935
+ };
936
+ const models: Model[] = [sharedModel];
937
+
938
+ const ctxWithServices: EmitterContext = {
939
+ ...ctx,
940
+ spec: { ...emptySpec, services: [orgsService, usersService], models },
941
+ };
942
+
943
+ const files = generateModels(models, ctxWithServices);
944
+ const sharedFile = files.find((f) => f.path.endsWith('/page_info.py'));
945
+ expect(sharedFile).toBeDefined();
946
+ expect(sharedFile!.path).toBe('src/workos/common/models/page_info.py');
947
+ // No service-local copy of the shared model.
948
+ expect(files.find((f) => f.path === 'src/workos/authorization/models/page_info.py')).toBeUndefined();
949
+ expect(files.find((f) => f.path === 'src/workos/organizations/models/page_info.py')).toBeUndefined();
950
+ // common/__init__.py and common/models/__init__.py both re-export PageInfo.
951
+ const commonInit = files.find((f) => f.path === 'src/workos/common/__init__.py');
952
+ expect(commonInit).toBeDefined();
953
+ expect(commonInit!.content).toContain('from .models import PageInfo as PageInfo');
954
+ const commonModelsInit = files.find((f) => f.path === 'src/workos/common/models/__init__.py');
955
+ expect(commonModelsInit).toBeDefined();
956
+ expect(commonModelsInit!.content).toContain('from .page_info import PageInfo as PageInfo');
957
+ });
958
+
959
+ it('keeps a model in service dir when only one service references it', () => {
960
+ const onlyModel: Model = {
961
+ name: 'OnlyOrg',
962
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
963
+ };
964
+ const service: Service = {
965
+ name: 'Organizations',
966
+ operations: [
967
+ {
968
+ name: 'getOrg',
969
+ httpMethod: 'get',
970
+ path: '/orgs/{id}',
971
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
972
+ queryParams: [],
973
+ headerParams: [],
974
+ response: { kind: 'model', name: 'OnlyOrg' },
975
+ errors: [],
976
+ injectIdempotencyKey: false,
977
+ },
978
+ ],
979
+ };
980
+
981
+ const ctxWithServices: EmitterContext = {
982
+ ...ctx,
983
+ spec: { ...emptySpec, services: [service], models: [onlyModel] },
984
+ };
985
+
986
+ const files = generateModels([onlyModel], ctxWithServices);
987
+ const file = files.find((f) => f.path.endsWith('/only_org.py'));
988
+ expect(file!.path).toBe('src/workos/organizations/models/only_org.py');
989
+ });
990
+
991
+ it('respects modelHints over the shared rule', () => {
992
+ const sharedModel: Model = {
993
+ name: 'PinnedThing',
994
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
995
+ };
996
+ const a: Service = {
997
+ name: 'Authorization',
998
+ operations: [
999
+ {
1000
+ name: 'a',
1001
+ httpMethod: 'get',
1002
+ path: '/a',
1003
+ pathParams: [],
1004
+ queryParams: [],
1005
+ headerParams: [],
1006
+ response: { kind: 'model', name: 'PinnedThing' },
1007
+ errors: [],
1008
+ injectIdempotencyKey: false,
1009
+ },
1010
+ ],
1011
+ };
1012
+ const b: Service = {
1013
+ name: 'Organizations',
1014
+ operations: [
1015
+ {
1016
+ name: 'b',
1017
+ httpMethod: 'get',
1018
+ path: '/b',
1019
+ pathParams: [],
1020
+ queryParams: [],
1021
+ headerParams: [],
1022
+ response: { kind: 'model', name: 'PinnedThing' },
1023
+ errors: [],
1024
+ injectIdempotencyKey: false,
1025
+ },
1026
+ ],
1027
+ };
1028
+
1029
+ // PinnedThing is referenced by 2 services but explicitly pinned to Authorization.
1030
+ const ctxWithServices: EmitterContext = {
1031
+ ...ctx,
1032
+ spec: { ...emptySpec, services: [a, b], models: [sharedModel] },
1033
+ modelHints: { PinnedThing: 'Authorization' },
1034
+ };
1035
+
1036
+ const files = generateModels([sharedModel], ctxWithServices);
1037
+ expect(files.find((f) => f.path === 'src/workos/authorization/models/pinned_thing.py')).toBeDefined();
1038
+ expect(files.find((f) => f.path === 'src/workos/common/models/pinned_thing.py')).toBeUndefined();
1039
+ });
815
1040
  });
@@ -83,8 +83,8 @@ describe('generateResources', () => {
83
83
  // GET method with path param
84
84
  expect(content).toContain('def get_organization(');
85
85
  expect(content).toContain('id: str,');
86
- expect(content).toContain(`f"organizations/{quote(str(id), safe='')}"`);
87
- expect(content).toContain('from urllib.parse import quote');
86
+ expect(content).toContain(`("organizations", str(id))`);
87
+ expect(content).not.toContain('from urllib.parse import quote');
88
88
  expect(content).toContain('model=Organization');
89
89
  // Public request methods (no underscore prefix)
90
90
  expect(content).toContain('self._client.request(');
@@ -167,6 +167,51 @@ describe('generateResources', () => {
167
167
  'after: An object ID that defines your place in the list. When the ID is not present, you are at the end of the list.',
168
168
  );
169
169
  expect(content).toContain('order: Order the results by the creation time.');
170
+ // The spec has no `default` for `order` here, so the SDK must NOT
171
+ // hardcode 'desc' on the client. Server's default applies instead.
172
+ expect(content).toContain('order: Optional[str] = None,');
173
+ expect(content).not.toContain('order: Optional[str] = "desc"');
174
+ });
175
+
176
+ it('reads pagination order default from the spec rather than hardcoding "desc"', () => {
177
+ const models: Model[] = [
178
+ { name: 'Organization', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
179
+ ];
180
+ const services: Service[] = [
181
+ {
182
+ name: 'Organizations',
183
+ operations: [
184
+ {
185
+ name: 'listOrganizations',
186
+ httpMethod: 'get',
187
+ path: '/organizations',
188
+ pathParams: [],
189
+ queryParams: [
190
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
191
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
192
+ {
193
+ name: 'order',
194
+ type: { kind: 'primitive', type: 'string' },
195
+ required: false,
196
+ default: 'desc',
197
+ },
198
+ ],
199
+ headerParams: [],
200
+ response: { kind: 'model', name: 'OrganizationList' },
201
+ errors: [],
202
+ injectIdempotencyKey: false,
203
+ pagination: {
204
+ strategy: 'cursor',
205
+ param: 'after',
206
+ dataPath: 'data',
207
+ itemType: { kind: 'model', name: 'Organization' },
208
+ },
209
+ },
210
+ ],
211
+ },
212
+ ];
213
+ const content = generateResources(services, { ...ctx, spec: { ...emptySpec, services, models } })[0].content;
214
+ expect(content).toContain('order: Optional[str] = "desc",');
170
215
  });
171
216
 
172
217
  it('indents multiline argument descriptions in docstrings', () => {
@@ -187,6 +187,64 @@ describe('ruby/resources', () => {
187
187
  expect(content).toContain('ListStruct.from_response(');
188
188
  });
189
189
 
190
+ it('reads pagination order default from the spec rather than hardcoding "desc"', () => {
191
+ const services: Service[] = [
192
+ {
193
+ name: 'Organizations',
194
+ operations: [
195
+ makeOp({
196
+ name: 'listOrganizations',
197
+ queryParams: [
198
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
199
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
200
+ {
201
+ name: 'order',
202
+ type: { kind: 'primitive', type: 'string' },
203
+ required: false,
204
+ default: 'desc',
205
+ },
206
+ ],
207
+ pagination: {
208
+ strategy: 'cursor',
209
+ param: 'after',
210
+ dataPath: 'data',
211
+ itemType: { kind: 'model', name: 'Organization' },
212
+ },
213
+ }),
214
+ ],
215
+ },
216
+ ];
217
+ const content = generateResources(services, makeCtx(makeSpec(services)))[0].content;
218
+ expect(content).toContain("order: 'desc'");
219
+ });
220
+
221
+ it("emits order: nil when the spec carries no default (no 'desc' hardcode)", () => {
222
+ const services: Service[] = [
223
+ {
224
+ name: 'Organizations',
225
+ operations: [
226
+ makeOp({
227
+ name: 'listOrganizations',
228
+ queryParams: [
229
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
230
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
231
+ { name: 'order', type: { kind: 'primitive', type: 'string' }, required: false },
232
+ ],
233
+ pagination: {
234
+ strategy: 'cursor',
235
+ param: 'after',
236
+ dataPath: 'data',
237
+ itemType: { kind: 'model', name: 'Organization' },
238
+ },
239
+ }),
240
+ ],
241
+ },
242
+ ];
243
+ const content = generateResources(services, makeCtx(makeSpec(services)))[0].content;
244
+ expect(content).toContain('order: nil');
245
+ expect(content).not.toContain("order: 'desc'");
246
+ });
247
+
190
248
  // ── P0-3: paginated response shape detection ──────────────────────────
191
249
 
192
250
  it('generates ListStruct for paginated endpoints with array response type', () => {
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateClient } from '../../src/rust/client.js';
5
+ import { generateModels } from '../../src/rust/models.js';
6
+ import { UnionRegistry } from '../../src/rust/type-map.js';
7
+
8
+ function makeCtx(spec: ApiSpec): EmitterContext {
9
+ return { namespace: 'workos', namespacePascal: 'WorkOS', spec };
10
+ }
11
+
12
+ const emptySpec: ApiSpec = {
13
+ name: 'Test',
14
+ version: '1.0.0',
15
+ baseUrl: '',
16
+ services: [],
17
+ models: [],
18
+ enums: [],
19
+ sdk: defaultSdkBehavior(),
20
+ };
21
+
22
+ describe('rust/client', () => {
23
+ it('emits the unions module and an empty resources_api shell when there are no mount targets', () => {
24
+ const files = generateClient(emptySpec, makeCtx(emptySpec), new UnionRegistry());
25
+ expect(files.map((f) => f.path).sort()).toEqual(['src/models/_unions.rs', 'src/resources_api.rs']);
26
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
27
+ expect(unions.content).toContain('No oneOf-style unions registered');
28
+ const api = files.find((f) => f.path === 'src/resources_api.rs')!;
29
+ expect(api.content).toContain('impl Client {');
30
+ });
31
+
32
+ it('renders unions registered earlier in the emit run', () => {
33
+ const registry = new UnionRegistry();
34
+ const models: Model[] = [
35
+ {
36
+ name: 'Event',
37
+ fields: [
38
+ {
39
+ name: 'payload',
40
+ type: {
41
+ kind: 'union',
42
+ variants: [
43
+ { kind: 'model', name: 'UserCreated' },
44
+ { kind: 'model', name: 'UserDeleted' },
45
+ ],
46
+ discriminator: {
47
+ property: 'event',
48
+ mapping: { 'user.created': 'UserCreated', 'user.deleted': 'UserDeleted' },
49
+ },
50
+ },
51
+ required: true,
52
+ },
53
+ ],
54
+ },
55
+ ];
56
+ generateModels(models, makeCtx(emptySpec), registry);
57
+ const files = generateClient(emptySpec, makeCtx(emptySpec), registry);
58
+ const unions = files.find((f) => f.path === 'src/models/_unions.rs')!;
59
+ expect(unions.content).toContain('#[serde(tag = "event")]');
60
+ expect(unions.content).toContain('pub enum EventPayloadOneOf {');
61
+ });
62
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateEnums } from '../../src/rust/enums.js';
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('rust/enums', () => {
23
+ it('emits a non_exhaustive enum with manual Serialize/Deserialize and a fallback variant', () => {
24
+ const enums: Enum[] = [
25
+ {
26
+ name: 'Status',
27
+ values: [
28
+ { name: 'active', value: 'active' },
29
+ { name: 'inactive', value: 'inactive' },
30
+ ],
31
+ },
32
+ ];
33
+ const files = generateEnums(enums, ctx);
34
+ const f = files.find((x) => x.path === 'src/enums/status.rs')!;
35
+ expect(f.content).toContain('pub enum Status {');
36
+ expect(f.content).toContain('Active,');
37
+ expect(f.content).toContain('Inactive,');
38
+ expect(f.content).toContain('#[non_exhaustive]');
39
+ expect(f.content).toContain('Unknown(String),');
40
+ expect(f.content).toContain('impl Serialize for Status');
41
+ expect(f.content).toContain("impl<'de> Deserialize<'de> for Status");
42
+ expect(f.content).toContain('Self::Active => "active"');
43
+ expect(f.content).toContain('Self::Inactive => "inactive"');
44
+ // No derive(Serialize/Deserialize) — they're hand-written.
45
+ expect(f.content).not.toContain('Serialize, Deserialize)]');
46
+ });
47
+
48
+ it('round-trips non-snake-case wire values through canonical strings', () => {
49
+ const enums: Enum[] = [
50
+ {
51
+ name: 'Mode',
52
+ values: [
53
+ { name: 'kebab-case', value: 'kebab-case' },
54
+ { name: 'mixedCase', value: 'mixedCase' },
55
+ ],
56
+ },
57
+ ];
58
+ const files = generateEnums(enums, ctx);
59
+ const f = files.find((x) => x.path === 'src/enums/mode.rs')!;
60
+ // FromStr matches the original wire string; as_str returns it back.
61
+ expect(f.content).toContain('"kebab-case" => Self::KebabCase');
62
+ expect(f.content).toContain('"mixedCase" => Self::MixedCase');
63
+ expect(f.content).toContain('Self::KebabCase => "kebab-case"');
64
+ expect(f.content).toContain('Self::MixedCase => "mixedCase"');
65
+ });
66
+
67
+ it('collapses alias wire values into a single canonical variant', () => {
68
+ const enums: Enum[] = [
69
+ {
70
+ name: 'Trigger',
71
+ values: [
72
+ { name: 'sign-up', value: 'sign-up' },
73
+ { name: 'sign_up', value: 'sign_up' },
74
+ { name: 'sign up', value: 'sign up' },
75
+ ],
76
+ },
77
+ ];
78
+ const files = generateEnums(enums, ctx);
79
+ const f = files.find((x) => x.path === 'src/enums/trigger.rs')!;
80
+ // One Rust variant for all three aliases.
81
+ expect(f.content.match(/^\s+SignUp,$/m)).not.toBeNull();
82
+ // Canonical wire value is the first one seen.
83
+ expect(f.content).toContain('Self::SignUp => "sign-up"');
84
+ // Every alias deserializes into the same variant.
85
+ expect(f.content).toContain('"sign-up" => Self::SignUp');
86
+ expect(f.content).toContain('"sign_up" => Self::SignUp');
87
+ expect(f.content).toContain('"sign up" => Self::SignUp');
88
+ });
89
+
90
+ it('falls back to a non-Unknown name when the spec defines an Unknown variant', () => {
91
+ const enums: Enum[] = [
92
+ {
93
+ name: 'State',
94
+ values: [
95
+ { name: 'unknown', value: 'unknown' },
96
+ { name: 'ready', value: 'ready' },
97
+ ],
98
+ },
99
+ ];
100
+ const files = generateEnums(enums, ctx);
101
+ const f = files.find((x) => x.path === 'src/enums/state.rs')!;
102
+ expect(f.content).toContain('Unknown,');
103
+ expect(f.content).toContain('Unrecognized(String),');
104
+ });
105
+
106
+ it('skips empty enums', () => {
107
+ const enums: Enum[] = [{ name: 'Empty', values: [] }];
108
+ const files = generateEnums(enums, ctx);
109
+ expect(files.find((f) => f.path === 'src/enums/empty.rs')).toBeUndefined();
110
+ });
111
+
112
+ it('always emits a barrel even when no enums', () => {
113
+ const files = generateEnums([], ctx);
114
+ expect(files).toHaveLength(1);
115
+ expect(files[0]!.path).toBe('src/enums/mod.rs');
116
+ });
117
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { buildOperationsMap } from '../../src/rust/manifest.js';
5
+
6
+ const spec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [
11
+ {
12
+ name: 'Organizations',
13
+ operations: [
14
+ {
15
+ name: 'createOrganization',
16
+ httpMethod: 'post',
17
+ path: '/organizations',
18
+ pathParams: [],
19
+ queryParams: [],
20
+ headerParams: [],
21
+ response: { kind: 'primitive', type: 'unknown' },
22
+ errors: [],
23
+ injectIdempotencyKey: false,
24
+ },
25
+ {
26
+ name: 'getOrganization',
27
+ httpMethod: 'get',
28
+ path: '/organizations/{id}',
29
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
30
+ queryParams: [],
31
+ headerParams: [],
32
+ response: { kind: 'primitive', type: 'unknown' },
33
+ errors: [],
34
+ injectIdempotencyKey: false,
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ models: [],
40
+ enums: [],
41
+ sdk: defaultSdkBehavior(),
42
+ };
43
+
44
+ const ctx: EmitterContext = {
45
+ namespace: 'workos',
46
+ namespacePascal: 'WorkOS',
47
+ spec,
48
+ resolvedOperations: spec.services.flatMap((service) =>
49
+ service.operations.map((operation) => ({
50
+ service,
51
+ operation,
52
+ methodName: operation.name,
53
+ mountOn: service.name,
54
+ defaults: {},
55
+ inferFromClient: [],
56
+ urlBuilder: false,
57
+ })),
58
+ ),
59
+ };
60
+
61
+ describe('rust/manifest', () => {
62
+ it('maps each HTTP operation to an SDK method + service accessor', () => {
63
+ const map = buildOperationsMap(spec, ctx);
64
+ expect(map['POST /organizations']).toEqual({
65
+ sdkMethod: 'create_organization',
66
+ service: 'organizations',
67
+ });
68
+ expect(map['GET /organizations/{id}']).toEqual({
69
+ sdkMethod: 'get_organization',
70
+ service: 'organizations',
71
+ });
72
+ });
73
+ });