@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
package/smoke/sdk-php.ts CHANGED
@@ -184,8 +184,8 @@ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
184
184
  // ---------------------------------------------------------------------------
185
185
 
186
186
  interface MethodResolution {
187
- className: string;
188
- method: string;
187
+ service: string; // camelCase accessor on client (e.g., "organizations")
188
+ method: string; // camelCase method name (e.g., "get")
189
189
  tier: ExchangeProvenance['resolutionTier'];
190
190
  confidence: number;
191
191
  }
@@ -200,14 +200,12 @@ function resolveMethod(
200
200
  if (manifest) {
201
201
  const entry = manifest.get(httpKey);
202
202
  if (entry) {
203
- const className = entry.service.charAt(0).toUpperCase() + entry.service.slice(1);
204
- return { className, method: entry.sdkMethod, tier: 'manifest', confidence: 1.0 };
203
+ return { service: entry.service, method: entry.sdkMethod, tier: 'manifest', confidence: 1.0 };
205
204
  }
206
205
  }
207
206
 
208
207
  const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
209
- const className = sdkProp.charAt(0).toUpperCase() + sdkProp.slice(1);
210
- return { className, method: toCamelCase(op.name), tier: 'exact', confidence: 0.8 };
208
+ return { service: sdkProp, method: toCamelCase(op.name), tier: 'exact', confidence: 0.8 };
211
209
  }
212
210
 
213
211
  // ---------------------------------------------------------------------------
@@ -278,9 +276,14 @@ function buildBatchedPhpScript(
278
276
  }
279
277
  lines.push('');
280
278
 
281
- // Configure SDK
282
- lines.push(`${namespace}\\Client::setApiKey('${apiKey}');`);
283
- lines.push(`${namespace}\\Client::setBaseUrl('http://127.0.0.1:${proxyPort}');`);
279
+ // Configure SDK — generated SDK uses instance-based client with Guzzle handler
280
+ lines.push(`use GuzzleHttp\\HandlerStack;`);
281
+ lines.push(`use GuzzleHttp\\Handler\\CurlHandler;`);
282
+ lines.push('');
283
+ lines.push(`$client = new ${namespace}\\${namespace}(`);
284
+ lines.push(` apiKey: '${escapePhpString(apiKey)}',`);
285
+ lines.push(` baseUrl: 'http://127.0.0.1:${proxyPort}',`);
286
+ lines.push(');');
284
287
  lines.push('');
285
288
 
