@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  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 +328 -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 +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -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
@@ -9,18 +9,19 @@ import {
9
9
  servicePropertyName,
10
10
  resolveMethodName,
11
11
  resolveInterfaceName,
12
+ wireInterfaceName,
12
13
  } from './naming.js';
13
14
  import { generateFixtures } from './fixtures.js';
14
15
  import { resolveResourceClassName } from './resources.js';
15
16
  import {
16
17
  assignModelsToServices,
17
18
  createServiceDirResolver,
18
- isServiceCoveredByExisting,
19
19
  uncoveredOperations,
20
20
  relativeImport,
21
21
  isListMetadataModel,
22
22
  isListWrapperModel,
23
23
  } from './utils.js';
24
+ import { groupByMount } from '../shared/resolved-ops.js';
24
25
 
25
26
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
26
27
  const files: GeneratedFile[] = [];
@@ -34,15 +35,32 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
34
35
  // Build model lookup for response field assertions
35
36
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
36
37
 
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);
38
+ // Generate test files per mount target merges all sub-services into one
39
+ // test file. Skip operations already covered by existing hand-written classes.
40
+ const mountGroups = groupByMount(ctx);
41
+
42
+ // Build mount-target → property name map so tests use the same accessor
43
+ // as the generated client, even when the mount target name doesn't match
44
+ // any IR service name directly.
45
+ const mountAccessors = new Map<string, string>();
46
+ for (const r of ctx.resolvedOperations ?? []) {
47
+ if (!mountAccessors.has(r.mountOn)) {
48
+ mountAccessors.set(r.mountOn, servicePropertyName(r.mountOn));
49
+ }
50
+ }
51
+
52
+ const testEntries: Array<{ name: string; operations: Operation[] }> =
53
+ mountGroups.size > 0
54
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
55
+ : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
56
+
57
+ for (const { name: mountName, operations } of testEntries) {
58
+ if (operations.length === 0) continue;
59
+ const mergedService: Service = { name: mountName, operations };
60
+ const ops = uncoveredOperations(mergedService, ctx);
43
61
  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));
62
+ const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
63
+ files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
46
64
  }
47
65
 
48
66
  // Generate serializer round-trip tests
