@workos/oagen-emitters 0.3.0 → 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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -40,6 +40,7 @@ import {
40
40
  getOpInferFromClient,
41
41
  } from '../shared/resolved-ops.js';
42
42
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
43
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
43
44
 
44
45
  /**
45
46
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -192,6 +193,62 @@ function deduplicateMethodNames(
192
193
  }
193
194
  }
194
195
 
196
+ /**
197
+ * Emit one interface file per paginated list operation that has extension
198
+ * query params. Placing the options interface under `interfaces/` lets the
199
+ * per-service barrel pick it up via `export * from './interfaces'`, which
200
+ * is what the root `src/index.ts` re-exports. When the interface was
201
+ * declared inline in the resource file, it was unreachable from the barrel
202
+ * and callers couldn't import the type by name from the package root.
203
+ */
204
+ function generatePaginatedOptionsInterfaces(
205
+ service: Service,
206
+ ctx: EmitterContext,
207
+ specEnumNames: Set<string>,
208
+ ): GeneratedFile[] {
209
+ const files: GeneratedFile[] = [];
210
+ const resolvedName = resolveResourceClassName(service, ctx);
211
+ const serviceDir = resolveServiceDir(resolvedName);
212
+
213
+ const plans = service.operations.map((op) => ({
214
+ op,
215
+ plan: planOperation(op),
216
+ method: resolveMethodName(op, service, ctx),
217
+ }));
218
+
219
+ for (const { op, plan, method } of plans) {
220
+ if (!plan.isPaginated) continue;
221
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
222
+ if (extraParams.length === 0) continue;
223
+
224
+ const optionsName = paginatedOptionsName(method, resolvedName);
225
+ const filePath = `src/${serviceDir}/interfaces/${fileName(optionsName)}.interface.ts`;
226
+
227
+ const lines: string[] = [];
228
+ lines.push("import type { PaginationOptions } from '../../common/interfaces/pagination-options.interface';");
229
+ lines.push('');
230
+ lines.push(`export interface ${optionsName} extends PaginationOptions {`);
231
+ for (const param of extraParams) {
232
+ const opt = !param.required ? '?' : '';
233
+ if (param.description || param.deprecated) {
234
+ const parts: string[] = [];
235
+ if (param.description) parts.push(param.description);
236
+ if (param.deprecated) parts.push('@deprecated');
237
+ lines.push(...docComment(parts.join('\n'), 2));
238
+ }
239
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
240
+ }
241
+ lines.push('}');
242
+
243
+ files.push({
244
+ path: filePath,
245
+ content: lines.join('\n'),
246
+ });
247
+ }
248
+
249
+ return files;
250
+ }
251
+
195
252
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
196
253
  if (services.length === 0) return [];
197
254
  const files: GeneratedFile[] = [];
@@ -202,6 +259,8 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
202
259
  const mergedServices: Service[] =
203
260
  mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
204
261
 
262
+ const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
263
+
205
264
  for (const service of mergedServices) {
206
265
  if (isServiceCoveredByExisting(service, ctx)) {
207
266
  // Fully covered: generate with ALL operations so the merger's docstring
@@ -233,10 +292,24 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
233
292
  file.headerPlacement = 'skip';
234
293
  files.push(file);
235
294
  } else {
236
- files.push(generateResourceClass(service, ctx));
295
+ // Purely oagen-managed: no baseline class exists, so the file is owned
296
+ // end-to-end by the emitter. Remove skipIfExists so regeneration always
297
+ // overwrites — emitter improvements (serializer dispatch, JSDoc, etc.)
298
+ // must propagate without manual intervention.
299
+ const file = generateResourceClass(service, ctx);
300
+ delete file.skipIfExists;
301
+ files.push(file);
237
302
  }
238
303
  }
239
304
 
305
+ // Emit paginated list options interfaces AFTER the resource classes so
306
+ // tests and manifest ordering that index `files[0]` as the class stay
307
+ // stable. Placing them under `interfaces/` lets the per-service barrel
308
+ // pick them up automatically.
309
+ for (const service of mergedServices) {
310
+ files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
311
+ }
312
+
240
313
  return files;
241
314
  }
242
315
 
@@ -367,6 +440,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
367
440
 
368
441
  // Collect response models from union split wrappers so their types and
