@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,161 @@
1
+ import type { EmitterContext, Operation, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import { className, fieldName, safeParamName } from './naming.js';
3
+ import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
4
+ import { mapTypeRefForYard } from './type-map.js';
5
+
6
+ /**
7
+ * Generate Ruby wrapper method lines for union split operations.
8
+ *
9
+ * Each wrapper is a typed convenience method that:
10
+ * - Accepts only the exposed params (keyword args)
11
+ * - Injects constant defaults (e.g., grant_type)
12
+ * - Reads inferred fields from client config (e.g., client_id)
13
+ * - Delegates to the same HTTP runtime as the main method
14
+ */
15
+ export function generateWrapperMethods(
16
+ resolvedOp: ResolvedOperation,
17
+ ctx: EmitterContext,
18
+ modelNames: Set<string>,
19
+ requires: Set<string>,
20
+ ): string[] {
21
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
22
+ const out: string[] = [];
23
+ for (const wrapper of resolvedOp.wrappers) {
24
+ const body = emitWrapperMethod(resolvedOp.operation, wrapper, ctx, modelNames, requires);
25
+ out.push(body);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Collect response model filenames needed for wrapper imports. */
31
+ export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
32
+ const models = new Set<string>();
33
+ for (const w of resolvedOp.wrappers ?? []) {
34
+ if (w.responseModelName) models.add(w.responseModelName);
35
+ }
36
+ return models;
37
+ }
38
+
39
+ function emitWrapperMethod(
40
+ op: Operation,
41
+ wrapper: ResolvedWrapper,
42
+ ctx: EmitterContext,
43
+ modelNames: Set<string>,
44
+ requires: Set<string>,
45
+ ): string {
46
+ void requires;
47
+ const method = wrapper.name; // already snake_case
48
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
49
+ const lines: string[] = [];
50
+
51
+ // YARD doc
52
+ lines.push(` # ${formatWrapperDescription(wrapper.name)}.`);
53
+ for (const p of op.pathParams ?? []) {
54
+ const pyType = mapTypeRefForYard(p.type);
55
+ lines.push(` # @param ${safeParamName(p.name)} [${pyType}]`);
56
+ }
57
+ for (const wp of wrapperParams) {
58
+ const type = wp.field ? mapTypeRefForYard(wp.field.type) : 'String';
59
+ const alreadyNilable = type.split(', ').includes('nil');
60
+ const suffix = wp.isOptional && !alreadyNilable ? ', nil' : '';
61
+ lines.push(` # @param ${fieldName(wp.paramName)} [${type}${suffix}]`);
62
+ }
63
+ lines.push(` # @param request_options [Hash] Per-request overrides.`);
64
+ if (wrapper.responseModelName) {
65
+ lines.push(` # @return [WorkOS::${className(wrapper.responseModelName)}]`);
66
+ } else {
67
+ lines.push(` # @return [nil]`);
68
+ }
69
+
70
+ // Signature
71
+ const sigParts: string[] = [];
72
+ const seen = new Set<string>();
73
+ for (const p of op.pathParams ?? []) {
74
+ const n = safeParamName(p.name);
75
+ if (seen.has(n)) continue;
76
+ seen.add(n);
77
+ sigParts.push(`${n}:`);
78
+ }
79
+ // Required first, then optional
80
+ for (const wp of wrapperParams) {
81
+ if (wp.isOptional) continue;
82
+ const n = fieldName(wp.paramName);
83
+ if (seen.has(n)) continue;
84
+ seen.add(n);
85
+ sigParts.push(`${n}:`);
86
+ }
87
+ for (const wp of wrapperParams) {
88
+ if (!wp.isOptional) continue;
89
+ const n = fieldName(wp.paramName);
90
+ if (seen.has(n)) continue;
91
+ seen.add(n);
92
+ sigParts.push(`${n}: nil`);
93
+ }
94
+ sigParts.push('request_options: {}');
95
+
96
+ if (sigParts.length === 1 && sigParts[0].length < 60) {
97
+ lines.push(` def ${method}(${sigParts[0]})`);
98
+ } else {
99
+ lines.push(` def ${method}(`);
100
+ for (let i = 0; i < sigParts.length; i++) {
101
+ const sep = i === sigParts.length - 1 ? '' : ',';
102
+ lines.push(` ${sigParts[i]}${sep}`);
103
+ }
104
+ lines.push(' )');
105
+ }
106
+
107
+ // Body hash
108
+ const bodyEntries: string[] = [];
109
+ for (const [k, v] of Object.entries(wrapper.defaults)) {
110
+ const lit = typeof v === 'string' ? rubyStringLit(v) : String(v);
111
+ bodyEntries.push(`${rubyStringLit(k)} => ${lit}`);
112
+ }
113
+ for (const fc of wrapper.inferFromClient) {
114
+ const clientProp = fc === 'client_secret' ? 'api_key' : fc;
115
+ bodyEntries.push(`${rubyStringLit(fc)} => @client.${clientProp}`);
116
+ }
117
+ for (const wp of wrapperParams) {
118
+ bodyEntries.push(`${rubyStringLit(wp.paramName)} => ${fieldName(wp.paramName)}`);
119
+ }
120
+ lines.push(' body = {');
121
+ for (let i = 0; i < bodyEntries.length; i++) {
122
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
123
+ lines.push(` ${bodyEntries[i]}${sep}`);
124
+ }
125
+ lines.push(' }.compact');
126
+
127
+ // Path string — use the unified @client.request helper.
128
+ const rubyPath = interpolateRubyPath(op.path, op.pathParams ?? []);
129
+ const verbSym = op.httpMethod.toLowerCase();
130
+ lines.push(' response = @client.request(');
131
+ lines.push(` method: :${verbSym},`);
132
+ lines.push(` path: ${rubyPath},`);
133
+ lines.push(' auth: true,');
134
+ lines.push(' body: body,');
135
+ lines.push(' request_options: request_options');
136
+ lines.push(' )');
137
+
138
+ // Response handling
139
+ if (wrapper.responseModelName && modelNames.has(wrapper.responseModelName)) {
140
+ lines.push(` WorkOS::${className(wrapper.responseModelName)}.new(response.body)`);
141
+ } else {
142
+ lines.push(' JSON.parse(response.body)');
143
+ }
144
+
145
+ lines.push(' end');
146
+
147
+ return lines.join('\n');
148
+ }
149
+
150
+ function interpolateRubyPath(path: string, pathParams: Operation['pathParams']): string {
151
+ if (!pathParams || pathParams.length === 0) return `'${path}'`;
152
+ let result = path;
153
+ for (const p of pathParams) {
154
+ result = result.split(`{${p.name}}`).join(`#{WorkOS::Util.encode_path(${safeParamName(p.name)})}`);
155
+ }
156
+ return `"${result}"`;
157
+ }
158
+
159
+ function rubyStringLit(s: string): string {
160
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
161
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
2
+ import { toSnakeCase } from '@workos/oagen';
2
3
  import { readFileSync, existsSync } from 'node:fs';
3
4
  import { resolve } from 'node:path';
4
5
  // @ts-ignore -- js-yaml has no type declarations in this project
@@ -159,7 +160,7 @@ function rawSchemaToTypeRef(
159
160
  if (schema.enum && collector && parentModelName && fName) {
160
161
  // Generate a synthetic enum
161
162
  const syntheticName = `${parentModelName}_${fName}`;
162
- if (!collector.usedNames.has(syntheticName)) {
163
+ if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
163
164
  collector.usedNames.add(syntheticName);
164
165
  collector.enums.push({
165
166
  name: syntheticName,
@@ -197,7 +198,7 @@ function rawSchemaToTypeRef(
197
198
  if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
198
199
  // Inline object -- generate a synthetic model
199
200
  const syntheticName = `${parentModelName}_${fName}`;
200
- if (!collector.usedNames.has(syntheticName)) {
201
+ if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
201
202
  collector.usedNames.add(syntheticName);
202
203
  const fields: Field[] = [];
203
204
  const requiredSet = new Set<string>(schema.required ?? []);
@@ -388,6 +389,102 @@ export function getSyntheticEnums(): Enum[] {
388
389
  return _lastSyntheticEnums;
389
390
  }
390
391
 
392
+ // ---------------------------------------------------------------------------
393
+ // Implicit discriminator detection
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Find a property name that has a `const` value in ALL oneOf variants.
398
+ * Returns null if no shared const property is found.
399
+ */
400
+ function findSharedConstProperty(oneOfSchemas: Record<string, any>[]): string | null {
401
+ if (oneOfSchemas.length === 0) return null;
402
+
403
+ const first = oneOfSchemas[0];
404
+ if (!first.properties) return null;
405
+
406
+ // Candidate properties from the first variant that have const values
407
+ const candidates = Object.keys(first.properties).filter((name) => first.properties[name].const !== undefined);
408
+
409
+ // Return the first candidate that has const values in ALL variants
410
+ for (const candidate of candidates) {
411
+ const allHaveConst = oneOfSchemas.every((variant) => variant.properties?.[candidate]?.const !== undefined);
412
+ if (allHaveConst) return candidate;
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ /**
419
+ * Build a discriminator mapping from const values to IR model names.
420
+ * For each oneOf variant's const value on `discProperty`, find the IR model
421
+ * whose field with the same name is a Literal type with that value.
422
+ */
423
+ function buildDiscriminatorMapping(
424
+ discProperty: string,
425
+ oneOfSchemas: Record<string, any>[],
426
+ models: Model[],
427
+ parentModelName: string,
428
+ ): Record<string, string> {
429
+ const mapping: Record<string, string> = {};
430
+
431
+ for (const variant of oneOfSchemas) {
432
+ const constValue = variant.properties?.[discProperty]?.const;
433
+ if (constValue === undefined) continue;
434
+
435
+ const variantModel = models.find(
436
+ (m) =>
437
+ m.name !== parentModelName &&
438
+ m.fields.some(
439
+ (f) => f.name === discProperty && f.type.kind === 'literal' && (f.type as any).value === constValue,
440
+ ),
441
+ );
442
+ if (variantModel) {
443
+ mapping[String(constValue)] = variantModel.name;
444
+ }
445
+ }
446
+
447
+ return mapping;
448
+ }
449
+
450
+ /**
451
+ * Detect implicit discriminators on models without full oneOf flattening.
452
+ * Returns a new array with discriminator annotations; models without
453
+ * discriminators are returned as-is. Use this when you need discriminator
454
+ * info but don't want the side-effects of full enrichment (synthetic
455
+ * models/enums, field flattening).
456
+ */
457
+ export function detectDiscriminators(models: Model[]): Model[] {
458
+ const spec = loadRawSpec();
459
+ if (!spec) return models;
460
+
461
+ let changed = false;
462
+ const result = models.map((model) => {
463
+ if ((model as any).discriminator) return model;
464
+
465
+ const rawSchema = lookupRawSchema(model.name);
466
+ if (!rawSchema) return model;
467
+
468
+ const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
469
+ if (!oneOfContainer?.oneOf || oneOfContainer.oneOf.length === 0) return model;
470
+
471
+ const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
472
+ if (!discProperty) return model;
473
+
474
+ const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
475
+ if (Object.keys(mapping).length === 0) return model;
476
+
477
+ changed = true;
478
+ return {
479
+ ...model,
480
+ fields: [],
481
+ discriminator: { property: discProperty, mapping },
482
+ };
483
+ });
484
+
485
+ return changed ? result : models;
486
+ }
487
+
391
488
  /**
392
489
  * Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
393
490
  *
@@ -411,8 +508,13 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
411
508
  }
412
509
 
413
510
  const collector = createCollector();
414
- // Avoid name collisions with existing models
415
- for (const m of models) collector.usedNames.add(m.name);
511
+ // Avoid name collisions with existing models (check both PascalCase and
512
+ // snake_case to prevent synthetic models from shadowing existing ones when
513
+ // they share a file name, e.g. FooBar vs Foo_bar -> foo_bar).
514
+ for (const m of models) {
515
+ collector.usedNames.add(m.name);
516
+ collector.usedNames.add(toSnakeCase(m.name));
517
+ }
416
518
 
417
519
  const enriched = models.map((model) => {
418
520
  const rawSchema = lookupRawSchema(model.name);
@@ -421,13 +523,32 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
421
523
  const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
422
524
  if (!hasOneOf) return model;
423
525
 
424
- // Skip schemas with discriminator -- those are intentional unions
526
+ // Skip schemas with explicit discriminator -- those are intentional unions
425
527
  const hasDiscriminator =
426
528
  rawSchema.discriminator ||
427
529
  rawSchema.oneOf?.some((v: any) => v.discriminator) ||
428
530
  rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
429
531
  if (hasDiscriminator) return model;
430
532
 
533
+ // Detect implicit discriminators: allOf+oneOf where all variants share a
534
+ // property with const values (e.g., EventSchema with event: const: "user.created").
535
+ // When found, attach a discriminator mapping and clear fields so the emitter
536
+ // generates a dispatcher class instead of a flat dataclass.
537
+ const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
538
+ if (oneOfContainer?.oneOf && oneOfContainer.oneOf.length > 0) {
539
+ const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
540
+ if (discProperty) {
541
+ const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
542
+ if (Object.keys(mapping).length > 0) {
543
+ return {
544
+ ...model,
545
+ fields: [],
546
+ discriminator: { property: discProperty, mapping },
547
+ };
548
+ }
549
+ }
550
+ }
551
+
431
552
  // Collect all variant fields from the raw schema, generating synthetic
432
553
  // models/enums for inline definitions along the way.
433
554
  const variantFields = collectOneOfFields(rawSchema, model.name, collector);
@@ -467,6 +588,9 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
467
588
  values: e.values.map((v) => ({ value: v.value, description: v.description })),
468
589
  })) as Enum[];
469
590
 
470
- // Append synthetic models to the output
471
- return [...enriched2, ...collector.models];
591
+ // Append synthetic models, skipping those whose snake_case name collides
592
+ // with an existing model (prevents broken TypeAlias self-imports).
593
+ const existingSnakeNames = new Set(enriched2.map((m) => toSnakeCase(m.name)));
594
+ const filteredSynthetic = collector.models.filter((m) => !existingSnakeNames.has(toSnakeCase(m.name)));
595
+ return [...enriched2, ...filteredSynthetic];
472
596
  }
@@ -125,10 +125,46 @@ function startsWithVerb(desc: string): boolean {
125
125
  return VERB_STARTERS.has(firstWord);
126
126
  }
127
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
+
128
154
  /**
129
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.
130
160
  */
131
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';
132
168
  return /^[aeiou]/i.test(word) ? 'an' : 'a';
133
169
  }
134
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
+ }
@@ -76,8 +76,8 @@ describe('dotnet/client', () => {
76
76
  expect(content).not.toContain('SendAsync');
77
77
  expect(content).not.toContain('RequestAsync');
78
78
  expect(content).not.toContain('ApiBaseURL');
79
- expect(content).not.toContain('AuthenticationError');
80
- expect(content).not.toContain('RateLimitExceededError');
79
+ expect(content).not.toContain('AuthenticationException');
80
+ expect(content).not.toContain('RateLimitExceededException');
81
81
  });
82
82
 
83
83
  it('deduplicates services by mount target', () => {
@@ -80,18 +80,17 @@ describe('dotnet/models', () => {
80
80
  // Class definition
81
81
  expect(content).toContain('public class Organization');
82
82
 
83
- // Required fields with JSON attributes
84
- expect(content).toContain('[JsonProperty("id")]');
83
+ // Required fields convention-based naming (no per-property JSON attributes)
85
84
  expect(content).toContain('public string Id');
86
- expect(content).toContain('[JsonProperty("name")]');
87
85
  expect(content).toContain('public string Name');
86
+ expect(content).not.toContain('[JsonProperty("id")]');
87
+ expect(content).not.toContain('[STJS.JsonPropertyName(');
88
88
 
89
89
  // DateTime field
90
90
  expect(content).toContain('DateTimeOffset');
91
- expect(content).toContain('[JsonProperty("created_at")]');
92
91
 
93
92
  // Optional/nullable field
94
- expect(content).toContain('[JsonProperty("external_id")]');
93
+ expect(content).toContain('public string? ExternalId');
95
94
  });
96
95
 
97
96
  it('skips list wrapper and list metadata models', () => {
@@ -168,10 +167,9 @@ describe('dotnet/models', () => {
168
167
  expect(canonicalFile).toBeDefined();
169
168
  expect(canonicalFile.content).toContain('public class OrganizationDomain');
170
169
 
171
- // Alias model should be a subclass of canonical
172
- const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'))!;
173
- expect(aliasFile).toBeDefined();
174
- expect(aliasFile.content).toContain('OrganizationDomain');
170
+ // Alias model should NOT be emitted references are rewritten to the canonical
171
+ const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'));
172
+ expect(aliasFile).toBeUndefined();
175
173
  });
176
174
 
177
175
  it('emits [System.Obsolete] for deprecated fields', () => {