@@ -59,11 +77,12 @@ function generateServiceTest(
59
77
  spec: ApiSpec,
60
78
  ctx: EmitterContext,
61
79
  modelMap: Map<string, Model>,
80
+ mountAccessors?: Map<string, string>,
62
81
  ): GeneratedFile {
63
82
  const resolvedName = resolveResourceClassName(service, ctx);
64
83
  const serviceDir = resolveServiceDir(resolvedName);
65
84
  const serviceClass = resolvedName;
66
- const serviceProp = servicePropertyName(resolvedName);
85
+ const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
67
86
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
68
87
 
69
88
  const plans = service.operations.map((op) => ({
@@ -733,9 +752,21 @@ function buildTestPayload(
733
752
  op: Operation,
734
753
  modelMap: Map<string, Model>,
735
754
  ): { camelCaseObj: string; snakeCaseObj: string } | null {
736
- if (!op.requestBody || op.requestBody.kind !== 'model') return null;
737
-
738
- const model = modelMap.get(op.requestBody.name);
755
+ if (!op.requestBody) return null;
756
+
757
+ // For discriminated unions, build a payload from the first variant so the
758
+ // generated test produces a value that satisfies the union type. Without
759
+ // this, emitted tests pass `{} as any` for union bodies — fine for older
760
+ // permissive runtimes, but the dispatch switch now throws on unknown
761
+ // discriminator values, so the tests would fail before hitting fetch.
762
+ let model: Model | undefined;
763
+ if (op.requestBody.kind === 'union') {
764
+ const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
765
+ if (!firstVariant || firstVariant.kind !== 'model') return null;
766
+ model = modelMap.get(firstVariant.name);
767
+ } else if (op.requestBody.kind === 'model') {
768
+ model = modelMap.get(op.requestBody.name);
769
+ }
739
770
  if (!model) return null;
740
771
 
741
772
  const fields = model.fields.filter((f) => f.required);
@@ -771,8 +802,14 @@ function buildTestPayload(
771
802
  * fall back to `{} as any` to bypass type checking for complex required fields.
772
803
  */
773
804
  function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
774
- if (!op.requestBody || op.requestBody.kind !== 'model') return '{} as any';
775
- const model = modelMap.get(op.requestBody.name);
805
+ if (!op.requestBody) return '{} as any';
806
+ let model: Model | undefined;
807
+ if (op.requestBody.kind === 'union') {
808
+ const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
809
+ if (firstVariant && firstVariant.kind === 'model') model = modelMap.get(firstVariant.name);
810
+ } else if (op.requestBody.kind === 'model') {
811
+ model = modelMap.get(op.requestBody.name);
812
+ }
776
813
  if (!model) return '{} as any';
777
814
  const hasRequiredFields = model.fields.some((f) => f.required);
778
815
  return hasRequiredFields ? '{} as any' : '{}';
@@ -826,6 +863,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
826
863
 
827
864
  // Collect imports
828
865
  const serializerImports: string[] = [];
866
+ const interfaceImports: string[] = [];
829
867
  const fixtureImports: string[] = [];
830
868
 
831
869
  for (const model of models) {
@@ -833,17 +871,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
833
871
  const service = modelToService.get(model.name);
834
872
  const modelDir = resolveDir(service);
835
873
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
874
+ const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
836
875
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
837
876
 
838
877
  serializerImports.push(
839
878
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
840
879
  );
841
- fixtureImports.push(`import ${toCamelCase(model.name)}Fixture from '${relativeImport(testPath, fixturePath)}';`);
880
+ const wireName = wireInterfaceName(domainName);
881
+ interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
882
+ const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
883
+ fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
842
884
  }
843
885
 
844
886
  for (const imp of serializerImports) {
845
887
  lines.push(imp);
846
888
  }
889
+ for (const imp of interfaceImports) {
890
+ lines.push(imp);
891
+ }
847
892
  for (const imp of fixtureImports) {
848
893
  lines.push(imp);
849
894
  }
@@ -851,11 +896,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
851
896
 
852
897
  for (const model of models) {
853
898
  const domainName = resolveInterfaceName(model.name, ctx);
854
- const fixtureName = `${toCamelCase(model.name)}Fixture`;
899
+ const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
900
+ const fixtureName = `${camelDomain}Fixture`;
855
901
 
902
+ const wireName = wireInterfaceName(domainName);
856
903
  lines.push(`describe('${domainName}Serializer', () => {`);
857
904
  lines.push(" it('round-trips through serialize/deserialize', () => {");
858
- lines.push(` const fixture = ${fixtureName};`);
905
+ // Cast the fixture to the wire-type interface — JSON imports are inferred
906
+ // with primitive types (e.g., `string`) that don't satisfy literal-typed
907
+ // response fields (e.g., `type: "enum"`), so the deserialize call needs
908
+ // the explicit cast to compile.
909
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
859
910
  lines.push(` const deserialized = deserialize${domainName}(fixture);`);
860
911
  lines.push(` const reserialized = serialize${domainName}(deserialized);`);
861
912
  lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
@@ -868,7 +919,6 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
868
919
  path: testPath,
869
920
  content: lines.join('\n'),
870
921
  skipIfExists: true,
871
- integrateTarget: false,
872
922
  });
873
923
  }
874
924
 
@@ -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,4 @@
1
- import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
1
+ import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
  export {
4
4
  collectModelRefs,
@@ -14,8 +14,8 @@ import {
14
14
  resolveServiceDir,
15
15
  resolveMethodName,
16
16
  buildServiceNameMap,
17
- SERVICE_COVERED_BY,
18
17
  } from './naming.js';
18
+ import { getMountTarget } from '../shared/resolved-ops.js';
19
19
  import { assignModelsToServices } from '@workos/oagen';
20
20
 
21
21
  /**
@@ -248,67 +248,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
248
248
  return false;
249
249
  }
250
250
 
251
- /**
252
- * Detect whether a model matches the standard list-metadata shape:
253
- * exactly 2 fields named `before` and `after`, both nullable string.
254
- *
255
- * These models are redundant because the SDK already has a shared
256
- * `ListMetadata` type in `src/common/utils/pagination.ts`.
257
- */
258
- export function isListMetadataModel(model: Model): boolean {
259
- if (model.fields.length !== 2) return false;
260
-
261
- const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
262
- const before = fieldsByName.get('before');
263
- const after = fieldsByName.get('after');
264
-
265
- if (!before || !after) return false;
266
-
267
- return isNullableString(before) && isNullableString(after);
268
- }
269
-
270
- /**
271
- * Detect whether a model is a list wrapper — the standard paginated
272
- * list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
273
- *
274
- * These models are redundant because the SDK already has `List<T>` and
275
- * `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
276
- * `deserializeList` handles deserialization.
277
- */
278
- export function isListWrapperModel(model: Model): boolean {
279
- const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
280
-
281
- // Must have a `data` field that is an array type
282
- const dataField = fieldsByName.get('data');
283
- if (!dataField) return false;
284
- if (dataField.type.kind !== 'array') return false;
285
-
286
- // Must have a `list_metadata` field (the IR uses snake_case names)
287
- const listMetadataField = fieldsByName.get('list_metadata');
288
- if (!listMetadataField) return false;
289
-
290
- // Optionally has an `object` field with literal value 'list'
291
- const objectField = fieldsByName.get('object');
292
- if (objectField) {
293
- if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
294
- return false;
295
- }
296
- }
297
-
298
- return true;
299
- }
300
-
301
- /** Check if a field type is nullable string (nullable<string> or just string). */
302
- function isNullableString(field: Field): boolean {
303
- const { type } = field;
304
- if (type.kind === 'nullable') {
305
- return type.inner.kind === 'primitive' && type.inner.type === 'string';
306
- }
307
- if (type.kind === 'primitive') {
308
- return type.type === 'string';
309
- }
310
- return false;
311
- }
251
+ // Re-export shared model detection utilities
252
+ export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
312
253
 
313
254
  /**
314
255
  * Compute a structural fingerprint for a model based on its fields.
@@ -392,8 +333,10 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
392
333
  * endpoints (e.g., `GET /connections`).
393
334
  */
394
335
  export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
395
- // Explicit override: services known to be covered by existing hand-written classes
396
- if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
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;
397
340
 
398
341
  const overlay = ctx.overlayLookup?.methodByOperation;
399
342
  if (!overlay || overlay.size === 0) return false;
@@ -426,12 +369,11 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
426
369
  const baselineClasses = ctx.apiSurface?.classes;
427
370
  if (!baselineClasses) return false;
428
371
 
429
- // For services explicitly mapped to an existing class via SERVICE_COVERED_BY,
430
- // check each operation's resolved method name against the target class directly.
431
- // This avoids the overlay gap where new endpoints are silently skipped.
432
- const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
433
- if (targetClassName) {
434
- const cls = baselineClasses[targetClassName];
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];
435
377
  if (!cls) return true; // Target class missing from baseline — treat as absent
436
378
  for (const op of service.operations) {
437
379
  const method = resolveMethodName(op, service, ctx);
@@ -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
+ }
@@ -0,0 +1,179 @@
1
+ import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { toPascalCase, toCamelCase } from '@workos/oagen';
3
+ import { className, servicePropertyName } from './naming.js';
4
+ import { getMountTarget } from '../shared/resolved-ops.js';
5
+ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
6
+
7
+ /**
8
+ * PHP-specific class-name overrides for non-spec services.
9
+ * If a service id isn't listed here, PascalCase(id) is used.
10
+ */
11
+ const PHP_NON_SPEC_CLASS_NAMES: Record<string, string> = {
12
+ webhook_verification: 'WebhookVerification',
13
+ session_manager: 'SessionManager',
14
+ pkce: 'PKCEHelper',
15
+ };
16
+
17
+ /** Derive PHP class name + property name from a non-spec service id. */
18
+ function phpNonSpecAccessor(id: string): { className: string; propName: string } {
19
+ return {
20
+ className: PHP_NON_SPEC_CLASS_NAMES[id] ?? toPascalCase(id),
21
+ propName:
22
+ id === 'webhook_verification'
23
+ ? 'webhookVerification'
24
+ : id === 'session_manager'
25
+ ? 'sessionManager'
26
+ : toCamelCase(id),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Generate the main PHP client class (service wiring only).
32
+ *
33
+ * Static infrastructure (HttpClient, PaginatedResponse, RequestOptions) is
34
+ * now hand-maintained in the target SDK with @oagen-ignore-file.
35
+ */
36
+ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
37
+ const ns = ctx.namespacePascal;
38
+ const dedupedServices = deduplicateByMount(spec.services, ctx);
39
+
40
+ return [
41
+ {
42
+ path: `lib/${ns}.php`,
43
+ content: generateMainClient(spec, dedupedServices, ctx),
44
+ overwriteExisting: true,
45
+ },
46
+ ];
47
+ }
48
+
49
+ /**
50
+ * Build a map from IR service name to the client accessor property name.
51
+ */
52
+ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
53
+ const map = new Map<string, string>();
54
+ for (const service of services) {
55
+ const target = getMountTarget(service, ctx);
56
+ map.set(service.name, servicePropertyName(target));
57
+ map.set(target, servicePropertyName(target));
58
+ }
59
+ return map;
60
+ }
61
+
62
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
63
+ const seen = new Map<string, { name: string; propName: string }>();
64
+ for (const service of services) {
65
+ const target = getMountTarget(service, ctx);
66
+ if (!seen.has(target)) {
67
+ seen.set(target, {
68
+ name: className(target),
69
+ propName: servicePropertyName(target),
70
+ });
71
+ }
72
+ }
73
+ return [...seen.values()];
74
+ }
75
+
76
+ function generateMainClient(
77
+ spec: ApiSpec,
78
+ services: { name: string; propName: string }[],
79
+ ctx: EmitterContext,
80
+ ): string {
81
+ const ns = ctx.namespacePascal;
82
+ const lines: string[] = [];
83
+
84
+ // No <?php here — the file header from fileHeader() provides it
85
+ lines.push(`namespace ${ns};`);
86
+ lines.push('');
87
+
88
+ // Use imports (sorted case-insensitively for PSR-12)
89
+ const nonSpecAccessors = NON_SPEC_SERVICES.map((s) => phpNonSpecAccessor(s.id));
90
+ const allImports: string[] = [];
91
+ for (const svc of services) {
92
+ allImports.push(`use ${ns}\\Service\\${svc.name};`);
93
+ }
94
+ allImports.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
95
+ for (const imp of allImports) {
96
+ lines.push(imp);
97
+ }
98
+ lines.push('');
99
+
100
+ lines.push(`class ${ns}`);
101
+ lines.push('{');
102
+ lines.push(' private static ?string $apiKey = null;');
103
+ lines.push(' private static ?string $clientId = null;');
104
+ lines.push(' private ?HttpClient $httpClient = null;');
105
+ lines.push('');
106
+ lines.push(' public static function getApiKey(): ?string');
107
+ lines.push(' {');
108
+ lines.push(' return self::$apiKey;');
109
+ lines.push(' }');
110
+ lines.push('');
111
+ lines.push(' public static function setApiKey(?string $key): void');
112
+ lines.push(' {');
113
+ lines.push(' self::$apiKey = $key;');
114
+ lines.push(' }');
115
+ lines.push('');
116
+ lines.push(' public static function getClientId(): ?string');
117
+ lines.push(' {');
118
+ lines.push(' return self::$clientId;');
119
+ lines.push(' }');
120
+ lines.push('');
121
+ lines.push(' public static function setClientId(?string $id): void');
122
+ lines.push(' {');
123
+ lines.push(' self::$clientId = $id;');
124
+ lines.push(' }');
125
+
126
+ // Nullable resource properties
127
+ for (const svc of services) {
128
+ lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
129
+ }
130
+ // Non-spec service properties — wrapped in ignore markers so the target
131
+ // SDK can hand-maintain the list. The emitter provides a positional anchor.
132
+ lines.push(' // @oagen-ignore-start — non-spec service properties (hand-maintained)');
133
+ for (const a of nonSpecAccessors) {
134
+ lines.push(` private ?${a.className} $${a.propName} = null;`);
135
+ }
136
+ lines.push(' // @oagen-ignore-end');
137
+
138
+ lines.push('');
139
+ lines.push(' public function __construct(');
140
+ lines.push(' ?string $apiKey = null,');
141
+ lines.push(' ?string $clientId = null,');
142
+ lines.push(` string $baseUrl = '${spec.baseUrl}',`);
143
+ lines.push(' int $timeout = 60,');
144
+ lines.push(' int $maxRetries = 3,');
145
+ lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
146
+ lines.push(' ?string $userAgent = null,');
147
+ lines.push(' ) {');
148
+ lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
149
+ lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
150
+ lines.push(
151
+ ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
152
+ );
153
+ lines.push(' }');
154
+
155
+ // Resource accessors
156
+ for (const svc of services) {
157
+ lines.push('');
158
+ lines.push(` public function ${svc.propName}(): ${svc.name}`);
159
+ lines.push(' {');
160
+ lines.push(` return $this->${svc.propName} ??= new Service\\${svc.name}($this->httpClient);`);
161
+ lines.push(' }');
162
+ }
163
+
164
+ // Non-spec service accessors — wrapped in ignore markers so the target
165
+ // SDK can hand-maintain these. The emitter provides a positional anchor.
166
+ lines.push('');
167
+ lines.push(' // @oagen-ignore-start — non-spec service accessors (hand-maintained)');
168
+ for (const a of nonSpecAccessors) {
169
+ lines.push('');
170
+ lines.push(` public function ${a.propName}(): ${a.className}`);
171
+ lines.push(' {');
172
+ lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
173
+ lines.push(' }');
174
+ }
175
+ lines.push(' // @oagen-ignore-end');
176
+
177
+ lines.push('}');
178
+ return lines.join('\n');
179
+ }