@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
@@ -1,3 +1,50 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared acronym fixes
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /**
6
+ * Base set of acronym corrections applied after PascalCase conversion.
7
+ * These are domain-specific terms that `toPascalCase` produces as e.g.
8
+ * "Sso" but should be rendered as "SSO" in every SDK language.
9
+ *
10
+ * Language emitters can extend this with additional entries (e.g. Go adds
11
+ * API, URL, HTTP, JSON, etc. per Go naming conventions).
12
+ */
13
+ export const BASE_ACRONYM_FIXES: readonly [RegExp, string][] = [
14
+ [/Workos/g, 'WorkOS'],
15
+ [/Sso/g, 'SSO'],
16
+ [/Mfa/g, 'MFA'],
17
+ [/Jwt/g, 'JWT'],
18
+ [/Cors/g, 'CORS'],
19
+ [/Saml/g, 'SAML'],
20
+ [/Scim/g, 'SCIM'],
21
+ [/Rbac/g, 'RBAC'],
22
+ [/Oauth/g, 'OAuth'],
23
+ [/Oidc/g, 'OIDC'],
24
+ ];
25
+
26
+ /**
27
+ * Apply acronym corrections to a PascalCase string.
28
+ * Uses the base set by default; pass extra entries for language-specific
29
+ * conventions (e.g. Go's `[/Api(?=[A-Z]|$)/g, 'API']`).
30
+ */
31
+ export function applyAcronymFixes(s: string, extra?: readonly [RegExp, string][]): string {
32
+ let result = s;
33
+ for (const [pattern, replacement] of BASE_ACRONYM_FIXES) {
34
+ result = result.replace(pattern, replacement);
35
+ }
36
+ if (extra) {
37
+ for (const [pattern, replacement] of extra) {
38
+ result = result.replace(pattern, replacement);
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // URN stripping
46
+ // ---------------------------------------------------------------------------
47
+
1
48
  /** Strip URN OAuth grant-type prefixes from discriminator-derived schema names. */
2
49
  export function stripUrnPrefix(name: string): string {
3
50
  // Handles both OAuth and Oauth casing from different PascalCase implementations
@@ -78,10 +125,46 @@ function startsWithVerb(desc: string): boolean {
78
125
  return VERB_STARTERS.has(firstWord);
79
126
  }
80
127
 
128
+ /**
129
+ * Words beginning with a vowel letter but a consonant /j/ or /w/ sound —
130
+ * take "a", not "an".
131
+ */
132
+ const CONSONANT_SOUND_INITIAL_VOWEL = new Set([
133
+ 'user',
134
+ 'unit',
135
+ 'unique',
136
+ 'united',
137
+ 'universal',
138
+ 'university',
139
+ 'european',
140
+ 'one',
141
+ 'once',
142
+ 'useful',
143
+ 'used',
144
+ 'usable',
145
+ 'ubiquitous',
146
+ ]);
147
+
148
+ /**
149
+ * Words beginning with a consonant letter but a vowel sound (silent h) —
150
+ * take "an", not "a".
151
+ */
152
+ const VOWEL_SOUND_INITIAL_CONSONANT = new Set(['hour', 'honest', 'honor', 'honorable', 'heir']);
153
+
81
154
  /**
82
155
  * Select the correct indefinite article ("a" or "an") for a word.
156
+ *
157
+ * Matches English phonetics, not orthography: "a user" (consonant /j/ sound
158
+ * despite leading 'u'), "an hour" (vowel sound despite leading 'h'). Falls
159
+ * back to a vowel-letter regex for words not in either exception set.
83
160
  */
84
161
  export function articleFor(word: string): string {
162
+ const firstWord = word
163
+ .split(/\s+/)[0]
164
+ .toLowerCase()
165
+ .replace(/[^a-z]/g, '');
166
+ if (CONSONANT_SOUND_INITIAL_VOWEL.has(firstWord)) return 'a';
167
+ if (VOWEL_SOUND_INITIAL_CONSONANT.has(firstWord)) return 'an';
85
168
  return /^[aeiou]/i.test(word) ? 'an' : 'a';
86
169
  }
87
170
 
@@ -18,6 +18,17 @@ export interface NonSpecService {
18
18
  * someone reading this file understands what the service does.
19
19
  */
20
20
  description: string;
21
+
22
+ /**
23
+ * When true, the generated Client struct includes a cached field for this
24
+ * service and a public accessor method — identical to spec-driven services.
25
+ * The hand-written file must export the service type (e.g. PasswordlessService)
26
+ * but should NOT define its own Client accessor (the generated code handles that).
27
+ *
28
+ * Defaults to false — most non-spec modules are standalone helpers, not
29
+ * Client-mounted services.
30
+ */
31
+ hasClientAccessor?: boolean;
21
32
  }
22
33
 
23
34
  /**
@@ -29,10 +40,12 @@ export const NON_SPEC_SERVICES: readonly NonSpecService[] = [
29
40
  {
30
41
  id: 'passwordless',
31
42
  description: 'Passwordless (magic-link) session endpoints, not yet in the OpenAPI spec.',
43
+ hasClientAccessor: true,
32
44
  },
33
45
  {
34
46
  id: 'vault',
35
47
  description: 'Vault KV storage, key operations, and client-side AES-GCM encrypt/decrypt.',
48
+ hasClientAccessor: true,
36
49
  },
37
50
  {
38
51
  id: 'webhook_verification',
@@ -1,11 +1,42 @@
1
- import type { Operation, EmitterContext, Service, ResolvedOperation } from '@workos/oagen';
1
+ import type { Operation, EmitterContext, Service, ResolvedOperation, Model, TypeRef } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
 
4
+ /**
5
+ * Fail fast when two distinct paths in the same mount resolve to the same SDK
6
+ * method name. Emitters can sometimes paper over this with per-language
7
+ * deduplication, but manifests and cross-language parity become inconsistent.
8
+ */
9
+ export function assertUniqueResolvedMethods(ctx: EmitterContext): void {
10
+ const seen = new Map<string, { path: string; httpMethod: string }>();
11
+
12
+ for (const resolved of ctx.resolvedOperations ?? []) {
13
+ const key = `${resolved.mountOn}.${resolved.methodName}`;
14
+ const current = {
15
+ path: resolved.operation.path,
16
+ httpMethod: resolved.operation.httpMethod.toUpperCase(),
17
+ };
18
+ const existing = seen.get(key);
19
+
20
+ if (existing && existing.path !== current.path) {
21
+ throw new Error(
22
+ `Resolved operation name collision for ${key}: ` +
23
+ `${existing.httpMethod} ${existing.path} conflicts with ${current.httpMethod} ${current.path}`,
24
+ );
25
+ }
26
+
27
+ if (!existing) {
28
+ seen.set(key, current);
29
+ }
30
+ }
31
+ }
32
+
4
33
  /**
5
34
  * Build a lookup map from "METHOD /path" to ResolvedOperation.
6
35
  * Used by emitters to find the resolved method name for any IR operation.
7
36
  */
8
37
  export function buildResolvedLookup(ctx: EmitterContext): Map<string, ResolvedOperation> {
38
+ assertUniqueResolvedMethods(ctx);
39
+
9
40
  const map = new Map<string, ResolvedOperation>();
10
41
  for (const r of ctx.resolvedOperations ?? []) {
11
42
  const key = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
@@ -107,3 +138,46 @@ export function buildHiddenParams(resolvedOp?: ResolvedOperation): Set<string> {
107
138
  export function hasHiddenParams(resolvedOp?: ResolvedOperation): boolean {
108
139
  return Object.keys(getOpDefaults(resolvedOp)).length > 0 || getOpInferFromClient(resolvedOp).length > 0;
109
140
  }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Parameter group helpers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Collect all parameter names that belong to any mutually-exclusive parameter group.
148
+ * These params are serialized via group-level dispatch (e.g. applyToQuery, isinstance,
149
+ * sealed-class when, etc.) instead of individual struct/class fields.
150
+ */
151
+ export function collectGroupedParamNames(op: Operation): Set<string> {
152
+ const names = new Set<string>();
153
+ for (const group of op.parameterGroups ?? []) {
154
+ for (const variant of group.variants) {
155
+ for (const param of variant.parameters) {
156
+ names.add(param.name);
157
+ }
158
+ }
159
+ }
160
+ return names;
161
+ }
162
+
163
+ /**
164
+ * Build a fallback map from request-body wire field name to TypeRef.
165
+ *
166
+ * Some parameter-group variants lose array/object fidelity in the IR and fall
167
+ * back to primitive strings. Cross-referencing the request body model restores
168
+ * the actual field type when the grouped params belong to the body.
169
+ */
170
+ export function collectBodyFieldTypes(op: Operation, models: Model[]): Map<string, TypeRef> {
171
+ const fieldTypes = new Map<string, TypeRef>();
172
+ const reqBody = op.requestBody;
173
+ if (reqBody?.kind !== 'model') return fieldTypes;
174
+
175
+ const bodyModel = models.find((model) => model.name === reqBody.name);
176
+ if (!bodyModel) return fieldTypes;
177
+
178
+ for (const field of bodyModel.fields) {
179
+ fieldTypes.set(field.name, field.type);
180
+ }
181
+
182
+ return fieldTypes;
183
+ }
@@ -42,7 +42,18 @@ export function resolveWrapperParams(wrapper: ResolvedWrapper, ctx: EmitterConte
42
42
  return wrapper.exposedParams.map((paramName) => {
43
43
  const field =
44
44
  variantFields.find((f) => f.name === paramName || toSnakeCase(f.name) === toSnakeCase(paramName)) ?? null;
45
- const isOptional = optionalSet.has(paramName) || !field?.required;
45
+ // Default to required: a wrapper exists to make a specific call shape work,
46
+ // and exposedParams is the contract for that shape. Mark optional only when
47
+ // (a) the wrapper hint explicitly says so, or (b) the IR variant model
48
+ // resolves and reports the field as not required.
49
+ let isOptional: boolean;
50
+ if (optionalSet.has(paramName)) {
51
+ isOptional = true;
52
+ } else if (field) {
53
+ isOptional = !field.required;
54
+ } else {
55
+ isOptional = false;
56
+ }
46
57
  return { paramName, field, isOptional };
47
58
  });
48
59
  }
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateClient } from '../../src/dotnet/client.js';
3
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
5
+
6
+ const models: Model[] = [
7
+ {
8
+ name: 'Organization',
9
+ fields: [
10
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
11
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
12
+ ],
13
+ },
14
+ ];
15
+
16
+ const services: Service[] = [
17
+ {
18
+ name: 'Organizations',
19
+ operations: [
20
+ {
21
+ name: 'listOrganizations',
22
+ httpMethod: 'get',
23
+ path: '/organizations',
24
+ pathParams: [],
25
+ queryParams: [],
26
+ headerParams: [],
27
+ response: { kind: 'model', name: 'Organization' },
28
+ errors: [],
29
+ injectIdempotencyKey: false,
30
+ },
31
+ ],
32
+ },
33
+ ];
34
+
35
+ const spec: ApiSpec = {
36
+ name: 'TestAPI',
37
+ version: '1.0.0',
38
+ baseUrl: 'https://api.workos.com',
39
+ services,
40
+ models,
41
+ enums: [],
42
+ sdk: defaultSdkBehavior(),
43
+ };
44
+
45
+ const ctx: EmitterContext = {
46
+ namespace: 'workos',
47
+ namespacePascal: 'WorkOS',
48
+ spec,
49
+ };
50
+
51
+ describe('dotnet/client', () => {
52
+ it('generates only WorkOSClient.Generated.cs', () => {
53
+ const files = generateClient(spec, ctx);
54
+ expect(files).toHaveLength(1);
55
+ expect(files[0].path).toBe('Client/WorkOSClient.Generated.cs');
56
+ });
57
+
58
+ it('generates partial class with service accessors', () => {
59
+ const files = generateClient(spec, ctx);
60
+ const content = files[0].content;
61
+
62
+ expect(content).toContain('namespace WorkOS');
63
+ expect(content).toContain('public partial class WorkOSClient');
64
+ // Lazy-initialized service property
65
+ expect(content).toContain('OrganizationsService');
66
+ expect(content).toContain('??= new OrganizationsService(this)');
67
+ });
68
+
69
+ it('does not contain static HTTP infrastructure', () => {
70
+ const files = generateClient(spec, ctx);
71
+ const content = files[0].content;
72
+
73
+ // These belong in the hand-maintained WorkOSClient.cs
74
+ expect(content).not.toContain('HttpClient');
75
+ expect(content).not.toContain('ApiKey');
76
+ expect(content).not.toContain('SendAsync');
77
+ expect(content).not.toContain('RequestAsync');
78
+ expect(content).not.toContain('ApiBaseURL');
79
+ expect(content).not.toContain('AuthenticationException');
80
+ expect(content).not.toContain('RateLimitExceededException');
81
+ });
82
+
83
+ it('deduplicates services by mount target', () => {
84
+ const multiSpec: ApiSpec = {
85
+ ...spec,
86
+ services: [
87
+ ...services,
88
+ {
89
+ name: 'OrganizationsApiKeys',
90
+ operations: [
91
+ {
92
+ name: 'listOrganizationApiKeys',
93
+ httpMethod: 'get',
94
+ path: '/organizations/api_keys',
95
+ pathParams: [],
96
+ queryParams: [],
97
+ headerParams: [],
98
+ response: { kind: 'model', name: 'Organization' },
99
+ errors: [],
100
+ injectIdempotencyKey: false,
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ };
106
+
107
+ const files = generateClient(multiSpec, { ...ctx, spec: multiSpec });
108
+ const content = files[0].content;
109
+
110
+ // Both services should appear since they have different mount targets
111
+ expect(content).toContain('OrganizationsService');
112
+ });
113
+
114
+ it('generates XML doc comments on service properties', () => {
115
+ const files = generateClient(spec, ctx);
116
+ const content = files[0].content;
117
+
118
+ expect(content).toContain('/// <summary>');
119
+ expect(content).toContain('OrganizationsService');
120
+ });
121
+ });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateEnums } from '../../src/dotnet/enums.js';
3
+ import type { EmitterContext, ApiSpec, Enum, Service } 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('dotnet/enums', () => {
23
+ it('returns empty for no enums', () => {
24
+ expect(generateEnums([], ctx)).toEqual([]);
25
+ });
26
+
27
+ it('generates C# enum with EnumMember attributes', () => {
28
+ const enums: Enum[] = [
29
+ {
30
+ name: 'Status',
31
+ values: [
32
+ { name: 'ACTIVE', value: 'active' },
33
+ { name: 'INACTIVE', value: 'inactive' },
34
+ { name: 'PENDING', value: 'pending' },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const service: Service = {
40
+ name: 'Organizations',
41
+ operations: [
42
+ {
43
+ name: 'getOrganization',
44
+ httpMethod: 'get',
45
+ path: '/organizations/{id}',
46
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
47
+ queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
48
+ headerParams: [],
49
+ response: { kind: 'model', name: 'Organization' },
50
+ errors: [],
51
+ injectIdempotencyKey: false,
52
+ },
53
+ ],
54
+ };
55
+
56
+ const files = generateEnums(enums, {
57
+ ...ctx,
58
+ spec: { ...emptySpec, services: [service], enums },
59
+ });
60
+ expect(files.length).toBe(1);
61
+
62
+ const content = files[0].content;
63
+ expect(content).toContain('namespace WorkOS');
64
+ expect(content).toContain('public enum Status');
65
+ expect(content).toContain('[EnumMember(Value = "active")]');
66
+ expect(content).toContain('Active');
67
+ expect(content).toContain('[EnumMember(Value = "inactive")]');
68
+ expect(content).toContain('Inactive');
69
+ expect(content).toContain('[EnumMember(Value = "pending")]');
70
+ expect(content).toContain('Pending');
71
+ // Unknown sentinel for forward compatibility
72
+ expect(content).toContain('Unknown');
73
+ });
74
+
75
+ it('skips single-value enums (discriminator consts)', () => {
76
+ const enums: Enum[] = [
77
+ {
78
+ name: 'DiscriminatorType',
79
+ values: [{ name: 'ONLY_VALUE', value: 'only_value' }],
80
+ },
81
+ ];
82
+
83
+ const files = generateEnums(enums, {
84
+ ...ctx,
85
+ spec: { ...emptySpec, enums },
86
+ });
87
+ expect(files).toHaveLength(0);
88
+ });
89
+
90
+ it('deduplicates structurally identical enums', () => {
91
+ const enums: Enum[] = [
92
+ {
93
+ name: 'ConnectionType',
94
+ values: [
95
+ { name: 'SAML', value: 'saml' },
96
+ { name: 'OIDC', value: 'oidc' },
97
+ ],
98
+ },
99
+ {
100
+ name: 'ProfileConnectionType',
101
+ values: [
102
+ { name: 'SAML', value: 'saml' },
103
+ { name: 'OIDC', value: 'oidc' },
104
+ ],
105
+ },
106
+ ];
107
+
108
+ const service: Service = {
109
+ name: 'Test',
110
+ operations: [
111
+ {
112
+ name: 'test',
113
+ httpMethod: 'get',
114
+ path: '/test',
115
+ pathParams: [],
116
+ queryParams: [
117
+ { name: 'type', type: { kind: 'enum', name: 'ConnectionType' }, required: false },
118
+ { name: 'profile_type', type: { kind: 'enum', name: 'ProfileConnectionType' }, required: false },
119
+ ],
120
+ headerParams: [],
121
+ response: { kind: 'primitive', type: 'unknown' },
122
+ errors: [],
123
+ injectIdempotencyKey: false,
124
+ },
125
+ ],
126
+ };
127
+
128
+ const files = generateEnums(enums, {
129
+ ...ctx,
130
+ spec: { ...emptySpec, services: [service], enums },
131
+ });
132
+ // Only one enum file should be generated (the canonical)
133
+ expect(files).toHaveLength(1);
134
+ expect(files[0].content).toContain('ConnectionType');
135
+ });
136
+
137
+ it('skips orphan enums not referenced by models or operations', () => {
138
+ const enums: Enum[] = [
139
+ {
140
+ name: 'OrphanEnum',
141
+ values: [
142
+ { name: 'A', value: 'a' },
143
+ { name: 'B', value: 'b' },
144
+ ],
145
+ },
146
+ ];
147
+
148
+ const files = generateEnums(enums, {
149
+ ...ctx,
150
+ spec: { ...emptySpec, enums },
151
+ });
152
+ expect(files).toHaveLength(0);
153
+ });
154
+
155
+ it('generates deprecated enum values with Obsolete attribute', () => {
156
+ const enums: Enum[] = [
157
+ {
158
+ name: 'Status',
159
+ values: [
160
+ { name: 'ACTIVE', value: 'active' },
161
+ { name: 'OLD_STATUS', value: 'old_status', deprecated: true, description: 'Use ACTIVE instead' },
162
+ ],
163
+ },
164
+ ];
165
+
166
+ const service: Service = {
167
+ name: 'Test',
168
+ operations: [
169
+ {
170
+ name: 'test',
171
+ httpMethod: 'get',
172
+ path: '/test',
173
+ pathParams: [],
174
+ queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
175
+ headerParams: [],
176
+ response: { kind: 'primitive', type: 'unknown' },
177
+ errors: [],
178
+ injectIdempotencyKey: false,
179
+ },
180
+ ],
181
+ };
182
+
183
+ const files = generateEnums(enums, {
184
+ ...ctx,
185
+ spec: { ...emptySpec, services: [service], enums },
186
+ });
187
+ expect(files).toHaveLength(1);
188
+ const content = files[0].content;
189
+
190
+ expect(content).toContain('[System.Obsolete');
191
+ expect(content).toContain('OldStatus');
192
+ });
193
+ });
@@ -0,0 +1,9 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { dotnetEmitter } from '../../src/dotnet/index.js';
3
+
4
+ describe('dotnet/errors', () => {
5
+ it('returns empty array (errors are hand-maintained in the target SDK)', () => {
6
+ const files = dotnetEmitter.generateErrors!({} as any);
7
+ expect(files).toHaveLength(0);
8
+ });
9
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateManifest } from '../../src/dotnet/manifest.js';
3
+ import type { ApiSpec, EmitterContext, Service, Model } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
5
+
6
+ const models: Model[] = [
7
+ {
8
+ name: 'Organization',
9
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
10
+ },
11
+ ];
12
+
13
+ const services: Service[] = [
14
+ {
15
+ name: 'Organizations',
16
+ operations: [
17
+ {
18
+ name: 'listOrganizations',
19
+ httpMethod: 'get',
20
+ path: '/organizations',
21
+ pathParams: [],
22
+ queryParams: [],
23
+ headerParams: [],
24
+ response: { kind: 'model', name: 'Organization' },
25
+ errors: [],
26
+ injectIdempotencyKey: false,
27
+ },
28
+ ],
29
+ },
30
+ {
31
+ name: 'OrganizationsApiKeys',
32
+ operations: [
33
+ {
34
+ name: 'listOrganizationApiKeys',
35
+ httpMethod: 'get',
36
+ path: '/organizations/api_keys',
37
+ pathParams: [],
38
+ queryParams: [],
39
+ headerParams: [],
40
+ response: { kind: 'model', name: 'Organization' },
41
+ errors: [],
42
+ injectIdempotencyKey: false,
43
+ },
44
+ ],
45
+ },
46
+ ];
47
+
48
+ const spec: ApiSpec = {
49
+ name: 'TestAPI',
50
+ version: '1.0.0',
51
+ baseUrl: 'https://api.workos.com',
52
+ services,
53
+ models,
54
+ enums: [],
55
+ sdk: defaultSdkBehavior(),
56
+ };
57
+
58
+ const ctx: EmitterContext = {
59
+ namespace: 'workos',
60
+ namespacePascal: 'WorkOS',
61
+ spec,
62
+ };
63
+
64
+ describe('dotnet/manifest', () => {
65
+ it('generates smoke-manifest.json', () => {
66
+ const files = generateManifest(spec, ctx);
67
+ expect(files).toHaveLength(1);
68
+ expect(files[0].path).toBe('smoke-manifest.json');
69
+ });
70
+
71
+ it('maps HTTP operations to SDK method names and services', () => {
72
+ const files = generateManifest(spec, ctx);
73
+ const manifest = JSON.parse(files[0].content) as Record<string, { sdkMethod: string; service: string }>;
74
+
75
+ expect(manifest['GET /organizations']).toBeDefined();
76
+ expect(manifest['GET /organizations'].sdkMethod).toBeDefined();
77
+ expect(manifest['GET /organizations'].service).toBeDefined();
78
+
79
+ expect(manifest['GET /organizations/api_keys']).toBeDefined();
80
+ expect(manifest['GET /organizations/api_keys'].sdkMethod).toBeDefined();
81
+ });
82
+ });