@workos/oagen-emitters 0.3.0 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3288 -791
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +42 -12
- package/package.json +2 -2
- 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 +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/index.ts +5 -2
- package/src/go/naming.ts +5 -17
- package/src/index.ts +1 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +50 -0
- package/src/node/index.ts +1 -0
- package/src/node/resources.ts +164 -44
- package/src/node/tests.ts +37 -7
- package/src/php/client.ts +11 -3
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +81 -6
- package/src/php/tests.ts +93 -17
- package/src/php/wrappers.ts +1 -0
- package/src/python/client.ts +37 -29
- package/src/python/enums.ts +7 -7
- package/src/python/models.ts +1 -1
- package/src/python/naming.ts +2 -22
- package/src/shared/model-utils.ts +232 -15
- package/src/shared/naming-utils.ts +47 -0
- 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 +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/resources.test.ts +216 -15
- package/test/php/client.test.ts +2 -1
- package/test/php/resources.test.ts +38 -0
- package/test/php/tests.test.ts +67 -0
package/src/node/resources.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
getOpInferFromClient,
|
|
41
41
|
} from '../shared/resolved-ops.js';
|
|
42
42
|
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
|
|
43
|
+
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
@@ -192,6 +193,62 @@ function deduplicateMethodNames(
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Emit one interface file per paginated list operation that has extension
|
|
198
|
+
* query params. Placing the options interface under `interfaces/` lets the
|
|
199
|
+
* per-service barrel pick it up via `export * from './interfaces'`, which
|
|
200
|
+
* is what the root `src/index.ts` re-exports. When the interface was
|
|
201
|
+
* declared inline in the resource file, it was unreachable from the barrel
|
|
202
|
+
* and callers couldn't import the type by name from the package root.
|
|
203
|
+
*/
|
|
204
|
+
function generatePaginatedOptionsInterfaces(
|
|
205
|
+
service: Service,
|
|
206
|
+
ctx: EmitterContext,
|
|
207
|
+
specEnumNames: Set<string>,
|
|
208
|
+
): GeneratedFile[] {
|
|
209
|
+
const files: GeneratedFile[] = [];
|
|
210
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
211
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
212
|
+
|
|
213
|
+
const plans = service.operations.map((op) => ({
|
|
214
|
+
op,
|
|
215
|
+
plan: planOperation(op),
|
|
216
|
+
method: resolveMethodName(op, service, ctx),
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
for (const { op, plan, method } of plans) {
|
|
220
|
+
if (!plan.isPaginated) continue;
|
|
221
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
222
|
+
if (extraParams.length === 0) continue;
|
|
223
|
+
|
|
224
|
+
const optionsName = paginatedOptionsName(method, resolvedName);
|
|
225
|
+
const filePath = `src/${serviceDir}/interfaces/${fileName(optionsName)}.interface.ts`;
|
|
226
|
+
|
|
227
|
+
const lines: string[] = [];
|
|
228
|
+
lines.push("import type { PaginationOptions } from '../../common/interfaces/pagination-options.interface';");
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(`export interface ${optionsName} extends PaginationOptions {`);
|
|
231
|
+
for (const param of extraParams) {
|
|
232
|
+
const opt = !param.required ? '?' : '';
|
|
233
|
+
if (param.description || param.deprecated) {
|
|
234
|
+
const parts: string[] = [];
|
|
235
|
+
if (param.description) parts.push(param.description);
|
|
236
|
+
if (param.deprecated) parts.push('@deprecated');
|
|
237
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
238
|
+
}
|
|
239
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
240
|
+
}
|
|
241
|
+
lines.push('}');
|
|
242
|
+
|
|
243
|
+
files.push({
|
|
244
|
+
path: filePath,
|
|
245
|
+
content: lines.join('\n'),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return files;
|
|
250
|
+
}
|
|
251
|
+
|
|
195
252
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
196
253
|
if (services.length === 0) return [];
|
|
197
254
|
const files: GeneratedFile[] = [];
|
|
@@ -202,6 +259,8 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
202
259
|
const mergedServices: Service[] =
|
|
203
260
|
mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
|
|
204
261
|
|
|
262
|
+
const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
263
|
+
|
|
205
264
|
for (const service of mergedServices) {
|
|
206
265
|
if (isServiceCoveredByExisting(service, ctx)) {
|
|
207
266
|
// Fully covered: generate with ALL operations so the merger's docstring
|
|
@@ -233,10 +292,24 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
233
292
|
file.headerPlacement = 'skip';
|
|
234
293
|
files.push(file);
|
|
235
294
|
} else {
|
|
236
|
-
|
|
295
|
+
// Purely oagen-managed: no baseline class exists, so the file is owned
|
|
296
|
+
// end-to-end by the emitter. Remove skipIfExists so regeneration always
|
|
297
|
+
// overwrites — emitter improvements (serializer dispatch, JSDoc, etc.)
|
|
298
|
+
// must propagate without manual intervention.
|
|
299
|
+
const file = generateResourceClass(service, ctx);
|
|
300
|
+
delete file.skipIfExists;
|
|
301
|
+
files.push(file);
|
|
237
302
|
}
|
|
238
303
|
}
|
|
239
304
|
|
|
305
|
+
// Emit paginated list options interfaces AFTER the resource classes so
|
|
306
|
+
// tests and manifest ordering that index `files[0]` as the class stay
|
|
307
|
+
// stable. Placing them under `interfaces/` lets the per-service barrel
|
|
308
|
+
// pick them up automatically.
|
|
309
|
+
for (const service of mergedServices) {
|
|
310
|
+
files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
|
|
311
|
+
}
|
|
312
|
+
|
|
240
313
|
return files;
|
|
241
314
|
}
|
|
242
315
|
|
|
@@ -367,6 +440,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
367
440
|
|
|
368
441
|
// Collect response models from union split wrappers so their types and
|
|
369
442
|
// deserializers are imported alongside the primary operation models.
|
|
443
|
+
// Also collect models referenced in wrapper param signatures (e.g.,
|
|
444
|
+
// `redirect_uris: RedirectUriInput[]`) — otherwise the wrapper emits a
|
|
445
|
+
// reference to a type it never imported.
|
|
370
446
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
371
447
|
for (const { op, method } of plans) {
|
|
372
448
|
if (baselineMethodSet.has(method)) continue;
|
|
@@ -375,6 +451,11 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
375
451
|
for (const name of collectWrapperResponseModels(resolved)) {
|
|
376
452
|
responseModels.add(name);
|
|
377
453
|
}
|
|
454
|
+
for (const wrapper of resolved.wrappers ?? []) {
|
|
455
|
+
for (const { field } of resolveWrapperParams(wrapper, ctx)) {
|
|
456
|
+
if (field) collectParamTypeRefs(field.type, paramEnums, paramModels);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
378
459
|
}
|
|
379
460
|
}
|
|
380
461
|
|
|
@@ -390,6 +471,17 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
390
471
|
lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
|
|
391
472
|
}
|
|
392
473
|
|
|
474
|
+
// Paginated list options live in their own interface files so they're
|
|
475
|
+
// picked up by the per-service barrel (and flow through to the root
|
|
476
|
+
// package barrel). Import them here rather than declaring inline.
|
|
477
|
+
for (const { op, plan, method } of plans) {
|
|
478
|
+
if (!plan.isPaginated) continue;
|
|
479
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
480
|
+
if (extraParams.length === 0) continue;
|
|
481
|
+
const optionsName = paginatedOptionsName(method, resolvedName);
|
|
482
|
+
lines.push(`import type { ${optionsName} } from './interfaces/${fileName(optionsName)}.interface';`);
|
|
483
|
+
}
|
|
484
|
+
|
|
393
485
|
// Check if any operation needs PostOptions (idempotent POST or custom encoding)
|
|
394
486
|
const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
|
|
395
487
|
const hasCustomEncoding = plans.some(
|
|
@@ -493,32 +585,43 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
493
585
|
|
|
494
586
|
lines.push('');
|
|
495
587
|
|
|
496
|
-
//
|
|
497
|
-
//
|
|
588
|
+
// Per-operation helpers (wire-format option serializers etc.) emitted
|
|
589
|
+
// alongside the resource class. The options interfaces themselves live
|
|
590
|
+
// in separate files under `interfaces/` so the per-service barrel can
|
|
591
|
+
// re-export them; see the earlier import block at the top of the file.
|
|
498
592
|
for (const { op, plan, method } of plans) {
|
|
499
593
|
if (plan.isPaginated) {
|
|
500
594
|
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
501
595
|
if (extraParams.length > 0) {
|
|
502
596
|
const optionsName = paginatedOptionsName(method, resolvedName);
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
597
|
+
|
|
598
|
+
// When any extension param has a camelCase domain name that differs
|
|
599
|
+
// from its snake_case wire name, emit a serializer that translates
|
|
600
|
+
// the user-facing options into the wire query shape. Without this,
|
|
601
|
+
// the query string uses camelCase keys (e.g. `organizationId=...`)
|
|
602
|
+
// that the API silently ignores — the filter becomes a no-op.
|
|
603
|
+
const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
604
|
+
if (needsWireSerializer) {
|
|
605
|
+
const serializerName = `serialize${optionsName}`;
|
|
606
|
+
lines.push(`const ${serializerName} = (options: ${optionsName}): PaginationOptions => {`);
|
|
607
|
+
// Pagination fields pass through unchanged (limit/before/after/order
|
|
608
|
+
// share spelling in both cases). Spread first so that wire-named
|
|
609
|
+
// extension fields land on top and the camelCase keys don't also
|
|
610
|
+
// leak into the query string.
|
|
611
|
+
lines.push(' const wire: Record<string, unknown> = {');
|
|
612
|
+
for (const p of PAGINATION_PARAM_NAMES) {
|
|
613
|
+
lines.push(` ${p}: options.${p},`);
|
|
517
614
|
}
|
|
518
|
-
lines.push(
|
|
615
|
+
lines.push(' };');
|
|
616
|
+
for (const param of extraParams) {
|
|
617
|
+
const camel = fieldName(param.name);
|
|
618
|
+
const snake = wireFieldName(param.name);
|
|
619
|
+
lines.push(` if (options.${camel} !== undefined) wire.${snake} = options.${camel};`);
|
|
620
|
+
}
|
|
621
|
+
lines.push(' return wire as PaginationOptions;');
|
|
622
|
+
lines.push('};');
|
|
623
|
+
lines.push('');
|
|
519
624
|
}
|
|
520
|
-
lines.push('}');
|
|
521
|
-
lines.push('');
|
|
522
625
|
}
|
|
523
626
|
} else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
|
|
524
627
|
// Non-paginated GET or void methods with query params get a typed options interface
|
|
@@ -708,7 +811,8 @@ function renderMethod(
|
|
|
708
811
|
const itemTypeName = resolveInterfaceName(itemRawName, ctx);
|
|
709
812
|
docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
|
|
710
813
|
} else if (responseModel) {
|
|
711
|
-
|
|
814
|
+
const returnTypeDoc = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
|
|
815
|
+
docParts.push(`@returns {Promise<${returnTypeDoc}>}`);
|
|
712
816
|
} else {
|
|
713
817
|
docParts.push('@returns {Promise<void>}');
|
|
714
818
|
}
|
|
@@ -811,13 +915,18 @@ function renderPaginatedMethod(
|
|
|
811
915
|
): void {
|
|
812
916
|
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
813
917
|
const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
|
|
918
|
+
// When any extension param has a camelCase/snake_case divergence, the
|
|
919
|
+
// resource file emits a `serialize<OptionsName>` helper — pass it to
|
|
920
|
+
// createPaginatedList so the wire query uses snake_case keys.
|
|
921
|
+
const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
922
|
+
const serializerArg = needsWireSerializer ? `, serialize${optionsType}` : '';
|
|
814
923
|
|
|
815
924
|
const pathParams = buildPathParams(op, specEnumNames);
|
|
816
925
|
const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
|
|
817
926
|
|
|
818
927
|
lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
|
|
819
928
|
lines.push(
|
|
820
|
-
` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
|
|
929
|
+
` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options${serializerArg});`,
|
|
821
930
|
);
|
|
822
931
|
lines.push(' }');
|
|
823
932
|
}
|
|
@@ -926,16 +1035,22 @@ function renderBodyMethod(
|
|
|
926
1035
|
const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
|
|
927
1036
|
const hasCustomEncoding = encodingOption !== '';
|
|
928
1037
|
|
|
929
|
-
|
|
1038
|
+
const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
|
|
1039
|
+
const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
|
|
1040
|
+
const returnExpr = plan.isArrayResponse
|
|
1041
|
+
? `data.map(deserialize${responseModel})`
|
|
1042
|
+
: `deserialize${responseModel}(data)`;
|
|
1043
|
+
|
|
1044
|
+
lines.push(` async ${method}(${paramsStr}): Promise<${returnType}> {`);
|
|
930
1045
|
if (plan.isIdempotentPost) {
|
|
931
1046
|
if (hasCustomEncoding) {
|
|
932
|
-
lines.push(` const { data } = await this.workos.${op.httpMethod}<${
|
|
1047
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
|
|
933
1048
|
lines.push(` ${pathStr},`);
|
|
934
1049
|
lines.push(` ${bodyExpr},`);
|
|
935
1050
|
lines.push(` { ...requestOptions${encodingOption} },`);
|
|
936
1051
|
lines.push(' );');
|
|
937
1052
|
} else {
|
|
938
|
-
lines.push(` const { data } = await this.workos.${op.httpMethod}<${
|
|
1053
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
|
|
939
1054
|
lines.push(` ${pathStr},`);
|
|
940
1055
|
lines.push(` ${bodyExpr},`);
|
|
941
1056
|
lines.push(' requestOptions,');
|
|
@@ -943,19 +1058,19 @@ function renderBodyMethod(
|
|
|
943
1058
|
}
|
|
944
1059
|
} else {
|
|
945
1060
|
if (hasCustomEncoding) {
|
|
946
|
-
lines.push(` const { data } = await this.workos.${op.httpMethod}<${
|
|
1061
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
|
|
947
1062
|
lines.push(` ${pathStr},`);
|
|
948
1063
|
lines.push(` ${bodyExpr},`);
|
|
949
1064
|
lines.push(` { ${encodingOption.slice(2)} },`);
|
|
950
1065
|
lines.push(' );');
|
|
951
1066
|
} else {
|
|
952
|
-
lines.push(` const { data } = await this.workos.${op.httpMethod}<${
|
|
1067
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
|
|
953
1068
|
lines.push(` ${pathStr},`);
|
|
954
1069
|
lines.push(` ${bodyExpr},`);
|
|
955
1070
|
lines.push(' );');
|
|
956
1071
|
}
|
|
957
1072
|
}
|
|
958
|
-
lines.push(` return
|
|
1073
|
+
lines.push(` return ${returnExpr};`);
|
|
959
1074
|
lines.push(' }');
|
|
960
1075
|
}
|
|
961
1076
|
|
|
@@ -989,7 +1104,13 @@ function renderGetMethod(
|
|
|
989
1104
|
: `options?: ${optionsType}`
|
|
990
1105
|
: params;
|
|
991
1106
|
|
|
992
|
-
|
|
1107
|
+
const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
|
|
1108
|
+
const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
|
|
1109
|
+
const returnExpr = plan.isArrayResponse
|
|
1110
|
+
? `data.map(deserialize${responseModel})`
|
|
1111
|
+
: `deserialize${responseModel}(data)`;
|
|
1112
|
+
|
|
1113
|
+
lines.push(` async ${method}(${allParams}): Promise<${returnType}> {`);
|
|
993
1114
|
if (hasQuery) {
|
|
994
1115
|
if (hasInjected) {
|
|
995
1116
|
// Build the query object with visible params, defaults, and inferred fields
|
|
@@ -1026,30 +1147,22 @@ function renderGetMethod(
|
|
|
1026
1147
|
queryParts.push(`${field}: ${clientFieldExpression(field)}`);
|
|
1027
1148
|
}
|
|
1028
1149
|
|
|
1029
|
-
lines.push(
|
|
1030
|
-
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
1031
|
-
);
|
|
1150
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
|
|
1032
1151
|
lines.push(` query: { ${queryParts.join(', ')} },`);
|
|
1033
1152
|
lines.push(' });');
|
|
1034
1153
|
} else {
|
|
1035
1154
|
const queryExpr = renderQueryExpr(visibleQueryParams);
|
|
1036
|
-
lines.push(
|
|
1037
|
-
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
1038
|
-
);
|
|
1155
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
|
|
1039
1156
|
lines.push(` query: ${queryExpr},`);
|
|
1040
1157
|
lines.push(' });');
|
|
1041
1158
|
}
|
|
1042
1159
|
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
1043
1160
|
// PUT/PATCH/POST require a body argument even when the spec has no request body
|
|
1044
|
-
lines.push(
|
|
1045
|
-
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
|
|
1046
|
-
);
|
|
1161
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {});`);
|
|
1047
1162
|
} else {
|
|
1048
|
-
lines.push(
|
|
1049
|
-
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
|
|
1050
|
-
);
|
|
1163
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr});`);
|
|
1051
1164
|
}
|
|
1052
|
-
lines.push(` return
|
|
1165
|
+
lines.push(` return ${returnExpr};`);
|
|
1053
1166
|
lines.push(' }');
|
|
1054
1167
|
}
|
|
1055
1168
|
|
|
@@ -1263,9 +1376,16 @@ function renderUnionBodySerializer(
|
|
|
1263
1376
|
const cases: string[] = [];
|
|
1264
1377
|
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
1265
1378
|
const resolved = resolveInterfaceName(modelName, ctx);
|
|
1266
|
-
|
|
1379
|
+
// Switch on a typed discriminator narrows `payload` to the variant, so the
|
|
1380
|
+
// serializer call type-checks without any casts.
|
|
1381
|
+
cases.push(`case '${value}': return serialize${resolved}(payload)`);
|
|
1267
1382
|
}
|
|
1268
|
-
|
|
1383
|
+
// Assign `payload` to `never` in the default branch to get a compile-time
|
|
1384
|
+
// exhaustiveness check — if a new variant is added to the union but not to
|
|
1385
|
+
// the switch, the build fails here. At runtime, we still throw so an
|
|
1386
|
+
// unknown discriminator slipping through via `as any` fails loudly rather
|
|
1387
|
+
// than silently forwarding camelCase to the API.
|
|
1388
|
+
return `(() => { switch (payload.${prop}) { ${cases.join('; ')}; default: { const _unknown: never = payload; throw new Error(\`Unknown ${prop}: \${(_unknown as { ${prop}?: unknown }).${prop}}\`) } } })()`;
|
|
1269
1389
|
}
|
|
1270
1390
|
|
|
1271
1391
|
/**
|
package/src/node/tests.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
servicePropertyName,
|
|
10
10
|
resolveMethodName,
|
|
11
11
|
resolveInterfaceName,
|
|
12
|
+
wireInterfaceName,
|
|
12
13
|
} from './naming.js';
|
|
13
14
|
import { generateFixtures } from './fixtures.js';
|
|
14
15
|
import { resolveResourceClassName } from './resources.js';
|
|
@@ -751,9 +752,21 @@ function buildTestPayload(
|
|
|
751
752
|
op: Operation,
|
|
752
753
|
modelMap: Map<string, Model>,
|
|
753
754
|
): { camelCaseObj: string; snakeCaseObj: string } | null {
|
|
754
|
-
if (!op.requestBody
|
|
755
|
-
|
|
756
|
-
|
|
755
|
+
if (!op.requestBody) return null;
|
|
756
|
+
|
|
757
|
+
// For discriminated unions, build a payload from the first variant so the
|
|
758
|
+
// generated test produces a value that satisfies the union type. Without
|
|
759
|
+
// this, emitted tests pass `{} as any` for union bodies — fine for older
|
|
760
|
+
// permissive runtimes, but the dispatch switch now throws on unknown
|
|
761
|
+
// discriminator values, so the tests would fail before hitting fetch.
|
|
762
|
+
let model: Model | undefined;
|
|
763
|
+
if (op.requestBody.kind === 'union') {
|
|
764
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
765
|
+
if (!firstVariant || firstVariant.kind !== 'model') return null;
|
|
766
|
+
model = modelMap.get(firstVariant.name);
|
|
767
|
+
} else if (op.requestBody.kind === 'model') {
|
|
768
|
+
model = modelMap.get(op.requestBody.name);
|
|
769
|
+
}
|
|
757
770
|
if (!model) return null;
|
|
758
771
|
|
|
759
772
|
const fields = model.fields.filter((f) => f.required);
|
|
@@ -789,8 +802,14 @@ function buildTestPayload(
|
|
|
789
802
|
* fall back to `{} as any` to bypass type checking for complex required fields.
|
|
790
803
|
*/
|
|
791
804
|
function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
|
|
792
|
-
if (!op.requestBody
|
|
793
|
-
|
|
805
|
+
if (!op.requestBody) return '{} as any';
|
|
806
|
+
let model: Model | undefined;
|
|
807
|
+
if (op.requestBody.kind === 'union') {
|
|
808
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
809
|
+
if (firstVariant && firstVariant.kind === 'model') model = modelMap.get(firstVariant.name);
|
|
810
|
+
} else if (op.requestBody.kind === 'model') {
|
|
811
|
+
model = modelMap.get(op.requestBody.name);
|
|
812
|
+
}
|
|
794
813
|
if (!model) return '{} as any';
|
|
795
814
|
const hasRequiredFields = model.fields.some((f) => f.required);
|
|
796
815
|
return hasRequiredFields ? '{} as any' : '{}';
|
|
@@ -844,6 +863,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
844
863
|
|
|
845
864
|
// Collect imports
|
|
846
865
|
const serializerImports: string[] = [];
|
|
866
|
+
const interfaceImports: string[] = [];
|
|
847
867
|
const fixtureImports: string[] = [];
|
|
848
868
|
|
|
849
869
|
for (const model of models) {
|
|
@@ -851,11 +871,14 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
851
871
|
const service = modelToService.get(model.name);
|
|
852
872
|
const modelDir = resolveDir(service);
|
|
853
873
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
874
|
+
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
854
875
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
|
|
855
876
|
|
|
856
877
|
serializerImports.push(
|
|
857
878
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
858
879
|
);
|
|
880
|
+
const wireName = wireInterfaceName(domainName);
|
|
881
|
+
interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
|
|
859
882
|
const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
860
883
|
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
861
884
|
}
|
|
@@ -863,6 +886,9 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
863
886
|
for (const imp of serializerImports) {
|
|
864
887
|
lines.push(imp);
|
|
865
888
|
}
|
|
889
|
+
for (const imp of interfaceImports) {
|
|
890
|
+
lines.push(imp);
|
|
891
|
+
}
|
|
866
892
|
for (const imp of fixtureImports) {
|
|
867
893
|
lines.push(imp);
|
|
868
894
|
}
|
|
@@ -873,9 +899,14 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
873
899
|
const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
874
900
|
const fixtureName = `${camelDomain}Fixture`;
|
|
875
901
|
|
|
902
|
+
const wireName = wireInterfaceName(domainName);
|
|
876
903
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
877
904
|
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
|
878
|
-
|
|
905
|
+
// Cast the fixture to the wire-type interface — JSON imports are inferred
|
|
906
|
+
// with primitive types (e.g., `string`) that don't satisfy literal-typed
|
|
907
|
+
// response fields (e.g., `type: "enum"`), so the deserialize call needs
|
|
908
|
+
// the explicit cast to compile.
|
|
909
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
879
910
|
lines.push(` const deserialized = deserialize${domainName}(fixture);`);
|
|
880
911
|
lines.push(` const reserialized = serialize${domainName}(deserialized);`);
|
|
881
912
|
lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
|
|
@@ -888,7 +919,6 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
888
919
|
path: testPath,
|
|
889
920
|
content: lines.join('\n'),
|
|
890
921
|
skipIfExists: true,
|
|
891
|
-
integrateTarget: false,
|
|
892
922
|
});
|
|
893
923
|
}
|
|
894
924
|
|
package/src/php/client.ts
CHANGED
|
@@ -127,10 +127,13 @@ function generateMainClient(
|
|
|
127
127
|
for (const svc of services) {
|
|
128
128
|
lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
|
|
129
129
|
}
|
|
130
|
-
// Non-spec service properties
|
|
130
|
+
// Non-spec service properties — wrapped in ignore markers so the target
|
|
131
|
+
// SDK can hand-maintain the list. The emitter provides a positional anchor.
|
|
132
|
+
lines.push(' // @oagen-ignore-start — non-spec service properties (hand-maintained)');
|
|
131
133
|
for (const a of nonSpecAccessors) {
|
|
132
134
|
lines.push(` private ?${a.className} $${a.propName} = null;`);
|
|
133
135
|
}
|
|
136
|
+
lines.push(' // @oagen-ignore-end');
|
|
134
137
|
|
|
135
138
|
lines.push('');
|
|
136
139
|
lines.push(' public function __construct(');
|
|
@@ -140,11 +143,12 @@ function generateMainClient(
|
|
|
140
143
|
lines.push(' int $timeout = 60,');
|
|
141
144
|
lines.push(' int $maxRetries = 3,');
|
|
142
145
|
lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
|
|
146
|
+
lines.push(' ?string $userAgent = null,');
|
|
143
147
|
lines.push(' ) {');
|
|
144
148
|
lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
|
|
145
149
|
lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
|
|
146
150
|
lines.push(
|
|
147
|
-
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
|
|
151
|
+
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
|
|
148
152
|
);
|
|
149
153
|
lines.push(' }');
|
|
150
154
|
|
|
@@ -157,7 +161,10 @@ function generateMainClient(
|
|
|
157
161
|
lines.push(' }');
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
// Non-spec service accessors
|
|
164
|
+
// Non-spec service accessors — wrapped in ignore markers so the target
|
|
165
|
+
// SDK can hand-maintain these. The emitter provides a positional anchor.
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(' // @oagen-ignore-start — non-spec service accessors (hand-maintained)');
|
|
161
168
|
for (const a of nonSpecAccessors) {
|
|
162
169
|
lines.push('');
|
|
163
170
|
lines.push(` public function ${a.propName}(): ${a.className}`);
|
|
@@ -165,6 +172,7 @@ function generateMainClient(
|
|
|
165
172
|
lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
|
|
166
173
|
lines.push(' }');
|
|
167
174
|
}
|
|
175
|
+
lines.push(' // @oagen-ignore-end');
|
|
168
176
|
|
|
169
177
|
lines.push('}');
|
|
170
178
|
return lines.join('\n');
|
package/src/php/naming.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
|
|
4
|
-
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
4
|
+
import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
|
|
5
5
|
|
|
6
6
|
/** Namespace grouping result (shared with client.ts). */
|
|
7
7
|
export interface NamespaceGroup {
|
|
@@ -16,22 +16,6 @@ export interface NamespaceGrouping {
|
|
|
16
16
|
namespaces: NamespaceGroup[];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Map of lowercase acronym forms to their correct casing.
|
|
21
|
-
*/
|
|
22
|
-
const ACRONYM_FIXES: [RegExp, string][] = [
|
|
23
|
-
[/Workos/g, 'WorkOS'],
|
|
24
|
-
[/Sso/g, 'SSO'],
|
|
25
|
-
[/Mfa/g, 'MFA'],
|
|
26
|
-
[/Jwt/g, 'JWT'],
|
|
27
|
-
[/Cors/g, 'CORS'],
|
|
28
|
-
[/Saml/g, 'SAML'],
|
|
29
|
-
[/Scim/g, 'SCIM'],
|
|
30
|
-
[/Rbac/g, 'RBAC'],
|
|
31
|
-
[/Oauth/g, 'OAuth'],
|
|
32
|
-
[/Oidc/g, 'OIDC'],
|
|
33
|
-
];
|
|
34
|
-
|
|
35
19
|
/**
|
|
36
20
|
* PHP reserved class names that would collide with builtins.
|
|
37
21
|
*/
|
|
@@ -99,10 +83,7 @@ export function enumClassName(name: string): string {
|
|
|
99
83
|
|
|
100
84
|
/** PascalCase class name with acronym preservation. */
|
|
101
85
|
export function className(name: string): string {
|
|
102
|
-
let result = toPascalCase(stripUrnPrefix(name));
|
|
103
|
-
for (const [pattern, replacement] of ACRONYM_FIXES) {
|
|
104
|
-
result = result.replace(pattern, replacement);
|
|
105
|
-
}
|
|
86
|
+
let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
|
|
106
87
|
if (PHP_RESERVED_CLASS_NAMES.has(result)) {
|
|
107
88
|
result += 'Model';
|
|
108
89
|
}
|