@workos/oagen-emitters 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,636 @@
1
+ import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
2
+ import { planOperation, toCamelCase } from '@workos/oagen';
3
+ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
+ import { className, fieldName, resolveMethodName } from './naming.js';
5
+ import { isListWrapperModel } from './models.js';
6
+ import {
7
+ groupByMount,
8
+ buildResolvedLookup,
9
+ lookupResolved,
10
+ getOpDefaults,
11
+ getOpInferFromClient,
12
+ } from '../shared/resolved-ops.js';
13
+ import { generateWrapperMethods } from './wrappers.js';
14
+ import { phpDocComment } from './utils.js';
15
+
16
+ /**
17
+ * Resolve the resource class name for a service (used by client.ts).
18
+ */
19
+ export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
20
+ for (const r of ctx.resolvedOperations ?? []) {
21
+ if (r.service.name === service.name) return r.mountOn;
22
+ }
23
+ return className(service.name);
24
+ }
25
+
26
+ /**
27
+ * Generate PHP resource class files from IR services.
28
+ * Uses mount-based grouping: one resource file per mount target.
29
+ */
30
+ export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
31
+ if (services.length === 0) return [];
32
+
33
+ const files: GeneratedFile[] = [];
34
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
35
+
36
+ // Group operations by mount target
37
+ const mountGroups = groupByMount(ctx);
38
+ const entries: Array<{ name: string; operations: Operation[] }> =
39
+ mountGroups.size > 0
40
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
41
+ : services.map((s) => ({ name: className(s.name), operations: s.operations }));
42
+
43
+ for (const { name: mountName, operations } of entries) {
44
+ if (operations.length === 0) continue;
45
+ const resourceName = className(mountName);
46
+ const mergedService: Service = { name: mountName, operations };
47
+ const lines: string[] = [];
48
+
49
+ // No <?php here — the file header from fileHeader() provides it
50
+ lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
51
+ lines.push('');
52
+
53
+ // Build resolved lookup early — used by both imports and method generation
54
+ const resolvedLookup = buildResolvedLookup(ctx);
55
+
56
+ // Collect imports
57
+ const imports = collectImports(mergedService, ctx, resolvedLookup);
58
+ for (const imp of imports) {
59
+ lines.push(`use ${imp};`);
60
+ }
61
+ if (imports.length > 0) lines.push('');
62
+
63
+ lines.push(`class ${resourceName}`);
64
+ lines.push('{');
65
+ lines.push(' public function __construct(');
66
+ lines.push(` private readonly \\${ctx.namespacePascal}\\HttpClient $client,`);
67
+ lines.push(' ) {');
68
+ lines.push(' }');
69
+
70
+ // Track emitted method names to avoid duplicates
71
+ const emittedMethods = new Set<string>();
72
+ for (const op of operations) {
73
+ const method = resolveMethodName(op, mergedService, ctx);
74
+ if (emittedMethods.has(method)) continue;
75
+ emittedMethods.add(method);
76
+ const resolved = lookupResolved(op, resolvedLookup);
77
+
78
+ // When wrappers exist, skip the base method and only emit wrappers
79
+ if (resolved?.wrappers && resolved.wrappers.length > 0) {
80
+ lines.push(...generateWrapperMethods(resolved, ctx));
81
+ } else {
82
+ lines.push('');
83
+ generateMethod(lines, op, mergedService, ctx, modelMap, resolved ?? undefined);
84
+ }
85
+ }
86
+
87
+ lines.push('}');
88
+
89
+ files.push({
90
+ path: `lib/Service/${resourceName}.php`,
91
+ content: lines.join('\n'),
92
+ overwriteExisting: true,
93
+ });
94
+ }
95
+
96
+ return files;
97
+ }
98
+
99
+ /**
100
+ * Check if an operation is a redirect endpoint that should construct a URL
101
+ * instead of making an HTTP request.
102
+ *
103
+ * Detection: GET endpoints with no response body (primitive unknown) and query
104
+ * params are redirect endpoints (e.g., SSO/OAuth authorize and logout flows).
105
+ * Also respects an explicit urlBuilder flag on the resolved operation and
106
+ * catches endpoints with 302 success responses.
107
+ */
108
+ export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation): boolean {
109
+ if ((resolvedOp as any)?.urlBuilder) return true;
110
+ if ((op as any).successResponses?.some((r: any) => r.statusCode >= 300 && r.statusCode < 400)) return true;
111
+ if (
112
+ op.httpMethod === 'get' &&
113
+ op.response.kind === 'primitive' &&
114
+ (op.response as any).type === 'unknown' &&
115
+ op.queryParams.length > 0
116
+ ) {
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ function generateMethod(
123
+ lines: string[],
124
+ op: Operation,
125
+ service: Service,
126
+ ctx: EmitterContext,
127
+ modelMap: Map<string, Model>,
128
+ resolvedOp?: ResolvedOperation,
129
+ ): void {
130
+ const plan = planOperation(op);
131
+ const method = resolveMethodName(op, service, ctx);
132
+
133
+ // Build the set of params hidden from the method signature
134
+ // (injected from client config or as constant defaults)
135
+ const hiddenParams = new Set<string>([
136
+ ...Object.keys(getOpDefaults(resolvedOp)),
137
+ ...getOpInferFromClient(resolvedOp),
138
+ ]);
139
+
140
+ const isRedirect = isRedirectEndpoint(op, resolvedOp);
141
+ const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
142
+ const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
143
+
144
+ // PHPDoc block
145
+ const docParts: string[] = [];
146
+ if (op.description) docParts.push(op.description);
147
+ const seenDocParams = new Set<string>();
148
+
149
+ // @param for path params
150
+ for (const p of op.pathParams) {
151
+ const docType = mapTypeRefForPHPDoc(p.type);
152
+ const phpName = fieldName(p.name);
153
+ seenDocParams.add(phpName);
154
+ const prefix = p.deprecated ? '(deprecated) ' : '';
155
+ let desc = p.description ? ` ${prefix}${p.description}` : p.deprecated ? ' (deprecated)' : '';
156
+ if (p.default != null) desc += ` Defaults to ${JSON.stringify(p.default)}.`;
157
+ docParts.push(`@param ${docType} $${phpName}${desc}`);
158
+ }
159
+
160
+ // @param for body fields
161
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
162
+ const bodyModel = modelMap.get(op.requestBody.name);
163
+ if (bodyModel) {
164
+ const bodyParamMap = buildBodyParamMap(op, bodyModel);
165
+ for (const field of bodyModel.fields) {
166
+ if (hiddenParams.has(field.name)) continue;
167
+ const docType = mapTypeRefForPHPDoc(field.type);
168
+ const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
169
+ if (seenDocParams.has(phpName)) continue;
170
+ seenDocParams.add(phpName);
171
+ const nullSuffix = !field.required && !docType.endsWith('|null') ? '|null' : '';
172
+ const prefix = field.deprecated ? '(deprecated) ' : '';
173
+ const desc = field.description ? ` ${prefix}${field.description}` : field.deprecated ? ' (deprecated)' : '';
174
+ docParts.push(`@param ${docType}${nullSuffix} $${phpName}${desc}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ // @param for query params
180
+ for (const q of op.queryParams) {
181
+ if (hiddenParams.has(q.name)) continue;
182
+ const docType = mapTypeRefForPHPDoc(q.type);
183
+ const phpName = fieldName(q.name);
184
+ if (seenDocParams.has(phpName)) continue;
185
+ seenDocParams.add(phpName);
186
+ const nullSuffix = !q.required && !docType.endsWith('|null') ? '|null' : '';
187
+ const prefix = q.deprecated ? '(deprecated) ' : '';
188
+ let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
189
+ if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
190
+ docParts.push(`@param ${docType}${nullSuffix} $${phpName}${desc}`);
191
+ }
192
+
193
+ // @return -- use generic annotation for paginated responses
194
+ if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
195
+ const itemType = op.pagination.itemType;
196
+ const itemModel = ctx.spec.models.find((m) => m.name === itemType.name);
197
+ let resolvedName = itemType.name;
198
+ if (itemModel && isListWrapperModel(itemModel)) {
199
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
200
+ if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
201
+ resolvedName = dataField.type.items.name;
202
+ }
203
+ }
204
+ const itemClass = className(resolvedName);
205
+ docParts.push(
206
+ `@return \\${ctx.namespacePascal}\\PaginatedResponse<\\${ctx.namespacePascal}\\Resource\\${itemClass}>`,
207
+ );
208
+ } else {
209
+ docParts.push(`@return ${returnType}`);
210
+ }
211
+
212
+ // @throws — scope to what the method actually calls
213
+ if (!isRedirect) {
214
+ // HTTP methods can throw any WorkOSException (config, transport, API response)
215
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\WorkOSException`);
216
+ } else if (getOpInferFromClient(resolvedOp).length > 0) {
217
+ // Redirect endpoints that inject client fields can throw ConfigurationException
218
+ docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\ConfigurationException`);
219
+ }
220
+ // Redirect endpoints with no inferFromClient: buildUrl() is pure, no @throws
221
+
222
+ if (op.deprecated) docParts.push('@deprecated');
223
+ lines.push(...phpDocComment(docParts.join('\n'), 4));
224
+
225
+ // Method signature
226
+ lines.push(` public function ${method}(`);
227
+ for (let i = 0; i < params.length; i++) {
228
+ const comma = i < params.length - 1 ? ',' : ',';
229
+ lines.push(` ${params[i]}${comma}`);
230
+ }
231
+ lines.push(` ): ${returnType} {`);
232
+
233
+ // Method body
234
+ const httpMethod = op.httpMethod.toUpperCase();
235
+ const path = buildPathString(op);
236
+
237
+ if (isRedirect) {
238
+ // Redirect endpoint: construct URL client-side instead of making HTTP request
239
+ const queryLines = buildQueryArray(op, hiddenParams);
240
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
241
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
242
+ const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
243
+
244
+ if (needsQuery) {
245
+ const hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
246
+ if (hasOptionalQuery) {
247
+ lines.push(' $query = array_filter([');
248
+ } else {
249
+ lines.push(' $query = [');
250
+ }
251
+ for (const q of queryLines) {
252
+ lines.push(` ${q}`);
253
+ }
254
+ // Inject constant defaults
255
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
256
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
257
+ }
258
+ if (hasOptionalQuery) {
259
+ lines.push(' ], fn ($v) => $v !== null);');
260
+ } else {
261
+ lines.push(' ];');
262
+ }
263
+ // Inject fields from client config
264
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
265
+ lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
266
+ }
267
+ lines.push(` return $this->client->buildUrl(${path}, $query, $options);`);
268
+ } else {
269
+ lines.push(` return $this->client->buildUrl(${path}, [], $options);`);
270
+ }
271
+ } else if (plan.isPaginated) {
272
+ const queryLines = buildQueryArray(op);
273
+ if (queryLines.length > 0) {
274
+ lines.push(' $query = array_filter([');
275
+ for (const q of queryLines) {
276
+ lines.push(` ${q}`);
277
+ }
278
+ lines.push(' ], fn ($v) => $v !== null);');
279
+ }
280
+ lines.push(' return $this->client->requestPage(');
281
+ lines.push(` method: '${httpMethod}',`);
282
+ lines.push(` path: ${path},`);
283
+ if (queryLines.length > 0) {
284
+ lines.push(' query: $query,');
285
+ }
286
+ const itemType = op.pagination?.itemType;
287
+ if (itemType?.kind === 'model') {
288
+ // Unwrap list wrapper models to the inner item type
289
+ const itemModel = ctx.spec.models.find((m) => m.name === itemType.name);
290
+ let resolvedName = itemType.name;
291
+ if (itemModel && isListWrapperModel(itemModel)) {
292
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
293
+ if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
294
+ resolvedName = dataField.type.items.name;
295
+ }
296
+ }
297
+ const itemClass = className(resolvedName);
298
+ lines.push(` modelClass: ${itemClass}::class,`);
299
+ }
300
+ lines.push(' options: $options,');
301
+ lines.push(' );');
302
+ } else if (plan.isDelete) {
303
+ // Build body if the operation has a request body (e.g., DELETE with criteria)
304
+ if (plan.hasBody) {
305
+ const bodyModel = op.requestBody?.kind === 'model' ? modelMap.get(op.requestBody.name) : null;
306
+ const bodyParamMap = buildBodyParamMap(op, bodyModel ?? null);
307
+ const visibleFields = bodyModel?.fields.filter((f) => !hiddenParams.has(f.name)) ?? [];
308
+ const hasOptionalFields = visibleFields.some((f) => !f.required);
309
+ if (hasOptionalFields) {
310
+ lines.push(' $body = array_filter([');
311
+ } else {
312
+ lines.push(' $body = [');
313
+ }
314
+ for (const field of visibleFields) {
315
+ const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
316
+ const nullsafe = field.required ? '' : '?';
317
+ const valueExpr = isEnumType(field.type) ? `$${phpName}${nullsafe}->value` : `$${phpName}`;
318
+ lines.push(` '${field.name}' => ${valueExpr},`);
319
+ }
320
+ // Inject constant defaults
321
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
322
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
323
+ }
324
+ if (hasOptionalFields) {
325
+ lines.push(' ], fn ($v) => $v !== null);');
326
+ } else {
327
+ lines.push(' ];');
328
+ }
329
+ // Inject fields from client config
330
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
331
+ lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
332
+ }
333
+ }
334
+ // Build query params if present
335
+ const deleteQueryLines = buildQueryArray(op);
336
+ if (deleteQueryLines.length > 0) {
337
+ lines.push(' $query = array_filter([');
338
+ for (const q of deleteQueryLines) {
339
+ lines.push(` ${q}`);
340
+ }
341
+ lines.push(' ], fn ($v) => $v !== null);');
342
+ }
343
+
344
+ lines.push(' $this->client->request(');
345
+ lines.push(` method: '${httpMethod}',`);
346
+ lines.push(` path: ${path},`);
347
+ if (plan.hasBody) {
348
+ lines.push(' body: $body,');
349
+ }
350
+ if (deleteQueryLines.length > 0) {
351
+ lines.push(' query: $query,');
352
+ }
353
+ lines.push(' options: $options,');
354
+ lines.push(' );');
355
+ } else if (plan.hasBody) {
356
+ const bodyModel = op.requestBody?.kind === 'model' ? modelMap.get(op.requestBody.name) : null;
357
+ const bodyParamMap = buildBodyParamMap(op, bodyModel ?? null);
358
+ const visibleFields = bodyModel?.fields.filter((f) => !hiddenParams.has(f.name)) ?? [];
359
+ const hasOptionalFields = visibleFields.some((f) => !f.required);
360
+ if (hasOptionalFields) {
361
+ lines.push(' $body = array_filter([');
362
+ } else {
363
+ lines.push(' $body = [');
364
+ }
365
+ for (const field of visibleFields) {
366
+ const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
367
+ const nullsafe = field.required ? '' : '?';
368
+ const valueExpr = isEnumType(field.type) ? `$${phpName}${nullsafe}->value` : `$${phpName}`;
369
+ lines.push(` '${field.name}' => ${valueExpr},`);
370
+ }
371
+ // Inject constant defaults
372
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
373
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
374
+ }
375
+ if (hasOptionalFields) {
376
+ lines.push(' ], fn ($v) => $v !== null);');
377
+ } else {
378
+ lines.push(' ];');
379
+ }
380
+ // Inject fields from client config
381
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
382
+ lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
383
+ }
384
+ lines.push(' $response = $this->client->request(');
385
+ lines.push(` method: '${httpMethod}',`);
386
+ lines.push(` path: ${path},`);
387
+ lines.push(' body: $body,');
388
+ lines.push(' options: $options,');
389
+ lines.push(' );');
390
+
391
+ if (plan.responseModelName) {
392
+ const responseClass = className(plan.responseModelName);
393
+ if (op.response.kind === 'array') {
394
+ lines.push(` return array_map(fn ($item) => ${responseClass}::fromArray($item), $response);`);
395
+ } else {
396
+ lines.push(` return ${responseClass}::fromArray($response);`);
397
+ }
398
+ } else {
399
+ lines.push(' return $response;');
400
+ }
401
+ } else {
402
+ const queryLines = buildQueryArray(op, hiddenParams);
403
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
404
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
405
+ const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
406
+
407
+ if (needsQuery) {
408
+ const hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
409
+ if (hasOptionalQuery) {
410
+ lines.push(' $query = array_filter([');
411
+ } else {
412
+ lines.push(' $query = [');
413
+ }
414
+ for (const q of queryLines) {
415
+ lines.push(` ${q}`);
416
+ }
417
+ // Inject constant defaults
418
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
419
+ lines.push(` '${key}' => ${phpLiteral(value)},`);
420
+ }
421
+ if (hasOptionalQuery) {
422
+ lines.push(' ], fn ($v) => $v !== null);');
423
+ } else {
424
+ lines.push(' ];');
425
+ }
426
+ // Inject fields from client config
427
+ for (const clientField of getOpInferFromClient(resolvedOp)) {
428
+ lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
429
+ }
430
+ }
431
+ lines.push(' $response = $this->client->request(');
432
+ lines.push(` method: '${httpMethod}',`);
433
+ lines.push(` path: ${path},`);
434
+ if (needsQuery) {
435
+ lines.push(' query: $query,');
436
+ }
437
+ lines.push(' options: $options,');
438
+ lines.push(' );');
439
+
440
+ if (plan.responseModelName) {
441
+ const responseClass = className(plan.responseModelName);
442
+ if (op.response.kind === 'array') {
443
+ lines.push(` return array_map(fn ($item) => ${responseClass}::fromArray($item), $response);`);
444
+ } else {
445
+ lines.push(` return ${responseClass}::fromArray($response);`);
446
+ }
447
+ } else {
448
+ lines.push(' return $response;');
449
+ }
450
+ }
451
+
452
+ lines.push(' }');
453
+ }
454
+
455
+ function buildMethodParams(
456
+ op: Operation,
457
+ plan: ReturnType<typeof planOperation>,
458
+ modelMap: Map<string, Model>,
459
+ ctx: EmitterContext,
460
+ hiddenParams?: Set<string>,
461
+ ): string[] {
462
+ // Collect all params into required/optional buckets to avoid
463
+ // PHP's "required after optional" deprecation.
464
+ const required: string[] = [];
465
+ const optional: string[] = [];
466
+ const usedNames = new Set<string>();
467
+ const hidden = hiddenParams ?? new Set();
468
+
469
+ // Path params (always required)
470
+ for (const p of op.pathParams) {
471
+ const phpType = mapTypeRef(p.type, { qualified: true });
472
+ let phpName = fieldName(p.name);
473
+ if (usedNames.has(phpName)) phpName = `path${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
474
+ usedNames.add(phpName);
475
+ required.push(`${phpType} $${phpName}`);
476
+ }
477
+
478
+ // Body fields
479
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
480
+ const bodyModel = modelMap.get(op.requestBody.name);
481
+ if (bodyModel) {
482
+ for (const field of bodyModel.fields) {
483
+ if (hidden.has(field.name)) continue;
484
+ const phpType = mapTypeRef(field.type, { qualified: true });
485
+ let phpName = fieldName(field.name);
486
+ if (usedNames.has(phpName)) {
487
+ // Disambiguate body field from path param with same name
488
+ phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
489
+ if (usedNames.has(phpName)) continue; // truly duplicate, skip
490
+ }
491
+ usedNames.add(phpName);
492
+ if (field.required) {
493
+ required.push(`${phpType} $${phpName}`);
494
+ } else {
495
+ const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
496
+ optional.push(`${nullableType} $${phpName} = null`);
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ // Query params
503
+ for (const q of op.queryParams) {
504
+ if (hidden.has(q.name)) continue;
505
+ const phpType = mapTypeRef(q.type, { qualified: true });
506
+ let phpName = fieldName(q.name);
507
+ if (usedNames.has(phpName)) continue;
508
+ usedNames.add(phpName);
509
+ if (q.required) {
510
+ required.push(`${phpType} $${phpName}`);
511
+ } else {
512
+ const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
513
+ optional.push(`${nullableType} $${phpName} = null`);
514
+ }
515
+ }
516
+
517
+ // RequestOptions (always last, always optional)
518
+ optional.push(`?\\${ctx.namespacePascal}\\RequestOptions $options = null`);
519
+
520
+ return [...required, ...optional];
521
+ }
522
+
523
+ function getReturnType(plan: ReturnType<typeof planOperation>, ctx: EmitterContext): string {
524
+ if (plan.isDelete) return 'void';
525
+ if (plan.isPaginated) return `\\${ctx.namespacePascal}\\PaginatedResponse`;
526
+ if (plan.responseModelName) {
527
+ if (plan.operation.response.kind === 'array') {
528
+ return 'array';
529
+ }
530
+ return `\\${ctx.namespacePascal}\\Resource\\${className(plan.responseModelName)}`;
531
+ }
532
+ return 'mixed';
533
+ }
534
+
535
+ /**
536
+ * Build a mapping from wire name to PHP variable name for body fields,
537
+ * disambiguating collisions with path param names.
538
+ */
539
+ function buildBodyParamMap(op: Operation, bodyModel: Model | null): Map<string, string> {
540
+ const map = new Map<string, string>();
541
+ if (!bodyModel) return map;
542
+ const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
543
+ for (const field of bodyModel.fields) {
544
+ let phpName = fieldName(field.name);
545
+ if (pathParamNames.has(phpName)) {
546
+ phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
547
+ }
548
+ map.set(field.name, phpName);
549
+ }
550
+ return map;
551
+ }
552
+
553
+ function buildPathString(op: Operation): string {
554
+ let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
555
+ if (op.pathParams.length === 0) {
556
+ return `'${path}'`;
557
+ }
558
+ // Build a map of param name → PHP expression (with ->value for enum types)
559
+ const paramExprs = new Map<string, string>();
560
+ for (const p of op.pathParams) {
561
+ const phpName = fieldName(p.name);
562
+ const isEnum = p.type.kind === 'enum' || p.type.kind === 'model';
563
+ paramExprs.set(p.name, isEnum ? `{$${phpName}->value}` : `{$${phpName}}`);
564
+ }
565
+ path = path.replace(/\{([^}]+)\}/g, (_match, param) => paramExprs.get(param) ?? `{$${fieldName(param)}}`);
566
+ return `"${path}"`;
567
+ }
568
+
569
+ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
570
+ if (ref.kind === 'enum') return true;
571
+ if (ref.kind === 'nullable') return isEnumType(ref.inner);
572
+ return false;
573
+ }
574
+
575
+ function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
576
+ const hidden = hiddenParams ?? new Set();
577
+ return op.queryParams
578
+ .filter((q) => !hidden.has(q.name))
579
+ .map((q) => {
580
+ const phpName = fieldName(q.name);
581
+ if (isEnumType(q.type)) {
582
+ const nullsafe = q.required ? '' : '?';
583
+ return `'${q.name}' => $${phpName}${nullsafe}->value,`;
584
+ }
585
+ return `'${q.name}' => $${phpName},`;
586
+ });
587
+ }
588
+
589
+ function phpLiteral(value: unknown): string {
590
+ if (typeof value === 'string') return `'${value}'`;
591
+ if (typeof value === 'number') return String(value);
592
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
593
+ return 'null';
594
+ }
595
+
596
+ function clientFieldExpression(field: string): string {
597
+ switch (field) {
598
+ case 'client_id':
599
+ return '$this->client->requireClientId()';
600
+ case 'client_secret':
601
+ return '$this->client->requireApiKey()';
602
+ default:
603
+ return `$this->client->${toCamelCase(field)}`;
604
+ }
605
+ }
606
+
607
+ function collectImports(
608
+ service: Service,
609
+ ctx: EmitterContext,
610
+ resolvedLookup?: Map<string, ResolvedOperation>,
611
+ ): string[] {
612
+ const imports = new Set<string>();
613
+ const ns = ctx.namespacePascal;
614
+
615
+ for (const op of service.operations) {
616
+ const plan = planOperation(op);
617
+ const resolved = resolvedLookup ? lookupResolved(op, resolvedLookup) : undefined;
618
+ if (plan.responseModelName && !plan.isPaginated && !isRedirectEndpoint(op, resolved)) {
619
+ imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
620
+ }
621
+ if (op.pagination?.itemType.kind === 'model') {
622
+ // Unwrap list wrapper models to import the inner item type
623
+ const itemModel = ctx.spec.models.find((m) => m.name === (op.pagination!.itemType as { name: string }).name);
624
+ let resolvedName = (op.pagination!.itemType as { name: string }).name;
625
+ if (itemModel && isListWrapperModel(itemModel)) {
626
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
627
+ if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
628
+ resolvedName = dataField.type.items.name;
629
+ }
630
+ }
631
+ imports.add(`${ns}\\Resource\\${className(resolvedName)}`);
632
+ }
633
+ }
634
+
635
+ return [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
636
+ }