@workos/oagen-emitters 0.4.0 → 0.6.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 (126) 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 +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.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 +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  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 +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -1,17 +1,26 @@
1
1
  import type { ApiSpec, Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { planOperation } from '@workos/oagen';
3
3
  import {
4
- className,
5
4
  fixtureFileName,
6
5
  fieldName as csFieldName,
7
6
  methodName as csMethodName,
7
+ appendAsyncSuffix,
8
+ modelClassName,
8
9
  resolveMethodName,
10
+ resolveMethodStem,
9
11
  serviceTypeName,
10
12
  } from './naming.js';
13
+ import { resolveModelName } from './type-map.js';
11
14
  import { resolveResourceClassName, sortPathParamsByTemplateOrder, optionsClassName } from './resources.js';
12
15
  import { generateFixtures, generateModelFixture } from './fixtures.js';
13
16
  import { isListWrapperModel } from './models.js';
14
- import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
17
+ import {
18
+ groupByMount,
19
+ buildResolvedLookup,
20
+ lookupResolved,
21
+ buildHiddenParams,
22
+ collectGroupedParamNames,
23
+ } from '../shared/resolved-ops.js';
15
24
 
16
25
  /**
17
26
  * Generate C# test files and JSON fixtures.
@@ -194,7 +203,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
194
203
  lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.Delete, "${expectedPath}");`);
195
204
  lines.push(' }');
196
205
  } else if (plan.responseModelName) {
197
- const respModel = plan.responseModelName;
206
+ const respModel = resolveModelName(plan.responseModelName);
198
207
  const fixturePath = `testdata/${fixtureFileName(respModel)}.json`;
199
208
  const httpMethodCs = op.httpMethod.charAt(0).toUpperCase() + op.httpMethod.slice(1).toLowerCase();
200
209
 
@@ -261,8 +270,9 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
261
270
  const plan = planOperation(op);
262
271
  if (!plan.isPaginated || !op.pagination) continue;
263
272
 
264
- const method = resolveCsMethodName(op, resolvedName, ctx);
265
- const autoPagingTestName = `Test${method}AutoPagingAsync`;
273
+ const methodStem = resolveCsMethodStem(op, resolvedName, ctx);
274
+ const autoPagingMethod = `${methodStem}AutoPagingAsync`;
275
+ const autoPagingTestName = `Test${autoPagingMethod}`;
266
276
  if (emittedTestMethods.has(autoPagingTestName)) continue;
267
277
  emittedTestMethods.add(autoPagingTestName);
268
278
 
@@ -282,8 +292,8 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
282
292
  if (inner) resolved = inner;
283
293
  }
284
294
  }
285
- itemTypeName = className(resolved.name);
286
- fixtureName = fixtureFileName(resolved.name);
295
+ itemTypeName = modelClassName(resolveModelName(resolved.name));
296
+ fixtureName = fixtureFileName(resolveModelName(resolved.name));
287
297
  }
288
298
  }
289
299
 
@@ -310,7 +320,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
310
320
  );
311
321
  lines.push('');
312
322
  lines.push(` var items = new List<${itemTypeName}>();`);
313
- lines.push(` await foreach (var item in this.service.${method}AutoPagingAsync(${autoPagingArgs}))`);
323
+ lines.push(` await foreach (var item in this.service.${autoPagingMethod}(${autoPagingArgs}))`);
314
324
  lines.push(' {');
315
325
  lines.push(' items.Add(item);');
316
326
  lines.push(' }');
@@ -319,7 +329,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
319
329
  lines.push(' }');
320
330
 
321
331
  // Test with empty first page
322
- const emptyTestName = `Test${method}AutoPagingAsyncEmpty`;
332
+ const emptyTestName = `Test${autoPagingMethod}Empty`;
323
333
  if (!emittedTestMethods.has(emptyTestName)) {
324
334
  emittedTestMethods.add(emptyTestName);
325
335
  lines.push('');
@@ -332,7 +342,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
332
342
  );
333
343
  lines.push('');
334
344
  lines.push(` var items = new List<${itemTypeName}>();`);
335
- lines.push(` await foreach (var item in this.service.${method}AutoPagingAsync(${autoPagingArgs}))`);
345
+ lines.push(` await foreach (var item in this.service.${autoPagingMethod}(${autoPagingArgs}))`);
336
346
  lines.push(' {');
337
347
  lines.push(' items.Add(item);');
338
348
  lines.push(' }');
@@ -348,7 +358,8 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
348
358
  if (!resolvedOp?.wrappers || resolvedOp.wrappers.length === 0) continue;
349
359
 
350
360
  for (const wrapper of resolvedOp.wrappers) {
351
- const wrapperMethod = csMethodName(wrapper.name);
361
+ const wrapperMethodStem = csMethodName(wrapper.name);
362
+ const wrapperMethod = appendAsyncSuffix(wrapperMethodStem);
352
363
  const wrapperTestName = `Test${wrapperMethod}`;
353
364
  if (emittedTestMethods.has(wrapperTestName)) continue;
354
365
  emittedTestMethods.add(wrapperTestName);
@@ -363,7 +374,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
363
374
  lines.push(' {');
364
375
 
365
376
  if (responseType) {
366
- const fixturePath = `testdata/${fixtureFileName(responseType)}.json`;
377
+ const fixturePath = `testdata/${fixtureFileName(resolveModelName(responseType))}.json`;
367
378
  lines.push(` var fixture = System.IO.File.ReadAllText("${fixturePath}");`);
368
379
  lines.push(
369
380
  ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, fixture);`,
@@ -379,7 +390,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
379
390
  for (const p of sortPathParamsByTemplateOrder(op)) {
380
391
  wrapperArgs.push(`"test_${p.name}"`);
381
392
  }
382
- wrapperArgs.push(`new ${wrapperMethod}Options()`);
393
+ wrapperArgs.push(`new ${wrapperMethodStem}Options()`);
383
394
 
384
395
  if (responseType) {
385
396
  lines.push(` var result = await this.service.${wrapperMethod}(${wrapperArgs.join(', ')});`);
@@ -411,11 +422,11 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
411
422
  );
412
423
  if (plan.isPaginated || plan.isDelete || !plan.responseModelName) {
413
424
  lines.push(
414
- ` await Assert.ThrowsAsync<AuthenticationError>(() => this.service.${method}(${callArgs}));`,
425
+ ` await Assert.ThrowsAsync<AuthenticationException>(() => this.service.${method}(${callArgs}));`,
415
426
  );
416
427
  } else {
417
428
  lines.push(
418
- ` await Assert.ThrowsAsync<AuthenticationError>(() => this.service.${method}(${callArgs}));`,
429
+ ` await Assert.ThrowsAsync<AuthenticationException>(() => this.service.${method}(${callArgs}));`,
419
430
  );
420
431
  }
421
432
  lines.push(' }');
@@ -428,7 +439,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
428
439
  lines.push(
429
440
  ` this.httpMock.MockResponseForAnyRequest(HttpStatusCode.NotFound, "{\\"code\\":\\"not_found\\",\\"message\\":\\"Not Found\\"}");`,
430
441
  );
431
- lines.push(` await Assert.ThrowsAsync<NotFoundError>(() => this.service.${method}(${callArgs}));`);
442
+ lines.push(` await Assert.ThrowsAsync<NotFoundException>(() => this.service.${method}(${callArgs}));`);
432
443
  lines.push(' }');
433
444
 
434
445
  // 422
@@ -440,7 +451,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
440
451
  ` this.httpMock.MockResponseForAnyRequest((HttpStatusCode)422, "{\\"code\\":\\"unprocessable_entity\\",\\"message\\":\\"Unprocessable\\"}");`,
441
452
  );
442
453
  lines.push(
443
- ` await Assert.ThrowsAsync<UnprocessableEntityError>(() => this.service.${method}(${callArgs}));`,
454
+ ` await Assert.ThrowsAsync<UnprocessableEntityException>(() => this.service.${method}(${callArgs}));`,
444
455
  );
445
456
  lines.push(' }');
446
457
 
@@ -453,7 +464,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
453
464
  ` this.httpMock.MockResponseForAnyRequest((HttpStatusCode)429, "{\\"code\\":\\"too_many_requests\\",\\"message\\":\\"Too Many Requests\\"}");`,
454
465
  );
455
466
  lines.push(
456
- ` await Assert.ThrowsAsync<RateLimitExceededError>(() => this.service.${method}(${callArgs}));`,
467
+ ` await Assert.ThrowsAsync<RateLimitExceededException>(() => this.service.${method}(${callArgs}));`,
457
468
  );
458
469
  lines.push(' }');
459
470
 
@@ -465,7 +476,7 @@ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContex
465
476
  lines.push(
466
477
  ` this.httpMock.MockResponseForAnyRequest(HttpStatusCode.InternalServerError, "{\\"code\\":\\"server_error\\",\\"message\\":\\"Server Error\\"}");`,
467
478
  );
468
- lines.push(` await Assert.ThrowsAsync<ServerError>(() => this.service.${method}(${callArgs}));`);
479
+ lines.push(` await Assert.ThrowsAsync<ServerException>(() => this.service.${method}(${callArgs}));`);
469
480
  lines.push(' }');
470
481
  }
471
482
 
@@ -483,6 +494,10 @@ function resolveCsMethodName(op: Operation, mountName: string, ctx: EmitterConte
483
494
  return resolveMethodName(op, { name: mountName, operations: [op] }, ctx);
484
495
  }
485
496
 
497
+ function resolveCsMethodStem(op: Operation, mountName: string, ctx: EmitterContext): string {
498
+ return resolveMethodStem(op, { name: mountName, operations: [op] }, ctx);
499
+ }
500
+
486
501
  function buildMethodCallArgs(op: Operation, plan: any, ctx: EmitterContext, mountName: string): string {
487
502
  const args: string[] = [];
488
503
 
@@ -502,7 +517,10 @@ function buildMethodCallArgs(op: Operation, plan: any, ctx: EmitterContext, moun
502
517
  const resolvedLookup = buildResolvedLookup(ctx);
503
518
  const resolvedOp = lookupResolved(op, resolvedLookup);
504
519
  const hidden = buildHiddenParams(resolvedOp);
505
- const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
520
+ const groupedParams = collectGroupedParamNames(op);
521
+ const hasVisibleQueryParams =
522
+ op.queryParams.filter((qp) => !hidden.has(qp.name) && !groupedParams.has(qp.name)).length > 0 ||
523
+ (op.parameterGroups?.length ?? 0) > 0;
506
524
  const hasBody = plan.hasBody && op.requestBody;
507
525
  let hasVisibleBodyFields = false;
508
526
  if (hasBody && op.requestBody?.kind === 'model') {
@@ -513,8 +531,8 @@ function buildMethodCallArgs(op: Operation, plan: any, ctx: EmitterContext, moun
513
531
  }
514
532
 
515
533
  if (hasVisibleBodyFields || hasVisibleQueryParams) {
516
- const method = resolveCsMethodName(op, mountName, ctx);
517
- const optName = optionsClassName(mountName, method);
534
+ const methodStem = resolveCsMethodStem(op, mountName, ctx);
535
+ const optName = optionsClassName(mountName, methodStem);
518
536
  args.push(`new ${optName}()`);
519
537
  }
520
538
 
@@ -566,8 +584,10 @@ function buildRequestShapeSeed(op: Operation, plan: any, ctx: EmitterContext, mo
566
584
  // call sends it via the body — so skip the query assertion to avoid a
567
585
  // false-failing `AssertQueryParam`.
568
586
  const bodyWireNames = new Set(bodySeeds.map((s) => s.wire));
587
+ const groupedParamNames = collectGroupedParamNames(op);
569
588
  for (const param of op.queryParams) {
570
589
  if (hidden.has(param.name)) continue;
590
+ if (groupedParamNames.has(param.name)) continue;
571
591
  if (!param.required) continue;
572
592
  if (!isSeedableStringRef(param.type)) continue;
573
593
  // Skip pagination fields — they're set by the caller or the autopaging loop
@@ -585,8 +605,8 @@ function buildRequestShapeSeed(op: Operation, plan: any, ctx: EmitterContext, mo
585
605
  return { setupLines: [], seededCallArgs: null, assertLines: [] };
586
606
  }
587
607
 
588
- const method = resolveCsMethodName(op, mountName, ctx);
589
- const optName = optionsClassName(mountName, method);
608
+ const methodStem = resolveCsMethodStem(op, mountName, ctx);
609
+ const optName = optionsClassName(mountName, methodStem);
590
610
 
591
611
  // Rebuild call args with a seeded options variable named `options`.
592
612
  const args: string[] = [];
@@ -1,6 +1,6 @@
1
1
  import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
2
  import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
- import { className } from './naming.js';
3
+ import { className, modelClassName } from './naming.js';
4
4
 
5
5
  /** Known C# value types that need `?` for nullable. */
6
6
  const VALUE_TYPES = new Set(['int', 'long', 'double', 'bool', 'float', 'decimal', 'byte', 'short', 'DateTimeOffset']);
@@ -21,12 +21,35 @@ const enumAliases = new Map<string, string>();
21
21
  */
22
22
  const singleValueEnumNames = new Set<string>();
23
23
 
24
+ /**
25
+ * Module-level alias map for structurally-identical models. Populated by
26
+ * `setModelAliases` from model deduplication; consulted by `mapTypeRef` so
27
+ * that every reference to a duplicate model resolves to the canonical name.
28
+ */
29
+ const modelAliases = new Map<string, string>();
30
+
24
31
  /** Replace the current enum-alias map. Safe to call more than once. */
25
32
  export function setEnumAliases(aliases: Map<string, string>): void {
26
33
  enumAliases.clear();
27
34
  for (const [k, v] of aliases) enumAliases.set(k, v);
28
35
  }
29
36
 
37
+ /** Replace the current model-alias map. Safe to call more than once. */
38
+ export function setModelAliases(aliases: Map<string, string>): void {
39
+ modelAliases.clear();
40
+ for (const [k, v] of aliases) modelAliases.set(k, v);
41
+ }
42
+
43
+ /** Check if a model name is an alias (i.e., structurally identical to another model). */
44
+ export function isModelAlias(name: string): boolean {
45
+ return modelAliases.has(name);
46
+ }
47
+
48
+ /** Resolve a model name to its canonical form (identity if not an alias). */
49
+ export function resolveModelName(name: string): string {
50
+ return modelAliases.get(name) ?? name;
51
+ }
52
+
30
53
  /** Replace the set of enum names that are single-value discriminator stand-ins. */
31
54
  export function setSingleValueEnumNames(names: Iterable<string>): void {
32
55
  singleValueEnumNames.clear();
@@ -45,7 +68,7 @@ export function mapTypeRef(ref: TypeRef): string {
45
68
  return irMapTypeRef<string>(ref, {
46
69
  primitive: mapPrimitive,
47
70
  array: (_ref, items) => `List<${items}>`,
48
- model: (r) => className(r.name),
71
+ model: (r) => modelClassName(modelAliases.get(r.name) ?? r.name),
49
72
  enum: (r) => {
50
73
  // Single-value enums (discriminator consts in disguise) map to `string`
51
74
  // so the caller can't misuse a public one-member enum type. The
@@ -123,21 +146,25 @@ export function isEnumRef(ref: TypeRef): boolean {
123
146
  }
124
147
 
125
148
  /**
126
- * Emit JSON attributes for a request-side property. When `isRequiredEnum` is
127
- * true, configure both serializers to skip the field when its value equals the
128
- * enum default (0 = Unknown sentinel). This converts "unset required enum"
129
- * from a silent `"unknown"` wire value into a clean omission so the API
130
- * returns a clear `missing required field` error instead of a confusing 422.
149
+ * Emit JSON attributes for a request-side property. Property name mapping is
150
+ * handled by a global SnakeCaseLower / SnakeCaseNamingStrategy configuration
151
+ * on both serializers, so per-property name attributes are not emitted.
152
+ *
153
+ * When `isRequiredEnum` is true, configure both serializers to skip the field
154
+ * when its value equals the enum default (0 = Unknown sentinel). This converts
155
+ * "unset required enum" from a silent `"unknown"` wire value into a clean
156
+ * omission so the API returns a clear `missing required field` error instead
157
+ * of a confusing 422.
131
158
  */
132
- export function emitJsonPropertyAttributes(wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
159
+ export function emitJsonPropertyAttributes(_wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
133
160
  if (options.isRequiredEnum) {
134
161
  return [
135
- ` [JsonProperty("${wireName}", DefaultValueHandling = DefaultValueHandling.Ignore)]`,
136
- ` [STJS.JsonPropertyName("${wireName}")]`,
162
+ ` [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`,
137
163
  ` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
138
164
  ];
139
165
  }
140
- return [` [JsonProperty("${wireName}")]`, ` [STJS.JsonPropertyName("${wireName}")]`];
166
+ // Convention-based: SnakeCaseLower naming policy handles the name mapping.
167
+ return [];
141
168
  }
142
169
 
143
170
  function mapPrimitive(ref: PrimitiveType): string {
@@ -188,13 +215,13 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
188
215
  return 'object';
189
216
  }
190
217
 
191
- if (unique.length >= 2 && unique.length <= 3) return `AnyOf<${unique.join(', ')}>`;
192
- // AnyOf only supports arity 2 and 3. Higher-arity unions collapse to
193
- // `object`, losing type information. Warn so the author knows the spec
194
- // outgrew the runtime support instead of silently degrading.
195
- if (unique.length >= 4) {
218
+ if (unique.length >= 2 && unique.length <= 9) return `OneOf.OneOf<${unique.join(', ')}>`;
219
+ // OneOf supports arity 2-9. Higher-arity unions collapse to `object`,
220
+ // losing type information. Warn so the author knows the spec outgrew the
221
+ // runtime support instead of silently degrading.
222
+ if (unique.length >= 10) {
196
223
  console.warn(
197
- `[oagen:dotnet] Union with ${unique.length} variants exceeds AnyOf<T1,T2,T3> arity; falling back to object. Variants: ${unique.join(', ')}`,
224
+ `[oagen:dotnet] Union with ${unique.length} variants exceeds OneOf<T0..T8> arity; falling back to object. Variants: ${unique.join(', ')}`,
198
225
  );
199
226
  }
200
227
  return 'object';
@@ -1,6 +1,5 @@
1
1
  import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
2
  import {
3
- className as csClassName,
4
3
  fieldName as csFieldName,
5
4
  methodName as csMethodName,
6
5
  localName,
@@ -10,6 +9,8 @@ import {
10
9
  escapeXml,
11
10
  emitXmlDoc,
12
11
  humanize,
12
+ appendAsyncSuffix,
13
+ modelClassName,
13
14
  } from './naming.js';
14
15
  import { sortPathParamsByTemplateOrder } from './resources.js';
15
16
  import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
@@ -44,9 +45,10 @@ function emitWrapperMethod(
44
45
  _ctx: EmitterContext,
45
46
  ): void {
46
47
  const op = resolvedOp.operation;
47
- const method = csMethodName(wrapper.name);
48
- const optionsClass = `${method}Options`;
49
- const responseType = wrapper.responseModelName ? csClassName(wrapper.responseModelName) : null;
48
+ const methodStem = csMethodName(wrapper.name);
49
+ const method = appendAsyncSuffix(methodStem);
50
+ const optionsClass = `${methodStem}Options`;
51
+ const responseType = wrapper.responseModelName ? modelClassName(wrapper.responseModelName) : null;
50
52
 
51
53
  // XML doc
52
54
  lines.push(` /// <summary>${formatWrapperDescription(wrapper.name)}.</summary>`);
@@ -63,12 +65,18 @@ function emitWrapperMethod(
63
65
 
64
66
  // Signature
65
67
  const sigParams: string[] = [];
68
+ const argNames: string[] = [];
66
69
  for (const p of sortPathParamsByTemplateOrder(op)) {
67
- sigParams.push(`string ${localName(p.name)}`);
70
+ const name = localName(p.name);
71
+ sigParams.push(`string ${name}`);
72
+ argNames.push(name);
68
73
  }
69
74
  sigParams.push(`${optionsClass} options`);
75
+ argNames.push('options');
70
76
  sigParams.push('RequestOptions? requestOptions = null');
77
+ argNames.push('requestOptions');
71
78
  sigParams.push('CancellationToken cancellationToken = default');
79
+ argNames.push('cancellationToken');
72
80
 
73
81
  const returnType = responseType ? `Task<${responseType}>` : 'Task';
74
82
  lines.push(` public async ${returnType} ${method}(${sigParams.join(', ')})`);
@@ -96,7 +104,7 @@ function emitWrapperMethod(
96
104
  if (op.pathParams.length > 0) {
97
105
  let interpolated = op.path;
98
106
  for (const p of sortPathParamsByTemplateOrder(op)) {
99
- interpolated = interpolated.replace(`{${p.name}}`, `{${localName(p.name)}}`);
107
+ interpolated = interpolated.replace(`{${p.name}}`, `{Uri.EscapeDataString(${localName(p.name)})}`);
100
108
  }
101
109
  pathExpr = `$"${interpolated}"`;
102
110
  } else {
@@ -116,6 +124,13 @@ function emitWrapperMethod(
116
124
  }
117
125
 
118
126
  lines.push(' }');
127
+
128
+ lines.push('');
129
+ lines.push(` /// <summary>Compatibility wrapper for <see cref="${method}"/>.</summary>`);
130
+ lines.push(` public Task${responseType ? `<${responseType}>` : ''} ${methodStem}(${sigParams.join(', ')})`);
131
+ lines.push(' {');
132
+ lines.push(` return this.${method}(${argNames.join(', ')});`);
133
+ lines.push(' }');
119
134
  }
120
135
 
121
136
  // NOTE: T26 (wrapper DRY) — the AuthenticateWith* wrappers share a small
@@ -158,8 +173,6 @@ export function generateWrapperOptionsClasses(resolvedOp: ResolvedOperation, ctx
158
173
  // Hidden fields (defaults + inferred)
159
174
  for (const key of Object.keys(wrapper.defaults)) {
160
175
  const csField = csFieldName(key);
161
- lines.push(` [JsonProperty("${key}")]`);
162
- lines.push(` [STJS.JsonPropertyName("${key}")]`);
163
176
  lines.push(` internal string ${csField} { get; set; } = default!;`);
164
177
  lines.push('');
165
178
  }
@@ -167,8 +180,6 @@ export function generateWrapperOptionsClasses(resolvedOp: ResolvedOperation, ctx
167
180
  const csField = csFieldName(key);
168
181
  // Skip if already added as a default
169
182
  if (Object.keys(wrapper.defaults).includes(key)) continue;
170
- lines.push(` [JsonProperty("${key}")]`);
171
- lines.push(` [STJS.JsonPropertyName("${key}")]`);
172
183
  lines.push(` internal string ${csField} { get; set; } = default!;`);
173
184
  lines.push('');
174
185
  }
package/src/go/client.ts CHANGED
@@ -2,8 +2,9 @@ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oa
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  // naming utilities used indirectly via resolveResourceClassName
4
4
  import { resolveResourceClassName } from './resources.js';
5
- import { unexportedName } from './naming.js';
5
+ import { className, unexportedName } from './naming.js';
6
6
  import { getMountTarget } from '../shared/resolved-ops.js';
7
+ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
8
 
8
9
  /**
9
10
  * Generate the Go client file with service accessors.
@@ -15,6 +16,16 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
15
16
  return [generateWorkOSFile(spec, ctx)];
16
17
  }
17
18
 
19
+ /**
20
+ * Non-spec services marked with `hasClientAccessor: true` (passwordless, vault)
21
+ * are included in the generated Client struct, constructor, and accessor methods
22
+ * — identical to spec-driven services. Their service type (e.g. PasswordlessService)
23
+ * is defined in a hand-written @oagen-ignore-file, but the Client wiring is generated.
24
+ *
25
+ * Other non-spec modules (webhook_verification, actions, etc.) remain fully
26
+ * self-contained in their @oagen-ignore-file files.
27
+ */
28
+
18
29
  /**
19
30
  * Deduplicate services by mount target.
20
31
  */
@@ -72,6 +83,8 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
72
83
  lines.push('\tbaseURL string');
73
84
  lines.push('\thttpClient *http.Client');
74
85
  lines.push('\tmaxRetries int');
86
+ lines.push('\tlogger Logger');
87
+ lines.push('\tappInfo appInfo');
75
88
  lines.push('');
76
89
  // Service fields
77
90
  for (const service of topLevel) {
@@ -80,6 +93,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
80
93
  const serviceTypeName = serviceType(resolvedName);
81
94
  lines.push(`\t${fieldNameStr} *${serviceTypeName}`);
82
95
  }
96
+ // Non-spec service fields (hand-written types, generated wiring)
97
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
98
+ const name = className(toPascalCase(ns.id));
99
+ lines.push(`\t${unexportedName(name)} *${serviceType(name)}`);
100
+ }
83
101
  lines.push('}');
84
102
  lines.push('');
85
103
 
@@ -102,6 +120,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
102
120
  const serviceTypeName = serviceType(resolvedName);
103
121
  lines.push(`\tc.${fieldNameStr} = &${serviceTypeName}{client: c}`);
104
122
  }
123
+ // Initialize non-spec services
124
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
125
+ const name = className(toPascalCase(ns.id));
126
+ lines.push(`\tc.${unexportedName(name)} = &${serviceType(name)}{client: c}`);
127
+ }
105
128
  lines.push('\treturn c');
106
129
  lines.push('}');
107
130
  lines.push('');
@@ -118,7 +141,16 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
118
141
  lines.push('}');
119
142
  lines.push('');
120
143
  }
121
-
144
+ // Non-spec service accessor methods
145
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
146
+ const name = className(toPascalCase(ns.id));
147
+ const typeName = serviceType(name);
148
+ lines.push(`// ${name} returns the ${name} service.`);
149
+ lines.push(`func (c *Client) ${name}() *${typeName} {`);
150
+ lines.push(`\treturn c.${unexportedName(name)}`);
151
+ lines.push('}');
152
+ lines.push('');
153
+ }
122
154
  return {
123
155
  path: `${ctx.namespace}.go`,
124
156
  content: lines.join('\n'),
@@ -137,5 +169,5 @@ function singularizePascal(name: string): string {
137
169
  }
138
170
 
139
171
  function serviceType(name: string): string {
140
- return `${unexportedName(singularizePascal(name))}Service`;
172
+ return `${className(singularizePascal(name))}Service`;
141
173
  }
package/src/go/enums.ts CHANGED
@@ -29,6 +29,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
29
29
  if (canonicalName) {
30
30
  const aliasType = className(enumDef.name);
31
31
  const canonicalType = className(canonicalName);
32
+ // Skip when different IR names map to the same Go type (e.g. synthetic
33
+ // enums from enrichModelsFromSpec whose underscore names collapse to the
34
+ // same PascalCase as the original enum).
35
+ if (aliasType === canonicalType) continue;
32
36
  lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
33
37
  lines.push(`type ${aliasType} = ${canonicalType}`);
34
38
  lines.push('');
package/src/go/index.ts CHANGED
@@ -15,7 +15,7 @@ import { generateEnums } from './enums.js';
15
15
  import { generateResources } from './resources.js';
16
16
  import { generateClient } from './client.js';
17
17
  import { generateTests } from './tests.js';
18
- import { generateManifest } from './manifest.js';
18
+ import { buildOperationsMap } from './manifest.js';
19
19
 
20
20
  /** Ensure every generated file's content ends with a trailing newline. */
21
21
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -64,20 +64,25 @@ export const goEmitter: Emitter = {
64
64
  return ensureTrailingNewlines(generateTests(spec, ctx));
65
65
  },
66
66
 
67
- generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
68
- return ensureTrailingNewlines(generateManifest(spec, ctx));
67
+ buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
68
+ return buildOperationsMap(spec, ctx);
69
69
  },
70
70
 
71
71
  fileHeader(): string {
72
72
  return '// Code generated by oagen. DO NOT EDIT.';
73
73
  },
74
74
 
75
- formatCommand(targetDir: string): FormatCommand | null {
76
- // Pass targetDir as the first path so gofmt formats the entire directory
77
- // (including hand-maintained files), not just the generated file list.
75
+ formatCommand(_targetDir: string): FormatCommand | null {
76
+ // oagen appends all generated file paths (including .json fixtures) to the
77
+ // format command. gofmt errors on non-.go files, so filter them out.
78
+ // Same pattern as the Python emitter's ruff wrapper.
78
79
  return {
79
- cmd: 'gofmt',
80
- args: ['-w', targetDir],
80
+ cmd: 'bash',
81
+ args: [
82
+ '-c',
83
+ 'GO_FILES=$(printf "%s\\n" "$@" | grep "\\.go$"); [ -n "$GO_FILES" ] && echo "$GO_FILES" | xargs gofmt -w',
84
+ '--',
85
+ ],
81
86
  batchSize: 999999,
82
87
  };
83
88
  },
@@ -1,13 +1,13 @@
1
- import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
1
+ import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
2
  import { resolveMethodName } from './naming.js';
3
3
  import { buildServiceAccessPaths } from './client.js';
4
4
  import { getMountTarget } from '../shared/resolved-ops.js';
5
5
 
6
6
  /**
7
- * Generate smoke test manifest mapping HTTP operations to SDK methods.
7
+ * Build operation-to-SDK-method mapping for the manifest.
8
8
  */
9
- export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
- const manifest: Record<string, { sdkMethod: string; service: string }> = {};
9
+ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
10
+ const manifest: OperationsMap = {};
11
11
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
12
12
 
13
13
  for (const service of spec.services) {
@@ -26,11 +26,5 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
26
26
  }
27
27
  }
28
28
 
29
- return [
30
- {
31
- path: 'smoke-manifest.json',
32
- content: JSON.stringify(manifest, null, 2),
33
- integrateTarget: false,
34
- },
35
- ];
29
+ return manifest;
36
30
  }
package/src/go/models.ts CHANGED
@@ -59,9 +59,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
59
59
  if (batchedAliases.has(model.name)) continue;
60
60
 
61
61
  const canonicalStruct = className(canonicalName);
62
+ // Skip when different IR names map to the same Go type (e.g. synthetic
63
+ // models from enrichModelsFromSpec whose underscore names collapse to the
64
+ // same PascalCase as the original model).
65
+ if (structName === canonicalStruct) continue;
66
+
62
67
  const hash = modelHashMap.get(model.name)!;
63
68
  const groupNames = hashGroups.get(hash) ?? [];
64
- const aliases = groupNames.filter((n) => aliasOf.has(n));
69
+ const aliases = groupNames.filter((n) => aliasOf.has(n) && className(n) !== className(aliasOf.get(n)!));
65
70
 
66
71
  if (aliases.length >= 5) {
67
72
  // Batch emit all aliases for this group at once