@workos/oagen-emitters 0.16.1 → 0.18.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.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CpO8rePT.mjs → plugin-DAa-HsN5.mjs} +2655 -1930
- package/dist/plugin-DAa-HsN5.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/discriminated-models.ts +197 -50
- package/src/node/index.ts +7 -1
- package/src/node/resources.ts +161 -10
- package/src/rust/resources.ts +78 -29
- package/src/rust/tests.ts +15 -4
- package/src/shared/union-flatten.ts +201 -0
- package/test/node/discriminated-pure-oneof.test.ts +108 -0
- package/test/node/resources.test.ts +147 -0
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-CpO8rePT.mjs.map +0 -1
package/src/node/resources.ts
CHANGED
|
@@ -327,6 +327,18 @@ function isValidTypeIdentifier(name: string): boolean {
|
|
|
327
327
|
return /^[A-Za-z_$][\w$]*$/.test(name);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Extract candidate named type references from a compound type expression such
|
|
332
|
+
* as `GetAccessTokenOptions & { provider: string }`. PascalCase tokens are type
|
|
333
|
+
* names worth importing; lowercase tokens are property keys or primitives and
|
|
334
|
+
* are skipped. The caller only imports the ones that resolve to a known source
|
|
335
|
+
* file, so unrecognized PascalCase tokens (e.g. `Date`, `Record`) are harmless.
|
|
336
|
+
*/
|
|
337
|
+
function extractNamedTypeRefs(typeExpr: string): string[] {
|
|
338
|
+
const matches = typeExpr.match(/\b[A-Z][A-Za-z0-9_$]*\b/g) ?? [];
|
|
339
|
+
return [...new Set(matches)];
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
331
343
|
// Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
|
|
332
344
|
// item types are extracted even when the hand-written code predates AutoPaginatable.
|
|
@@ -933,6 +945,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
933
945
|
lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
|
|
934
946
|
lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
|
|
935
947
|
}
|
|
948
|
+
// URL-builder methods serialize their query string client-side via toQueryString.
|
|
949
|
+
const needsQueryStringImport = plans.some((p) => {
|
|
950
|
+
const r = lookupResolved(p.op, resolvedLookup);
|
|
951
|
+
if (!r?.urlBuilder) return false;
|
|
952
|
+
const hidden = hiddenParamsFor(r);
|
|
953
|
+
return (
|
|
954
|
+
p.op.queryParams.some((qp) => !hidden.has(qp.name)) ||
|
|
955
|
+
Object.keys(getOpDefaults(r)).length > 0 ||
|
|
956
|
+
getOpInferFromClient(r).length > 0
|
|
957
|
+
);
|
|
958
|
+
});
|
|
959
|
+
if (needsQueryStringImport) {
|
|
960
|
+
lines.push("import { toQueryString } from '../common/utils/query-string';");
|
|
961
|
+
}
|
|
936
962
|
const shouldEmitVaultCryptoHelpers =
|
|
937
963
|
serviceClass === 'Vault' && !ignoredMethodNames.has('encrypt') && !ignoredMethodNames.has('decrypt');
|
|
938
964
|
if (shouldEmitVaultCryptoHelpers) {
|
|
@@ -950,16 +976,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
950
976
|
|
|
951
977
|
const importedTypeNames = new Set<string>();
|
|
952
978
|
for (const optionType of optionObjectTypes) {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
979
|
+
if (isValidTypeIdentifier(optionType)) {
|
|
980
|
+
if (importedTypeNames.has(optionType)) continue;
|
|
981
|
+
importedTypeNames.add(optionType);
|
|
982
|
+
const sourceFile = baselineTypeSourceFile(ctx, optionType);
|
|
983
|
+
const relPath = sourceFile
|
|
984
|
+
? relativeImport(resourcePath, sourceFile)
|
|
985
|
+
: `./interfaces/${fileName(optionType)}.interface`;
|
|
986
|
+
lines.push(`import type { ${optionType} } from '${relPath}';`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
// Compound option types (e.g. a baseline `GetAccessTokenOptions & { provider:
|
|
990
|
+
// string }`) keep their inline object literal inline, but the named type(s)
|
|
991
|
+
// they reference must still be imported. Only import names that resolve to a
|
|
992
|
+
// known source file — inline literals, primitives, and property keys have no
|
|
993
|
+
// importable source and are skipped.
|
|
994
|
+
for (const typeName of extractNamedTypeRefs(optionType)) {
|
|
995
|
+
if (importedTypeNames.has(typeName)) continue;
|
|
996
|
+
const sourceFile = baselineTypeSourceFile(ctx, typeName) ?? liveSurfaceInterfacePath(typeName);
|
|
997
|
+
if (!sourceFile) continue;
|
|
998
|
+
importedTypeNames.add(typeName);
|
|
999
|
+
lines.push(`import type { ${typeName} } from '${relativeImport(resourcePath, sourceFile)}';`);
|
|
1000
|
+
}
|
|
963
1001
|
}
|
|
964
1002
|
for (const typeName of returnTypeImports) {
|
|
965
1003
|
if (importedTypeNames.has(typeName)) continue;
|
|
@@ -1333,6 +1371,16 @@ function renderMethod(
|
|
|
1333
1371
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
1334
1372
|
const baselineClassMethod = baselineMethodFor(service, method, ctx);
|
|
1335
1373
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineClassMethod, resolvedOp);
|
|
1374
|
+
|
|
1375
|
+
// URL-builder operations (e.g. GET /sso/authorize) are spec-marked client-side
|
|
1376
|
+
// URL constructors: emit a synchronous method that returns the request URL as
|
|
1377
|
+
// a string without performing any I/O. This bypasses the HTTP method dispatch
|
|
1378
|
+
// and the Promise-typed JSDoc below.
|
|
1379
|
+
if (resolvedOp?.urlBuilder) {
|
|
1380
|
+
renderUrlBuilderMethod(lines, op, method, pathStr, optionInfo, specEnumNames, resolvedOp);
|
|
1381
|
+
return lines;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1336
1384
|
const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey) ?? baselineClassMethod;
|
|
1337
1385
|
let validParamNames: Set<string> | null = null;
|
|
1338
1386
|
if (optionInfo) {
|
|
@@ -2209,6 +2257,109 @@ function clientFieldExpression(field: string): string {
|
|
|
2209
2257
|
}
|
|
2210
2258
|
}
|
|
2211
2259
|
|
|
2260
|
+
/**
|
|
2261
|
+
* Compose a `` `${this.workos.baseURL}<path>[?${query}]` `` template literal from
|
|
2262
|
+
* a path expression produced by {@link buildPathStr}. The path expression is
|
|
2263
|
+
* either a single-quoted static literal (`'/sso/authorize'`) or a backtick
|
|
2264
|
+
* template with interpolated path params; either way its inner body is spliced
|
|
2265
|
+
* into the URL template.
|
|
2266
|
+
*/
|
|
2267
|
+
function urlTemplateLiteral(pathExpr: string, hasQuery: boolean): string {
|
|
2268
|
+
let inner: string;
|
|
2269
|
+
if ((pathExpr.startsWith('`') && pathExpr.endsWith('`')) || (pathExpr.startsWith("'") && pathExpr.endsWith("'"))) {
|
|
2270
|
+
inner = pathExpr.slice(1, -1);
|
|
2271
|
+
} else {
|
|
2272
|
+
inner = '${' + pathExpr + '}';
|
|
2273
|
+
}
|
|
2274
|
+
return '`${this.workos.baseURL}' + inner + (hasQuery ? '?${query}' : '') + '`';
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* Emit a URL-builder method for an operation marked with the `urlBuilder` hint
|
|
2279
|
+
* (OAuth-style redirect endpoints such as `/sso/authorize`). The method is
|
|
2280
|
+
* synchronous, returns the constructed URL as a string, and makes no HTTP call.
|
|
2281
|
+
* Visible query params, constant `defaults`, and `inferFromClient` fields are
|
|
2282
|
+
* serialized via `toQueryString` (wire-name keyed), matching the SDK's
|
|
2283
|
+
* hand-written URL builders.
|
|
2284
|
+
*/
|
|
2285
|
+
function renderUrlBuilderMethod(
|
|
2286
|
+
lines: string[],
|
|
2287
|
+
op: Operation,
|
|
2288
|
+
method: string,
|
|
2289
|
+
pathStr: string,
|
|
2290
|
+
optionInfo: OptionsObjectParam | undefined,
|
|
2291
|
+
specEnumNames: Set<string> | undefined,
|
|
2292
|
+
resolvedOp: ResolvedOperation | undefined,
|
|
2293
|
+
): void {
|
|
2294
|
+
const hidden = hiddenParamsFor(resolvedOp);
|
|
2295
|
+
const visibleQueryParams = op.queryParams.filter((p) => !hidden.has(p.name));
|
|
2296
|
+
const hasQuery =
|
|
2297
|
+
visibleQueryParams.length > 0 ||
|
|
2298
|
+
Object.keys(getOpDefaults(resolvedOp)).length > 0 ||
|
|
2299
|
+
getOpInferFromClient(resolvedOp).length > 0;
|
|
2300
|
+
|
|
2301
|
+
// Concise JSDoc — url builders return a string, not a Promise.
|
|
2302
|
+
{
|
|
2303
|
+
const docParts: string[] = [];
|
|
2304
|
+
if (op.description) docParts.push(op.description);
|
|
2305
|
+
docParts.push('@returns {string} The constructed URL.');
|
|
2306
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
2307
|
+
const allLines = docParts.flatMap((p) => p.split('\n'));
|
|
2308
|
+
if (allLines.length === 1) {
|
|
2309
|
+
lines.push(` /** ${allLines[0]} */`);
|
|
2310
|
+
} else {
|
|
2311
|
+
lines.push(' /**');
|
|
2312
|
+
for (const line of allLines) lines.push(line === '' ? ' *' : ` * ${line}`);
|
|
2313
|
+
lines.push(' */');
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
if (optionInfo) {
|
|
2318
|
+
// Options-object convention (matches the surrounding generated methods).
|
|
2319
|
+
lines.push(` ${method}(${renderOptionsParam(optionInfo)}): string {`);
|
|
2320
|
+
if (hasQuery) {
|
|
2321
|
+
const queryExpr = renderQueryExprWithOptions(visibleQueryParams, optionInfo.optional, resolvedOp);
|
|
2322
|
+
lines.push(` const query = toQueryString(${queryExpr});`);
|
|
2323
|
+
lines.push(` return ${urlTemplateLiteral(pathStr, true)};`);
|
|
2324
|
+
} else {
|
|
2325
|
+
lines.push(` return ${urlTemplateLiteral(pathStr, false)};`);
|
|
2326
|
+
}
|
|
2327
|
+
lines.push(' }');
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Positional convention (path-only url builders, possibly with injected fields).
|
|
2332
|
+
// Invariant: any visible query param forces the options-object branch above
|
|
2333
|
+
// (operationHasOptionsInput is true whenever one exists), so a positional
|
|
2334
|
+
// builder never declares query params in its signature. Fail loudly if a
|
|
2335
|
+
// future spec breaks that — its query value would have to come from an
|
|
2336
|
+
// undeclared parameter. Past this guard the query is assembled solely from
|
|
2337
|
+
// injected defaults and inferFromClient fields.
|
|
2338
|
+
if (visibleQueryParams.length > 0) {
|
|
2339
|
+
throw new Error(
|
|
2340
|
+
`renderUrlBuilderMethod: positional url builder "${method}" has visible query params ` +
|
|
2341
|
+
`(${visibleQueryParams.map((p) => p.name).join(', ')}) but no options object; they would be ` +
|
|
2342
|
+
'referenced without being declared. Expected the options-object convention.',
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
const params = buildPathParams(op, specEnumNames);
|
|
2346
|
+
lines.push(` ${method}(${params}): string {`);
|
|
2347
|
+
if (hasQuery) {
|
|
2348
|
+
const queryParts: string[] = [];
|
|
2349
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
2350
|
+
queryParts.push(`${key}: ${tsLiteral(value)}`);
|
|
2351
|
+
}
|
|
2352
|
+
for (const field of getOpInferFromClient(resolvedOp)) {
|
|
2353
|
+
queryParts.push(`${field}: ${clientFieldExpression(field)}`);
|
|
2354
|
+
}
|
|
2355
|
+
lines.push(` const query = toQueryString({ ${queryParts.join(', ')} });`);
|
|
2356
|
+
lines.push(` return ${urlTemplateLiteral(pathStr, true)};`);
|
|
2357
|
+
} else {
|
|
2358
|
+
lines.push(` return ${urlTemplateLiteral(pathStr, false)};`);
|
|
2359
|
+
}
|
|
2360
|
+
lines.push(' }');
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2212
2363
|
function renderVoidMethod(
|
|
2213
2364
|
lines: string[],
|
|
2214
2365
|
op: Operation,
|
package/src/rust/resources.ts
CHANGED
|
@@ -886,29 +886,40 @@ function renderAutoPagingMethod(
|
|
|
886
886
|
// need a different stream wrapper.
|
|
887
887
|
if (op.pagination.strategy !== 'cursor') return null;
|
|
888
888
|
if (resolved.urlBuilder) return null;
|
|
889
|
-
if (op.response.kind !== 'model') return null;
|
|
890
|
-
|
|
891
|
-
const responseModel = ctx.spec.models.find((m) => m.name === (op.response as { name: string }).name);
|
|
892
|
-
if (!responseModel) return null;
|
|
893
889
|
|
|
894
890
|
const cursorParam = op.pagination.param;
|
|
895
891
|
const dataPath = op.pagination.dataPath ?? 'data';
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
892
|
+
let itemType: string;
|
|
893
|
+
|
|
894
|
+
if (op.response.kind === 'model') {
|
|
895
|
+
const responseModel = ctx.spec.models.find((m) => m.name === (op.response as { name: string }).name);
|
|
896
|
+
if (!responseModel) return null;
|
|
897
|
+
|
|
898
|
+
const dataField = responseModel.fields.find((f) => f.name === dataPath);
|
|
899
|
+
if (!dataField || dataField.type.kind !== 'array') return null;
|
|
900
|
+
const listMetadataField = responseModel.fields.find((f) => f.name === 'list_metadata');
|
|
901
|
+
if (!listMetadataField || listMetadataField.type.kind !== 'model') return null;
|
|
902
|
+
|
|
903
|
+
// The response cursor lives on the list-metadata model under the same name
|
|
904
|
+
// as the request param. Bail if it doesn't — that would mean a spec/IR
|
|
905
|
+
// mismatch and a hand-written wrapper is safer than a broken generated one.
|
|
906
|
+
const metadataModel = ctx.spec.models.find((m) => m.name === (listMetadataField.type as { name: string }).name);
|
|
907
|
+
if (!metadataModel) return null;
|
|
908
|
+
if (!metadataModel.fields.some((f) => f.name === cursorParam)) return null;
|
|
909
|
+
|
|
910
|
+
// The IR's `pagination.itemType` is the response wrapper model (e.g.
|
|
911
|
+
// `OrganizationList`), so reach into the model's `data: Vec<T>` field to
|
|
912
|
+
// pull out the actual element type.
|
|
913
|
+
itemType = mapTypeRef(dataField.type.items);
|
|
914
|
+
} else if (isInlineEnvelopeList(op) && cursorParam === 'after') {
|
|
915
|
+
// Inline-envelope responses decode into `crate::pagination::Page<T>`,
|
|
916
|
+
// which declares `data` + `list_metadata.after` by construction — only
|
|
917
|
+
// the request-side cursor param needs to exist.
|
|
918
|
+
if (!op.queryParams.some((p) => p.name === cursorParam)) return null;
|
|
919
|
+
itemType = mapTypeRef((op.response as { items: TypeRef }).items);
|
|
920
|
+
} else {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
912
923
|
|
|
913
924
|
const cursorField = fieldName(cursorParam);
|
|
914
925
|
const dataAccessor = fieldName(dataPath);
|
|
@@ -1144,19 +1155,27 @@ function renderWrapperMethod(
|
|
|
1144
1155
|
|
|
1145
1156
|
sig.push(` let method = http::Method::${op.httpMethod.toUpperCase()};`);
|
|
1146
1157
|
|
|
1147
|
-
// Build the JSON body inline: defaults +
|
|
1148
|
-
// client at request time)
|
|
1149
|
-
|
|
1158
|
+
// Build the JSON body inline: defaults + each exposed param. inferFromClient
|
|
1159
|
+
// fields (read from the client at request time) are added afterwards, and
|
|
1160
|
+
// only when non-empty — a client configured without an API key (e.g. a
|
|
1161
|
+
// public client running a PKCE flow) must omit `client_secret` entirely
|
|
1162
|
+
// rather than send `""`, which the API rejects. Mirrors the Go emitter's
|
|
1163
|
+
// `omitempty` on inferred fields.
|
|
1164
|
+
const inferredFields = wrapper.inferFromClient ?? [];
|
|
1165
|
+
sig.push(` let ${inferredFields.length > 0 ? 'mut ' : ''}body = serde_json::json!({`);
|
|
1150
1166
|
for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
|
|
1151
1167
|
sig.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`);
|
|
1152
1168
|
}
|
|
1153
|
-
for (const k of wrapper.inferFromClient ?? []) {
|
|
1154
|
-
sig.push(` ${JSON.stringify(k)}: ${clientFieldExpression(k)},`);
|
|
1155
|
-
}
|
|
1156
1169
|
for (const rp of params) {
|
|
1157
1170
|
sig.push(` ${JSON.stringify(rp.paramName)}: params.${fieldName(rp.paramName)},`);
|
|
1158
1171
|
}
|
|
1159
1172
|
sig.push(' });');
|
|
1173
|
+
for (const k of inferredFields) {
|
|
1174
|
+
const expr = clientFieldExpression(k);
|
|
1175
|
+
sig.push(` if !${expr}.is_empty() {`);
|
|
1176
|
+
sig.push(` body[${JSON.stringify(k)}] = serde_json::Value::String(${expr}.to_string());`);
|
|
1177
|
+
sig.push(' }');
|
|
1178
|
+
}
|
|
1160
1179
|
|
|
1161
1180
|
sig.push(' #[derive(Serialize)]');
|
|
1162
1181
|
sig.push(' struct EmptyQuery {}');
|
|
@@ -1170,8 +1189,11 @@ function renderWrapperMethod(
|
|
|
1170
1189
|
|
|
1171
1190
|
/**
|
|
1172
1191
|
* Rust expression for reading a client-config field at request time. Mirrors
|
|
1173
|
-
* the Go emitter's `clientFieldExpression`.
|
|
1174
|
-
*
|
|
1192
|
+
* the Go emitter's `clientFieldExpression`. Throws on unknown fields rather
|
|
1193
|
+
* than falling back to an empty literal: with the `if !expr.is_empty()` guard
|
|
1194
|
+
* in `renderWrapperMethod`, an empty literal would silently drop the field from
|
|
1195
|
+
* every request body (and emit dead `if !"".is_empty()` Rust). Failing loud at
|
|
1196
|
+
* generation time surfaces a missing case instead of shipping a broken SDK.
|
|
1175
1197
|
*/
|
|
1176
1198
|
function clientFieldExpression(field: string): string {
|
|
1177
1199
|
switch (field) {
|
|
@@ -1180,7 +1202,10 @@ function clientFieldExpression(field: string): string {
|
|
|
1180
1202
|
case 'client_secret':
|
|
1181
1203
|
return 'self.client.api_key()';
|
|
1182
1204
|
default:
|
|
1183
|
-
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
`Rust emitter: no client-config accessor for inferFromClient field "${field}". ` +
|
|
1207
|
+
'Add a case to clientFieldExpression.',
|
|
1208
|
+
);
|
|
1184
1209
|
}
|
|
1185
1210
|
}
|
|
1186
1211
|
|
|
@@ -1266,9 +1291,33 @@ function methodDocLines(op: Operation): string[] {
|
|
|
1266
1291
|
|
|
1267
1292
|
function renderResponseType(op: Operation): string {
|
|
1268
1293
|
if (isEmptyResponse(op)) return '()';
|
|
1294
|
+
if (isInlineEnvelopeList(op)) {
|
|
1295
|
+
return `crate::pagination::Page<${mapTypeRef((op.response as { items: TypeRef }).items)}>`;
|
|
1296
|
+
}
|
|
1269
1297
|
return mapTypeRef(op.response!);
|
|
1270
1298
|
}
|
|
1271
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* True when the spec declared this response as an inline pagination envelope
|
|
1302
|
+
* (`{ object, data: [...], list_metadata }` without a named component). The IR
|
|
1303
|
+
* models these as a bare array plus `pagination.dataPath`, but the wire format
|
|
1304
|
+
* is still the envelope — decoding the body straight into `Vec<T>` fails, so
|
|
1305
|
+
* these ops decode into the hand-maintained `crate::pagination::Page<T>`
|
|
1306
|
+
* instead. Restricted to `data` because that's the field `Page<T>` declares.
|
|
1307
|
+
*
|
|
1308
|
+
* The `=== 'data'` is a strict equality on purpose — deliberately *not* the
|
|
1309
|
+
* `?? 'data'` fallback used elsewhere. `dataPath` is the only signal that
|
|
1310
|
+
* separates an inline envelope (decoded as `Page<T>`) from a genuine paginated
|
|
1311
|
+
* bare array (decoded as `Vec<T>`, see the `responseKind === 'array'` branch in
|
|
1312
|
+
* tests.ts). This therefore relies on the IR setting `dataPath: 'data'`
|
|
1313
|
+
* explicitly for envelope responses and leaving it unset for bare arrays. If
|
|
1314
|
+
* the IR ever omitted it for an envelope op, this would return `false` and the
|
|
1315
|
+
* op would decode into `Vec<T>` and fail — so that invariant must hold upstream.
|
|
1316
|
+
*/
|
|
1317
|
+
export function isInlineEnvelopeList(op: Operation): boolean {
|
|
1318
|
+
return op.response?.kind === 'array' && op.pagination?.dataPath === 'data';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1272
1321
|
/**
|
|
1273
1322
|
* True when the operation has no usable response schema. We treat the IR's
|
|
1274
1323
|
* `primitive: unknown` and missing-response cases the same way: the spec
|
package/src/rust/tests.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { methodName, moduleName, typeName } from './naming.js';
|
|
|
14
14
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
15
15
|
import { exampleFor, generateFixtures } from './fixtures.js';
|
|
16
16
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
17
|
+
import { isInlineEnvelopeList } from './resources.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Generate integration tests under `tests/`. Each mount group gets one
|
|
@@ -163,7 +164,11 @@ function renderRegularTest(
|
|
|
163
164
|
const m = methodName(resolved.methodName);
|
|
164
165
|
const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
|
|
165
166
|
const httpMethod = op.httpMethod.toUpperCase();
|
|
166
|
-
|
|
167
|
+
// Inline-envelope lists decode via crate::pagination::Page<T>, so the mock
|
|
168
|
+
// must serve the envelope even though the IR types the response as an array.
|
|
169
|
+
const responseExpr = isInlineEnvelopeList(op)
|
|
170
|
+
? JSON.stringify('{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}')
|
|
171
|
+
: responseBodyExpr(op.response, modelMap, enumMap);
|
|
167
172
|
const isUrlBuilder = resolved.urlBuilder === true;
|
|
168
173
|
|
|
169
174
|
const callArgs = buildCallArgs(op, resolved, crate, accessor, modelMap, enumMap).join(', ');
|
|
@@ -413,7 +418,12 @@ function emptyPageTest(op: Operation, shape: CallShape, accessor: string): strin
|
|
|
413
418
|
const responseKind = op.response.kind;
|
|
414
419
|
let body: string;
|
|
415
420
|
let dataAccessor: string;
|
|
416
|
-
if (
|
|
421
|
+
if (isInlineEnvelopeList(op)) {
|
|
422
|
+
// Inline-envelope paginated response: SDK returns crate::pagination::Page<T>,
|
|
423
|
+
// so the mock must serve the envelope and assertions go through `.data`.
|
|
424
|
+
body = '{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}';
|
|
425
|
+
dataAccessor = 'resp.data';
|
|
426
|
+
} else if (responseKind === 'array') {
|
|
417
427
|
// Bare-array paginated response: SDK returns Vec<T>.
|
|
418
428
|
body = '[]';
|
|
419
429
|
dataAccessor = 'resp';
|
|
@@ -558,9 +568,10 @@ function encodesQueryParamsTest(
|
|
|
558
568
|
/** Body expression for the encoding-test response (success, ignored). */
|
|
559
569
|
function encodingResponseExpr(op: Operation, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
|
|
560
570
|
// For paginated ops we serve an empty page so the call succeeds. Use the
|
|
561
|
-
// bare-array shape for `Vec<T>` responses, the wrapper shape
|
|
571
|
+
// bare-array shape for `Vec<T>` responses, the wrapper shape for named
|
|
572
|
+
// wrapper models and inline envelopes (decoded via Page<T>).
|
|
562
573
|
if (op.pagination) {
|
|
563
|
-
if (op.response.kind === 'array') return JSON.stringify('[]');
|
|
574
|
+
if (op.response.kind === 'array' && !isInlineEnvelopeList(op)) return JSON.stringify('[]');
|
|
564
575
|
return JSON.stringify('{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}');
|
|
565
576
|
}
|
|
566
577
|
return responseBodyExpr(op.response, modelMap, enumMap);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Model, Field, TypeRef, UnionType } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten field-level discriminated unions into a single superset model for
|
|
5
|
+
* the flat-emit languages (Go, Kotlin, Node) that have no native sum type.
|
|
6
|
+
*
|
|
7
|
+
* Background: a property like `ApiKey.owner` is a discriminated `oneOf` whose
|
|
8
|
+
* variants are inline objects — `{ type: 'organization', id }` and
|
|
9
|
+
* `{ type: 'user', id, organization_id }`. The IR represents this as a `union`
|
|
10
|
+
* TypeRef referencing two variant models (`ApiKeyOwner`, `UserApiKeyOwner`).
|
|
11
|
+
* Languages that render such a union as "the first variant" — Go's
|
|
12
|
+
* `unionResolverName`, Kotlin's `baseName` — silently drop every field that
|
|
13
|
+
* only exists on a later variant, so `organization_id` disappears for
|
|
14
|
+
* user-scoped keys. (See the SDK compat report's owner-field note.)
|
|
15
|
+
*
|
|
16
|
+
* This transform, applied only by the flat-emit emitters, merges every
|
|
17
|
+
* variant's fields into the first variant (the union's canonical model),
|
|
18
|
+
* marks any field not shared by all variants optional, widens the
|
|
19
|
+
* discriminator property to the union of its per-variant literal values, and
|
|
20
|
+
* rewrites the field to a plain model ref to that canonical model. The result
|
|
21
|
+
* is one flat struct/data class/interface that carries every variant field,
|
|
22
|
+
* with the discriminator property telling callers which variant they hold —
|
|
23
|
+
* exactly how these emitters already flatten `allOf [base, oneOf]`
|
|
24
|
+
* discriminated bases (see `enrichModelsFromSpec`).
|
|
25
|
+
*
|
|
26
|
+
* Returns a new models array; the input models are not mutated. Union-emitting
|
|
27
|
+
* languages (Python, PHP, Rust, Ruby, .NET) must NOT call this — they emit a
|
|
28
|
+
* real discriminated union and lose nothing.
|
|
29
|
+
*/
|
|
30
|
+
export function flattenDiscriminatedUnionFields(models: Model[]): Model[] {
|
|
31
|
+
const byName = new Map(models.map((m) => [m.name, m]));
|
|
32
|
+
// Canonical (first-variant) model name → its merged superset field list.
|
|
33
|
+
const mergedFieldsByCanonical = new Map<string, Field[]>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether a union is a flat-flattenable discriminated union of inline
|
|
37
|
+
* object variants. When it is, record the merged field set for its canonical
|
|
38
|
+
* model and return that model's name; otherwise return null.
|
|
39
|
+
*/
|
|
40
|
+
function planUnion(union: UnionType): string | null {
|
|
41
|
+
if (!union.discriminator) return null;
|
|
42
|
+
|
|
43
|
+
const variantNames = union.variants.map((v) => (v.kind === 'model' ? v.name : null));
|
|
44
|
+
// Require that *every* variant is a model ref (the inline-object oneOf
|
|
45
|
+
// shape). Untagged unions of primitives (e.g. AuditLogEvent actor
|
|
46
|
+
// metadata: string | number | boolean) carry no discriminator and never
|
|
47
|
+
// reach here, but guard anyway.
|
|
48
|
+
if (variantNames.length < 2 || variantNames.some((n) => n === null)) return null;
|
|
49
|
+
|
|
50
|
+
const variantModels = (variantNames as string[]).map((n) => byName.get(n));
|
|
51
|
+
// Every variant must resolve to a concrete data model — not a discriminator
|
|
52
|
+
// dispatcher (empty-field base with its own `discriminator`) and not a
|
|
53
|
+
// fieldless placeholder. This keeps event-style unions out of scope.
|
|
54
|
+
if (variantModels.some((m) => !m || (m as { discriminator?: unknown }).discriminator || m.fields.length === 0)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const canonical = (variantNames as string[])[0];
|
|
59
|
+
const merged = mergeVariantFields(variantModels as Model[], union.discriminator.property);
|
|
60
|
+
|
|
61
|
+
// The merge map is keyed by the first-variant model name. The same union
|
|
62
|
+
// referenced by several container fields re-plans to an identical merge
|
|
63
|
+
// (harmless). But two *distinct* unions that share a first variant would
|
|
64
|
+
// each want a different superset on that one model — pass 2 can apply only
|
|
65
|
+
// one, silently dropping the other's fields. Fail loudly instead; the spec
|
|
66
|
+
// must disambiguate (rename one union's leading variant).
|
|
67
|
+
const existing = mergedFieldsByCanonical.get(canonical);
|
|
68
|
+
if (existing && fieldListSignature(existing) !== fieldListSignature(merged)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`flattenDiscriminatedUnionFields: model "${canonical}" is the first variant of two distinct ` +
|
|
71
|
+
'discriminated unions that merge to different field sets. Flattening both onto one model would ' +
|
|
72
|
+
'silently drop fields; disambiguate the variants in the spec (rename the leading variant of one union).',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
mergedFieldsByCanonical.set(canonical, merged);
|
|
76
|
+
return canonical;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Rewrite a TypeRef, collapsing flattenable unions to a canonical model ref. */
|
|
80
|
+
function rewriteRef(ref: TypeRef): TypeRef {
|
|
81
|
+
switch (ref.kind) {
|
|
82
|
+
case 'union': {
|
|
83
|
+
const canonical = planUnion(ref);
|
|
84
|
+
return canonical ? { kind: 'model', name: canonical } : ref;
|
|
85
|
+
}
|
|
86
|
+
case 'nullable': {
|
|
87
|
+
// Preserve reference identity when nothing inside changed, so pass 1's
|
|
88
|
+
// `type === field.type` check doesn't flag (and rebuild) union-free fields.
|
|
89
|
+
const inner = rewriteRef(ref.inner);
|
|
90
|
+
return inner === ref.inner ? ref : { kind: 'nullable', inner };
|
|
91
|
+
}
|
|
92
|
+
case 'array': {
|
|
93
|
+
const items = rewriteRef(ref.items);
|
|
94
|
+
return items === ref.items ? ref : { kind: 'array', items };
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
return ref;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass 1: rewrite container fields, recording the merges to apply in pass 2.
|
|
102
|
+
const rewritten = models.map((model) => {
|
|
103
|
+
let changed = false;
|
|
104
|
+
const fields = model.fields.map((field) => {
|
|
105
|
+
const type = rewriteRef(field.type);
|
|
106
|
+
if (type === field.type) return field;
|
|
107
|
+
changed = true;
|
|
108
|
+
return { ...field, type };
|
|
109
|
+
});
|
|
110
|
+
return changed ? { ...model, fields } : model;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (mergedFieldsByCanonical.size === 0) return models;
|
|
114
|
+
|
|
115
|
+
// Pass 2: replace each canonical variant model with its merged superset.
|
|
116
|
+
return rewritten.map((model) => {
|
|
117
|
+
const merged = mergedFieldsByCanonical.get(model.name);
|
|
118
|
+
return merged ? { ...model, fields: merged } : model;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the merged field list for a discriminated union's variant models.
|
|
124
|
+
*
|
|
125
|
+
* - A field is required only when present-and-required in *every* variant; a
|
|
126
|
+
* field missing from some variant (e.g. the user variant's `organization_id`)
|
|
127
|
+
* becomes optional.
|
|
128
|
+
* - The discriminator property is widened to the union of its per-variant
|
|
129
|
+
* literal values (`'organization' | 'user'`) so it isn't pinned to the first
|
|
130
|
+
* variant's constant. (Flat-emit type maps collapse a single-typed literal
|
|
131
|
+
* union to a plain string, so this is a no-op for Go/Kotlin and a precise
|
|
132
|
+
* `'organization' | 'user'` for Node.)
|
|
133
|
+
* - Field order follows the first variant, then newly-seen fields from later
|
|
134
|
+
* variants.
|
|
135
|
+
*/
|
|
136
|
+
function mergeVariantFields(variants: Model[], discriminatorProp: string): Field[] {
|
|
137
|
+
const total = variants.length;
|
|
138
|
+
const order: string[] = [];
|
|
139
|
+
const defByName = new Map<string, Field>();
|
|
140
|
+
const presence = new Map<string, number>();
|
|
141
|
+
const requiredCount = new Map<string, number>();
|
|
142
|
+
|
|
143
|
+
for (const variant of variants) {
|
|
144
|
+
for (const field of variant.fields) {
|
|
145
|
+
const seen = defByName.get(field.name);
|
|
146
|
+
if (!seen) {
|
|
147
|
+
defByName.set(field.name, field);
|
|
148
|
+
order.push(field.name);
|
|
149
|
+
} else if (field.name !== discriminatorProp && !sameTypeRef(seen.type, field.type)) {
|
|
150
|
+
// Only the first-seen definition is kept, so a shared field whose type
|
|
151
|
+
// differs across variants would be merged with the wrong type for the
|
|
152
|
+
// other variants. The discriminator is exempt (it is widened below).
|
|
153
|
+
throw new Error(
|
|
154
|
+
`flattenDiscriminatedUnionFields: field "${field.name}" has conflicting types across variants ` +
|
|
155
|
+
'of a discriminated union; a flat superset model cannot represent both. Align the field type ' +
|
|
156
|
+
'across variants in the spec.',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
presence.set(field.name, (presence.get(field.name) ?? 0) + 1);
|
|
160
|
+
if (field.required) requiredCount.set(field.name, (requiredCount.get(field.name) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return order.map((name) => {
|
|
165
|
+
const def = defByName.get(name)!;
|
|
166
|
+
|
|
167
|
+
if (name === discriminatorProp) {
|
|
168
|
+
const literals = dedupeLiteralTypes(
|
|
169
|
+
variants.map((v) => v.fields.find((f) => f.name === name)?.type).filter((t): t is TypeRef => t != null),
|
|
170
|
+
);
|
|
171
|
+
const type: TypeRef = literals.length > 1 ? { kind: 'union', variants: literals } : (literals[0] ?? def.type);
|
|
172
|
+
return { ...def, type, required: presence.get(name) === total };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const required = presence.get(name) === total && requiredCount.get(name) === total;
|
|
176
|
+
return required === def.required ? def : { ...def, required };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Structural equality of two TypeRefs (IR refs have a stable, deterministic shape). */
|
|
181
|
+
function sameTypeRef(a: TypeRef, b: TypeRef): boolean {
|
|
182
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Stable signature of a merged field list, used to detect canonical collisions. */
|
|
186
|
+
function fieldListSignature(fields: Field[]): string {
|
|
187
|
+
return JSON.stringify(fields.map((f) => [f.name, f.required, f.type]));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Deduplicate literal TypeRefs by value, preserving first-seen order. */
|
|
191
|
+
function dedupeLiteralTypes(types: TypeRef[]): TypeRef[] {
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const out: TypeRef[] = [];
|
|
194
|
+
for (const t of types) {
|
|
195
|
+
const key = t.kind === 'literal' ? `lit:${String(t.value)}` : JSON.stringify(t);
|
|
196
|
+
if (seen.has(key)) continue;
|
|
197
|
+
seen.add(key);
|
|
198
|
+
out.push(t);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|