369
442
  // deserializers are imported alongside the primary operation models.
443
+ // Also collect models referenced in wrapper param signatures (e.g.,
444
+ // `redirect_uris: RedirectUriInput[]`) — otherwise the wrapper emits a
445
+ // reference to a type it never imported.
370
446
  const resolvedLookup = buildResolvedLookup(ctx);
371
447
  for (const { op, method } of plans) {
372
448
  if (baselineMethodSet.has(method)) continue;
@@ -375,6 +451,11 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
375
451
  for (const name of collectWrapperResponseModels(resolved)) {
376
452
  responseModels.add(name);
377
453
  }
454
+ for (const wrapper of resolved.wrappers ?? []) {
455
+ for (const { field } of resolveWrapperParams(wrapper, ctx)) {
456
+ if (field) collectParamTypeRefs(field.type, paramEnums, paramModels);
457
+ }
458
+ }
378
459
  }
379
460
  }
380
461
 
@@ -390,6 +471,17 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
390
471
  lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
391
472
  }
392
473
 
474
+ // Paginated list options live in their own interface files so they're
475
+ // picked up by the per-service barrel (and flow through to the root
476
+ // package barrel). Import them here rather than declaring inline.
477
+ for (const { op, plan, method } of plans) {
478
+ if (!plan.isPaginated) continue;
479
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
480
+ if (extraParams.length === 0) continue;
481
+ const optionsName = paginatedOptionsName(method, resolvedName);
482
+ lines.push(`import type { ${optionsName} } from './interfaces/${fileName(optionsName)}.interface';`);
483
+ }
484
+
393
485
  // Check if any operation needs PostOptions (idempotent POST or custom encoding)
394
486
  const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
