@workos/oagen-emitters 0.2.0 → 0.3.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 (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,41 @@
1
+ import type { SdkBehavior } from '@workos/oagen';
2
+
3
+ /**
4
+ * Node-specific overrides for exception kind names.
5
+ *
6
+ * The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
7
+ * but the Node SDK historically uses different names for some status codes.
8
+ * This map translates the IR kind name to the Node-specific name before
9
+ * appending the 'Exception' suffix.
10
+ */
11
+ const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
12
+ Authentication: 'Unauthorized',
13
+ };
14
+
15
+ /** Fallback status code map when no SDK behavior is provided. */
16
+ const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
17
+ '400': 'BadRequest',
18
+ '401': 'Authentication',
19
+ '403': 'Authorization',
20
+ '404': 'NotFound',
21
+ '409': 'Conflict',
22
+ '422': 'UnprocessableEntity',
23
+ '429': 'RateLimitExceeded',
24
+ };
25
+
26
+ /**
27
+ * Build the status-code-to-exception-class-name map from SDK behavior,
28
+ * applying Node-specific naming overrides.
29
+ *
30
+ * Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
31
+ * because Node uses `UnauthorizedException` instead of `AuthenticationException`.
32
+ */
33
+ export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
34
+ const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
35
+ return Object.fromEntries(
36
+ Object.entries(statusCodeMap).map(([code, kind]) => {
37
+ const nodeKind = NODE_EXCEPTION_KIND_OVERRIDES[kind] ?? kind;
38
+ return [Number(code), `${nodeKind}Exception`];
39
+ }),
40
+ );
41
+ }
package/src/node/tests.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  fieldName,
6
6
  wireFieldName,
7
7
  fileName,
8
- serviceDirName,
8
+ resolveServiceDir,
9
9
  servicePropertyName,
10
10
  resolveMethodName,
11
11
  resolveInterfaceName,
@@ -15,12 +15,12 @@ import { resolveResourceClassName } from './resources.js';
15
15
  import {
16
16
  assignModelsToServices,
17
17
  createServiceDirResolver,
18
- isServiceCoveredByExisting,
19
18
  uncoveredOperations,
20
19
  relativeImport,
21
20
  isListMetadataModel,
22
21
  isListWrapperModel,
23
22
  } from './utils.js';
23
+ import { groupByMount } from '../shared/resolved-ops.js';
24
24
 
25
25
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
26
26
  const files: GeneratedFile[] = [];
@@ -28,21 +28,38 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
28
28
  // Generate fixture JSON files
29
29
  const fixtures = generateFixtures(spec, ctx);
30
30
  for (const f of fixtures) {
31
- files.push({ path: f.path, content: f.content, headerPlacement: 'skip' });
31
+ files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
32
32
  }
33
33
 
34
34
  // Build model lookup for response field assertions
35
35
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
36
36
 
