@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.
- 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/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-BLnR-FMi.mjs} +3687 -2393
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +271 -5
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +166 -3
- package/src/node/utils.ts +140 -22
- 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/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +611 -0
- package/test/node/utils.test.ts +157 -2
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/src/node/resources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
*
|
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);
|