@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -0,0 +1,561 @@
|
|
|
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
|
+
// Collect imports
|
|
54
|
+
const imports = collectImports(mergedService, ctx);
|
|
55
|
+
for (const imp of imports) {
|
|
56
|
+
lines.push(`use ${imp};`);
|
|
57
|
+
}
|
|
58
|
+
if (imports.length > 0) lines.push('');
|
|
59
|
+
|
|
60
|
+
lines.push(`class ${resourceName}`);
|
|
61
|
+
lines.push('{');
|
|
62
|
+
lines.push(' public function __construct(');
|
|
63
|
+
lines.push(` private readonly \\${ctx.namespacePascal}\\HttpClient $client,`);
|
|
64
|
+
lines.push(' ) {');
|
|
65
|
+
lines.push(' }');
|
|
66
|
+
|
|
67
|
+
// Track emitted method names to avoid duplicates
|
|
68
|
+
const emittedMethods = new Set<string>();
|
|
69
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
70
|
+
for (const op of operations) {
|
|
71
|
+
const method = resolveMethodName(op, mergedService, ctx);
|
|
72
|
+
if (emittedMethods.has(method)) continue;
|
|
73
|
+
emittedMethods.add(method);
|
|
74
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
75
|
+
|
|
76
|
+
// When wrappers exist, skip the base method and only emit wrappers
|
|
77
|
+
if (resolved?.wrappers && resolved.wrappers.length > 0) {
|
|
78
|
+
lines.push(...generateWrapperMethods(resolved, ctx));
|
|
79
|
+
} else {
|
|
80
|
+
lines.push('');
|
|
81
|
+
generateMethod(lines, op, mergedService, ctx, modelMap, resolved ?? undefined);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push('}');
|
|
86
|
+
|
|
87
|
+
files.push({
|
|
88
|
+
path: `lib/Service/${resourceName}.php`,
|
|
89
|
+
content: lines.join('\n'),
|
|
90
|
+
overwriteExisting: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function generateMethod(
|
|
98
|
+
lines: string[],
|
|
99
|
+
op: Operation,
|
|
100
|
+
service: Service,
|
|
101
|
+
ctx: EmitterContext,
|
|
102
|
+
modelMap: Map<string, Model>,
|
|
103
|
+
resolvedOp?: ResolvedOperation,
|
|
104
|
+
): void {
|
|
105
|
+
const plan = planOperation(op);
|
|
106
|
+
const method = resolveMethodName(op, service, ctx);
|
|
107
|
+
|
|
108
|
+
// Build the set of params hidden from the method signature
|
|
109
|
+
// (injected from client config or as constant defaults)
|
|
110
|
+
const hiddenParams = new Set<string>([
|
|
111
|
+
...Object.keys(getOpDefaults(resolvedOp)),
|
|
112
|
+
...getOpInferFromClient(resolvedOp),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
|
|
116
|
+
const returnType = getReturnType(plan, ctx);
|
|
117
|
+
|
|
118
|
+
// PHPDoc block
|
|
119
|
+
const docParts: string[] = [];
|
|
120
|
+
if (op.description) docParts.push(op.description);
|
|
121
|
+
const seenDocParams = new Set<string>();
|
|
122
|
+
|
|
123
|
+
// @param for path params
|
|
124
|
+
for (const p of op.pathParams) {
|
|
125
|
+
const docType = mapTypeRefForPHPDoc(p.type);
|
|
126
|
+
const phpName = fieldName(p.name);
|
|
127
|
+
seenDocParams.add(phpName);
|
|
128
|
+
const prefix = p.deprecated ? '(deprecated) ' : '';
|
|
129
|
+
let desc = p.description ? ` ${prefix}${p.description}` : p.deprecated ? ' (deprecated)' : '';
|
|
130
|
+
if (p.default != null) desc += ` Defaults to ${JSON.stringify(p.default)}.`;
|
|
131
|
+
docParts.push(`@param ${docType} $${phpName}${desc}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// @param for body fields
|
|
135
|
+
if (plan.hasBody && op.requestBody?.kind === 'model') {
|
|
136
|
+
const bodyModel = modelMap.get(op.requestBody.name);
|
|
137
|
+
if (bodyModel) {
|
|
138
|
+
const bodyParamMap = buildBodyParamMap(op, bodyModel);
|
|
139
|
+
for (const field of bodyModel.fields) {
|
|
140
|
+
if (hiddenParams.has(field.name)) continue;
|
|
141
|
+
const docType = mapTypeRefForPHPDoc(field.type);
|
|
142
|
+
const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
|
|
143
|
+
if (seenDocParams.has(phpName)) continue;
|
|
144
|
+
seenDocParams.add(phpName);
|
|
145
|
+
const nullSuffix = !field.required && !docType.endsWith('|null') ? '|null' : '';
|
|
146
|
+
const prefix = field.deprecated ? '(deprecated) ' : '';
|
|
147
|
+
const desc = field.description ? ` ${prefix}${field.description}` : field.deprecated ? ' (deprecated)' : '';
|
|
148
|
+
docParts.push(`@param ${docType}${nullSuffix} $${phpName}${desc}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// @param for query params
|
|
154
|
+
for (const q of op.queryParams) {
|
|
155
|
+
if (hiddenParams.has(q.name)) continue;
|
|
156
|
+
const docType = mapTypeRefForPHPDoc(q.type);
|
|
157
|
+
const phpName = fieldName(q.name);
|
|
158
|
+
if (seenDocParams.has(phpName)) continue;
|
|
159
|
+
seenDocParams.add(phpName);
|
|
160
|
+
const nullSuffix = !q.required && !docType.endsWith('|null') ? '|null' : '';
|
|
161
|
+
const prefix = q.deprecated ? '(deprecated) ' : '';
|
|
162
|
+
let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
|
|
163
|
+
if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
|
|
164
|
+
docParts.push(`@param ${docType}${nullSuffix} $${phpName}${desc}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// @return -- use generic annotation for paginated responses
|
|
168
|
+
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
169
|
+
const itemType = op.pagination.itemType;
|
|
170
|
+
const itemModel = ctx.spec.models.find((m) => m.name === itemType.name);
|
|
171
|
+
let resolvedName = itemType.name;
|
|
172
|
+
if (itemModel && isListWrapperModel(itemModel)) {
|
|
173
|
+
const dataField = itemModel.fields.find((f) => f.name === 'data');
|
|
174
|
+
if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
|
|
175
|
+
resolvedName = dataField.type.items.name;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const itemClass = className(resolvedName);
|
|
179
|
+
docParts.push(
|
|
180
|
+
`@return \\${ctx.namespacePascal}\\PaginatedResponse<\\${ctx.namespacePascal}\\Resource\\${itemClass}>`,
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
docParts.push(`@return ${returnType}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
187
|
+
lines.push(...phpDocComment(docParts.join('\n'), 4));
|
|
188
|
+
|
|
189
|
+
// Method signature
|
|
190
|
+
lines.push(` public function ${method}(`);
|
|
191
|
+
for (let i = 0; i < params.length; i++) {
|
|
192
|
+
const comma = i < params.length - 1 ? ',' : ',';
|
|
193
|
+
lines.push(` ${params[i]}${comma}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push(` ): ${returnType} {`);
|
|
196
|
+
|
|
197
|
+
// Method body
|
|
198
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
199
|
+
const path = buildPathString(op);
|
|
200
|
+
|
|
201
|
+
if (plan.isPaginated) {
|
|
202
|
+
const queryLines = buildQueryArray(op);
|
|
203
|
+
if (queryLines.length > 0) {
|
|
204
|
+
lines.push(' $query = array_filter([');
|
|
205
|
+
for (const q of queryLines) {
|
|
206
|
+
lines.push(` ${q}`);
|
|
207
|
+
}
|
|
208
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
209
|
+
}
|
|
210
|
+
lines.push(' return $this->client->requestPage(');
|
|
211
|
+
lines.push(` method: '${httpMethod}',`);
|
|
212
|
+
lines.push(` path: ${path},`);
|
|
213
|
+
if (queryLines.length > 0) {
|
|
214
|
+
lines.push(' query: $query,');
|
|
215
|
+
}
|
|
216
|
+
const itemType = op.pagination?.itemType;
|
|
217
|
+
if (itemType?.kind === 'model') {
|
|
218
|
+
// Unwrap list wrapper models to the inner item type
|
|
219
|
+
const itemModel = ctx.spec.models.find((m) => m.name === itemType.name);
|
|
220
|
+
let resolvedName = itemType.name;
|
|
221
|
+
if (itemModel && isListWrapperModel(itemModel)) {
|
|
222
|
+
const dataField = itemModel.fields.find((f) => f.name === 'data');
|
|
223
|
+
if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
|
|
224
|
+
resolvedName = dataField.type.items.name;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const itemClass = className(resolvedName);
|
|
228
|
+
lines.push(` modelClass: ${itemClass}::class,`);
|
|
229
|
+
}
|
|
230
|
+
lines.push(' options: $options,');
|
|
231
|
+
lines.push(' );');
|
|
232
|
+
} else if (plan.isDelete) {
|
|
233
|
+
// Build body if the operation has a request body (e.g., DELETE with criteria)
|
|
234
|
+
if (plan.hasBody) {
|
|
235
|
+
const bodyModel = op.requestBody?.kind === 'model' ? modelMap.get(op.requestBody.name) : null;
|
|
236
|
+
const bodyParamMap = buildBodyParamMap(op, bodyModel ?? null);
|
|
237
|
+
const visibleFields = bodyModel?.fields.filter((f) => !hiddenParams.has(f.name)) ?? [];
|
|
238
|
+
const hasOptionalFields = visibleFields.some((f) => !f.required);
|
|
239
|
+
if (hasOptionalFields) {
|
|
240
|
+
lines.push(' $body = array_filter([');
|
|
241
|
+
} else {
|
|
242
|
+
lines.push(' $body = [');
|
|
243
|
+
}
|
|
244
|
+
for (const field of visibleFields) {
|
|
245
|
+
const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
|
|
246
|
+
const nullsafe = field.required ? '' : '?';
|
|
247
|
+
const valueExpr = isEnumType(field.type) ? `$${phpName}${nullsafe}->value` : `$${phpName}`;
|
|
248
|
+
lines.push(` '${field.name}' => ${valueExpr},`);
|
|
249
|
+
}
|
|
250
|
+
// Inject constant defaults
|
|
251
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
252
|
+
lines.push(` '${key}' => ${phpLiteral(value)},`);
|
|
253
|
+
}
|
|
254
|
+
if (hasOptionalFields) {
|
|
255
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
256
|
+
} else {
|
|
257
|
+
lines.push(' ];');
|
|
258
|
+
}
|
|
259
|
+
// Inject fields from client config
|
|
260
|
+
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
261
|
+
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Build query params if present
|
|
265
|
+
const deleteQueryLines = buildQueryArray(op);
|
|
266
|
+
if (deleteQueryLines.length > 0) {
|
|
267
|
+
lines.push(' $query = array_filter([');
|
|
268
|
+
for (const q of deleteQueryLines) {
|
|
269
|
+
lines.push(` ${q}`);
|
|
270
|
+
}
|
|
271
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
lines.push(' $this->client->request(');
|
|
275
|
+
lines.push(` method: '${httpMethod}',`);
|
|
276
|
+
lines.push(` path: ${path},`);
|
|
277
|
+
if (plan.hasBody) {
|
|
278
|
+
lines.push(' body: $body,');
|
|
279
|
+
}
|
|
280
|
+
if (deleteQueryLines.length > 0) {
|
|
281
|
+
lines.push(' query: $query,');
|
|
282
|
+
}
|
|
283
|
+
lines.push(' options: $options,');
|
|
284
|
+
lines.push(' );');
|
|
285
|
+
} else if (plan.hasBody) {
|
|
286
|
+
const bodyModel = op.requestBody?.kind === 'model' ? modelMap.get(op.requestBody.name) : null;
|
|
287
|
+
const bodyParamMap = buildBodyParamMap(op, bodyModel ?? null);
|
|
288
|
+
const visibleFields = bodyModel?.fields.filter((f) => !hiddenParams.has(f.name)) ?? [];
|
|
289
|
+
const hasOptionalFields = visibleFields.some((f) => !f.required);
|
|
290
|
+
if (hasOptionalFields) {
|
|
291
|
+
lines.push(' $body = array_filter([');
|
|
292
|
+
} else {
|
|
293
|
+
lines.push(' $body = [');
|
|
294
|
+
}
|
|
295
|
+
for (const field of visibleFields) {
|
|
296
|
+
const phpName = bodyParamMap.get(field.name) ?? fieldName(field.name);
|
|
297
|
+
const nullsafe = field.required ? '' : '?';
|
|
298
|
+
const valueExpr = isEnumType(field.type) ? `$${phpName}${nullsafe}->value` : `$${phpName}`;
|
|
299
|
+
lines.push(` '${field.name}' => ${valueExpr},`);
|
|
300
|
+
}
|
|
301
|
+
// Inject constant defaults
|
|
302
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
303
|
+
lines.push(` '${key}' => ${phpLiteral(value)},`);
|
|
304
|
+
}
|
|
305
|
+
if (hasOptionalFields) {
|
|
306
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
307
|
+
} else {
|
|
308
|
+
lines.push(' ];');
|
|
309
|
+
}
|
|
310
|
+
// Inject fields from client config
|
|
311
|
+
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
312
|
+
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
313
|
+
}
|
|
314
|
+
lines.push(' $response = $this->client->request(');
|
|
315
|
+
lines.push(` method: '${httpMethod}',`);
|
|
316
|
+
lines.push(` path: ${path},`);
|
|
317
|
+
lines.push(' body: $body,');
|
|
318
|
+
lines.push(' options: $options,');
|
|
319
|
+
lines.push(' );');
|
|
320
|
+
|
|
321
|
+
if (plan.responseModelName) {
|
|
322
|
+
const responseClass = className(plan.responseModelName);
|
|
323
|
+
if (op.response.kind === 'array') {
|
|
324
|
+
lines.push(` return array_map(fn ($item) => ${responseClass}::fromArray($item), $response);`);
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(` return ${responseClass}::fromArray($response);`);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
lines.push(' return $response;');
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
const queryLines = buildQueryArray(op, hiddenParams);
|
|
333
|
+
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
334
|
+
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
335
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
|
|
336
|
+
|
|
337
|
+
if (needsQuery) {
|
|
338
|
+
const hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
|
|
339
|
+
if (hasOptionalQuery) {
|
|
340
|
+
lines.push(' $query = array_filter([');
|
|
341
|
+
} else {
|
|
342
|
+
lines.push(' $query = [');
|
|
343
|
+
}
|
|
344
|
+
for (const q of queryLines) {
|
|
345
|
+
lines.push(` ${q}`);
|
|
346
|
+
}
|
|
347
|
+
// Inject constant defaults
|
|
348
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
349
|
+
lines.push(` '${key}' => ${phpLiteral(value)},`);
|
|
350
|
+
}
|
|
351
|
+
if (hasOptionalQuery) {
|
|
352
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
353
|
+
} else {
|
|
354
|
+
lines.push(' ];');
|
|
355
|
+
}
|
|
356
|
+
// Inject fields from client config
|
|
357
|
+
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
358
|
+
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
lines.push(' $response = $this->client->request(');
|
|
362
|
+
lines.push(` method: '${httpMethod}',`);
|
|
363
|
+
lines.push(` path: ${path},`);
|
|
364
|
+
if (needsQuery) {
|
|
365
|
+
lines.push(' query: $query,');
|
|
366
|
+
}
|
|
367
|
+
lines.push(' options: $options,');
|
|
368
|
+
lines.push(' );');
|
|
369
|
+
|
|
370
|
+
if (plan.responseModelName) {
|
|
371
|
+
const responseClass = className(plan.responseModelName);
|
|
372
|
+
if (op.response.kind === 'array') {
|
|
373
|
+
lines.push(` return array_map(fn ($item) => ${responseClass}::fromArray($item), $response);`);
|
|
374
|
+
} else {
|
|
375
|
+
lines.push(` return ${responseClass}::fromArray($response);`);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
lines.push(' return $response;');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
lines.push(' }');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildMethodParams(
|
|
386
|
+
op: Operation,
|
|
387
|
+
plan: ReturnType<typeof planOperation>,
|
|
388
|
+
modelMap: Map<string, Model>,
|
|
389
|
+
ctx: EmitterContext,
|
|
390
|
+
hiddenParams?: Set<string>,
|
|
391
|
+
): string[] {
|
|
392
|
+
// Collect all params into required/optional buckets to avoid
|
|
393
|
+
// PHP's "required after optional" deprecation.
|
|
394
|
+
const required: string[] = [];
|
|
395
|
+
const optional: string[] = [];
|
|
396
|
+
const usedNames = new Set<string>();
|
|
397
|
+
const hidden = hiddenParams ?? new Set();
|
|
398
|
+
|
|
399
|
+
// Path params (always required)
|
|
400
|
+
for (const p of op.pathParams) {
|
|
401
|
+
const phpType = mapTypeRef(p.type, { qualified: true });
|
|
402
|
+
let phpName = fieldName(p.name);
|
|
403
|
+
if (usedNames.has(phpName)) phpName = `path${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
|
|
404
|
+
usedNames.add(phpName);
|
|
405
|
+
required.push(`${phpType} $${phpName}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Body fields
|
|
409
|
+
if (plan.hasBody && op.requestBody?.kind === 'model') {
|
|
410
|
+
const bodyModel = modelMap.get(op.requestBody.name);
|
|
411
|
+
if (bodyModel) {
|
|
412
|
+
for (const field of bodyModel.fields) {
|
|
413
|
+
if (hidden.has(field.name)) continue;
|
|
414
|
+
const phpType = mapTypeRef(field.type, { qualified: true });
|
|
415
|
+
let phpName = fieldName(field.name);
|
|
416
|
+
if (usedNames.has(phpName)) {
|
|
417
|
+
// Disambiguate body field from path param with same name
|
|
418
|
+
phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
|
|
419
|
+
if (usedNames.has(phpName)) continue; // truly duplicate, skip
|
|
420
|
+
}
|
|
421
|
+
usedNames.add(phpName);
|
|
422
|
+
if (field.required) {
|
|
423
|
+
required.push(`${phpType} $${phpName}`);
|
|
424
|
+
} else {
|
|
425
|
+
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
426
|
+
optional.push(`${nullableType} $${phpName} = null`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Query params
|
|
433
|
+
for (const q of op.queryParams) {
|
|
434
|
+
if (hidden.has(q.name)) continue;
|
|
435
|
+
const phpType = mapTypeRef(q.type, { qualified: true });
|
|
436
|
+
let phpName = fieldName(q.name);
|
|
437
|
+
if (usedNames.has(phpName)) continue;
|
|
438
|
+
usedNames.add(phpName);
|
|
439
|
+
if (q.required) {
|
|
440
|
+
required.push(`${phpType} $${phpName}`);
|
|
441
|
+
} else {
|
|
442
|
+
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
443
|
+
optional.push(`${nullableType} $${phpName} = null`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// RequestOptions (always last, always optional)
|
|
448
|
+
optional.push(`?\\${ctx.namespacePascal}\\RequestOptions $options = null`);
|
|
449
|
+
|
|
450
|
+
return [...required, ...optional];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getReturnType(plan: ReturnType<typeof planOperation>, ctx: EmitterContext): string {
|
|
454
|
+
if (plan.isDelete) return 'void';
|
|
455
|
+
if (plan.isPaginated) return `\\${ctx.namespacePascal}\\PaginatedResponse`;
|
|
456
|
+
if (plan.responseModelName) {
|
|
457
|
+
if (plan.operation.response.kind === 'array') {
|
|
458
|
+
return 'array';
|
|
459
|
+
}
|
|
460
|
+
return `\\${ctx.namespacePascal}\\Resource\\${className(plan.responseModelName)}`;
|
|
461
|
+
}
|
|
462
|
+
return 'mixed';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Build a mapping from wire name to PHP variable name for body fields,
|
|
467
|
+
* disambiguating collisions with path param names.
|
|
468
|
+
*/
|
|
469
|
+
function buildBodyParamMap(op: Operation, bodyModel: Model | null): Map<string, string> {
|
|
470
|
+
const map = new Map<string, string>();
|
|
471
|
+
if (!bodyModel) return map;
|
|
472
|
+
const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
|
|
473
|
+
for (const field of bodyModel.fields) {
|
|
474
|
+
let phpName = fieldName(field.name);
|
|
475
|
+
if (pathParamNames.has(phpName)) {
|
|
476
|
+
phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
|
|
477
|
+
}
|
|
478
|
+
map.set(field.name, phpName);
|
|
479
|
+
}
|
|
480
|
+
return map;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function buildPathString(op: Operation): string {
|
|
484
|
+
let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
|
|
485
|
+
if (op.pathParams.length === 0) {
|
|
486
|
+
return `'${path}'`;
|
|
487
|
+
}
|
|
488
|
+
// Build a map of param name → PHP expression (with ->value for enum types)
|
|
489
|
+
const paramExprs = new Map<string, string>();
|
|
490
|
+
for (const p of op.pathParams) {
|
|
491
|
+
const phpName = fieldName(p.name);
|
|
492
|
+
const isEnum = p.type.kind === 'enum' || p.type.kind === 'model';
|
|
493
|
+
paramExprs.set(p.name, isEnum ? `{$${phpName}->value}` : `{$${phpName}}`);
|
|
494
|
+
}
|
|
495
|
+
path = path.replace(/\{([^}]+)\}/g, (_match, param) => paramExprs.get(param) ?? `{$${fieldName(param)}}`);
|
|
496
|
+
return `"${path}"`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
|
|
500
|
+
if (ref.kind === 'enum') return true;
|
|
501
|
+
if (ref.kind === 'nullable') return isEnumType(ref.inner);
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
|
|
506
|
+
const hidden = hiddenParams ?? new Set();
|
|
507
|
+
return op.queryParams
|
|
508
|
+
.filter((q) => !hidden.has(q.name))
|
|
509
|
+
.map((q) => {
|
|
510
|
+
const phpName = fieldName(q.name);
|
|
511
|
+
if (isEnumType(q.type)) {
|
|
512
|
+
const nullsafe = q.required ? '' : '?';
|
|
513
|
+
return `'${q.name}' => $${phpName}${nullsafe}->value,`;
|
|
514
|
+
}
|
|
515
|
+
return `'${q.name}' => $${phpName},`;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function phpLiteral(value: unknown): string {
|
|
520
|
+
if (typeof value === 'string') return `'${value}'`;
|
|
521
|
+
if (typeof value === 'number') return String(value);
|
|
522
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
523
|
+
return 'null';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function clientFieldExpression(field: string): string {
|
|
527
|
+
switch (field) {
|
|
528
|
+
case 'client_id':
|
|
529
|
+
return '$this->client->requireClientId()';
|
|
530
|
+
case 'client_secret':
|
|
531
|
+
return '$this->client->requireApiKey()';
|
|
532
|
+
default:
|
|
533
|
+
return `$this->client->${toCamelCase(field)}`;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function collectImports(service: Service, ctx: EmitterContext): string[] {
|
|
538
|
+
const imports = new Set<string>();
|
|
539
|
+
const ns = ctx.namespacePascal;
|
|
540
|
+
|
|
541
|
+
for (const op of service.operations) {
|
|
542
|
+
const plan = planOperation(op);
|
|
543
|
+
if (plan.responseModelName && !plan.isPaginated) {
|
|
544
|
+
imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
|
|
545
|
+
}
|
|
546
|
+
if (op.pagination?.itemType.kind === 'model') {
|
|
547
|
+
// Unwrap list wrapper models to import the inner item type
|
|
548
|
+
const itemModel = ctx.spec.models.find((m) => m.name === (op.pagination!.itemType as { name: string }).name);
|
|
549
|
+
let resolvedName = (op.pagination!.itemType as { name: string }).name;
|
|
550
|
+
if (itemModel && isListWrapperModel(itemModel)) {
|
|
551
|
+
const dataField = itemModel.fields.find((f) => f.name === 'data');
|
|
552
|
+
if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
|
|
553
|
+
resolvedName = dataField.type.items.name;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
imports.add(`${ns}\\Resource\\${className(resolvedName)}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
561
|
+
}
|