37
- // Generate test files per serviceskip services whose endpoints are fully
38
- // covered by existing hand-written service classes. For partially covered
39
- // services, generate tests only for uncovered operations.
40
- for (const service of spec.services) {
41
- if (isServiceCoveredByExisting(service, ctx)) continue;
42
- const ops = uncoveredOperations(service, ctx);
37
+ // Generate test files per mount target merges all sub-services into one
38
+ // test file. Skip operations already covered by existing hand-written classes.
39
+ const mountGroups = groupByMount(ctx);
40
+
41
+ // Build mount-target → property name map so tests use the same accessor
42
+ // as the generated client, even when the mount target name doesn't match
43
+ // any IR service name directly.
44
+ const mountAccessors = new Map<string, string>();
45
+ for (const r of ctx.resolvedOperations ?? []) {
46
+ if (!mountAccessors.has(r.mountOn)) {
47
+ mountAccessors.set(r.mountOn, servicePropertyName(r.mountOn));
48
+ }
49
+ }
50
+
51
+ const testEntries: Array<{ name: string; operations: Operation[] }> =
52
+ mountGroups.size > 0
53
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
54
+ : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
55
+
56
+ for (const { name: mountName, operations } of testEntries) {
57
+ if (operations.length === 0) continue;
58
+ const mergedService: Service = { name: mountName, operations };
59
+ const ops = uncoveredOperations(mergedService, ctx);
43
60
  if (ops.length === 0) continue;
44
- const testService = ops.length < service.operations.length ? { ...service, operations: ops } : service;
45
- files.push(generateServiceTest(testService, spec, ctx, modelMap));
61
+ const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
62
+ files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
46
63
  }
47
64
 
48
65
  // Generate serializer round-trip tests
@@ -59,11 +76,12 @@ function generateServiceTest(
59
76
  spec: ApiSpec,
60
77
  ctx: EmitterContext,
61
78
  modelMap: Map<string, Model>,
79
+ mountAccessors?: Map<string, string>,
62
80
  ): GeneratedFile {
63
81
  const resolvedName = resolveResourceClassName(service, ctx);
64
- const serviceDir = serviceDirName(resolvedName);
82
+ const serviceDir = resolveServiceDir(resolvedName);
65
83
  const serviceClass = resolvedName;
66
- const serviceProp = servicePropertyName(resolvedName);
84
+ const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
67
85
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
68
86
 
69
87
  const plans = service.operations.map((op) => ({
@@ -510,8 +528,9 @@ function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>):
510
528
 
511
529
  if (isPaginated) return pathArgs || '';
512
530
  if (hasBody) {
513
- const fb = fallbackBodyArg(op, modelMap);
514
- return pathArgs ? `${pathArgs}, ${fb}` : fb;
531
+ const payload = buildTestPayload(op, modelMap);
532
+ const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
533
+ return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
515
534
  }
516
535
  return pathArgs || '';
517
536
  }
@@ -626,10 +645,18 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
626
645
  }
627
646
 
628
647
  /**
629
- * Return a JS literal string for the expected fixture value of a primitive field.
630
- * Returns null for non-primitive or complex types (arrays, models, etc.).
648
+ * Return a JS literal string for the expected fixture value of a field.
649
+ * Returns null for types that cannot be deterministically generated.
650
+ * When a modelMap is provided, recursively builds object literals for nested model types.
651
+ * When wire is true, uses snake_case keys for nested model objects (wire format).
631
652
  */
632
- function fixtureValueForType(ref: TypeRef, name: string, modelName: string): string | null {
653
+ function fixtureValueForType(
654
+ ref: TypeRef,
655
+ name: string,
656
+ modelName: string,
657
+ modelMap?: Map<string, Model>,
658
+ wire?: boolean,
659
+ ): string | null {
633
660
  switch (ref.kind) {
634
661
  case 'primitive':
635
662
  return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
@@ -646,10 +673,24 @@ function fixtureValueForType(ref: TypeRef, name: string, modelName: string): str
646
673
  // For arrays of primitives/enums, generate a single-element array assertion.
647
674
  // For arrays of models/complex types, return null to skip the assertion —
648
675
  // the fixture will have populated items that we can't predict here.
649
- const itemValue = fixtureValueForType(ref.items, name, modelName);
676
+ const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
650
677
  if (itemValue !== null) return `[${itemValue}]`;
651
678
  return null;
652
679
  }
680
+ case 'model': {
681
+ if (!modelMap) return null;
682
+ const nested = modelMap.get(ref.name);
683
+ if (!nested) return null;
684
+ const requiredFields = nested.fields.filter((f) => f.required);
685
+ const entries: string[] = [];
686
+ for (const field of requiredFields) {
687
+ const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
688
+ if (value === null) return null; // Can't build a complete object
689
+ const key = wire ? wireFieldName(field.name) : fieldName(field.name);
690
+ entries.push(`${key}: ${value}`);
691
+ }
692
+ return `{ ${entries.join(', ')} }`;
693
+ }
653
694
  default:
654
695
  return null;
655
696
  }
@@ -716,8 +757,8 @@ function buildTestPayload(
716
757
  if (!model) return null;
717
758
 
718
759
  const fields = model.fields.filter((f) => f.required);
719
- // Only use primitive/literal/enum/array fields that we can generate deterministic values for
720
- const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
760
+ // Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
761
+ const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
721
762
 
722
763
  // Only generate a typed payload when ALL required fields have fixture values.
723
764
  // A partial payload missing required fields would fail TypeScript type checking.
@@ -727,11 +768,12 @@ function buildTestPayload(
727
768
  const snakeEntries: string[] = [];
728
769
 
729
770
  for (const field of usableFields) {
730
- const value = fixtureValueForType(field.type, field.name, model.name)!;
771
+ const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
772
+ const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
731
773
  const camelKey = fieldName(field.name);
732
774
  const snakeKey = wireFieldName(field.name);
733
- camelEntries.push(`${camelKey}: ${value}`);
734
- snakeEntries.push(`${snakeKey}: ${value}`);
775
+ camelEntries.push(`${camelKey}: ${camelValue}`);
776
+ snakeEntries.push(`${snakeKey}: ${wireValue}`);
735
777
  }
736
778
 
737
779
  return {
@@ -775,7 +817,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
775
817
  serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
776
818
  }
777
819
  const resolveDir = (irService: string | undefined) =>
778
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
820
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
779
821
 
780
822
  // Only generate round-trip tests for models with fields that have serializers generated.
781
823
  // Skip list metadata and list wrapper models since their serializers are not emitted.
@@ -814,7 +856,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
814
856
  serializerImports.push(
815
857
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
816
858
  );
817
- fixtureImports.push(`import ${toCamelCase(model.name)}Fixture from '${relativeImport(testPath, fixturePath)}';`);
859
+ const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
860
+ fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
818
861
  }
819
862
 
820
863
  for (const imp of serializerImports) {
@@ -827,7 +870,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
827
870
 
828
871
  for (const model of models) {
829
872
  const domainName = resolveInterfaceName(model.name, ctx);
830
- const fixtureName = `${toCamelCase(model.name)}Fixture`;
873
+ const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
874
+ const fixtureName = `${camelDomain}Fixture`;
831
875
 
832
876
  lines.push(`describe('${domainName}Serializer', () => {`);
833
877
  lines.push(" it('round-trips through serialize/deserialize', () => {");
@@ -122,10 +122,12 @@ function mapWirePrimitive(ref: PrimitiveType): string {
122
122
  * allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
123
123
  */
124
124
  function joinUnionVariants(ref: UnionType, variants: string[]): string {
125
+ const unique = [...new Set(variants)];
125
126
  if (ref.compositionKind === 'allOf') {
126
- return variants.join(' & ');
127
+ return unique.join(' & ');
127
128
  }
128
- return variants.join(' | ');
129
+ if (unique.length === 1) return unique[0];
130
+ return unique.join(' | ');
129
131
  }
130
132
 
131
133
  /** Wrap union/intersection types in parentheses when used as array item type. */
package/src/node/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
1
+ import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
2
3
  export {
3
4
  collectModelRefs,
4
5
  collectEnumRefs,
@@ -7,7 +8,14 @@ export {
7
8
  collectRequestBodyModels,
8
9
  } from '@workos/oagen';
9
10
  import { mapTypeRef } from './type-map.js';
10
- import { resolveInterfaceName, fieldName, serviceDirName, buildServiceNameMap } from './naming.js';
11
+ import {
12
+ resolveInterfaceName,
13
+ fieldName,
14
+ resolveServiceDir,
15
+ resolveMethodName,
16
+ buildServiceNameMap,
17
+ } from './naming.js';
18
+ import { getMountTarget } from '../shared/resolved-ops.js';
11
19
  import { assignModelsToServices } from '@workos/oagen';
12
20
 
13
21
  /**
@@ -218,7 +226,7 @@ export function createServiceDirResolver(
218
226
  const modelToService = assignModelsToServices(models, services);
219
227
  const serviceNameMap = buildServiceNameMap(services, ctx);
220
228
  const resolveDir = (irService: string | undefined) =>
221
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
229
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
222
230
  return { modelToService, serviceNameMap, resolveDir };
223
231
  }
224
232
 
@@ -240,67 +248,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
240
248
  return false;
241
249
  }
242
250
 
243
- /**
244
- * Detect whether a model matches the standard list-metadata shape:
245
- * exactly 2 fields named `before` and `after`, both nullable string.
246
- *
247
- * These models are redundant because the SDK already has a shared
248
- * `ListMetadata` type in `src/common/utils/pagination.ts`.
249
- */
250
- export function isListMetadataModel(model: Model): boolean {
251
- if (model.fields.length !== 2) return false;
252
-
253
- const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
254
- const before = fieldsByName.get('before');
255
- const after = fieldsByName.get('after');
256
-
257
- if (!before || !after) return false;
258
-
259
- return isNullableString(before) && isNullableString(after);
260
- }
261
-
262
- /**
263
- * Detect whether a model is a list wrapper — the standard paginated
264
- * list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
265
- *
266
- * These models are redundant because the SDK already has `List<T>` and
267
- * `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
268
- * `deserializeList` handles deserialization.
269
- */
270
- export function isListWrapperModel(model: Model): boolean {
271
- const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
272
-
273
- // Must have a `data` field that is an array type
274
- const dataField = fieldsByName.get('data');
275
- if (!dataField) return false;
276
- if (dataField.type.kind !== 'array') return false;
277
-
278
- // Must have a `list_metadata` field (the IR uses snake_case names)
279
- const listMetadataField = fieldsByName.get('list_metadata');
280
- if (!listMetadataField) return false;
281
-
282
- // Optionally has an `object` field with literal value 'list'
283
- const objectField = fieldsByName.get('object');
284
- if (objectField) {
285
- if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
286
- return false;
287
- }
288
- }
289
-
290
- return true;
291
- }
292
-
293
- /** Check if a field type is nullable string (nullable<string> or just string). */
294
- function isNullableString(field: Field): boolean {
295
- const { type } = field;
296
- if (type.kind === 'nullable') {
297
- return type.inner.kind === 'primitive' && type.inner.type === 'string';
298
- }
299
- if (type.kind === 'primitive') {
300
- return type.type === 'string';
301
- }
302
- return false;
303
- }
251
+ // Re-export shared model detection utilities
252
+ export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
304
253
 
305
254
  /**
306
255
  * Compute a structural fingerprint for a model based on its fields.
@@ -384,6 +333,11 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
384
333
  * endpoints (e.g., `GET /connections`).
385
334
  */
386
335
  export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
336
+ // A service is "covered" when its mountOn differs from its own name,
337
+ // meaning its operations are mounted on a different (existing) class.
338
+ const mountTarget = getMountTarget(service, ctx);
339
+ if (mountTarget !== toPascalCase(service.name)) return true;
340
+
387
341
  const overlay = ctx.overlayLookup?.methodByOperation;
388
342
  if (!overlay || overlay.size === 0) return false;
389
343
  if (service.operations.length === 0) return false;
@@ -405,6 +359,44 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
405
359
  });
406
360
  }
407
361
 
362
+ /**
363
+ * Check whether a fully-covered service has operations whose overlay-mapped
364
+ * methods are missing from the baseline class. Returns true when at least
365
+ * one operation maps to a method name that the baseline class does not have,
366
+ * meaning the merger needs to add new methods (skipIfExists must be removed).
367
+ */
368
+ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
369
+ const baselineClasses = ctx.apiSurface?.classes;
370
+ if (!baselineClasses) return false;
371
+
372
+ // When a service mounts on a different class (via mount rules), check
373
+ // each operation's resolved method name against the target class directly.
374
+ const mountTarget = getMountTarget(service, ctx);
375
+ if (mountTarget !== toPascalCase(service.name)) {
376
+ const cls = baselineClasses[mountTarget];
377
+ if (!cls) return true; // Target class missing from baseline — treat as absent
378
+ for (const op of service.operations) {
379
+ const method = resolveMethodName(op, service, ctx);
380
+ if (!cls.methods?.[method]) return true;
381
+ }
382
+ return false;
383
+ }
384
+
385
+ // Default overlay-based detection
386
+ const overlay = ctx.overlayLookup?.methodByOperation;
387
+ if (!overlay) return false;
388
+
389
+ for (const op of service.operations) {
390
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
391
+ const match = overlay.get(httpKey);
392
+ if (!match) continue;
393
+ const cls = baselineClasses[match.className];
394
+ if (!cls) continue;
395
+ if (!cls.methods?.[match.methodName]) return true;
396
+ }
397
+ return false;
398
+ }
399
+
408
400
  /**
409
401
  * Return operations in a service that are NOT covered by existing hand-written
410
402
  * service classes. For fully uncovered services, returns all operations.
@@ -0,0 +1,151 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import { toCamelCase } from '@workos/oagen';
3
+ import { fieldName, resolveInterfaceName, wireInterfaceName } from './naming.js';
4
+ import { mapTypeRef } from './type-map.js';
5
+ import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
6
+
7
+ /**
8
+ * Generate TypeScript wrapper method lines for union split operations.
9
+ *
10
+ * Each wrapper is a typed convenience method that:
11
+ * - Accepts only the exposed params (not the full union body)
12
+ * - Injects constant defaults (e.g., grant_type)
13
+ * - Reads inferred fields from client config (e.g., clientId)
14
+ * - Delegates to the HTTP client with the constructed body
15
+ */
16
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
17
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
18
+
19
+ const lines: string[] = [];
20
+
21
+ for (const wrapper of resolvedOp.wrappers) {
22
+ lines.push('');
23
+ emitWrapperMethod(lines, resolvedOp, wrapper, ctx);
24
+ }
25
+
26
+ return lines;
27
+ }
28
+
29
+ /**
30
+ * Collect response model names referenced by wrappers on a resolved operation.
31
+ * Used by the resource generator to ensure the correct imports are emitted.
32
+ */
33
+ export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
34
+ const models = new Set<string>();
35
+ for (const wrapper of resolvedOp.wrappers ?? []) {
36
+ if (wrapper.responseModelName) {
37
+ models.add(wrapper.responseModelName);
38
+ }
39
+ }
40
+ return models;
41
+ }
42
+
43
+ function emitWrapperMethod(
44
+ lines: string[],
45
+ resolvedOp: ResolvedOperation,
46
+ wrapper: ResolvedWrapper,
47
+ ctx: EmitterContext,
48
+ ): void {
49
+ const op = resolvedOp.operation;
50
+ const method = toCamelCase(wrapper.name);
51
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
52
+
53
+ // Build parameter list: path params, then required exposed, then optional exposed
54
+ const paramParts: string[] = [];
55
+
56
+ for (const p of op.pathParams) {
57
+ paramParts.push(`${fieldName(p.name)}: string`);
58
+ }
59
+
60
+ for (const { paramName, field, isOptional } of wrapperParams) {
61
+ if (isOptional) continue;
62
+ const tsName = fieldName(paramName);
63
+ const tsType = field ? mapTypeRef(field.type) : 'string';
64
+ paramParts.push(`${tsName}: ${tsType}`);
65
+ }
66
+
67
+ for (const { paramName, field, isOptional } of wrapperParams) {
68
+ if (!isOptional) continue;
69
+ const tsName = fieldName(paramName);
70
+ const tsType = field ? mapTypeRef(field.type) : 'string';
71
+ paramParts.push(`${tsName}?: ${tsType}`);
72
+ }
73
+
74
+ // Response type
75
+ const responseTypeName = wrapper.responseModelName ? resolveInterfaceName(wrapper.responseModelName, ctx) : null;
76
+ const wireType = responseTypeName ? wireInterfaceName(responseTypeName) : null;
77
+ const returnType = responseTypeName ?? 'void';
78
+
79
+ // JSDoc
80
+ lines.push(` /** ${formatWrapperDescription(wrapper.name)}. */`);
81
+
82
+ // Method signature
83
+ lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
84
+
85
+ // Build body with wire-format (snake_case) keys
86
+ lines.push(' const body: Record<string, unknown> = {');
87
+
88
+ // Constant defaults
89
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
90
+ lines.push(` ${key}: ${tsLiteral(value)},`);
91
+ }
92
+
93
+ // Inferred fields from client config
94
+ for (const field of wrapper.inferFromClient) {
95
+ const expr = clientFieldExpression(field);
96
+ lines.push(` ${field}: ${expr},`);
97
+ }
98
+
99
+ // Required exposed params (wire-format key, camelCase value)
100
+ for (const { paramName, isOptional } of wrapperParams) {
101
+ if (isOptional) continue;
102
+ lines.push(` ${paramName}: ${fieldName(paramName)},`);
103
+ }
104
+
105
+ lines.push(' };');
106
+
107
+ // Optional exposed params — add conditionally
108
+ for (const { paramName, isOptional } of wrapperParams) {
109
+ if (!isOptional) continue;
110
+ const tsName = fieldName(paramName);
111
+ lines.push(` if (${tsName} !== undefined) body.${paramName} = ${tsName};`);
112
+ }
113
+
114
+ // Build path expression
115
+ const pathStr = buildPathStr(op);
116
+
117
+ // Make the request
118
+ if (responseTypeName) {
119
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, body);`);
120
+ lines.push(` return deserialize${responseTypeName}(data);`);
121
+ } else {
122
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, body);`);
123
+ }
124
+
125
+ lines.push(' }');
126
+ }
127
+
128
+ /** Build a path template string from an Operation. */
129
+ function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
130
+ const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
131
+ return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
132
+ }
133
+
134
+ /** Convert a JS value to a TypeScript literal. */
135
+ function tsLiteral(value: string | number | boolean): string {
136
+ if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
137
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
138
+ return String(value);
139
+ }
140
+
141
+ /** Get the TypeScript expression for reading a client config field. */
142
+ function clientFieldExpression(field: string): string {
143
+ switch (field) {
144
+ case 'client_id':
145
+ return 'this.workos.options.clientId';
146
+ case 'client_secret':
147
+ return 'this.workos.key';
148
+ default:
149
+ return `this.workos.${toCamelCase(field)}`;
150
+ }
151
+ }