286
289
  for (const call of calls) {
@@ -289,7 +292,8 @@ function buildBatchedPhpScript(
289
292
  // Marker: start
290
293
  lines.push(`fwrite(STDERR, "OAGEN_CALL_START:${index}\\n");`);
291
294
 
292
- // Build arguments
295
+ // Build arguments — generated PHP SDK takes positional path params,
296
+ // then named keyword args for body fields and query params
293
297
  const phpArgs: string[] = [];
294
298
 
295
299
  for (const p of op.pathParams) {
@@ -299,33 +303,31 @@ function buildBatchedPhpScript(
299
303
 
300
304
  if (op.requestBody) {
301
305
  const payload = generatePayload(op, spec);
302
- if (payload && Object.keys(payload).length > 0) {
303
- phpArgs.push(phpArrayLiteral(payload));
304
- } else {
305
- phpArgs.push('[]');
306
+ if (payload && typeof payload === 'object') {
307
+ // Pass as named arguments (the generated SDK uses promoted properties)
308
+ for (const [key, value] of Object.entries(payload)) {
309
+ phpArgs.push(`${toCamelCase(key)}: ${phpArrayLiteral(value)}`);
310
+ }
306
311
  }
307
312
  }
308
313
 
309
314
  if (!op.requestBody && op.queryParams.some((p) => p.required)) {
310
315
  const queryOpts = generateQueryParams(op, spec);
311
- if (Object.keys(queryOpts).length > 0) {
312
- phpArgs.push(phpArrayLiteral(queryOpts));
316
+ for (const [key, value] of Object.entries(queryOpts)) {
317
+ phpArgs.push(`${toCamelCase(key)}: ${phpArrayLiteral(value)}`);
313
318
  }
314
319
  }
315
320
 
316
- if (op.pagination && phpArgs.length === 0) {
317
- phpArgs.push("['limit' => 1]");
318
- } else if (op.pagination && !op.requestBody) {
319
- const last = phpArgs[phpArgs.length - 1];
320
- if (last && last.startsWith('[')) {
321
- phpArgs[phpArgs.length - 1] = last.replace(/\]$/, ", 'limit' => 1]");
322
- } else {
323
- phpArgs.push("['limit' => 1]");
321
+ if (op.pagination) {
322
+ if (!phpArgs.some((a) => a.startsWith('limit:'))) {
323
+ phpArgs.push('limit: 1');
324
324
  }
325
325
  }
326
326
 
327
+ // The generated SDK uses $client->resource()->method(...) pattern
328
+ const serviceAccessor = resolution.service;
327
329
  lines.push('try {');
328
- lines.push(` $result = ${namespace}\\${resolution.className}::${resolution.method}(${phpArgs.join(', ')});`);
330
+ lines.push(` $result = $client->${serviceAccessor}()->${resolution.method}(${phpArgs.join(', ')});`);
329
331
  lines.push(` fwrite(STDERR, "OAGEN_CALL_OK:${index}\\n");`);
330
332
  lines.push('} catch (\\Throwable $e) {');
331
333
  lines.push(` fwrite(STDERR, "OAGEN_CALL_ERROR:${index}:" . $e->getMessage() . "\\n");`);
@@ -669,7 +671,7 @@ function buildExchange(
669
671
  provenance: {
670
672
  resolutionTier: resolution.tier,
671
673
  resolutionConfidence: resolution.confidence,
672
- sdkMethodName: `${resolution.className}::${resolution.method}`,
674
+ sdkMethodName: `${resolution.service}->${resolution.method}`,
673
675
  captureIndex: 0,
674
676
  totalCaptures: 1,
675
677
  },
@@ -284,7 +284,10 @@ function buildBatchedPythonScript(
284
284
  calls: PlannedCall[],
285
285
  spec: any,
286
286
  ): string {
287
- const srcPath = resolve(sdkPath, 'src');
287
+ // Use src/ subdirectory if it exists, otherwise use the SDK root directly.
288
+ // Generated SDKs use a flat layout (workos/ at root), while some hand-written
289
+ // SDKs nest under src/.
290
+ const srcPath = existsSync(resolve(sdkPath, 'src')) ? resolve(sdkPath, 'src') : resolve(sdkPath);
288
291
  const lines: string[] = [];
289
292
 
290
293
  // Preamble -- loaded once
@@ -504,7 +507,7 @@ async function main(): Promise<void> {
504
507
  const child = spawn(python3Path, [scriptPath], {
505
508
  env: {
506
509
  ...process.env,
507
- PYTHONPATH: resolve(sdkPath, 'src'),
510
+ PYTHONPATH: existsSync(resolve(sdkPath, 'src')) ? resolve(sdkPath, 'src') : resolve(sdkPath),
508
511
  PYTHONDONTWRITEBYTECODE: '1',
509
512
  },
510
513
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -0,0 +1,141 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
+ // naming utilities used indirectly via resolveResourceClassName
4
+ import { resolveResourceClassName } from './resources.js';
5
+ import { unexportedName } from './naming.js';
6
+ import { getMountTarget } from '../shared/resolved-ops.js';
7
+
8
+ /**
9
+ * Generate the Go client file with service accessors.
10
+ * Produces: workos.go (Client struct + constructor + service accessors).
11
+ * Static files (client.go, pagination.go, errors.go, go.mod, options.go)
12
+ * are hand-maintained in the target SDK with @oagen-ignore-file.
13
+ */
14
+ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
15
+ return [generateWorkOSFile(spec, ctx)];
16
+ }
17
+
18
+ /**
19
+ * Deduplicate services by mount target.
20
+ */
21
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): Service[] {
22
+ const byTarget = new Map<string, Service>();
23
+ for (const s of services) {
24
+ const target = getMountTarget(s, ctx);
25
+ const existing = byTarget.get(target);
26
+ if (!existing || toPascalCase(s.name) === target) {
27
+ byTarget.set(target, s);
28
+ }
29
+ }
30
+ return [...byTarget.values()];
31
+ }
32
+
33
+ /**
34
+ * Build map of service name -> accessor property name.
35
+ */
36
+ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
37
+ const topLevel = deduplicateByMount(services, ctx);
38
+ const paths = new Map<string, string>();
39
+
40
+ for (const service of topLevel) {
41
+ const resolvedName = resolveResourceClassName(service, ctx);
42
+ const prop = toSnakeCase(resolvedName);
43
+ paths.set(service.name, prop);
44
+ }
45
+
46
+ // Also map mount targets
47
+ for (const service of services) {
48
+ const target = getMountTarget(service, ctx);
49
+ if (!paths.has(target)) {
50
+ const existing = paths.get(service.name);
51
+ if (existing) paths.set(target, existing);
52
+ }
53
+ }
54
+
55
+ return paths;
56
+ }
57
+
58
+ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
59
+ const topLevel = deduplicateByMount(spec.services, ctx);
60
+ const lines: string[] = [];
61
+
62
+ lines.push(`package ${ctx.namespace}`);
63
+ lines.push('');
64
+ lines.push('import "net/http"');
65
+ lines.push('');
66
+
67
+ // Client struct
68
+ lines.push('// Client is the WorkOS API client.');
69
+ lines.push('type Client struct {');
70
+ lines.push('\tapiKey string');
71
+ lines.push('\tclientID string');
72
+ lines.push('\tbaseURL string');
73
+ lines.push('\thttpClient *http.Client');
74
+ lines.push('\tmaxRetries int');
75
+ lines.push('');
76
+ // Service fields
77
+ for (const service of topLevel) {
78
+ const resolvedName = resolveResourceClassName(service, ctx);
79
+ const fieldNameStr = unexportedName(resolvedName);
80
+ const serviceTypeName = serviceType(resolvedName);
81
+ lines.push(`\t${fieldNameStr} *${serviceTypeName}`);
82
+ }
83
+ lines.push('}');
84
+ lines.push('');
85
+
86
+ // NewClient constructor
87
+ lines.push('// NewClient creates a new WorkOS API client.');
88
+ lines.push('func NewClient(apiKey string, opts ...ClientOption) *Client {');
89
+ lines.push('\tc := &Client{');
90
+ lines.push('\t\tapiKey: apiKey,');
91
+ lines.push('\t\tbaseURL: defaultBaseURL,');
92
+ lines.push('\t\thttpClient: &http.Client{Timeout: defaultTimeout},');
93
+ lines.push('\t\tmaxRetries: defaultMaxRetries,');
94
+ lines.push('\t}');
95
+ lines.push('\tfor _, opt := range opts {');
96
+ lines.push('\t\topt(c)');
97
+ lines.push('\t}');
98
+ // Initialize services
99
+ for (const service of topLevel) {
100
+ const resolvedName = resolveResourceClassName(service, ctx);
101
+ const fieldNameStr = unexportedName(resolvedName);
102
+ const serviceTypeName = serviceType(resolvedName);
103
+ lines.push(`\tc.${fieldNameStr} = &${serviceTypeName}{client: c}`);
104
+ }
105
+ lines.push('\treturn c');
106
+ lines.push('}');
107
+ lines.push('');
108
+
109
+ // Service accessor methods
110
+ for (const service of topLevel) {
111
+ const resolvedName = resolveResourceClassName(service, ctx);
112
+ const accessorName = resolvedName;
113
+ const fieldNameStr = unexportedName(resolvedName);
114
+ const serviceTypeName = serviceType(resolvedName);
115
+ lines.push(`// ${accessorName} returns the ${resolvedName} service.`);
116
+ lines.push(`func (c *Client) ${accessorName}() *${serviceTypeName} {`);
117
+ lines.push(`\treturn c.${fieldNameStr}`);
118
+ lines.push('}');
119
+ lines.push('');
120
+ }
121
+
122
+ return {
123
+ path: `${ctx.namespace}.go`,
124
+ content: lines.join('\n'),
125
+ overwriteExisting: true,
126
+ };
127
+ }
128
+
129
+ function singularizePascal(name: string): string {
130
+ if (name.endsWith('ies')) {
131
+ return `${name.slice(0, -3)}y`;
132
+ }
133
+ if (name.endsWith('s') && !name.endsWith('ss')) {
134
+ return name.slice(0, -1);
135
+ }
136
+ return name;
137
+ }
138
+
139
+ function serviceType(name: string): string {
140
+ return `${unexportedName(singularizePascal(name))}Service`;
141
+ }
@@ -0,0 +1,196 @@
1
+ import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+
5
+ /**
6
+ * Generate Go typed string enum constants from IR Enum definitions.
7
+ *
8
+ * Each enum becomes a named string type + const block:
9
+ * type Status string
10
+ * const (
11
+ * StatusActive Status = "active"
12
+ * StatusInactive Status = "inactive"
13
+ * )
14
+ */
15
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
16
+ if (enums.length === 0) return [];
17
+
18
+ const aliasOf = collectEnumAliasOf(enums);
19
+ const files: GeneratedFile[] = [];
20
+
21
+ // Group all enums into a single file per SDK
22
+ const lines: string[] = [];
23
+ lines.push(`package ${ctx.namespace}`);
24
+ lines.push('');
25
+
26
+ for (const enumDef of enums) {
27
+ // If this enum is an alias, emit a simple type alias
28
+ const canonicalName = aliasOf.get(enumDef.name);
29
+ if (canonicalName) {
30
+ const aliasType = className(enumDef.name);
31
+ const canonicalType = className(canonicalName);
32
+ lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
33
+ lines.push(`type ${aliasType} = ${canonicalType}`);
34
+ lines.push('');
35
+ continue;
36
+ }
37
+
38
+ const typeName = className(enumDef.name);
39
+
40
+ if (enumDef.values.length === 0) {
41
+ const humanized = humanize(enumDef.name);
42
+ lines.push(`// ${typeName} represents ${humanized} values.`);
43
+ lines.push(`type ${typeName} = string`);
44
+ lines.push('');
45
+ continue;
46
+ }
47
+
48
+ // Deduplicate values
49
+ const seenValues = new Set<string>();
50
+ const uniqueValues: typeof enumDef.values = [];
51
+ for (const v of enumDef.values) {
52
+ const vs = String(v.value);
53
+ if (!seenValues.has(vs)) {
54
+ seenValues.add(vs);
55
+ uniqueValues.push(v);
56
+ }
57
+ }
58
+
59
+ const humanized = humanize(enumDef.name);
60
+ lines.push(`// ${typeName} represents ${humanized} values.`);
61
+ lines.push(`type ${typeName} string`);
62
+ lines.push('');
63
+ lines.push('const (');
64
+
65
+ const usedNames = new Set<string>();
66
+ for (const v of uniqueValues) {
67
+ let constSuffix = className(String(v.value));
68
+ // Avoid collision with the type itself
69
+ if (usedNames.has(`${typeName}${constSuffix}`)) {
70
+ let suffix = 2;
71
+ while (usedNames.has(`${typeName}${constSuffix}${suffix}`)) suffix++;
72
+ constSuffix = `${constSuffix}${suffix}`;
73
+ }
74
+ const constName = `${typeName}${constSuffix}`;
75
+ usedNames.add(constName);
76
+ const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
77
+ if (v.description) {
78
+ lines.push(`\t// ${constName} is ${v.description}.`);
79
+ }
80
+ if (v.deprecated) {
81
+ if (v.description) lines.push(`\t//`);
82
+ lines.push(`\t// Deprecated: this value is deprecated.`);
83
+ }
84
+ lines.push(`\t${constName} ${typeName} = ${valueStr}`);
85
+ }
86
+ lines.push(')');
87
+ lines.push('');
88
+ }
89
+
90
+ files.push({
91
+ path: 'enums.go',
92
+ content: lines.join('\n'),
93
+ overwriteExisting: true,
94
+ });
95
+
96
+ return files;
97
+ }
98
+
99
+ /** Known acronyms to preserve as single tokens during humanization. */
100
+ const HUMANIZE_ACRONYMS: [RegExp, string][] = [
101
+ [/OAuth/g, 'OAUTH_ACRN'],
102
+ [/URN/g, 'URN_ACRN'],
103
+ [/IETF/g, 'IETF_ACRN'],
104
+ [/API/g, 'API_ACRN'],
105
+ [/SSO/g, 'SSO_ACRN'],
106
+ [/PKCE/g, 'PKCE_ACRN'],
107
+ [/JWT/g, 'JWT_ACRN'],
108
+ [/MFA/g, 'MFA_ACRN'],
109
+ [/TOTP/g, 'TOTP_ACRN'],
110
+ [/SAML/g, 'SAML_ACRN'],
111
+ [/SCIM/g, 'SCIM_ACRN'],
112
+ [/OIDC/g, 'OIDC_ACRN'],
113
+ [/CORS/g, 'CORS_ACRN'],
114
+ [/RBAC/g, 'RBAC_ACRN'],
115
+ ];
116
+
117
+ const HUMANIZE_RESTORE: [RegExp, string][] = [
118
+ [/oauth_acrn/g, 'OAuth'],
119
+ [/urn_acrn/g, 'URN'],
120
+ [/ietf_acrn/g, 'IETF'],
121
+ [/api_acrn/g, 'API'],
122
+ [/sso_acrn/g, 'SSO'],
123
+ [/pkce_acrn/g, 'PKCE'],
124
+ [/jwt_acrn/g, 'JWT'],
125
+ [/mfa_acrn/g, 'MFA'],
126
+ [/totp_acrn/g, 'TOTP'],
127
+ [/saml_acrn/g, 'SAML'],
128
+ [/scim_acrn/g, 'SCIM'],
129
+ [/oidc_acrn/g, 'OIDC'],
130
+ [/cors_acrn/g, 'CORS'],
131
+ [/rbac_acrn/g, 'RBAC'],
132
+ ];
133
+
134
+ function humanize(name: string): string {
135
+ // Replace known acronyms with placeholders before splitting
136
+ let s = name;
137
+ for (const [pattern, replacement] of HUMANIZE_ACRONYMS) {
138
+ s = s.replace(pattern, replacement);
139
+ }
140
+ let result = s.replace(/([a-z])([A-Z])/g, '$1 $2');
141
+ result = result.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
142
+ result = result.toLowerCase();
143
+ for (const [pattern, replacement] of HUMANIZE_RESTORE) {
144
+ result = result.replace(pattern, replacement);
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
150
+ const hashGroups = new Map<string, string[]>();
151
+ for (const enumDef of enums) {
152
+ const hash = [...enumDef.values]
153
+ .map((v) => String(v.value))
154
+ .sort()
155
+ .join('|');
156
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
157
+ hashGroups.get(hash)!.push(enumDef.name);
158
+ }
159
+
160
+ const aliasOf = new Map<string, string>();
161
+ for (const [, names] of hashGroups) {
162
+ if (names.length <= 1) continue;
163
+ const sorted = [...names].sort();
164
+ const canonical = sorted[0];
165
+ for (let i = 1; i < sorted.length; i++) {
166
+ aliasOf.set(sorted[i], canonical);
167
+ }
168
+ }
169
+ return aliasOf;
170
+ }
171
+
172
+ export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
173
+ const enumToService = new Map<string, string>();
174
+ const enumNames = new Set(enums.map((e) => e.name));
175
+
176
+ for (const service of services) {
177
+ for (const op of service.operations) {
178
+ const refs = new Set<string>();
179
+ const collect = (ref: any) => {
180
+ walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
181
+ };
182
+ if (op.requestBody) collect(op.requestBody);
183
+ collect(op.response);
184
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
185
+ collect(p.type);
186
+ }
187
+ for (const name of refs) {
188
+ if (enumNames.has(name) && !enumToService.has(name)) {
189
+ enumToService.set(name, service.name);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ return enumToService;
196
+ }
@@ -0,0 +1,212 @@
1
+ import type { Model, TypeRef, Enum } from '@workos/oagen';
2
+ import { fileName, fieldName } from './naming.js';
3
+ import { isListMetadataModel, isListWrapperModel } from './models.js';
4
+
5
+ /**
6
+ * Prefix mapping for generating realistic ID fixture values.
7
+ */
8
+ export const ID_PREFIXES: Record<string, string> = {
9
+ Connection: 'conn_',
10
+ Organization: 'org_',
11
+ OrganizationMembership: 'om_',
12
+ User: 'user_',
13
+ Directory: 'directory_',
14
+ DirectoryGroup: 'dir_grp_',
15
+ DirectoryUser: 'dir_usr_',
16
+ Invitation: 'inv_',
17
+ Session: 'session_',
18
+ AuthenticationFactor: 'auth_factor_',
19
+ EmailVerification: 'email_verification_',
20
+ MagicAuth: 'magic_auth_',
21
+ PasswordReset: 'password_reset_',
22
+ };
23
+
24
+ /**
25
+ * Generate JSON fixture files for test data.
26
+ */
27
+ export function generateFixtures(spec: {
28
+ models: Model[];
29
+ enums: Enum[];
30
+ services: any[];
31
+ }): { path: string; content: string }[] {
32
+ if (spec.models.length === 0) return [];
33
+
34
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
35
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
36
+ const files: { path: string; content: string }[] = [];
37
+
38
+ for (const model of spec.models) {
39
+ if (isListMetadataModel(model)) continue;
40
+ if (isListWrapperModel(model)) continue;
41
+
42
+ const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
43
+
44
+ files.push({
45
+ path: `testdata/${fileName(model.name)}.json`,
46
+ content: JSON.stringify(fixture, null, 2),
47
+ });
48
+ }
49
+
50
+ // Generate list fixtures for paginated responses
51
+ for (const service of spec.services) {
52
+ for (const op of service.operations) {
53
+ if (op.pagination) {
54
+ let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
55
+ if (itemModel) {
56
+ const unwrapped = unwrapListModel(itemModel, modelMap);
57
+ if (unwrapped) itemModel = unwrapped;
58
+ if (itemModel.fields.length === 0) continue;
59
+ const fixture = generateModelFixture(itemModel, modelMap, enumMap);
60
+ const listFixture = {
61
+ data: [fixture],
62
+ list_metadata: {
63
+ before: null,
64
+ after: null,
65
+ },
66
+ };
67
+ files.push({
68
+ path: `testdata/list_${fileName(itemModel.name)}.json`,
69
+ content: JSON.stringify(listFixture, null, 2),
70
+ });
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // Deduplicate fixtures with identical content.
77
+ // When multiple fixtures have the same content, emit one shared file and
78
+ // rewrite the others as references to the shared path.
79
+ const contentGroups = new Map<string, string[]>();
80
+ for (const f of files) {
81
+ if (!contentGroups.has(f.content)) contentGroups.set(f.content, []);
82
+ contentGroups.get(f.content)!.push(f.path);
83
+ }
84
+
85
+ const pathRewrites = new Map<string, string>();
86
+ for (const [_content, paths] of contentGroups) {
87
+ if (paths.length < 3) continue; // only dedup when 3+ files are identical
88
+ // Use the shortest path as the canonical shared fixture
89
+ const sorted = [...paths].sort((a, b) => a.length - b.length);
90
+ const canonical = sorted[0];
91
+ for (let i = 1; i < sorted.length; i++) {
92
+ pathRewrites.set(sorted[i], canonical);
93
+ }
94
+ }
95
+
96
+ // Remove duplicate files (they'll reference the canonical)
97
+ const deduped = files.filter((f) => !pathRewrites.has(f.path));
98
+
99
+ return deduped;
100
+ }
101
+
102
+ function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
103
+ const dataField = model.fields.find((f) => f.name === 'data');
104
+ const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
105
+ if (dataField && hasListMetadata && dataField.type.kind === 'array') {
106
+ const itemType = dataField.type.items;
107
+ if (itemType.kind === 'model') {
108
+ return modelMap.get(itemType.name) ?? null;
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export function generateModelFixture(
115
+ model: Model,
116
+ modelMap: Map<string, Model>,
117
+ enumMap: Map<string, Enum>,
118
+ ): Record<string, any> {
119
+ const fixture: Record<string, any> = {};
120
+
121
+ const seenFieldNames = new Set<string>();
122
+ const deduplicatedFields = model.fields.filter((f) => {
123
+ const goName = fieldName(f.name);
124
+ if (seenFieldNames.has(goName)) return false;
125
+ seenFieldNames.add(goName);
126
+ return true;
127
+ });
128
+
129
+ for (const field of deduplicatedFields) {
130
+ const wireName = field.name;
131
+ if (field.example !== undefined) {
132
+ fixture[wireName] = field.example;
133
+ } else {
134
+ fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
135
+ }
136
+ }
137
+
138
+ return fixture;
139
+ }
140
+
141
+ function generateFieldValue(
142
+ ref: TypeRef,
143
+ fName: string,
144
+ modelName: string,
145
+ modelMap: Map<string, Model>,
146
+ enumMap: Map<string, Enum>,
147
+ ): any {
148
+ switch (ref.kind) {
149
+ case 'primitive':
150
+ return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
151
+ case 'literal':
152
+ return ref.value;
153
+ case 'enum': {
154
+ const e = enumMap.get(ref.name);
155
+ return e?.values[0]?.value ?? 'unknown';
156
+ }
157
+ case 'model': {
158
+ const nested = modelMap.get(ref.name);
159
+ if (nested) return generateModelFixture(nested, modelMap, enumMap);
160
+ return {};
161
+ }
162
+ case 'array': {
163
+ if (ref.items.kind === 'enum') {
164
+ const e = enumMap.get(ref.items.name);
165
+ if (e && e.values.length > 0) {
166
+ return e.values.map((v) => v.value);
167
+ }
168
+ }
169
+ const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
170
+ return [item];
171
+ }
172
+ case 'nullable':
173
+ return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
174
+ case 'union':
175
+ if (ref.variants.length > 0) {
176
+ return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
177
+ }
178
+ return null;
179
+ case 'map':
180
+ return {
181
+ key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
182
+ };
183
+ }
184
+ }
185
+
186
+ function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
187
+ switch (type) {
188
+ case 'string':
189
+ if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
190
+ if (format === 'date') return '2023-01-01';
191
+ if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
192
+ if (name === 'id') {
193
+ const prefix = ID_PREFIXES[modelName] ?? '';
194
+ return `${prefix}01234`;
195
+ }
196
+ if (name.includes('id')) return `${name}_01234`;
197
+ if (name.includes('email')) return 'test@example.com';
198
+ if (name.includes('url') || name.includes('uri')) return 'https://example.com';
199
+ if (name.includes('name')) return 'Test';
200
+ return `test_${name}`;
201
+ case 'integer':
202
+ return 1;
203
+ case 'number':
204
+ return 1.0;
205
+ case 'boolean':
206
+ return true;
207
+ case 'unknown':
208
+ return {};
209
+ default:
210
+ return null;
211
+ }
212
+ }