@workos/oagen-emitters 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,533 @@
1
+ import type {
2
+ ApiSpec,
3
+ Service,
4
+ Operation,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ Model,
8
+ ResolvedOperation,
9
+ } from '@workos/oagen';
10
+ import { planOperation, toCamelCase } from '@workos/oagen';
11
+ import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
12
+ import { isListWrapperModel } from './models.js';
13
+ import { generateFixtures } from './fixtures.js';
14
+ import { getMountTarget, groupByMount, buildHiddenParams } from '../shared/resolved-ops.js';
15
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
16
+
17
+ /**
18
+ * Generate PHPUnit test files and fixture JSON files.
19
+ */
20
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
21
+ const files: GeneratedFile[] = [];
22
+
23
+ // Generate fixture JSON files
24
+ const fixtures = generateFixtures(spec);
25
+ for (const fixture of fixtures) {
26
+ files.push({
27
+ path: fixture.path,
28
+ content: fixture.content,
29
+ headerPlacement: 'skip',
30
+ });
31
+ }
32
+
33
+ // TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
34
+
35
+ // Collect all operations per mount target using resolved per-operation mounts.
36
+ // This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
37
+ const mountGroupsFromResolved = groupByMount(ctx);
38
+ const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
39
+ if (mountGroupsFromResolved.size > 0) {
40
+ for (const [target, group] of mountGroupsFromResolved) {
41
+ mountGroups.set(
42
+ target,
43
+ group.resolvedOps.map((r) => ({ op: r.operation, service: r.service, resolvedOp: r })),
44
+ );
45
+ }
46
+ } else {
47
+ // Fallback: group by service
48
+ for (const service of spec.services) {
49
+ const target = getMountTarget(service, ctx);
50
+ if (!mountGroups.has(target)) mountGroups.set(target, []);
51
+ for (const op of service.operations) {
52
+ mountGroups.get(target)!.push({ op, service });
53
+ }
54
+ }
55
+ }
56
+
57
+ // Generate resource tests (one per mount target, all operations included)
58
+ // Use overwriteExisting so the integration step always writes the latest
59
+ // test content rather than attempting additive AST merge.
60
+ for (const [target, ops] of mountGroups) {
61
+ files.push({
62
+ path: `tests/Service/${className(target)}Test.php`,
63
+ content: generateMountGroupTest(target, ops, ctx),
64
+ overwriteExisting: true,
65
+ });
66
+ }
67
+
68
+ // Generate client test
69
+ files.push({
70
+ path: 'tests/ClientTest.php',
71
+ content: generateClientTest(ctx),
72
+ overwriteExisting: true,
73
+ });
74
+
75
+ return files;
76
+ }
77
+
78
+ function generateMountGroupTest(
79
+ target: string,
80
+ ops: { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[],
81
+ ctx: EmitterContext,
82
+ ): string {
83
+ const ns = ctx.namespacePascal;
84
+ const name = className(target);
85
+ const accessor = servicePropertyName(target);
86
+ const lines: string[] = [];
87
+
88
+ // No <?php here — the file header from fileHeader() provides it
89
+ lines.push('namespace Tests\\Service;');
90
+ lines.push('');
91
+ lines.push('use PHPUnit\\Framework\\TestCase;');
92
+ lines.push('use WorkOS\\TestHelper;');
93
+ lines.push('');
94
+ lines.push(`class ${name}Test extends TestCase`);
95
+ lines.push('{');
96
+ lines.push(' use TestHelper;');
97
+
98
+ // Track emitted test names to avoid duplicates
99
+ const emitted = new Set<string>();
100
+
101
+ // Generate tests for all operations across all services in the mount group.
102
+ // Uses the hand-maintained TestHelper API:
103
+ // - loadFixture(name) appends .json automatically
104
+ // - createMockClient([['status' => N, 'body' => [...]]]) wraps into Response
105
+ for (const { op, service, resolvedOp } of ops) {
106
+ // Skip base method when wrappers exist (matches resources.ts)
107
+ if (resolvedOp?.wrappers && resolvedOp.wrappers.length > 0) continue;
108
+
109
+ const plan = planOperation(op);
110
+ const method = resolveMethodName(op, service, ctx);
111
+ const testName = `test${method.charAt(0).toUpperCase()}${method.slice(1)}`;
112
+ const hidden = buildHiddenParams(resolvedOp);
113
+
114
+ if (emitted.has(testName)) continue;
115
+ emitted.add(testName);
116
+
117
+ lines.push('');
118
+ lines.push(` public function ${testName}(): void`);
119
+ lines.push(' {');
120
+
121
+ const expectedPath = buildExpectedPath(op, ctx);
122
+
123
+ if (plan.isDelete) {
124
+ lines.push(" $client = $this->createMockClient([['status' => 204]]);");
125
+ lines.push(` $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
126
+ // Request assertions
127
+ lines.push(' $request = $this->getLastRequest();');
128
+ lines.push(" $this->assertSame('DELETE', $request->getMethod());");
129
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
130
+ // Body assertions for DELETE-with-body
131
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
132
+ emitBodyAssertions(lines, op, ctx, hidden);
133
+ }
134
+ } else if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
135
+ const fixtureName = `list_${resolveFixtureModelName(op.pagination.itemType.name, ctx)}`;
136
+ // Pass all params (including optional enums) to verify serialization
137
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
138
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
139
+ lines.push(
140
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
141
+ );
142
+ lines.push(` $this->assertInstanceOf(\\${ns}\\PaginatedResponse::class, $result);`);
143
+ // Request assertions
144
+ lines.push(' $request = $this->getLastRequest();');
145
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
146
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
147
+ // Query string serialization assertions
148
+ emitQueryAssertions(lines, op, ctx, hidden);
149
+ } else if (plan.responseModelName) {
150
+ const modelName = className(plan.responseModelName);
151
+ const fixtureName = `${snakeName(plan.responseModelName)}`;
152
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
153
+ if (op.response.kind === 'array') {
154
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => [$fixture]]]);");
155
+ } else {
156
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
157
+ }
158
+ lines.push(` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
159
+ if (op.response.kind === 'array') {
160
+ lines.push(' $this->assertIsArray($result);');
161
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result[0]);`);
162
+ emitFieldHydrationAssertions(lines, plan.responseModelName, '$result[0]', '$fixture', ctx);
163
+ // Round-trip: fromArray -> toArray must not throw
164
+ lines.push(' $this->assertIsArray($result[0]->toArray());');
165
+ } else {
166
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result);`);
167
+ emitFieldHydrationAssertions(lines, plan.responseModelName, '$result', '$fixture', ctx);
168
+ // Round-trip: fromArray -> toArray must not throw
169
+ lines.push(' $this->assertIsArray($result->toArray());');
170
+ }
171
+ // Request assertions
172
+ lines.push(' $request = $this->getLastRequest();');
173
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
174
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
175
+ // Body assertions for POST/PUT/PATCH
176
+ if (plan.hasBody && ['post', 'put', 'patch'].includes(op.httpMethod.toLowerCase())) {
177
+ emitBodyAssertions(lines, op, ctx, hidden);
178
+ }
179
+ } else {
180
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => []]]);");
181
+ lines.push(` $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
182
+ // Request assertions
183
+ lines.push(' $request = $this->getLastRequest();');
184
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
185
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
186
+ }
187
+
188
+ lines.push(' }');
189
+ }
190
+
191
+ // Generate tests for wrapper methods (union split operations)
192
+ for (const resolved of ctx.resolvedOperations ?? []) {
193
+ if (resolved.mountOn !== target) continue;
194
+ for (const wrapper of resolved.wrappers ?? []) {
195
+ const method = toCamelCase(wrapper.name);
196
+ const testName = `test${method.charAt(0).toUpperCase()}${method.slice(1)}`;
197
+
198
+ if (emitted.has(testName)) continue;
199
+ emitted.add(testName);
200
+
201
+ const op = resolved.operation;
202
+ const responseModel = op.response.kind === 'model' ? op.response.name : null;
203
+
204
+ lines.push('');
205
+ lines.push(` public function ${testName}(): void`);
206
+ lines.push(' {');
207
+
208
+ // Build required args for wrapper methods
209
+ const wrapperArgs = buildWrapperTestArgs(wrapper, ctx);
210
+
211
+ if (responseModel) {
212
+ const modelName = className(responseModel);
213
+ const fixtureName = `${snakeName(responseModel)}`;
214
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
215
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
216
+ lines.push(` $result = $client->${accessor}()->${method}(${wrapperArgs});`);
217
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result);`);
218
+ } else {
219
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => []]]);");
220
+ lines.push(` $client->${accessor}()->${method}(${wrapperArgs});`);
221
+ lines.push(' $this->assertTrue(true);');
222
+ }
223
+
224
+ lines.push(' }');
225
+ }
226
+ }
227
+
228
+ // Pagination boundary test: verify iteration works when before/after cursors are null
229
+ const firstPaginatedOp = ops.find(({ op }) => {
230
+ const p = planOperation(op);
231
+ return p.isPaginated && op.pagination?.itemType.kind === 'model';
232
+ });
233
+ if (firstPaginatedOp) {
234
+ const testName = 'testPaginationBoundary';
235
+ if (!emitted.has(testName)) {
236
+ emitted.add(testName);
237
+ const op = firstPaginatedOp.op;
238
+ const paginatedHidden = buildHiddenParams(firstPaginatedOp.resolvedOp);
239
+ const itemType = op.pagination!.itemType as { name: string };
240
+ const fixtureName = `list_${resolveFixtureModelName(itemType.name, ctx)}`;
241
+ const method = resolveMethodName(op, firstPaginatedOp.service, ctx);
242
+
243
+ lines.push('');
244
+ lines.push(` public function ${testName}(): void`);
245
+ lines.push(' {');
246
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
247
+ lines.push(' // Ensure cursors are null (first/last page boundary)');
248
+ lines.push(" $fixture['list_metadata']['before'] = null;");
249
+ lines.push(" $fixture['list_metadata']['after'] = null;");
250
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
251
+ lines.push(
252
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden: paginatedHidden })});`,
253
+ );
254
+ lines.push(` $this->assertInstanceOf(\\${ns}\\PaginatedResponse::class, $result);`);
255
+ lines.push(' // Verify cursors are null on boundary page');
256
+ lines.push(" $this->assertNull($result->listMetadata['before']);");
257
+ lines.push(" $this->assertNull($result->listMetadata['after']);");
258
+ lines.push(' // Iterating should not throw on null cursors');
259
+ lines.push(' foreach ($result as $item) {');
260
+ lines.push(' $this->assertNotNull($item);');
261
+ lines.push(' break;');
262
+ lines.push(' }');
263
+ lines.push(' }');
264
+ }
265
+ }
266
+
267
+ lines.push('}');
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function generateClientTest(ctx: EmitterContext): string {
272
+ const ns = ctx.namespacePascal;
273
+ const lines: string[] = [];
274
+
275
+ // No <?php here — the file header from fileHeader() provides it
276
+ lines.push('namespace Tests;');
277
+ lines.push('');
278
+ lines.push('use PHPUnit\\Framework\\TestCase;');
279
+ lines.push(`use ${ns}\\${ns};`);
280
+ lines.push('');
281
+ lines.push('class ClientTest extends TestCase');
282
+ lines.push('{');
283
+ lines.push(' public function testConstructor(): void');
284
+ lines.push(' {');
285
+ lines.push(` $client = new ${ns}(apiKey: 'test-key');`);
286
+ lines.push(' $this->assertNotNull($client);');
287
+ lines.push(' }');
288
+ lines.push('}');
289
+
290
+ return lines.join('\n');
291
+ }
292
+
293
+ function buildTestArgs(
294
+ op: Operation,
295
+ ctx: EmitterContext,
296
+ opts?: { includeOptional?: boolean; hidden?: Set<string> },
297
+ ): string {
298
+ const includeOptional = opts?.includeOptional ?? false;
299
+ const hidden = opts?.hidden ?? new Set<string>();
300
+ const args: string[] = [];
301
+ const usedNames = new Set<string>();
302
+
303
+ // Path params (use enum values for enum-typed path params)
304
+ for (const p of op.pathParams) {
305
+ if (p.type.kind === 'enum' || p.type.kind === 'model') {
306
+ args.push(generateTestValue(p.type, ctx));
307
+ } else {
308
+ args.push(`'test_${p.name}'`);
309
+ }
310
+ usedNames.add(toCamelCase(p.name));
311
+ }
312
+
313
+ // Required body fields
314
+ if (op.requestBody?.kind === 'model') {
315
+ const bodyModel = ctx.spec.models.find((m) => m.name === (op.requestBody as { name: string }).name);
316
+ if (bodyModel) {
317
+ const pathParamNames = new Set(op.pathParams.map((p) => toCamelCase(p.name)));
318
+ for (const f of bodyModel.fields) {
319
+ if (hidden.has(f.name)) continue;
320
+ if (!f.required && !includeOptional) continue;
321
+ let phpName = toCamelCase(f.name);
322
+ if (pathParamNames.has(phpName)) {
323
+ phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
324
+ }
325
+ if (usedNames.has(phpName)) continue;
326
+ usedNames.add(phpName);
327
+ args.push(`${phpName}: ${generateTestValue(f.type, ctx)}`);
328
+ }
329
+ }
330
+ }
331
+
332
+ // Query params
333
+ for (const q of op.queryParams) {
334
+ if (hidden.has(q.name)) continue;
335
+ if (!q.required && !includeOptional) continue;
336
+ const phpName = toCamelCase(q.name);
337
+ if (usedNames.has(phpName)) continue;
338
+ usedNames.add(phpName);
339
+ args.push(`${phpName}: ${generateTestValue(q.type, ctx)}`);
340
+ }
341
+
342
+ return args.join(', ');
343
+ }
344
+
345
+ function generateTestValue(ref: { kind: string; type?: string; name?: string }, ctx?: EmitterContext): string {
346
+ switch (ref.kind) {
347
+ case 'primitive':
348
+ switch (ref.type) {
349
+ case 'string':
350
+ return "'test_value'";
351
+ case 'integer':
352
+ return '1';
353
+ case 'number':
354
+ return '1.0';
355
+ case 'boolean':
356
+ return 'true';
357
+ default:
358
+ return "'test_value'";
359
+ }
360
+ case 'enum': {
361
+ // Use the first enum value so PHP type-checking passes
362
+ if (ctx && ref.name) {
363
+ const e = ctx.spec.enums.find((en) => en.name === ref.name);
364
+ if (e && e.values.length > 0) {
365
+ const enumClass = enumClassName(ref.name);
366
+ const caseName = String(e.values[0].name)
367
+ .split(/[_\s-]+/)
368
+ .filter(Boolean)
369
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
370
+ .join('');
371
+ return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
372
+ }
373
+ }
374
+ return "'test_value'";
375
+ }
376
+ case 'array':
377
+ return '[]';
378
+ case 'model': {
379
+ if (ref.name) {
380
+ const modelClass = className(ref.name);
381
+ const fixtureName = snakeName(ref.name);
382
+ return `\\WorkOS\\Resource\\${modelClass}::fromArray($this->loadFixture('${fixtureName}'))`;
383
+ }
384
+ return '[]';
385
+ }
386
+ default:
387
+ return "'test_value'";
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Build test arguments for wrapper method calls, providing values for required exposed params.
393
+ */
394
+ function buildWrapperTestArgs(wrapper: import('@workos/oagen').ResolvedWrapper, ctx: EmitterContext): string {
395
+ const params = resolveWrapperParams(wrapper, ctx);
396
+ const args: string[] = [];
397
+ for (const { paramName, field, isOptional } of params) {
398
+ if (isOptional) continue;
399
+ const phpName = toCamelCase(paramName);
400
+ const value = field ? generateTestValue(field.type, ctx) : "'test_value'";
401
+ args.push(`${phpName}: ${value}`);
402
+ }
403
+ return args.join(', ');
404
+ }
405
+
406
+ /**
407
+ * Resolve the fixture model name, unwrapping list wrapper models to match
408
+ * the fixture generator's naming (which unwraps before naming).
409
+ */
410
+ function resolveFixtureModelName(modelName: string, ctx: EmitterContext): string {
411
+ const model = ctx.spec.models.find((m: Model) => m.name === modelName);
412
+ if (model && isListWrapperModel(model)) {
413
+ const dataField = model.fields.find((f) => f.name === 'data');
414
+ if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
415
+ return snakeName(dataField.type.items.name);
416
+ }
417
+ }
418
+ return snakeName(modelName);
419
+ }
420
+
421
+ /**
422
+ * Build the expected URL path for an operation, substituting test values for path params.
423
+ */
424
+ function buildExpectedPath(op: Operation, ctx: EmitterContext): string {
425
+ let path = op.path.replace(/^\//, '');
426
+ for (const p of op.pathParams) {
427
+ if (p.type.kind === 'enum' && (p.type as { name: string }).name) {
428
+ // Use the actual first enum backing value for the path
429
+ const e = ctx.spec.enums.find((en) => en.name === (p.type as { name: string }).name);
430
+ const firstValue = e?.values[0]?.value;
431
+ path = path.replace(`{${p.name}}`, firstValue != null ? String(firstValue) : `test_${p.name}`);
432
+ } else {
433
+ path = path.replace(`{${p.name}}`, `test_${p.name}`);
434
+ }
435
+ }
436
+ return path;
437
+ }
438
+
439
+ /**
440
+ * Emit field hydration assertions: verify that deserialized model fields
441
+ * match the fixture data. Checks up to 2 primitive string fields (id + one more).
442
+ */
443
+ function emitFieldHydrationAssertions(
444
+ lines: string[],
445
+ modelName: string,
446
+ resultVar: string,
447
+ fixtureVar: string,
448
+ ctx: EmitterContext,
449
+ ): void {
450
+ const model = ctx.spec.models.find((m) => m.name === modelName);
451
+ if (!model) return;
452
+
453
+ // Pick required primitive string fields for assertion (id first, then others)
454
+ const candidates = model.fields.filter(
455
+ (f) => f.required && f.type.kind === 'primitive' && f.type.type === 'string' && !f.type.format,
456
+ );
457
+ const idField = candidates.find((f) => f.name === 'id');
458
+ const others = candidates.filter((f) => f.name !== 'id');
459
+ const assertFields = [idField, others[0]].filter(Boolean);
460
+
461
+ for (const f of assertFields) {
462
+ if (!f) continue;
463
+ const phpProp = toCamelCase(f.name);
464
+ lines.push(` $this->assertSame(${fixtureVar}['${f.name}'], ${resultVar}->${phpProp});`);
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Emit query string assertions for list operations.
470
+ * Asserts that all query params (including optional enums) are serialized correctly.
471
+ */
472
+ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
473
+ if (op.queryParams.length === 0) return;
474
+ lines.push(' parse_str($request->getUri()->getQuery(), $query);');
475
+ for (const q of op.queryParams) {
476
+ if (hidden?.has(q.name)) continue;
477
+ const innerType =
478
+ q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
479
+ if (innerType.kind === 'enum' && innerType.name) {
480
+ // Assert enum is serialized as its backing value, not the enum instance
481
+ const e = ctx.spec.enums.find((en) => en.name === innerType.name);
482
+ if (e && e.values.length > 0) {
483
+ lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
484
+ }
485
+ } else if (innerType.kind === 'primitive') {
486
+ switch (innerType.type) {
487
+ case 'string':
488
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
489
+ break;
490
+ case 'integer':
491
+ case 'number':
492
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
493
+ break;
494
+ case 'boolean':
495
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Emit body field assertions for POST/PUT/PATCH operations.
504
+ * Only asserts primitive required fields (strings, numbers, booleans).
505
+ */
506
+ function emitBodyAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
507
+ if (op.requestBody?.kind !== 'model') return;
508
+ const bodyModel = ctx.spec.models.find((m) => m.name === (op.requestBody as { name: string }).name);
509
+ if (!bodyModel) return;
510
+ // Skip fields that collide with path param names (they get deduped in the resource)
511
+ const pathParamNames = new Set(op.pathParams.map((p) => p.name));
512
+ const primitiveRequired = bodyModel.fields.filter(
513
+ (f) =>
514
+ f.required &&
515
+ (f.type.kind === 'primitive' || f.type.kind === 'literal') &&
516
+ !pathParamNames.has(f.name) &&
517
+ !hidden?.has(f.name),
518
+ );
519
+ if (primitiveRequired.length === 0) return;
520
+
521
+ lines.push(' $body = json_decode((string) $request->getBody(), true);');
522
+ for (const f of primitiveRequired) {
523
+ if (f.type.kind === 'primitive' && f.type.type === 'string') {
524
+ lines.push(` $this->assertSame('test_value', $body['${f.name}']);`);
525
+ } else if (f.type.kind === 'primitive' && f.type.type === 'integer') {
526
+ lines.push(` $this->assertSame(1, $body['${f.name}']);`);
527
+ } else if (f.type.kind === 'primitive' && f.type.type === 'boolean') {
528
+ lines.push(` $this->assertTrue($body['${f.name}']);`);
529
+ } else {
530
+ lines.push(` $this->assertArrayHasKey('${f.name}', $body);`);
531
+ }
532
+ }
533
+ }
@@ -0,0 +1,90 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className, enumClassName } from './naming.js';
4
+
5
+ /**
6
+ * Map an IR TypeRef to a PHP type hint string.
7
+ */
8
+ export function mapTypeRef(ref: TypeRef, opts?: { qualified?: boolean }): string {
9
+ const qualify = opts?.qualified ?? false;
10
+ const prefix = qualify ? '\\WorkOS\\Resource\\' : '';
11
+ return irMapTypeRef<string>(ref, {
12
+ primitive: mapPrimitive,
13
+ array: (_ref, _items) => 'array',
14
+ model: (r) => `${prefix}${className(r.name)}`,
15
+ enum: (r) => `${prefix}${enumClassName(r.name)}`,
16
+ union: (r, variants) => joinUnionVariants(r, variants),
17
+ nullable: (_ref, inner) => `?${inner}`,
18
+ literal: (r) => (typeof r.value === 'number' ? (Number.isInteger(r.value) ? 'int' : 'float') : 'string'),
19
+ map: (_ref, _value) => 'array',
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Map an IR TypeRef to a PHPDoc type string for richer documentation.
25
+ * Uses fully-qualified names (leading \) so types resolve correctly
26
+ * regardless of the namespace the docblock appears in.
27
+ */
28
+ export function mapTypeRefForPHPDoc(ref: TypeRef, opts?: { prefix?: string }): string {
29
+ const prefix = opts?.prefix ?? '\\WorkOS\\Resource\\';
30
+ return irMapTypeRef<string>(ref, {
31
+ primitive: mapPrimitiveDoc,
32
+ array: (_ref, items) => `array<${items}>`,
33
+ model: (r) => `${prefix}${className(r.name)}`,
34
+ enum: (r) => `${prefix}${enumClassName(r.name)}`,
35
+ union: (r, variants) => joinDocUnionVariants(r, variants),
36
+ nullable: (_ref, inner) => `${inner}|null`,
37
+ literal: (r) => (typeof r.value === 'string' ? 'string' : typeof r.value === 'number' ? 'int' : 'string'),
38
+ map: (_ref, value) => `array<string, ${value}>`,
39
+ });
40
+ }
41
+
42
+ function mapPrimitive(ref: PrimitiveType): string {
43
+ if (ref.format === 'date-time') return '\\DateTimeImmutable';
44
+ switch (ref.type) {
45
+ case 'string':
46
+ return 'string';
47
+ case 'integer':
48
+ return 'int';
49
+ case 'number':
50
+ return 'float';
51
+ case 'boolean':
52
+ return 'bool';
53
+ case 'unknown':
54
+ return 'mixed';
55
+ }
56
+ }
57
+
58
+ function mapPrimitiveDoc(ref: PrimitiveType): string {
59
+ if (ref.format === 'date-time') return '\\DateTimeImmutable';
60
+ switch (ref.type) {
61
+ case 'string':
62
+ return 'string';
63
+ case 'integer':
64
+ return 'int';
65
+ case 'number':
66
+ return 'float';
67
+ case 'boolean':
68
+ return 'bool';
69
+ case 'unknown':
70
+ return 'mixed';
71
+ }
72
+ }
73
+
74
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
75
+ if (ref.compositionKind === 'allOf') {
76
+ return variants[0] ?? 'mixed';
77
+ }
78
+ const unique = [...new Set(variants)];
79
+ if (unique.length === 1) return unique[0];
80
+ return unique.join('|');
81
+ }
82
+
83
+ function joinDocUnionVariants(ref: UnionType, variants: string[]): string {
84
+ if (ref.compositionKind === 'allOf') {
85
+ return variants[0] ?? 'mixed';
86
+ }
87
+ const unique = [...new Set(variants)];
88
+ if (unique.length === 1) return unique[0];
89
+ return unique.join('|');
90
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Render a PHPDoc comment block from a description string.
3
+ * Handles multiline descriptions by prefixing each line with ` * `.
4
+ * Returns the lines with the given indent (default 0 spaces).
5
+ */
6
+ export function phpDocComment(description: string, indent = 0): string[] {
7
+ const pad = ' '.repeat(indent);
8
+ const descLines = description.split('\n');
9
+ if (descLines.length === 1) {
10
+ return [`${pad}/** ${descLines[0]} */`];
11
+ }
12
+ const lines: string[] = [`${pad}/**`];
13
+ for (const line of descLines) {
14
+ lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
15
+ }
16
+ lines.push(`${pad} */`);
17
+ return lines;
18
+ }