@workos/oagen-emitters 0.2.1 → 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 (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. 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
@@ -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[] = [];
@@ -34,15 +34,32 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
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
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) => ({
@@ -838,7 +856,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
838
856
  serializerImports.push(
839
857
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
840
858
  );
841
- 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)}';`);
842
861
  }
843
862
 
844
863
  for (const imp of serializerImports) {
@@ -851,7 +870,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
851
870
 
852
871
  for (const model of models) {
853
872
  const domainName = resolveInterfaceName(model.name, ctx);
854
- const fixtureName = `${toCamelCase(model.name)}Fixture`;
873
+ const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
874
+ const fixtureName = `${camelDomain}Fixture`;
855
875
 
856
876
  lines.push(`describe('${domainName}Serializer', () => {`);
857
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,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,171 @@
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 (hand-maintained modules)
131
+ for (const a of nonSpecAccessors) {
132
+ lines.push(` private ?${a.className} $${a.propName} = null;`);
133
+ }
134
+
135
+ lines.push('');
136
+ lines.push(' public function __construct(');
137
+ lines.push(' ?string $apiKey = null,');
138
+ lines.push(' ?string $clientId = null,');
139
+ lines.push(` string $baseUrl = '${spec.baseUrl}',`);
140
+ lines.push(' int $timeout = 60,');
141
+ lines.push(' int $maxRetries = 3,');
142
+ lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
143
+ lines.push(' ) {');
144
+ lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
145
+ lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
146
+ lines.push(
147
+ ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
148
+ );
149
+ lines.push(' }');
150
+
151
+ // Resource accessors
152
+ for (const svc of services) {
153
+ lines.push('');
154
+ lines.push(` public function ${svc.propName}(): ${svc.name}`);
155
+ lines.push(' {');
156
+ lines.push(` return $this->${svc.propName} ??= new Service\\${svc.name}($this->httpClient);`);
157
+ lines.push(' }');
158
+ }
159
+
160
+ // Non-spec service accessors (hand-maintained modules)
161
+ for (const a of nonSpecAccessors) {
162
+ lines.push('');
163
+ lines.push(` public function ${a.propName}(): ${a.className}`);
164
+ lines.push(' {');
165
+ lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
166
+ lines.push(' }');
167
+ }
168
+
169
+ lines.push('}');
170
+ return lines.join('\n');
171
+ }
@@ -0,0 +1,67 @@
1
+ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
3
+ import { className, resolveEnumName } from './naming.js';
4
+ import { phpDocComment } from './utils.js';
5
+
6
+ /**
7
+ * Generate PHP enum files from IR enums.
8
+ */
9
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
10
+ if (enums.length === 0) return [];
11
+
12
+ const files: GeneratedFile[] = [];
13
+ const emittedCanonical = new Set<string>();
14
+
15
+ for (const e of enums) {
16
+ const canonical = resolveEnumName(e.name);
17
+ if (emittedCanonical.has(canonical)) continue; // skip aliases
18
+ emittedCanonical.add(canonical);
19
+
20
+ const name = className(canonical);
21
+ const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
22
+ const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
23
+ const backingType = isAllInts ? 'int' : 'string';
24
+
25
+ const lines: string[] = [];
26
+ // No <?php here — the file header from fileHeader() provides it
27
+ lines.push(`namespace ${ctx.namespacePascal}\\Resource;`);
28
+ lines.push('');
29
+ lines.push(`enum ${name}: ${backingType}`);
30
+ lines.push('{');
31
+
32
+ // Deduplicate case names
33
+ const usedNames = new Map<string, number>();
34
+ for (const val of e.values) {
35
+ let caseName = toPascalCase(val.name.toLowerCase());
36
+ const baseName = caseName;
37
+ const count = usedNames.get(baseName) ?? 0;
38
+ if (count > 0) {
39
+ caseName = `${baseName}${count + 1}`;
40
+ }
41
+ usedNames.set(baseName, count + 1);
42
+
43
+ if (val.description || val.deprecated) {
44
+ const parts: string[] = [];
45
+ if (val.description) parts.push(val.description);
46
+ if (val.deprecated) parts.push('@deprecated');
47
+ lines.push(...phpDocComment(parts.join('\n'), 4));
48
+ }
49
+
50
+ if (typeof val.value === 'string') {
51
+ lines.push(` case ${caseName} = '${val.value}';`);
52
+ } else {
53
+ lines.push(` case ${caseName} = ${val.value};`);
54
+ }
55
+ }
56
+
57
+ lines.push('}');
58
+
59
+ files.push({
60
+ path: `lib/Resource/${name}.php`,
61
+ content: lines.join('\n'),
62
+ overwriteExisting: true,
63
+ });
64
+ }
65
+
66
+ return files;
67
+ }
@@ -0,0 +1,9 @@
1
+ import type { GeneratedFile } from '@workos/oagen';
2
+
3
+ /**
4
+ * PHP exception classes are now hand-maintained in the target SDK
5
+ * (lib/Exception/*.php with @oagen-ignore-file).
6
+ */
7
+ export function generateErrors(): GeneratedFile[] {
8
+ return [];
9
+ }