@workos/oagen-emitters 0.3.0 → 0.5.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/php/resources.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
|
|
2
|
-
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
2
|
+
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
4
4
|
import { className, fieldName, resolveMethodName } from './naming.js';
|
|
5
5
|
import { isListWrapperModel } from './models.js';
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
lookupResolved,
|
|
10
10
|
getOpDefaults,
|
|
11
11
|
getOpInferFromClient,
|
|
12
|
+
collectGroupedParamNames,
|
|
13
|
+
collectBodyFieldTypes,
|
|
12
14
|
} from '../shared/resolved-ops.js';
|
|
13
15
|
import { generateWrapperMethods } from './wrappers.js';
|
|
14
16
|
import { phpDocComment } from './utils.js';
|
|
@@ -50,8 +52,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
50
52
|
lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
|
|
51
53
|
lines.push('');
|
|
52
54
|
|
|
55
|
+
// Build resolved lookup early — used by both imports and method generation
|
|
56
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
57
|
+
|
|
53
58
|
// Collect imports
|
|
54
|
-
const imports = collectImports(mergedService, ctx);
|
|
59
|
+
const imports = collectImports(mergedService, ctx, resolvedLookup);
|
|
55
60
|
for (const imp of imports) {
|
|
56
61
|
lines.push(`use ${imp};`);
|
|
57
62
|
}
|
|
@@ -66,7 +71,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
66
71
|
|
|
67
72
|
// Track emitted method names to avoid duplicates
|
|
68
73
|
const emittedMethods = new Set<string>();
|
|
69
|
-
const resolvedLookup = buildResolvedLookup(ctx);
|
|
70
74
|
for (const op of operations) {
|
|
71
75
|
const method = resolveMethodName(op, mergedService, ctx);
|
|
72
76
|
if (emittedMethods.has(method)) continue;
|
|
@@ -89,11 +93,135 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
89
93
|
content: lines.join('\n'),
|
|
90
94
|
overwriteExisting: true,
|
|
91
95
|
});
|
|
96
|
+
|
|
97
|
+
// Generate variant class files for operations with parameter groups
|
|
98
|
+
for (const op of operations) {
|
|
99
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
100
|
+
files.push(...generateParameterGroupFiles(op, ctx, modelMap));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return files;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if an operation is a redirect endpoint that should construct a URL
|
|
110
|
+
* instead of making an HTTP request.
|
|
111
|
+
*
|
|
112
|
+
* Detection: GET endpoints with no response body (primitive unknown) and query
|
|
113
|
+
* params are redirect endpoints (e.g., SSO/OAuth authorize and logout flows).
|
|
114
|
+
* Also respects an explicit urlBuilder flag on the resolved operation and
|
|
115
|
+
* catches endpoints with 302 success responses.
|
|
116
|
+
*/
|
|
117
|
+
export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation): boolean {
|
|
118
|
+
if ((resolvedOp as any)?.urlBuilder) return true;
|
|
119
|
+
if ((op as any).successResponses?.some((r: any) => r.statusCode >= 300 && r.statusCode < 400)) return true;
|
|
120
|
+
if (
|
|
121
|
+
op.httpMethod === 'get' &&
|
|
122
|
+
op.response.kind === 'primitive' &&
|
|
123
|
+
(op.response as any).type === 'unknown' &&
|
|
124
|
+
op.queryParams.length > 0
|
|
125
|
+
) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Mutually-exclusive parameter group support
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** PHP class name for a parameter group variant (e.g. ParentResourceById). */
|
|
136
|
+
function groupVariantClassName(groupName: string, variantName: string): string {
|
|
137
|
+
return `${className(groupName)}${className(variantName)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Derive a short PHP property name for a parameter within a variant class.
|
|
142
|
+
* Strips the group name prefix when present to avoid stuttering
|
|
143
|
+
* (e.g. parent_resource_id in group parent_resource -> id -> camelCase).
|
|
144
|
+
*/
|
|
145
|
+
export function deriveVariantFieldName(paramName: string, groupName: string): string {
|
|
146
|
+
const prefix = groupName + '_';
|
|
147
|
+
const stripped = paramName.startsWith(prefix) ? paramName.slice(prefix.length) : paramName;
|
|
148
|
+
return fieldName(stripped);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate PHP variant class files for all parameter groups on an operation.
|
|
153
|
+
* Each variant becomes a simple PHP class with readonly constructor properties.
|
|
154
|
+
*/
|
|
155
|
+
function generateParameterGroupFiles(
|
|
156
|
+
op: Operation,
|
|
157
|
+
ctx: EmitterContext,
|
|
158
|
+
modelMap: Map<string, Model>,
|
|
159
|
+
): GeneratedFile[] {
|
|
160
|
+
const files: GeneratedFile[] = [];
|
|
161
|
+
const bodyFieldTypes = collectBodyFieldTypes(op, [...modelMap.values()]);
|
|
162
|
+
|
|
163
|
+
for (const group of op.parameterGroups ?? []) {
|
|
164
|
+
for (const variant of group.variants) {
|
|
165
|
+
const variantClass = groupVariantClassName(group.name, variant.name);
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
|
|
168
|
+
lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push(`class ${variantClass}`);
|
|
171
|
+
lines.push('{');
|
|
172
|
+
lines.push(' public function __construct(');
|
|
173
|
+
for (let i = 0; i < variant.parameters.length; i++) {
|
|
174
|
+
const param = variant.parameters[i];
|
|
175
|
+
const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
|
|
176
|
+
const phpType = mapTypeRef(effectiveType, { qualified: true });
|
|
177
|
+
const phpName = deriveVariantFieldName(param.name, group.name);
|
|
178
|
+
const comma = ',';
|
|
179
|
+
lines.push(` public readonly ${phpType} $${phpName}${comma}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push(' ) {');
|
|
182
|
+
lines.push(' }');
|
|
183
|
+
lines.push('}');
|
|
184
|
+
|
|
185
|
+
files.push({
|
|
186
|
+
path: `lib/Service/${variantClass}.php`,
|
|
187
|
+
content: lines.join('\n'),
|
|
188
|
+
overwriteExisting: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
92
191
|
}
|
|
93
192
|
|
|
94
193
|
return files;
|
|
95
194
|
}
|
|
96
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Generate instanceof dispatch lines to serialize a grouped parameter
|
|
198
|
+
* into a target array ($query or $body) using each variant's wire names.
|
|
199
|
+
*/
|
|
200
|
+
function generateGroupDispatch(op: Operation, indent: string, target: '$query' | '$body' = '$query'): string[] {
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const group of op.parameterGroups ?? []) {
|
|
204
|
+
const phpParamName = fieldName(group.name);
|
|
205
|
+
|
|
206
|
+
for (let vi = 0; vi < group.variants.length; vi++) {
|
|
207
|
+
const variant = group.variants[vi];
|
|
208
|
+
const variantClass = groupVariantClassName(group.name, variant.name);
|
|
209
|
+
const keyword = vi === 0 ? 'if' : 'elseif';
|
|
210
|
+
|
|
211
|
+
lines.push(`${indent}${keyword} ($${phpParamName} instanceof ${variantClass}) {`);
|
|
212
|
+
|
|
213
|
+
for (const param of variant.parameters) {
|
|
214
|
+
const phpField = deriveVariantFieldName(param.name, group.name);
|
|
215
|
+
lines.push(`${indent} ${target}['${param.name}'] = $${phpParamName}->${phpField};`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push(`${indent}}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
97
225
|
function generateMethod(
|
|
98
226
|
lines: string[],
|
|
99
227
|
op: Operation,
|
|
@@ -112,8 +240,9 @@ function generateMethod(
|
|
|
112
240
|
...getOpInferFromClient(resolvedOp),
|
|
113
241
|
]);
|
|
114
242
|
|
|
243
|
+
const isRedirect = isRedirectEndpoint(op, resolvedOp);
|
|
115
244
|
const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
|
|
116
|
-
const returnType = getReturnType(plan, ctx);
|
|
245
|
+
const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
|
|
117
246
|
|
|
118
247
|
// PHPDoc block
|
|
119
248
|
const docParts: string[] = [];
|
|
@@ -150,14 +279,29 @@ function generateMethod(
|
|
|
150
279
|
}
|
|
151
280
|
}
|
|
152
281
|
|
|
153
|
-
// @param for
|
|
282
|
+
// @param for parameter groups (union-typed)
|
|
283
|
+
const groupedParamNames = collectGroupedParamNames(op);
|
|
284
|
+
for (const group of op.parameterGroups ?? []) {
|
|
285
|
+
const phpName = fieldName(group.name);
|
|
286
|
+
if (seenDocParams.has(phpName)) continue;
|
|
287
|
+
seenDocParams.add(phpName);
|
|
288
|
+
const variantTypes = group.variants.map((v) => groupVariantClassName(group.name, v.name));
|
|
289
|
+
const unionDocType = variantTypes.join('|');
|
|
290
|
+
const nullPrefix = group.optional ? 'null|' : '';
|
|
291
|
+
docParts.push(`@param ${nullPrefix}${unionDocType} $${phpName}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// @param for query params (skip grouped params — they appear as group union params)
|
|
154
295
|
for (const q of op.queryParams) {
|
|
155
296
|
if (hiddenParams.has(q.name)) continue;
|
|
297
|
+
if (groupedParamNames.has(q.name)) continue;
|
|
156
298
|
const docType = mapTypeRefForPHPDoc(q.type);
|
|
157
299
|
const phpName = fieldName(q.name);
|
|
158
300
|
if (seenDocParams.has(phpName)) continue;
|
|
159
301
|
seenDocParams.add(phpName);
|
|
160
|
-
|
|
302
|
+
// order params with enum defaults are non-nullable (they default to Desc, not null)
|
|
303
|
+
const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
|
|
304
|
+
const nullSuffix = !q.required && !isNonNullableOrder && !docType.endsWith('|null') ? '|null' : '';
|
|
161
305
|
const prefix = q.deprecated ? '(deprecated) ' : '';
|
|
162
306
|
let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
|
|
163
307
|
if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
|
|
@@ -183,6 +327,16 @@ function generateMethod(
|
|
|
183
327
|
docParts.push(`@return ${returnType}`);
|
|
184
328
|
}
|
|
185
329
|
|
|
330
|
+
// @throws — scope to what the method actually calls
|
|
331
|
+
if (!isRedirect) {
|
|
332
|
+
// HTTP methods can throw any WorkOSException (config, transport, API response)
|
|
333
|
+
docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\WorkOSException`);
|
|
334
|
+
} else if (getOpInferFromClient(resolvedOp).length > 0) {
|
|
335
|
+
// Redirect endpoints that inject client fields can throw ConfigurationException
|
|
336
|
+
docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\ConfigurationException`);
|
|
337
|
+
}
|
|
338
|
+
// Redirect endpoints with no inferFromClient: buildUrl() is pure, no @throws
|
|
339
|
+
|
|
186
340
|
if (op.deprecated) docParts.push('@deprecated');
|
|
187
341
|
lines.push(...phpDocComment(docParts.join('\n'), 4));
|
|
188
342
|
|
|
@@ -198,19 +352,69 @@ function generateMethod(
|
|
|
198
352
|
const httpMethod = op.httpMethod.toUpperCase();
|
|
199
353
|
const path = buildPathString(op);
|
|
200
354
|
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
355
|
+
if (isRedirect) {
|
|
356
|
+
// Redirect endpoint: construct URL client-side instead of making HTTP request
|
|
357
|
+
const queryLines = buildQueryArray(op, hiddenParams);
|
|
358
|
+
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
359
|
+
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
360
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
361
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
|
|
362
|
+
|
|
363
|
+
if (needsQuery) {
|
|
364
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
365
|
+
const hasOptionalQuery = op.queryParams.some(
|
|
366
|
+
(q) => !q.required && !hiddenParams.has(q.name) && !groupedParams.has(q.name),
|
|
367
|
+
);
|
|
368
|
+
if (hasOptionalQuery) {
|
|
369
|
+
lines.push(' $query = array_filter([');
|
|
370
|
+
} else if (queryLines.length > 0) {
|
|
371
|
+
lines.push(' $query = [');
|
|
372
|
+
} else {
|
|
373
|
+
lines.push(' $query = [');
|
|
374
|
+
}
|
|
205
375
|
for (const q of queryLines) {
|
|
206
376
|
lines.push(` ${q}`);
|
|
207
377
|
}
|
|
208
|
-
|
|
378
|
+
// Inject constant defaults
|
|
379
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
380
|
+
lines.push(` '${key}' => ${phpLiteral(value)},`);
|
|
381
|
+
}
|
|
382
|
+
if (hasOptionalQuery) {
|
|
383
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
384
|
+
} else {
|
|
385
|
+
lines.push(' ];');
|
|
386
|
+
}
|
|
387
|
+
// Inject fields from client config
|
|
388
|
+
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
389
|
+
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
390
|
+
}
|
|
391
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
392
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
393
|
+
lines.push(` return $this->client->buildUrl(path: ${path}, query: $query, options: $options);`);
|
|
394
|
+
} else {
|
|
395
|
+
lines.push(` return $this->client->buildUrl(path: ${path}, query: [], options: $options);`);
|
|
396
|
+
}
|
|
397
|
+
} else if (plan.isPaginated) {
|
|
398
|
+
const queryLines = buildQueryArray(op);
|
|
399
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
400
|
+
const needsQuery = queryLines.length > 0 || hasGroups;
|
|
401
|
+
if (needsQuery) {
|
|
402
|
+
if (queryLines.length > 0) {
|
|
403
|
+
lines.push(' $query = array_filter([');
|
|
404
|
+
for (const q of queryLines) {
|
|
405
|
+
lines.push(` ${q}`);
|
|
406
|
+
}
|
|
407
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
408
|
+
} else {
|
|
409
|
+
lines.push(' $query = [];');
|
|
410
|
+
}
|
|
411
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
412
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
209
413
|
}
|
|
210
414
|
lines.push(' return $this->client->requestPage(');
|
|
211
415
|
lines.push(` method: '${httpMethod}',`);
|
|
212
416
|
lines.push(` path: ${path},`);
|
|
213
|
-
if (
|
|
417
|
+
if (needsQuery) {
|
|
214
418
|
lines.push(' query: $query,');
|
|
215
419
|
}
|
|
216
420
|
const itemType = op.pagination?.itemType;
|
|
@@ -260,6 +464,10 @@ function generateMethod(
|
|
|
260
464
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
261
465
|
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
262
466
|
}
|
|
467
|
+
// Inject parameter group dispatch into body
|
|
468
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
469
|
+
lines.push(...generateGroupDispatch(op, ' ', '$body'));
|
|
470
|
+
}
|
|
263
471
|
}
|
|
264
472
|
// Build query params if present
|
|
265
473
|
const deleteQueryLines = buildQueryArray(op);
|
|
@@ -311,6 +519,11 @@ function generateMethod(
|
|
|
311
519
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
312
520
|
lines.push(` $body['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
313
521
|
}
|
|
522
|
+
// Inject parameter group dispatch into body so sensitive fields
|
|
523
|
+
// (passwords, role slugs) never leak into the URL query string.
|
|
524
|
+
if ((op.parameterGroups?.length ?? 0) > 0) {
|
|
525
|
+
lines.push(...generateGroupDispatch(op, ' ', '$body'));
|
|
526
|
+
}
|
|
314
527
|
lines.push(' $response = $this->client->request(');
|
|
315
528
|
lines.push(` method: '${httpMethod}',`);
|
|
316
529
|
lines.push(` path: ${path},`);
|
|
@@ -332,12 +545,18 @@ function generateMethod(
|
|
|
332
545
|
const queryLines = buildQueryArray(op, hiddenParams);
|
|
333
546
|
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
334
547
|
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
335
|
-
const
|
|
548
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
549
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred || hasGroups;
|
|
336
550
|
|
|
337
551
|
if (needsQuery) {
|
|
338
|
-
const
|
|
552
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
553
|
+
const hasOptionalQuery = op.queryParams.some(
|
|
554
|
+
(q) => !q.required && !hiddenParams.has(q.name) && !groupedParams.has(q.name),
|
|
555
|
+
);
|
|
339
556
|
if (hasOptionalQuery) {
|
|
340
557
|
lines.push(' $query = array_filter([');
|
|
558
|
+
} else if (queryLines.length > 0) {
|
|
559
|
+
lines.push(' $query = [');
|
|
341
560
|
} else {
|
|
342
561
|
lines.push(' $query = [');
|
|
343
562
|
}
|
|
@@ -357,6 +576,8 @@ function generateMethod(
|
|
|
357
576
|
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
358
577
|
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
359
578
|
}
|
|
579
|
+
// Inject parameter group dispatch (instanceof checks)
|
|
580
|
+
lines.push(...generateGroupDispatch(op, ' '));
|
|
360
581
|
}
|
|
361
582
|
lines.push(' $response = $this->client->request(');
|
|
362
583
|
lines.push(` method: '${httpMethod}',`);
|
|
@@ -395,6 +616,7 @@ function buildMethodParams(
|
|
|
395
616
|
const optional: string[] = [];
|
|
396
617
|
const usedNames = new Set<string>();
|
|
397
618
|
const hidden = hiddenParams ?? new Set();
|
|
619
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
398
620
|
|
|
399
621
|
// Path params (always required)
|
|
400
622
|
for (const p of op.pathParams) {
|
|
@@ -429,15 +651,41 @@ function buildMethodParams(
|
|
|
429
651
|
}
|
|
430
652
|
}
|
|
431
653
|
|
|
432
|
-
//
|
|
654
|
+
// Parameter group union-typed params (before individual query params)
|
|
655
|
+
for (const group of op.parameterGroups ?? []) {
|
|
656
|
+
const phpName = fieldName(group.name);
|
|
657
|
+
if (usedNames.has(phpName)) continue;
|
|
658
|
+
usedNames.add(phpName);
|
|
659
|
+
// PHP 8.0+ union syntax: VariantA|VariantB $paramName
|
|
660
|
+
const variantTypes = group.variants.map((v) => groupVariantClassName(group.name, v.name));
|
|
661
|
+
const unionType = variantTypes.join('|');
|
|
662
|
+
if (group.optional) {
|
|
663
|
+
optional.push(`null|${unionType} $${phpName} = null`);
|
|
664
|
+
} else {
|
|
665
|
+
required.push(`${unionType} $${phpName}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Query params (skip grouped params — they are serialized via group dispatch)
|
|
433
670
|
for (const q of op.queryParams) {
|
|
434
671
|
if (hidden.has(q.name)) continue;
|
|
672
|
+
if (groupedParams.has(q.name)) continue;
|
|
435
673
|
const phpType = mapTypeRef(q.type, { qualified: true });
|
|
436
674
|
let phpName = fieldName(q.name);
|
|
437
675
|
if (usedNames.has(phpName)) continue;
|
|
438
676
|
usedNames.add(phpName);
|
|
439
677
|
if (q.required) {
|
|
440
678
|
required.push(`${phpType} $${phpName}`);
|
|
679
|
+
} else if (q.name === 'order') {
|
|
680
|
+
// Hardcode order default to desc for pagination consistency
|
|
681
|
+
if (q.type.kind === 'enum') {
|
|
682
|
+
const enumType = mapTypeRef(q.type, { qualified: true });
|
|
683
|
+
const caseName = toPascalCase('desc');
|
|
684
|
+
optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
|
|
685
|
+
} else {
|
|
686
|
+
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
687
|
+
optional.push(`${nullableType} $${phpName} = 'desc'`);
|
|
688
|
+
}
|
|
441
689
|
} else {
|
|
442
690
|
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
443
691
|
optional.push(`${nullableType} $${phpName} = null`);
|
|
@@ -504,12 +752,15 @@ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
|
|
|
504
752
|
|
|
505
753
|
function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
|
|
506
754
|
const hidden = hiddenParams ?? new Set();
|
|
755
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
507
756
|
return op.queryParams
|
|
508
|
-
.filter((q) => !hidden.has(q.name))
|
|
757
|
+
.filter((q) => !hidden.has(q.name) && !groupedParams.has(q.name))
|
|
509
758
|
.map((q) => {
|
|
510
759
|
const phpName = fieldName(q.name);
|
|
511
760
|
if (isEnumType(q.type)) {
|
|
512
|
-
|
|
761
|
+
// order params with enum defaults are non-nullable (default to Desc, not null)
|
|
762
|
+
const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
|
|
763
|
+
const nullsafe = q.required || isNonNullableOrder ? '' : '?';
|
|
513
764
|
return `'${q.name}' => $${phpName}${nullsafe}->value,`;
|
|
514
765
|
}
|
|
515
766
|
return `'${q.name}' => $${phpName},`;
|
|
@@ -534,13 +785,18 @@ function clientFieldExpression(field: string): string {
|
|
|
534
785
|
}
|
|
535
786
|
}
|
|
536
787
|
|
|
537
|
-
function collectImports(
|
|
788
|
+
function collectImports(
|
|
789
|
+
service: Service,
|
|
790
|
+
ctx: EmitterContext,
|
|
791
|
+
resolvedLookup?: Map<string, ResolvedOperation>,
|
|
792
|
+
): string[] {
|
|
538
793
|
const imports = new Set<string>();
|
|
539
794
|
const ns = ctx.namespacePascal;
|
|
540
795
|
|
|
541
796
|
for (const op of service.operations) {
|
|
542
797
|
const plan = planOperation(op);
|
|
543
|
-
|
|
798
|
+
const resolved = resolvedLookup ? lookupResolved(op, resolvedLookup) : undefined;
|
|
799
|
+
if (plan.responseModelName && !plan.isPaginated && !isRedirectEndpoint(op, resolved)) {
|
|
544
800
|
imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
|
|
545
801
|
}
|
|
546
802
|
if (op.pagination?.itemType.kind === 'model') {
|
package/src/php/tests.ts
CHANGED
|
@@ -7,12 +7,20 @@ import type {
|
|
|
7
7
|
Model,
|
|
8
8
|
ResolvedOperation,
|
|
9
9
|
} from '@workos/oagen';
|
|
10
|
-
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
10
|
+
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
11
11
|
import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
|
|
12
12
|
import { isListWrapperModel } from './models.js';
|
|
13
13
|
import { generateFixtures } from './fixtures.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getMountTarget,
|
|
16
|
+
groupByMount,
|
|
17
|
+
buildHiddenParams,
|
|
18
|
+
getOpDefaults,
|
|
19
|
+
getOpInferFromClient,
|
|
20
|
+
collectGroupedParamNames,
|
|
21
|
+
} from '../shared/resolved-ops.js';
|
|
15
22
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
23
|
+
import { isRedirectEndpoint, deriveVariantFieldName } from './resources.js';
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* Generate PHPUnit test files and fixture JSON files.
|
|
@@ -20,16 +28,6 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
|
20
28
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
21
29
|
const files: GeneratedFile[] = [];
|
|
22
30
|
|
|
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
31
|
// TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
|
|
34
32
|
|
|
35
33
|
// Collect all operations per mount target using resolved per-operation mounts.
|
|
@@ -72,6 +70,16 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
72
70
|
overwriteExisting: true,
|
|
73
71
|
});
|
|
74
72
|
|
|
73
|
+
// Generate fixture JSON files
|
|
74
|
+
const fixtures = generateFixtures(spec);
|
|
75
|
+
for (const fixture of fixtures) {
|
|
76
|
+
files.push({
|
|
77
|
+
path: fixture.path,
|
|
78
|
+
content: fixture.content,
|
|
79
|
+
headerPlacement: 'skip',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
return files;
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -146,6 +154,17 @@ function generateMountGroupTest(
|
|
|
146
154
|
lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
|
|
147
155
|
// Query string serialization assertions
|
|
148
156
|
emitQueryAssertions(lines, op, ctx, hidden);
|
|
157
|
+
} else if (isRedirectEndpoint(op, resolvedOp)) {
|
|
158
|
+
// Redirect endpoint: URL is built locally, no HTTP request made.
|
|
159
|
+
// Pass all params (including optional) to verify they appear in the URL.
|
|
160
|
+
lines.push(' $client = $this->createMockClient([]);');
|
|
161
|
+
lines.push(
|
|
162
|
+
` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
|
|
163
|
+
);
|
|
164
|
+
lines.push(' $this->assertIsString($result);');
|
|
165
|
+
lines.push(` $this->assertStringContainsString('${expectedPath}', $result);`);
|
|
166
|
+
// Query param assertions for the generated URL
|
|
167
|
+
emitRedirectQueryAssertions(lines, op, ctx, hidden, resolvedOp);
|
|
149
168
|
} else if (plan.responseModelName) {
|
|
150
169
|
const modelName = className(plan.responseModelName);
|
|
151
170
|
const fixtureName = `${snakeName(plan.responseModelName)}`;
|
|
@@ -329,9 +348,23 @@ function buildTestArgs(
|
|
|
329
348
|
}
|
|
330
349
|
}
|
|
331
350
|
|
|
332
|
-
//
|
|
351
|
+
// Parameter group args (union-typed) — emit first variant constructor
|
|
352
|
+
const groupedParamNames = collectGroupedParamNames(op);
|
|
353
|
+
for (const group of op.parameterGroups ?? []) {
|
|
354
|
+
if (!group.optional || includeOptional) {
|
|
355
|
+
const variant = group.variants[0];
|
|
356
|
+
const variantClass = `${className(group.name)}${className(variant.name)}`;
|
|
357
|
+
const variantArgs = variant.parameters
|
|
358
|
+
.map((p) => `${deriveVariantFieldName(p.name, group.name)}: 'test_value'`)
|
|
359
|
+
.join(', ');
|
|
360
|
+
args.push(`${toCamelCase(group.name)}: new \\${ctx.namespacePascal}\\Service\\${variantClass}(${variantArgs})`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Query params (skip grouped params — they're handled above)
|
|
333
365
|
for (const q of op.queryParams) {
|
|
334
366
|
if (hidden.has(q.name)) continue;
|
|
367
|
+
if (groupedParamNames.has(q.name)) continue;
|
|
335
368
|
if (!q.required && !includeOptional) continue;
|
|
336
369
|
const phpName = toCamelCase(q.name);
|
|
337
370
|
if (usedNames.has(phpName)) continue;
|
|
@@ -363,11 +396,8 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
|
|
|
363
396
|
const e = ctx.spec.enums.find((en) => en.name === ref.name);
|
|
364
397
|
if (e && e.values.length > 0) {
|
|
365
398
|
const enumClass = enumClassName(ref.name);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
.filter(Boolean)
|
|
369
|
-
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
370
|
-
.join('');
|
|
399
|
+
// Must match the case-name logic in enums.ts
|
|
400
|
+
const caseName = toPascalCase(String(e.values[0].name).toLowerCase());
|
|
371
401
|
return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
|
|
372
402
|
}
|
|
373
403
|
}
|
|
@@ -375,6 +405,13 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
|
|
|
375
405
|
}
|
|
376
406
|
case 'array':
|
|
377
407
|
return '[]';
|
|
408
|
+
case 'map':
|
|
409
|
+
return '[]';
|
|
410
|
+
case 'nullable':
|
|
411
|
+
return generateTestValue(
|
|
412
|
+
(ref as unknown as { inner: { kind: string; type?: string; name?: string } }).inner,
|
|
413
|
+
ctx,
|
|
414
|
+
);
|
|
378
415
|
case 'model': {
|
|
379
416
|
if (ref.name) {
|
|
380
417
|
const modelClass = className(ref.name);
|
|
@@ -471,9 +508,18 @@ function emitFieldHydrationAssertions(
|
|
|
471
508
|
*/
|
|
472
509
|
function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
|
|
473
510
|
if (op.queryParams.length === 0) return;
|
|
511
|
+
const groupedParams = collectGroupedParamNames(op);
|
|
474
512
|
lines.push(' parse_str($request->getUri()->getQuery(), $query);');
|
|
513
|
+
// Assert first variant's params from parameter groups
|
|
514
|
+
for (const group of op.parameterGroups ?? []) {
|
|
515
|
+
const variant = group.variants[0];
|
|
516
|
+
for (const param of variant.parameters) {
|
|
517
|
+
lines.push(` $this->assertSame('test_value', $query['${param.name}']);`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
475
520
|
for (const q of op.queryParams) {
|
|
476
521
|
if (hidden?.has(q.name)) continue;
|
|
522
|
+
if (groupedParams.has(q.name)) continue;
|
|
477
523
|
const innerType =
|
|
478
524
|
q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
|
|
479
525
|
if (innerType.kind === 'enum' && innerType.name) {
|
|
@@ -499,6 +545,60 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
|
|
|
499
545
|
}
|
|
500
546
|
}
|
|
501
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Emit query param assertions for redirect endpoint URLs.
|
|
550
|
+
* Parses the query string from the built URL and asserts visible params,
|
|
551
|
+
* hidden defaults (e.g., response_type), and inferred client fields (e.g., client_id).
|
|
552
|
+
*/
|
|
553
|
+
function emitRedirectQueryAssertions(
|
|
554
|
+
lines: string[],
|
|
555
|
+
op: Operation,
|
|
556
|
+
ctx: EmitterContext,
|
|
557
|
+
hidden: Set<string>,
|
|
558
|
+
resolvedOp?: ResolvedOperation,
|
|
559
|
+
): void {
|
|
560
|
+
const hasVisibleQueryParams = op.queryParams.some((q) => !hidden.has(q.name));
|
|
561
|
+
const defaults = getOpDefaults(resolvedOp);
|
|
562
|
+
const inferred = getOpInferFromClient(resolvedOp);
|
|
563
|
+
if (!hasVisibleQueryParams && Object.keys(defaults).length === 0 && inferred.length === 0) return;
|
|
564
|
+
|
|
565
|
+
lines.push(" parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);");
|
|
566
|
+
|
|
567
|
+
// Assert visible query params (same logic as emitQueryAssertions but reading from $query parsed from URL)
|
|
568
|
+
for (const q of op.queryParams) {
|
|
569
|
+
if (hidden.has(q.name)) continue;
|
|
570
|
+
const innerType =
|
|
571
|
+
q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
|
|
572
|
+
if (innerType.kind === 'enum' && innerType.name) {
|
|
573
|
+
const e = ctx.spec.enums.find((en) => en.name === innerType.name);
|
|
574
|
+
if (e && e.values.length > 0) {
|
|
575
|
+
lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
|
|
576
|
+
}
|
|
577
|
+
} else if (innerType.kind === 'primitive') {
|
|
578
|
+
switch (innerType.type) {
|
|
579
|
+
case 'string':
|
|
580
|
+
lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
|
|
581
|
+
break;
|
|
582
|
+
case 'integer':
|
|
583
|
+
case 'number':
|
|
584
|
+
case 'boolean':
|
|
585
|
+
lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Assert hidden defaults (e.g., response_type => 'code')
|
|
592
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
593
|
+
lines.push(` $this->assertSame('${value}', $query['${key}']);`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Assert inferred client fields are present (e.g., client_id)
|
|
597
|
+
for (const key of inferred) {
|
|
598
|
+
lines.push(` $this->assertArrayHasKey('${key}', $query);`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
502
602
|
/**
|
|
503
603
|
* Emit body field assertions for POST/PUT/PATCH operations.
|
|
504
604
|
* Only asserts primitive required fields (strings, numbers, booleans).
|
package/src/php/type-map.ts
CHANGED
|
@@ -15,7 +15,14 @@ export function mapTypeRef(ref: TypeRef, opts?: { qualified?: boolean }): string
|
|
|
15
15
|
enum: (r) => `${prefix}${enumClassName(r.name)}`,
|
|
16
16
|
union: (r, variants) => joinUnionVariants(r, variants),
|
|
17
17
|
nullable: (_ref, inner) => `?${inner}`,
|
|
18
|
-
literal: (r) =>
|
|
18
|
+
literal: (r) =>
|
|
19
|
+
typeof r.value === 'number'
|
|
20
|
+
? Number.isInteger(r.value)
|
|
21
|
+
? 'int'
|
|
22
|
+
: 'float'
|
|
23
|
+
: typeof r.value === 'boolean'
|
|
24
|
+
? 'bool'
|
|
25
|
+
: 'string',
|
|
19
26
|
map: (_ref, _value) => 'array',
|
|
20
27
|
});
|
|
21
28
|
}
|
|
@@ -34,7 +41,14 @@ export function mapTypeRefForPHPDoc(ref: TypeRef, opts?: { prefix?: string }): s
|
|
|
34
41
|
enum: (r) => `${prefix}${enumClassName(r.name)}`,
|
|
35
42
|
union: (r, variants) => joinDocUnionVariants(r, variants),
|
|
36
43
|
nullable: (_ref, inner) => `${inner}|null`,
|
|
37
|
-
literal: (r) =>
|
|
44
|
+
literal: (r) =>
|
|
45
|
+
typeof r.value === 'string'
|
|
46
|
+
? 'string'
|
|
47
|
+
: typeof r.value === 'number'
|
|
48
|
+
? 'int'
|
|
49
|
+
: typeof r.value === 'boolean'
|
|
50
|
+
? 'bool'
|
|
51
|
+
: 'string',
|
|
38
52
|
map: (_ref, value) => `array<string, ${value}>`,
|
|
39
53
|
});
|
|
40
54
|
}
|