@workos/oagen-emitters 0.16.0 → 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.
@@ -225,7 +225,12 @@ function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string):
225
225
  const methods = new Set<string>();
226
226
  for (const block of content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)) {
227
227
  for (const line of block[0].split('\n')) {
228
- const match = line.match(/^\s{2}(?:(?:public|private|protected)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/);
228
+ // Match the method name followed by `(` or by `<` — the latter covers
229
+ // generic methods (`getProfile<T extends ...>(...)`), including
230
+ // multi-line type-parameter lists where the line ends right after `<`.
231
+ // Matching only up to the opening bracket sidesteps balancing nested
232
+ // angle brackets like `<T extends Record<string, unknown> = ...>`.
233
+ const match = line.match(/^\s{2}(?:(?:public|private|protected)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*[<(]/);
229
234
  if (match) methods.add(match[1]);
230
235
  }
231
236
  }
@@ -310,6 +315,18 @@ function renderOptionsParam(param: OptionsObjectParam): string {
310
315
  return `options${param.optional ? '?' : ''}: ${param.type}`;
311
316
  }
312
317
 
318
+ /**
319
+ * Whether a baseline-derived type reference is a plain TypeScript identifier
320
+ * that can appear in a named import. Baseline (live-SDK) method params can
321
+ * carry inline object-literal TYPES (e.g. `{ intent: GenerateLinkIntent; ... }`);
322
+ * treating that literal text as a type NAME would slugify it into a filename
323
+ * and emit a named import of a brace-expression — both invalid. Literal types
324
+ * are kept inline in the emitted signature instead and never imported.
325
+ */
326
+ function isValidTypeIdentifier(name: string): boolean {
327
+ return /^[A-Za-z_$][\w$]*$/.test(name);
328
+ }
329
+
313
330
  function autoPaginatableItemType(returnType: string | undefined): string | undefined {
314
331
  // Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
315
332
  // item types are extracted even when the hand-written code predates AutoPaginatable.
@@ -916,6 +933,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
916
933
  lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
917
934
  lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
918
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
+ }
919
950
  const shouldEmitVaultCryptoHelpers =
920
951
  serviceClass === 'Vault' && !ignoredMethodNames.has('encrypt') && !ignoredMethodNames.has('decrypt');
921
952
  if (shouldEmitVaultCryptoHelpers) {
@@ -933,6 +964,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
933
964
 
934
965
  const importedTypeNames = new Set<string>();
935
966
  for (const optionType of optionObjectTypes) {
967
+ // Inline object-literal types from the baseline surface are rendered
968
+ // inline in the method signature — they have no importable name or file.
969
+ if (!isValidTypeIdentifier(optionType)) continue;
936
970
  if (importedTypeNames.has(optionType)) continue;
937
971
  importedTypeNames.add(optionType);
938
972
  const sourceFile = baselineTypeSourceFile(ctx, optionType);
@@ -1313,6 +1347,16 @@ function renderMethod(
1313
1347
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
1314
1348
  const baselineClassMethod = baselineMethodFor(service, method, ctx);
1315
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
+
1316
1360
  const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey) ?? baselineClassMethod;
1317
1361
  let validParamNames: Set<string> | null = null;
1318
1362
  if (optionInfo) {
@@ -1675,7 +1719,17 @@ function renderOptionsObjectMethod(
1675
1719
  const wireType = wireInterfaceName(itemType);
1676
1720
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
1677
1721
  const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
1678
- const paginationType = needsWireSerializer ? 'PaginationOptions' : optionParam.type;
1722
+ // When path params are destructured out of the options object, the value
1723
+ // passed to AutoPaginatable (and to fetchAndDeserialize) is the REST
1724
+ // object — typed Omit<FullOptions, pathFields> — not the full options
1725
+ // interface. Declaring the full interface as the second type argument
1726
+ // fails TS2322 because the rest object lacks the required path-param
1727
+ // fields, so parameterize over the rest type actually passed.
1728
+ const restOptionsType =
1729
+ pathBindings.length > 0
1730
+ ? `Omit<${optionParam.type}, ${pathBindings.map((b) => `'${b}'`).join(' | ')}>`
1731
+ : optionParam.type;
1732
+ const paginationType = needsWireSerializer ? 'PaginationOptions' : restOptionsType;
1679
1733
  const returnType = needsWireSerializer
1680
1734
  ? `Promise<AutoPaginatable<${itemType}, ${paginationType}>>`
1681
1735
  : (preferredBaselineReturnType(ctx, baselineMethod?.returnType) ??
@@ -1793,7 +1847,13 @@ function renderOptionsObjectMethod(
1793
1847
 
1794
1848
  lines.push(` async ${method}(${renderOptionsParam(optionParam)}): ${methodReturnType} {`);
1795
1849
  renderOptionsObjectDestructure(lines, pathBindings);
1796
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${queryOptionsArg});`);
1850
+ // POST/PUT/PATCH require the entity argument even without a request body —
1851
+ // the client signature is `post(path, entity, options?)`, so a body-less
1852
+ // call must still pass `{}` or the generated code fails with TS2554.
1853
+ const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
1854
+ lines.push(
1855
+ ` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${emptyBodyArg}${queryOptionsArg});`,
1856
+ );
1797
1857
  if (override?.returnExpression) {
1798
1858
  lines.push(` const result = ${returnExpr};`);
1799
1859
  lines.push(` return ${override.returnExpression};`);
@@ -2173,6 +2233,109 @@ function clientFieldExpression(field: string): string {
2173
2233
  }
2174
2234
  }
2175
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
+
2176
2339
  function renderVoidMethod(
2177
2340
  lines: string[],
2178
2341
  op: Operation,
package/src/node/utils.ts CHANGED
@@ -11,12 +11,15 @@ import { mapTypeRef } from './type-map.js';
11
11
  import {
12
12
  resolveInterfaceName,
13
13
  fieldName,
14
+ fileName,
14
15
  resolveServiceDir,
15
16
  resolveMethodName,
16
17
  buildServiceNameMap,
17
18
  } from './naming.js';
18
- import { getMountTarget } from '../shared/resolved-ops.js';
19
- import { assignModelsToServices } from '@workos/oagen';
19
+ import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
20
+ import { assignModelsToServices, collectModelRefs, collectFieldDependencies } from '@workos/oagen';
21
+ import { isNodeOwnedService } from './options.js';
22
+ import { liveSurfaceHasExistingSdk, liveSurfaceHasFile } from './live-surface.js';
20
23
 
21
24
  /**
22
25
  * Compute a relative import path between two files within the generated SDK.
@@ -203,7 +206,7 @@ export function createServiceDirResolver(
203
206
  serviceNameMap: Map<string, string>;
204
207
  resolveDir: (irService: string | undefined) => string;
205
208
  } {
206
- const modelToService = assignModelsToServices(models, services, ctx.modelHints);
209
+ const modelToService = assignModelsToEmittableServices(models, services, ctx);
207
210
  const serviceNameMap = buildServiceNameMap(services, ctx);
208
211
 
209
212
  // Per-name → directory override, harvested from the live SDK surface.
@@ -212,25 +215,7 @@ export function createServiceDirResolver(
212
215
  // baseline directory string (e.g., "user-management"). The override map is
213
216
  // attached by tagging the model name with a directory prefix that bypasses
214
217
  // the IR-service lookup. Concretely we keep a side map.
215
- const baselineDirByModel = new Map<string, string>();
216
- const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
217
- const sourceFile = info?.sourceFile;
218
- if (!sourceFile) return;
219
- const m = sourceFile.match(/^src\/([^/]+)\//);
220
- if (!m) return;
221
- baselineDirByModel.set(name, m[1]);
222
- };
223
- // Both interfaces and type aliases can shadow IR model names — e.g.
224
- // `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
225
- // canonical Role definition even though the IR represents Role as a model.
226
- for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
227
- recordSource(name, info as { sourceFile?: string });
228
- }
229
- for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
230
- if (!baselineDirByModel.has(name)) {
231
- recordSource(name, info as { sourceFile?: string });
232
- }
233
- }
218
+ const baselineDirByModel = harvestBaselineDirByModel(ctx);
234
219
 
235
220
  // Override modelToService for any IR model that has a baseline sourceFile.
236
221
  // We invent a synthetic IR-service key that maps directly to the baseline
@@ -255,6 +240,139 @@ export function createServiceDirResolver(
255
240
  return { modelToService, serviceNameMap, resolveDir };
256
241
  }
257
242
 
243
+ /**
244
+ * Map baseline interface / type-alias names to the top-level `src/<dir>/`
245
+ * their `sourceFile` lives in. Both kinds can shadow IR model names — e.g.
246
+ * `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
247
+ * canonical Role definition even though the IR represents Role as a model.
248
+ */
249
+ function harvestBaselineDirByModel(ctx: EmitterContext): Map<string, string> {
250
+ const baselineDirByModel = new Map<string, string>();
251
+ const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
252
+ const sourceFile = info?.sourceFile;
253
+ if (!sourceFile) return;
254
+ const m = sourceFile.match(/^src\/([^/]+)\//);
255
+ if (!m) return;
256
+ baselineDirByModel.set(name, m[1]);
257
+ };
258
+ for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
259
+ recordSource(name, info as { sourceFile?: string });
260
+ }
261
+ for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
262
+ if (!baselineDirByModel.has(name)) {
263
+ recordSource(name, info as { sourceFile?: string });
264
+ }
265
+ }
266
+ return baselineDirByModel;
267
+ }
268
+
269
+ /**
270
+ * `assignModelsToServices` plus an owned-service correction pass.
271
+ *
272
+ * The engine's assignment is first-reference-wins: a model referenced by both
273
+ * Organizations and AuditLogs lands in `organizations/` even when only
274
+ * AuditLogs is owned this run. Against an existing SDK, `applyLiveSurface`
275
+ * then drops the model file (a non-owned, non-adopted directory cannot
276
+ * receive new paths) while the owned resource still imports
277
+ * `../organizations/interfaces/<model>.interface` — an import that resolves
278
+ * to nothing (TS2307).
279
+ *
280
+ * The correction re-homes such models to the owned service that references
281
+ * them, so emission and import planning agree on a directory that is allowed
282
+ * to receive files. It only fires when:
283
+ * 1. `ownedServices` is configured and the run targets an existing SDK;
284
+ * 2. the model has no baseline `sourceFile` (otherwise the baseline-dir
285
+ * override keeps imports pointing at the on-disk location);
286
+ * 3. the model's computed interface path does not already exist on disk;
287
+ * 4. the assigned service is neither owned itself nor sharing a directory
288
+ * with an owned service.
289
+ */
290
+ export function assignModelsToEmittableServices(
291
+ models: Model[],
292
+ services: Service[],
293
+ ctx?: EmitterContext,
294
+ ): Map<string, string> {
295
+ const modelToService = assignModelsToServices(models, services, ctx?.modelHints);
296
+ if (ctx) {
297
+ reassignOwnedServiceDependencies(modelToService, models, services, ctx);
298
+ }
299
+ return modelToService;
300
+ }
301
+
302
+ function reassignOwnedServiceDependencies(
303
+ modelToService: Map<string, string>,
304
+ models: Model[],
305
+ services: Service[],
306
+ ctx: EmitterContext,
307
+ ): void {
308
+ const serviceNameMap = buildServiceNameMap(services, ctx);
309
+ // Ownership is a property of the MOUNT target, not the IR service: an op
310
+ // can live on a non-owned IR service (e.g. Organizations, because its path
311
+ // starts with /organizations) while being mounted on an owned service via
312
+ // `resolvedOperations` (e.g. AuditLogs' retention endpoints). Walking only
313
+ // IR services misses such ops entirely, so the models they reference stay
314
+ // assigned to the unemittable IR directory and are never emitted anywhere.
315
+ // Regroup by mount target when resolved operations exist — same as
316
+ // `buildGeneratedResourceModelUsage` and `computeOwnedServiceDirs`.
317
+ const mountGroups = groupByMount(ctx);
318
+ const candidateServices: Service[] =
319
+ mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
320
+ const ownedServices = candidateServices.filter((s) => isNodeOwnedService(ctx, s.name, serviceNameMap.get(s.name)));
321
+ if (ownedServices.length === 0) return;
322
+ // Greenfield generation emits every directory; nothing is unemittable.
323
+ if (!liveSurfaceHasExistingSdk()) return;
324
+
325
+ const dirOf = (irService: string | undefined): string =>
326
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
327
+ const ownedDirs = new Set(ownedServices.map((s) => dirOf(s.name)));
328
+ const baselineDirByModel = harvestBaselineDirByModel(ctx);
329
+ const modelsByName = new Map(models.map((m) => [m.name, m]));
330
+
331
+ for (const service of ownedServices) {
332
+ for (const name of collectServiceModelClosure(service, modelsByName)) {
333
+ if (!modelsByName.has(name)) continue;
334
+ if (baselineDirByModel.has(name)) continue; // declared in the baseline → on disk
335
+ const assigned = modelToService.get(name);
336
+ if (assigned && isNodeOwnedService(ctx, assigned, serviceNameMap.get(assigned))) continue;
337
+ const assignedDir = dirOf(assigned);
338
+ if (ownedDirs.has(assignedDir)) continue;
339
+ if (liveSurfaceHasFile(`src/${assignedDir}/interfaces/${fileName(name)}.interface.ts`)) continue;
340
+ modelToService.set(name, service.name);
341
+ }
342
+ }
343
+ }
344
+
345
+ /** Model names referenced by a service's operations, expanded through fields. */
346
+ function collectServiceModelClosure(service: Service, modelsByName: Map<string, Model>): Set<string> {
347
+ const referenced = new Set<string>();
348
+ const add = (ref: TypeRef | undefined): void => {
349
+ if (!ref) return;
350
+ for (const name of collectModelRefs(ref)) referenced.add(name);
351
+ };
352
+ for (const op of service.operations) {
353
+ add(op.requestBody);
354
+ add(op.response);
355
+ for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
356
+ add(param.type);
357
+ }
358
+ if (op.pagination) add(op.pagination.itemType);
359
+ }
360
+
361
+ const queue = [...referenced];
362
+ while (queue.length > 0) {
363
+ const name = queue.pop()!;
364
+ const model = modelsByName.get(name);
365
+ if (!model) continue;
366
+ for (const dep of collectFieldDependencies(model).models) {
367
+ if (!referenced.has(dep)) {
368
+ referenced.add(dep);
369
+ queue.push(dep);
370
+ }
371
+ }
372
+ }
373
+ return referenced;
374
+ }
375
+
258
376
  /**
259
377
  * Check if baseline interface fields appear to contain generic type parameters.
260
378
  *
@@ -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);