@workos/oagen-emitters 0.16.1 → 0.17.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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-CpO8rePT.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-BLnR-FMi.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
package/src/go/index.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
 
12
12
  import { generateModels } from './models.js';
13
13
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
14
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
14
15
  import { generateEnums } from './enums.js';
15
16
  import { generateResources } from './resources.js';
16
17
  import { generateClient } from './client.js';
@@ -47,7 +48,11 @@ export const goEmitter: Emitter = {
47
48
  }
48
49
  return m;
49
50
  });
50
- return ensureTrailingNewlines(generateModels(goModels, ctx));
51
+ // Go has no sum types: a discriminated-union field (e.g. ApiKey.owner)
52
+ // renders as its first variant, dropping fields that only exist on later
53
+ // variants (organization_id on the user owner). Flatten such unions into a
54
+ // single superset struct so every variant field survives.
55
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(goModels), ctx));
51
56
  },
52
57
 
53
58
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
21
22
 
22
23
  /** Ensure every generated file ends with a trailing newline. */
23
24
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -49,7 +50,11 @@ export const kotlinEmitter: Emitter = {
49
50
  }
50
51
  return m;
51
52
  });
52
- return ensureTrailingNewlines(generateModels(kotlinModels, ctx));
53
+ // Kotlin renders a discriminated-union field as its first variant's data
54
+ // class, so fields unique to later variants (organization_id on the user
55
+ // owner) are lost. Flatten such unions into one superset data class so
56
+ // every variant field is reachable.
57
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(kotlinModels), ctx));
53
58
  },
54
59
 
55
60
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -78,8 +83,9 @@ export const kotlinEmitter: Emitter = {
78
83
 
79
84
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
80
85
  // Pass enriched models so round-trip tests see the full field set
81
- // (including optional oneOf-enriched fields) and can filter accurately.
82
- const enrichedModels = enrichModelsFromSpec(spec.models);
86
+ // (including optional oneOf-enriched fields and flattened discriminated-
87
+ // union owner fields) and can filter accurately.
88
+ const enrichedModels = flattenDiscriminatedUnionFields(enrichModelsFromSpec(spec.models));
83
89
  const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
84
90
  return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
85
91
  },
package/src/node/index.ts CHANGED
@@ -18,6 +18,7 @@ import { generateResources, resolveResourceClassName, resolveResourceDir } from
18
18
  import { generateClient } from './client.js';
19
19
  import { generateTests as generateTestFiles } from './tests.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
21
22
  import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