395
487
  const hasCustomEncoding = plans.some(
@@ -493,32 +585,43 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
493
585
 
494
586
  lines.push('');
495
587
 
496
- // Options interfaces for operations with query params.
497
- // Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
588
+ // Per-operation helpers (wire-format option serializers etc.) emitted
589
+ // alongside the resource class. The options interfaces themselves live
590
+ // in separate files under `interfaces/` so the per-service barrel can
591
+ // re-export them; see the earlier import block at the top of the file.
498
592
  for (const { op, plan, method } of plans) {
499
593
  if (plan.isPaginated) {
500
594
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
501
595
  if (extraParams.length > 0) {
502
596
  const optionsName = paginatedOptionsName(method, resolvedName);
503
- // Always generate the options interface locally in the resource file.
504
- // Previously we skipped generation when a baseline interface with a matching
505
- // name existed, but the baseline interface may live in a different module
506
- // (e.g., `user-management/` vs `user-management-users/`) and would not be
507
- // available without an import. Generating locally is safe and avoids
508
- // cross-module import resolution issues.
509
- lines.push(`export interface ${optionsName} extends PaginationOptions {`);
510
- for (const param of extraParams) {
511
- const opt = !param.required ? '?' : '';
512
- if (param.description || param.deprecated) {
513
- const parts: string[] = [];
514
- if (param.description) parts.push(param.description);
515
- if (param.deprecated) parts.push('@deprecated');
516
- lines.push(...docComment(parts.join('\n'), 2));
597
+
598
+ // When any extension param has a camelCase domain name that differs
599
+ // from its snake_case wire name, emit a serializer that translates
600
+ // the user-facing options into the wire query shape. Without this,
601
+ // the query string uses camelCase keys (e.g. `organizationId=...`)
602
+ // that the API silently ignores — the filter becomes a no-op.
603
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
604
+ if (needsWireSerializer) {
605
+ const serializerName = `serialize${optionsName}`;
606
+ lines.push(`const ${serializerName} = (options: ${optionsName}): PaginationOptions => {`);
607
+ // Pagination fields pass through unchanged (limit/before/after/order
608
+ // share spelling in both cases). Spread first so that wire-named
609
+ // extension fields land on top and the camelCase keys don't also
610
+ // leak into the query string.
611
+ lines.push(' const wire: Record<string, unknown> = {');
612
+ for (const p of PAGINATION_PARAM_NAMES) {
613
+ lines.push(` ${p}: options.${p},`);
517
614
  }
518
- lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
615
+ lines.push(' };');
616
+ for (const param of extraParams) {
617
+ const camel = fieldName(param.name);
618
+ const snake = wireFieldName(param.name);
619
+ lines.push(` if (options.${camel} !== undefined) wire.${snake} = options.${camel};`);
620
+ }
621
+ lines.push(' return wire as PaginationOptions;');
622
+ lines.push('};');
623
+ lines.push('');
519
624
  }
520
- lines.push('}');
521
- lines.push('');
522
625
  }
523
626
  } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
524
627
  // Non-paginated GET or void methods with query params get a typed options interface
@@ -708,7 +811,8 @@ function renderMethod(
708
811
  const itemTypeName = resolveInterfaceName(itemRawName, ctx);
709
812
  docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
710
813
  } else if (responseModel) {
711
- docParts.push(`@returns {Promise<${responseModel}>}`);
814
+ const returnTypeDoc = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
815
+ docParts.push(`@returns {Promise<${returnTypeDoc}>}`);
712
816
  } else {
713
817
  docParts.push('@returns {Promise<void>}');
714
818
  }
@@ -811,13 +915,18 @@ function renderPaginatedMethod(
811
915
  ): void {
812
916
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
813
917
  const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
918
+ // When any extension param has a camelCase/snake_case divergence, the
919
+ // resource file emits a `serialize<OptionsName>` helper — pass it to
920
+ // createPaginatedList so the wire query uses snake_case keys.
921
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
922
+ const serializerArg = needsWireSerializer ? `, serialize${optionsType}` : '';
814
923
 
815
924
  const pathParams = buildPathParams(op, specEnumNames);
816
925
  const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
817
926
 
818
927
  lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
819
928
  lines.push(
820
- ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
929
+ ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options${serializerArg});`,
821
930
  );
822
931
  lines.push(' }');
823
932
  }
@@ -926,16 +1035,22 @@ function renderBodyMethod(
926
1035
  const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
927
1036
  const hasCustomEncoding = encodingOption !== '';
928
1037
 
929
- lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
1038
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1039
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1040
+ const returnExpr = plan.isArrayResponse
1041
+ ? `data.map(deserialize${responseModel})`
1042
+ : `deserialize${responseModel}(data)`;
1043
+
1044
+ lines.push(` async ${method}(${paramsStr}): Promise<${returnType}> {`);
930
1045
  if (plan.isIdempotentPost) {
931
1046
  if (hasCustomEncoding) {
932
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1047
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
933
1048
  lines.push(` ${pathStr},`);
934
1049
  lines.push(` ${bodyExpr},`);
935
1050
  lines.push(` { ...requestOptions${encodingOption} },`);
936
1051
  lines.push(' );');
937
1052
  } else {
938
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1053
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
939
1054
  lines.push(` ${pathStr},`);
940
1055
  lines.push(` ${bodyExpr},`);
941
1056
  lines.push(' requestOptions,');
@@ -943,19 +1058,19 @@ function renderBodyMethod(
943
1058
  }
944
1059
  } else {
945
1060
  if (hasCustomEncoding) {
946
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1061
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
947
1062
  lines.push(` ${pathStr},`);
948
1063
  lines.push(` ${bodyExpr},`);
949
1064
  lines.push(` { ${encodingOption.slice(2)} },`);
950
1065
  lines.push(' );');
951
1066
  } else {
952
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1067
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
953
1068
  lines.push(` ${pathStr},`);
954
1069
  lines.push(` ${bodyExpr},`);
955
1070
  lines.push(' );');
956
1071
  }
957
1072
  }
958
- lines.push(` return deserialize${responseModel}(data);`);
1073
+ lines.push(` return ${returnExpr};`);
959
1074
  lines.push(' }');
960
1075
  }
961
1076
 
@@ -989,7 +1104,13 @@ function renderGetMethod(
989
1104
  : `options?: ${optionsType}`
990
1105
  : params;
991
1106
 
992
- lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
1107
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1108
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1109
+ const returnExpr = plan.isArrayResponse
1110
+ ? `data.map(deserialize${responseModel})`
1111
+ : `deserialize${responseModel}(data)`;
1112
+
1113
+ lines.push(` async ${method}(${allParams}): Promise<${returnType}> {`);
993
1114
  if (hasQuery) {
994
1115
  if (hasInjected) {
995
1116
  // Build the query object with visible params, defaults, and inferred fields
@@ -1026,30 +1147,22 @@ function renderGetMethod(
1026
1147
  queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1027
1148
  }
1028
1149
 
1029
- lines.push(
1030
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1031
- );
1150
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1032
1151
  lines.push(` query: { ${queryParts.join(', ')} },`);
1033
1152
  lines.push(' });');
1034
1153
  } else {
1035
1154
  const queryExpr = renderQueryExpr(visibleQueryParams);
1036
- lines.push(
1037
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1038
- );
1155
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1039
1156
  lines.push(` query: ${queryExpr},`);
1040
1157
  lines.push(' });');
1041
1158
  }
1042
1159
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1043
1160
  // PUT/PATCH/POST require a body argument even when the spec has no request body
1044
- lines.push(
1045
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
1046
- );
1161
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {});`);
1047
1162
  } else {
1048
- lines.push(
1049
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
1050
- );
1163
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr});`);
1051
1164
  }
1052
- lines.push(` return deserialize${responseModel}(data);`);
1165
+ lines.push(` return ${returnExpr};`);
1053
1166
  lines.push(' }');
1054
1167
  }
1055
1168
 
@@ -1263,9 +1376,16 @@ function renderUnionBodySerializer(
1263
1376
  const cases: string[] = [];
1264
1377
  for (const [value, modelName] of Object.entries(disc.mapping)) {
1265
1378
  const resolved = resolveInterfaceName(modelName, ctx);
1266
- cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
1379
+ // Switch on a typed discriminator narrows `payload` to the variant, so the
1380
+ // serializer call type-checks without any casts.
1381
+ cases.push(`case '${value}': return serialize${resolved}(payload)`);
1267
1382
  }
1268
- return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
1383
+ // Assign `payload` to `never` in the default branch to get a compile-time
1384
+ // exhaustiveness check — if a new variant is added to the union but not to
1385
+ // the switch, the build fails here. At runtime, we still throw so an
1386
+ // unknown discriminator slipping through via `as any` fails loudly rather
1387
+ // than silently forwarding camelCase to the API.
1388
+ return `(() => { switch (payload.${prop}) { ${cases.join('; ')}; default: { const _unknown: never = payload; throw new Error(\`Unknown ${prop}: \${(_unknown as { ${prop}?: unknown }).${prop}}\`) } } })()`;
1269
1389
  }
1270
1390
 
1271
1391
  /**
package/src/node/tests.ts CHANGED
@@ -9,6 +9,7 @@ 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';
@@ -751,9 +752,21 @@ function buildTestPayload(
751
752
  op: Operation,
752
753
  modelMap: Map<string, Model>,
753
754
  ): { camelCaseObj: string; snakeCaseObj: string } | null {
754
- if (!op.requestBody || op.requestBody.kind !== 'model') return null;
755
-
756
- 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
+ }
757
770
  if (!model) return null;
758
771
 
759
772
  const fields = model.fields.filter((f) => f.required);
@@ -789,8 +802,14 @@ function buildTestPayload(
789
802
  * fall back to `{} as any` to bypass type checking for complex required fields.
790
803
  */
791
804
  function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
792
- if (!op.requestBody || op.requestBody.kind !== 'model') return '{} as any';
793
- 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
+ }
794
813
  if (!model) return '{} as any';
795
814
  const hasRequiredFields = model.fields.some((f) => f.required);
796
815
  return hasRequiredFields ? '{} as any' : '{}';
@@ -844,6 +863,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
844
863
 
845
864
  // Collect imports
846
865
  const serializerImports: string[] = [];
866
+ const interfaceImports: string[] = [];
847
867
  const fixtureImports: string[] = [];
848
868
 
849
869
  for (const model of models) {
@@ -851,11 +871,14 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
851
871
  const service = modelToService.get(model.name);
852
872
  const modelDir = resolveDir(service);
853
873
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
874
+ const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
854
875
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
855
876
 
856
877
  serializerImports.push(
857
878
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
858
879
  );
880
+ const wireName = wireInterfaceName(domainName);
881
+ interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
859
882
  const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
860
883
  fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
861
884
  }
@@ -863,6 +886,9 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
863
886
  for (const imp of serializerImports) {
864
887
  lines.push(imp);
865
888
  }
889
+ for (const imp of interfaceImports) {
890
+ lines.push(imp);
891
+ }
866
892
  for (const imp of fixtureImports) {
867
893
  lines.push(imp);
868
894
  }
@@ -873,9 +899,14 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
873
899
  const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
874
900
  const fixtureName = `${camelDomain}Fixture`;
875
901
 
902
+ const wireName = wireInterfaceName(domainName);
876
903
  lines.push(`describe('${domainName}Serializer', () => {`);
877
904
  lines.push(" it('round-trips through serialize/deserialize', () => {");
878
- 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};`);
879
910
  lines.push(` const deserialized = deserialize${domainName}(fixture);`);
880
911
  lines.push(` const reserialized = serialize${domainName}(deserialized);`);
881
912
  lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
@@ -888,7 +919,6 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
888
919
  path: testPath,
889
920
  content: lines.join('\n'),
890
921
  skipIfExists: true,
891
- integrateTarget: false,
892
922
  });
893
923
  }
894
924
 
package/src/php/client.ts CHANGED
@@ -127,10 +127,13 @@ function generateMainClient(
127
127
  for (const svc of services) {
128
128
  lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
129
129
  }
130
- // Non-spec service properties (hand-maintained modules)
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)');
131
133
  for (const a of nonSpecAccessors) {
132
134
  lines.push(` private ?${a.className} $${a.propName} = null;`);
133
135
  }
136
+ lines.push(' // @oagen-ignore-end');
134
137
 
135
138
  lines.push('');
136
139
  lines.push(' public function __construct(');
@@ -140,11 +143,12 @@ function generateMainClient(
140
143
  lines.push(' int $timeout = 60,');
141
144
  lines.push(' int $maxRetries = 3,');
142
145
  lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
146
+ lines.push(' ?string $userAgent = null,');
143
147
  lines.push(' ) {');
144
148
  lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
145
149
  lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
146
150
  lines.push(
147
- ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
151
+ ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
148
152
  );
149
153
  lines.push(' }');
150
154
 
@@ -157,7 +161,10 @@ function generateMainClient(
157
161
  lines.push(' }');
158
162
  }
159
163
 
160
- // Non-spec service accessors (hand-maintained modules)
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)');
161
168
  for (const a of nonSpecAccessors) {
162
169
  lines.push('');
163
170
  lines.push(` public function ${a.propName}(): ${a.className}`);
@@ -165,6 +172,7 @@ function generateMainClient(
165
172
  lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
166
173
  lines.push(' }');
167
174
  }
175
+ lines.push(' // @oagen-ignore-end');
168
176
 
169
177
  lines.push('}');
170
178
  return lines.join('\n');
package/src/php/naming.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
- import { stripUrnPrefix } from '../shared/naming-utils.js';
4
+ import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
5
5
 
6
6
  /** Namespace grouping result (shared with client.ts). */
7
7
  export interface NamespaceGroup {
@@ -16,22 +16,6 @@ export interface NamespaceGrouping {
16
16
  namespaces: NamespaceGroup[];
17
17
  }
18
18
 
19
- /**
20
- * Map of lowercase acronym forms to their correct casing.
21
- */
22
- const ACRONYM_FIXES: [RegExp, string][] = [
23
- [/Workos/g, 'WorkOS'],
24
- [/Sso/g, 'SSO'],
25
- [/Mfa/g, 'MFA'],
26
- [/Jwt/g, 'JWT'],
27
- [/Cors/g, 'CORS'],
28
- [/Saml/g, 'SAML'],
29
- [/Scim/g, 'SCIM'],
30
- [/Rbac/g, 'RBAC'],
31
- [/Oauth/g, 'OAuth'],
32
- [/Oidc/g, 'OIDC'],
33
- ];
34
-
35
19
  /**
36
20
  * PHP reserved class names that would collide with builtins.
37
21
  */
@@ -99,10 +83,7 @@ export function enumClassName(name: string): string {
99
83
 
100
84
  /** PascalCase class name with acronym preservation. */
101
85
  export function className(name: string): string {
102
- let result = toPascalCase(stripUrnPrefix(name));
103
- for (const [pattern, replacement] of ACRONYM_FIXES) {
104
- result = result.replace(pattern, replacement);
105
- }
86
+ let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
106
87
  if (PHP_RESERVED_CLASS_NAMES.has(result)) {
107
88
  result += 'Model';
108
89
  }