22
23
  import {
23
24
  buildLiveSurface,
@@ -766,7 +767,7 @@ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): Ge
766
767
  function enrichModelsForNode(models: Model[]): Model[] {
767
768
  const enriched = enrichModelsFromSpec(models);
768
769
  const originalByName = new Map(models.map((m) => [m.name, m]));
769
- return enriched.map((m) => {
770
+ const restored = enriched.map((m) => {
770
771
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
771
772
  const original = originalByName.get(m.name);
772
773
  if (original && original.fields.length > 0) {
@@ -775,6 +776,11 @@ function enrichModelsForNode(models: Model[]): Model[] {
775
776
  }
776
777
  return m;
777
778
  });
779
+ // Field-level discriminated unions (e.g. ApiKey.owner) otherwise render as
780
+ // `FirstVariant | SecondVariant`; collapse them to one flat superset
781
+ // interface so callers see every variant field (organization_id on the user
782
+ // owner) on a single type — parity with the other flat-emit languages.
783
+ return flattenDiscriminatedUnionFields(restored);
778
784
  }
779
785
 
780
786
  export const nodeEmitter: Emitter = {
@@ -933,6 +933,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
933
933
  lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
934
934
  lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
935
935
  }
936
+ // URL-builder methods serialize their query string client-side via toQueryString.
937
+ const needsQueryStringImport = plans.some((p) => {
938
+ const r = lookupResolved(p.op, resolvedLookup);
939
+ if (!r?.urlBuilder) return false;
940
+ const hidden = hiddenParamsFor(r);
941
+ return (
942
+ p.op.queryParams.some((qp) => !hidden.has(qp.name)) ||
943
+ Object.keys(getOpDefaults(r)).length > 0 ||
944
+ getOpInferFromClient(r).length > 0
945
+ );
946
+ });
947
+ if (needsQueryStringImport) {
948
+ lines.push("import { toQueryString } from '../common/utils/query-string';");
949
+ }
936
950
  const shouldEmitVaultCryptoHelpers =
937
951
  serviceClass === 'Vault' && !ignoredMethodNames.has('encrypt') && !ignoredMethodNames.has('decrypt');
938
952
  if (shouldEmitVaultCryptoHelpers) {
@@ -1333,6 +1347,16 @@ function renderMethod(
1333
1347
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
1334
1348
  const baselineClassMethod = baselineMethodFor(service, method, ctx);
1335
1349
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineClassMethod, resolvedOp);
1350
+
1351
+ // URL-builder operations (e.g. GET /sso/authorize) are spec-marked client-side
1352
+ // URL constructors: emit a synchronous method that returns the request URL as
1353
+ // a string without performing any I/O. This bypasses the HTTP method dispatch
1354
+ // and the Promise-typed JSDoc below.
1355
+ if (resolvedOp?.urlBuilder) {
1356
+ renderUrlBuilderMethod(lines, op, method, pathStr, optionInfo, specEnumNames, resolvedOp);
1357
+ return lines;
1358
+ }
1359
+
1336
1360
  const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey) ?? baselineClassMethod;
1337
1361
  let validParamNames: Set<string> | null = null;
1338
1362
  if (optionInfo) {
@@ -2209,6 +2233,109 @@ function clientFieldExpression(field: string): string {
2209
2233
  }
2210
2234
  }
2211
2235
 
2236
+ /**
2237
+ * Compose a `` `${this.workos.baseURL}<path>[?${query}]` `` template literal from
2238
+ * a path expression produced by {@link buildPathStr}. The path expression is
2239
+ * either a single-quoted static literal (`'/sso/authorize'`) or a backtick
2240
+ * template with interpolated path params; either way its inner body is spliced
2241
+ * into the URL template.
2242
+ */
2243
+ function urlTemplateLiteral(pathExpr: string, hasQuery: boolean): string {
2244
+ let inner: string;
2245
+ if ((pathExpr.startsWith('`') && pathExpr.endsWith('`')) || (pathExpr.startsWith("'") && pathExpr.endsWith("'"))) {
2246
+ inner = pathExpr.slice(1, -1);
2247
+ } else {
2248
+ inner = '${' + pathExpr + '}';
2249
+ }
2250
+ return '`${this.workos.baseURL}' + inner + (hasQuery ? '?${query}' : '') + '`';
2251
+ }
2252
+
2253
+ /**
2254
+ * Emit a URL-builder method for an operation marked with the `urlBuilder` hint
2255
+ * (OAuth-style redirect endpoints such as `/sso/authorize`). The method is
2256
+ * synchronous, returns the constructed URL as a string, and makes no HTTP call.
2257
+ * Visible query params, constant `defaults`, and `inferFromClient` fields are
2258
+ * serialized via `toQueryString` (wire-name keyed), matching the SDK's
2259
+ * hand-written URL builders.
2260
+ */
2261
+ function renderUrlBuilderMethod(
2262
+ lines: string[],
2263
+ op: Operation,
2264
+ method: string,
2265
+ pathStr: string,
2266
+ optionInfo: OptionsObjectParam | undefined,
2267
+ specEnumNames: Set<string> | undefined,
2268
+ resolvedOp: ResolvedOperation | undefined,
2269
+ ): void {
2270
+ const hidden = hiddenParamsFor(resolvedOp);
2271
+ const visibleQueryParams = op.queryParams.filter((p) => !hidden.has(p.name));
2272
+ const hasQuery =
2273
+ visibleQueryParams.length > 0 ||
2274
+ Object.keys(getOpDefaults(resolvedOp)).length > 0 ||
2275
+ getOpInferFromClient(resolvedOp).length > 0;
2276
+
2277
+ // Concise JSDoc — url builders return a string, not a Promise.
2278
+ {
2279
+ const docParts: string[] = [];
2280
+ if (op.description) docParts.push(op.description);
2281
+ docParts.push('@returns {string} The constructed URL.');
2282
+ if (op.deprecated) docParts.push('@deprecated');
2283
+ const allLines = docParts.flatMap((p) => p.split('\n'));
2284
+ if (allLines.length === 1) {
2285
+ lines.push(` /** ${allLines[0]} */`);
2286
+ } else {
2287
+ lines.push(' /**');
2288
+ for (const line of allLines) lines.push(line === '' ? ' *' : ` * ${line}`);
2289
+ lines.push(' */');
2290
+ }
2291
+ }
2292
+
2293
+ if (optionInfo) {
2294
+ // Options-object convention (matches the surrounding generated methods).
2295
+ lines.push(` ${method}(${renderOptionsParam(optionInfo)}): string {`);
2296
+ if (hasQuery) {
2297
+ const queryExpr = renderQueryExprWithOptions(visibleQueryParams, optionInfo.optional, resolvedOp);
2298
+ lines.push(` const query = toQueryString(${queryExpr});`);
2299
+ lines.push(` return ${urlTemplateLiteral(pathStr, true)};`);
2300
+ } else {
2301
+ lines.push(` return ${urlTemplateLiteral(pathStr, false)};`);
2302
+ }
2303
+ lines.push(' }');
2304
+ return;
2305
+ }
2306
+
2307
+ // Positional convention (path-only url builders, possibly with injected fields).
2308
+ // Invariant: any visible query param forces the options-object branch above
2309
+ // (operationHasOptionsInput is true whenever one exists), so a positional
2310
+ // builder never declares query params in its signature. Fail loudly if a
2311
+ // future spec breaks that — its query value would have to come from an
2312
+ // undeclared parameter. Past this guard the query is assembled solely from
2313
+ // injected defaults and inferFromClient fields.
2314
+ if (visibleQueryParams.length > 0) {
2315
+ throw new Error(
2316
+ `renderUrlBuilderMethod: positional url builder "${method}" has visible query params ` +
2317
+ `(${visibleQueryParams.map((p) => p.name).join(', ')}) but no options object; they would be ` +
2318
+ 'referenced without being declared. Expected the options-object convention.',
2319
+ );
2320
+ }
2321
+ const params = buildPathParams(op, specEnumNames);
2322
+ lines.push(` ${method}(${params}): string {`);
2323
+ if (hasQuery) {
2324
+ const queryParts: string[] = [];
2325
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
2326
+ queryParts.push(`${key}: ${tsLiteral(value)}`);
2327
+ }
2328
+ for (const field of getOpInferFromClient(resolvedOp)) {
2329
+ queryParts.push(`${field}: ${clientFieldExpression(field)}`);
2330
+ }
2331
+ lines.push(` const query = toQueryString({ ${queryParts.join(', ')} });`);
2332
+ lines.push(` return ${urlTemplateLiteral(pathStr, true)};`);
2333
+ } else {
2334
+ lines.push(` return ${urlTemplateLiteral(pathStr, false)};`);
2335
+ }
2336
+ lines.push(' }');
2337
+ }
2338
+
2212
2339
  function renderVoidMethod(
2213
2340
  lines: string[],
2214
2341
  op: Operation,
@@ -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
- const dataField = responseModel.fields.find((f) => f.name === dataPath);
897
- if (!dataField || dataField.type.kind !== 'array') return null;
898
- const listMetadataField = responseModel.fields.find((f) => f.name === 'list_metadata');
899
- if (!listMetadataField || listMetadataField.type.kind !== 'model') return null;
900
-
901
- // The response cursor lives on the list-metadata model under the same name
902
- // as the request param. Bail if it doesn't — that would mean a spec/IR
903
- // mismatch and a hand-written wrapper is safer than a broken generated one.
904
- const metadataModel = ctx.spec.models.find((m) => m.name === (listMetadataField.type as { name: string }).name);
905
- if (!metadataModel) return null;
906
- if (!metadataModel.fields.some((f) => f.name === cursorParam)) return null;
907
-
908
- // The IR's `pagination.itemType` is the response wrapper model (e.g.
909
- // `OrganizationList`), so reach into the model's `data: Vec<T>` field to
910
- // pull out the actual element type.
911
- const itemType = mapTypeRef(dataField.type.items);
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 + inferFromClient (read from the
1148
- // client at request time) + each exposed param.
1149
- sig.push(' let body = serde_json::json!({');
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`. Falls back to an empty literal
1174
- * for unknown fields so the body still compiles.
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
- return '""';
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
- const responseExpr = responseBodyExpr(op.response, modelMap, enumMap);
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 (responseKind === 'array') {
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 otherwise.
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
+ }