@workos/oagen-emitters 0.15.2 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +48 -1
- package/dist/index.d.mts +51 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +852 -2
- package/dist/index.mjs.map +1 -0
- package/dist/{plugin-Xkr83G9A.mjs → plugin-CpO8rePT.mjs} +1219 -493
- package/dist/plugin-CpO8rePT.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/dotnet/naming.ts +1 -1
- package/src/go/naming.ts +1 -1
- package/src/index.ts +15 -0
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +264 -4
- 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 +39 -3
- package/src/node/tests.ts +29 -3
- package/src/node/utils.ts +140 -22
- package/src/snippets/dotnet.ts +159 -0
- package/src/snippets/go.ts +148 -0
- package/src/snippets/index.ts +8 -0
- package/src/snippets/kotlin.ts +144 -0
- package/src/snippets/php.ts +149 -0
- package/src/snippets/plugin.ts +36 -0
- package/src/snippets/python.ts +135 -0
- package/src/snippets/ruby.ts +152 -0
- package/src/snippets/rust.ts +189 -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 +464 -0
- package/test/node/utils.test.ts +157 -2
- package/test/snippets/_helpers.ts +67 -0
- package/test/snippets/dotnet.test.ts +49 -0
- package/test/snippets/go.test.ts +94 -0
- package/test/snippets/kotlin.test.ts +53 -0
- package/test/snippets/php.test.ts +48 -0
- package/test/snippets/python.test.ts +73 -0
- package/test/snippets/ruby.test.ts +339 -0
- package/test/snippets/rust.test.ts +76 -0
- package/dist/plugin-Xkr83G9A.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.
|
|
@@ -933,6 +950,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
933
950
|
|
|
934
951
|
const importedTypeNames = new Set<string>();
|
|
935
952
|
for (const optionType of optionObjectTypes) {
|
|
953
|
+
// Inline object-literal types from the baseline surface are rendered
|
|
954
|
+
// inline in the method signature — they have no importable name or file.
|
|
955
|
+
if (!isValidTypeIdentifier(optionType)) continue;
|
|
936
956
|
if (importedTypeNames.has(optionType)) continue;
|
|
937
957
|
importedTypeNames.add(optionType);
|
|
938
958
|
const sourceFile = baselineTypeSourceFile(ctx, optionType);
|
|
@@ -1675,7 +1695,17 @@ function renderOptionsObjectMethod(
|
|
|
1675
1695
|
const wireType = wireInterfaceName(itemType);
|
|
1676
1696
|
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
1677
1697
|
const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
1678
|
-
|
|
1698
|
+
// When path params are destructured out of the options object, the value
|
|
1699
|
+
// passed to AutoPaginatable (and to fetchAndDeserialize) is the REST
|
|
1700
|
+
// object — typed Omit<FullOptions, pathFields> — not the full options
|
|
1701
|
+
// interface. Declaring the full interface as the second type argument
|
|
1702
|
+
// fails TS2322 because the rest object lacks the required path-param
|
|
1703
|
+
// fields, so parameterize over the rest type actually passed.
|
|
1704
|
+
const restOptionsType =
|
|
1705
|
+
pathBindings.length > 0
|
|
1706
|
+
? `Omit<${optionParam.type}, ${pathBindings.map((b) => `'${b}'`).join(' | ')}>`
|
|
1707
|
+
: optionParam.type;
|
|
1708
|
+
const paginationType = needsWireSerializer ? 'PaginationOptions' : restOptionsType;
|
|
1679
1709
|
const returnType = needsWireSerializer
|
|
1680
1710
|
? `Promise<AutoPaginatable<${itemType}, ${paginationType}>>`
|
|
1681
1711
|
: (preferredBaselineReturnType(ctx, baselineMethod?.returnType) ??
|
|
@@ -1793,7 +1823,13 @@ function renderOptionsObjectMethod(
|
|
|
1793
1823
|
|
|
1794
1824
|
lines.push(` async ${method}(${renderOptionsParam(optionParam)}): ${methodReturnType} {`);
|
|
1795
1825
|
renderOptionsObjectDestructure(lines, pathBindings);
|
|
1796
|
-
|
|
1826
|
+
// POST/PUT/PATCH require the entity argument even without a request body —
|
|
1827
|
+
// the client signature is `post(path, entity, options?)`, so a body-less
|
|
1828
|
+
// call must still pass `{}` or the generated code fails with TS2554.
|
|
1829
|
+
const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
|
|
1830
|
+
lines.push(
|
|
1831
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${emptyBodyArg}${queryOptionsArg});`,
|
|
1832
|
+
);
|
|
1797
1833
|
if (override?.returnExpression) {
|
|
1798
1834
|
lines.push(` const result = ${returnExpr};`);
|
|
1799
1835
|
lines.push(` return ${override.returnExpression};`);
|
package/src/node/tests.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ApiSpec,
|
|
5
|
+
Service,
|
|
6
|
+
Operation,
|
|
7
|
+
Model,
|
|
8
|
+
TypeRef,
|
|
9
|
+
Parameter,
|
|
10
|
+
EmitterContext,
|
|
11
|
+
GeneratedFile,
|
|
12
|
+
} from '@workos/oagen';
|
|
4
13
|
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
5
14
|
import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
|
|
6
15
|
import {
|
|
@@ -333,6 +342,20 @@ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined,
|
|
|
333
342
|
return 'test_id';
|
|
334
343
|
}
|
|
335
344
|
|
|
345
|
+
function queryParamTestValue(param: Parameter, modelMap?: Map<string, Model>): string {
|
|
346
|
+
if (param.example !== undefined) {
|
|
347
|
+
if (Array.isArray(param.example)) {
|
|
348
|
+
return `[${param.example.map((v: unknown) => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ')}]`;
|
|
349
|
+
}
|
|
350
|
+
const isDateTime = param.type.kind === 'primitive' && param.type.format === 'date-time';
|
|
351
|
+
if (isDateTime && typeof param.example === 'string') {
|
|
352
|
+
return `new Date('${param.example}')`;
|
|
353
|
+
}
|
|
354
|
+
return typeof param.example === 'string' ? `'${param.example}'` : String(param.example);
|
|
355
|
+
}
|
|
356
|
+
return fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
|
|
357
|
+
}
|
|
358
|
+
|
|
336
359
|
/** Build test arguments for all path params (handles multiple path params). */
|
|
337
360
|
function buildTestPathArgs(op: Operation): string {
|
|
338
361
|
// Detect path template variables (may be more than op.pathParams if spec is incomplete)
|
|
@@ -372,7 +395,10 @@ function renderPaginatedTest(
|
|
|
372
395
|
const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
|
|
373
396
|
const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
|
|
374
397
|
const generatedItemType = ctx ? resolveInterfaceName(itemModelName, ctx) : null;
|
|
375
|
-
const
|
|
398
|
+
const baselineHasNonPaginatableReturn = Boolean(baselineMethod?.returnType && !baselineItemType);
|
|
399
|
+
const skipFieldAssertions =
|
|
400
|
+
baselineHasNonPaginatableReturn ||
|
|
401
|
+
Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
|
|
376
402
|
|
|
377
403
|
lines.push(" it('returns paginated results', async () => {");
|
|
378
404
|
lines.push(` fetchOnce(list${itemModelName}Fixture);`);
|
|
@@ -649,7 +675,7 @@ function buildOptionsObjectTestArg(
|
|
|
649
675
|
: op.queryParams;
|
|
650
676
|
for (const param of queryParams) {
|
|
651
677
|
const localName = fieldName(param.name);
|
|
652
|
-
const value =
|
|
678
|
+
const value = queryParamTestValue(param, modelMap);
|
|
653
679
|
entries.push(`${localName}: ${value}`);
|
|
654
680
|
}
|
|
655
681
|
|
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
|
*
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { EmitterContext, ExampleBuilder, ResolvedOperation, SnippetArg, SnippetEmitter } from '@workos/oagen';
|
|
2
|
+
import { collectSnippetArgs, collectWrapperArgs } from '@workos/oagen';
|
|
3
|
+
import {
|
|
4
|
+
appendAsyncSuffix,
|
|
5
|
+
className,
|
|
6
|
+
fieldName,
|
|
7
|
+
methodName,
|
|
8
|
+
trimMountedResourceFromMethod,
|
|
9
|
+
} from '../dotnet/naming.js';
|
|
10
|
+
|
|
11
|
+
const INDENT = ' ';
|
|
12
|
+
|
|
13
|
+
export const dotnetSnippetEmitter: SnippetEmitter = {
|
|
14
|
+
language: 'dotnet',
|
|
15
|
+
fileExtension: 'cs',
|
|
16
|
+
|
|
17
|
+
renderOperation(resolved, ctx, examples) {
|
|
18
|
+
if (resolved.urlBuilder) return null;
|
|
19
|
+
|
|
20
|
+
// Mirror the .NET SDK's naming pipeline directly from the resolved op:
|
|
21
|
+
// PascalCase → trim mount-target resource → append Async. We avoid
|
|
22
|
+
// calling back into resolveMethodName so the snippet emitter does not
|
|
23
|
+
// re-validate the whole resolved-ops set on every invocation.
|
|
24
|
+
const rawMethodName =
|
|
25
|
+
resolved.wrappers && resolved.wrappers.length > 0 ? resolved.wrappers[0]!.name : resolved.methodName;
|
|
26
|
+
const stem = trimMountedResourceFromMethod(methodName(rawMethodName), resolved.mountOn);
|
|
27
|
+
const method = appendAsyncSuffix(stem);
|
|
28
|
+
|
|
29
|
+
return renderCall(resolved, ctx, examples, method);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function renderCall(
|
|
34
|
+
resolved: ResolvedOperation,
|
|
35
|
+
ctx: EmitterContext,
|
|
36
|
+
examples: ExampleBuilder,
|
|
37
|
+
method: string,
|
|
38
|
+
): string {
|
|
39
|
+
const accessor = className(resolved.mountOn);
|
|
40
|
+
// Options class follows the `{Resource}{Method}Options` convention used by
|
|
41
|
+
// the SDK (e.g. OrganizationsCreateOptions). The method here already had its
|
|
42
|
+
// resource prefix trimmed and Async suffix appended, so reconstruct.
|
|
43
|
+
const stem = method.replace(/Async$/, '');
|
|
44
|
+
const optsType = `${accessor}${stem}Options`;
|
|
45
|
+
|
|
46
|
+
let args: SnippetArg[];
|
|
47
|
+
let pathArgs: SnippetArg[];
|
|
48
|
+
let optionsArgs: SnippetArg[];
|
|
49
|
+
|
|
50
|
+
if (resolved.wrappers && resolved.wrappers.length > 0) {
|
|
51
|
+
args = collectWrapperArgs(resolved.wrappers[0]!, ctx, examples);
|
|
52
|
+
pathArgs = [];
|
|
53
|
+
optionsArgs = args;
|
|
54
|
+
} else {
|
|
55
|
+
const collected = collectSnippetArgs(resolved, ctx, examples);
|
|
56
|
+
args = collected.args;
|
|
57
|
+
pathArgs = args.filter((a) => a.source === 'path');
|
|
58
|
+
optionsArgs = args.filter((a) => a.source !== 'path');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
lines.push('using WorkOS;');
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push('var client = new WorkOSClient(new WorkOSOptions');
|
|
65
|
+
lines.push('{');
|
|
66
|
+
lines.push(`${INDENT}ApiKey = "sk_example_123456789",`);
|
|
67
|
+
lines.push(`${INDENT}ClientId = "client_123456789",`);
|
|
68
|
+
lines.push('});');
|
|
69
|
+
lines.push('');
|
|
70
|
+
|
|
71
|
+
const callParts: string[] = [];
|
|
72
|
+
for (const p of pathArgs) callParts.push(renderValue(p.value));
|
|
73
|
+
if (optionsArgs.length > 0) callParts.push(renderOptions(optsType, optionsArgs));
|
|
74
|
+
|
|
75
|
+
const lhs = `await client.${accessor}.${method}`;
|
|
76
|
+
if (callParts.length === 0) {
|
|
77
|
+
lines.push(`${lhs}();`);
|
|
78
|
+
} else if (callParts.length === 1) {
|
|
79
|
+
lines.push(`${lhs}(${indentContinuationLines(callParts[0]!, INDENT)});`);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(`${lhs}(`);
|
|
82
|
+
for (let i = 0; i < callParts.length; i++) {
|
|
83
|
+
const trailing = i < callParts.length - 1 ? ',' : '';
|
|
84
|
+
lines.push(`${INDENT}${indentContinuationLines(callParts[i]!, INDENT)}${trailing}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(');');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderOptions(typeName: string, args: SnippetArg[]): string {
|
|
93
|
+
const lines: string[] = [`new ${typeName}`, '{'];
|
|
94
|
+
for (let i = 0; i < args.length; i++) {
|
|
95
|
+
const a = args[i]!;
|
|
96
|
+
const field = fieldName(a.wireName);
|
|
97
|
+
const value = renderValue(a.value);
|
|
98
|
+
const trailing = i < args.length - 1 ? ',' : ',';
|
|
99
|
+
lines.push(`${INDENT}${field} = ${indentContinuationLines(value, INDENT)}${trailing}`);
|
|
100
|
+
}
|
|
101
|
+
lines.push('}');
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// C# literal rendering
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function renderValue(value: unknown): string {
|
|
110
|
+
if (value === null || value === undefined) return 'null';
|
|
111
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
112
|
+
if (typeof value === 'number') return String(value);
|
|
113
|
+
if (typeof value === 'string') return csharpString(value);
|
|
114
|
+
if (Array.isArray(value)) return renderArray(value);
|
|
115
|
+
if (typeof value === 'object') return renderDict(value as Record<string, unknown>);
|
|
116
|
+
return 'null';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function csharpString(s: string): string {
|
|
120
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderArray(items: unknown[]): string {
|
|
124
|
+
if (items.length === 0) return 'new[] { }';
|
|
125
|
+
const rendered = items.map((v) => renderValue(v));
|
|
126
|
+
const oneline = `new[] { ${rendered.join(', ')} }`;
|
|
127
|
+
if (oneline.length <= 80 && rendered.every((r) => !r.includes('\n'))) return oneline;
|
|
128
|
+
const lines: string[] = ['new[]', '{'];
|
|
129
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
130
|
+
const trailing = i < rendered.length - 1 ? ',' : ',';
|
|
131
|
+
lines.push(`${INDENT}${indentContinuationLines(rendered[i]!, INDENT)}${trailing}`);
|
|
132
|
+
}
|
|
133
|
+
lines.push('}');
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderDict(obj: Record<string, unknown>): string {
|
|
138
|
+
// Plain dictionary literal — the dotnet SDK exposes models, but the snippet
|
|
139
|
+
// doesn't know which model class corresponds to each nested object. Emit a
|
|
140
|
+
// Dictionary<string, object> initializer that the SDK serializer can handle
|
|
141
|
+
// or a developer can replace with the typed model.
|
|
142
|
+
const entries = Object.entries(obj);
|
|
143
|
+
if (entries.length === 0) return 'new Dictionary<string, object>()';
|
|
144
|
+
const rendered = entries.map(([k, v]) => ({ key: k, value: renderValue(v) }));
|
|
145
|
+
const lines: string[] = ['new Dictionary<string, object>', '{'];
|
|
146
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
147
|
+
const e = rendered[i]!;
|
|
148
|
+
const trailing = i < rendered.length - 1 ? ',' : ',';
|
|
149
|
+
lines.push(`${INDENT}{ "${e.key}", ${indentContinuationLines(e.value, INDENT)} }${trailing}`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('}');
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function indentContinuationLines(s: string, indent: string): string {
|
|
156
|
+
if (!s.includes('\n')) return s;
|
|
157
|
+
const lines = s.split('\n');
|
|
158
|
+
return lines.map((line, i) => (i === 0 ? line : `${indent}${line}`)).join('\n');
|
|
159
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { EmitterContext, ExampleBuilder, ResolvedOperation, SnippetArg, SnippetEmitter } from '@workos/oagen';
|
|
2
|
+
import { collectSnippetArgs, collectWrapperArgs } from '@workos/oagen';
|
|
3
|
+
import { className, fieldName, methodName, trimMountedResourceFromMethod } from '../go/naming.js';
|
|
4
|
+
|
|
5
|
+
const INDENT = '\t';
|
|
6
|
+
|
|
7
|
+
export const goSnippetEmitter: SnippetEmitter = {
|
|
8
|
+
language: 'go',
|
|
9
|
+
fileExtension: 'go',
|
|
10
|
+
|
|
11
|
+
renderOperation(resolved, ctx, examples) {
|
|
12
|
+
if (resolved.urlBuilder) return null;
|
|
13
|
+
|
|
14
|
+
// Mirror the Go SDK's naming pipeline: PascalCase the resolved method
|
|
15
|
+
// name (or the wrapper name for split ops), then trim any mount-target
|
|
16
|
+
// resource words from the end so `CreateOrganization` on `Organizations`
|
|
17
|
+
// becomes `Create`. We compute these directly from the resolved op so
|
|
18
|
+
// the snippet emitter does not depend on a fresh `buildResolvedLookup`
|
|
19
|
+
// (which would re-validate the entire spec for uniqueness).
|
|
20
|
+
const rawMethodName =
|
|
21
|
+
resolved.wrappers && resolved.wrappers.length > 0 ? resolved.wrappers[0]!.name : resolved.methodName;
|
|
22
|
+
const method = trimMountedResourceFromMethod(methodName(rawMethodName), resolved.mountOn);
|
|
23
|
+
|
|
24
|
+
return renderCall(resolved, ctx, examples, method);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function renderCall(
|
|
29
|
+
resolved: ResolvedOperation,
|
|
30
|
+
ctx: EmitterContext,
|
|
31
|
+
examples: ExampleBuilder,
|
|
32
|
+
method: string,
|
|
33
|
+
): string {
|
|
34
|
+
const accessorMethod = className(resolved.mountOn);
|
|
35
|
+
const optsTypeName = `${accessorMethod}${method}Params`;
|
|
36
|
+
|
|
37
|
+
let args: SnippetArg[];
|
|
38
|
+
let pathArgs: SnippetArg[];
|
|
39
|
+
let bodyAndQueryArgs: SnippetArg[];
|
|
40
|
+
|
|
41
|
+
if (resolved.wrappers && resolved.wrappers.length > 0) {
|
|
42
|
+
args = collectWrapperArgs(resolved.wrappers[0]!, ctx, examples);
|
|
43
|
+
pathArgs = [];
|
|
44
|
+
bodyAndQueryArgs = args;
|
|
45
|
+
} else {
|
|
46
|
+
const collected = collectSnippetArgs(resolved, ctx, examples);
|
|
47
|
+
args = collected.args;
|
|
48
|
+
pathArgs = args.filter((a) => a.source === 'path');
|
|
49
|
+
bodyAndQueryArgs = args.filter((a) => a.source !== 'path');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
lines.push('package main');
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push('import (');
|
|
56
|
+
lines.push(`${INDENT}"context"`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push(`${INDENT}"github.com/workos/workos-go/v9"`);
|
|
59
|
+
lines.push(')');
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push('func main() {');
|
|
62
|
+
lines.push(`${INDENT}client := workos.NewClient("sk_example_123456789")`);
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
// Build the method call. Go SDK methods take (ctx, positional path params..., *OptsStruct).
|
|
66
|
+
const callParts: string[] = ['context.Background()'];
|
|
67
|
+
for (const p of pathArgs) {
|
|
68
|
+
callParts.push(renderValue(p.value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (bodyAndQueryArgs.length === 0) {
|
|
72
|
+
lines.push(`${INDENT}_, err := client.${accessorMethod}().${method}(${callParts.join(', ')})`);
|
|
73
|
+
} else {
|
|
74
|
+
const optsLines = buildOptsStruct(optsTypeName, bodyAndQueryArgs);
|
|
75
|
+
callParts.push(optsLines);
|
|
76
|
+
const joined = callParts.length === 2 ? callParts.join(', ') : callParts.join(', ');
|
|
77
|
+
lines.push(`${INDENT}_, err := client.${accessorMethod}().${method}(${joined})`);
|
|
78
|
+
}
|
|
79
|
+
lines.push(`${INDENT}if err != nil {`);
|
|
80
|
+
lines.push(`${INDENT}${INDENT}panic(err)`);
|
|
81
|
+
lines.push(`${INDENT}}`);
|
|
82
|
+
lines.push('}');
|
|
83
|
+
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildOptsStruct(typeName: string, args: SnippetArg[]): string {
|
|
88
|
+
const lines: string[] = [`&workos.${typeName}{`];
|
|
89
|
+
for (const a of args) {
|
|
90
|
+
const field = fieldName(a.wireName);
|
|
91
|
+
const value = renderValue(a.value);
|
|
92
|
+
const indentedValue = indentContinuationLines(value, `${INDENT}${INDENT}`);
|
|
93
|
+
lines.push(`${INDENT}${INDENT}${field}: ${indentedValue},`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(`${INDENT}}`);
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Go literal rendering
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function renderValue(value: unknown): string {
|
|
104
|
+
if (value === null || value === undefined) return 'nil';
|
|
105
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
106
|
+
if (typeof value === 'number') return String(value);
|
|
107
|
+
if (typeof value === 'string') return goString(value);
|
|
108
|
+
if (Array.isArray(value)) return renderSlice(value);
|
|
109
|
+
if (typeof value === 'object') return renderStruct(value as Record<string, unknown>);
|
|
110
|
+
return 'nil';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function goString(s: string): string {
|
|
114
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderSlice(items: unknown[]): string {
|
|
118
|
+
if (items.length === 0) return '[]any{}';
|
|
119
|
+
const rendered = items.map((v) => renderValue(v));
|
|
120
|
+
const oneline = `[]any{${rendered.join(', ')}}`;
|
|
121
|
+
if (oneline.length <= 80 && rendered.every((r) => !r.includes('\n'))) return oneline;
|
|
122
|
+
const lines: string[] = ['[]any{'];
|
|
123
|
+
for (const r of rendered) {
|
|
124
|
+
lines.push(`${INDENT}${indentContinuationLines(r, INDENT)},`);
|
|
125
|
+
}
|
|
126
|
+
lines.push('}');
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderStruct(obj: Record<string, unknown>): string {
|
|
131
|
+
const entries = Object.entries(obj);
|
|
132
|
+
if (entries.length === 0) return 'map[string]any{}';
|
|
133
|
+
const rendered = entries.map(([k, v]) => ({ key: k, value: renderValue(v) }));
|
|
134
|
+
const oneline = `map[string]any{${rendered.map((e) => `"${e.key}": ${e.value}`).join(', ')}}`;
|
|
135
|
+
if (oneline.length <= 80 && rendered.every((e) => !e.value.includes('\n'))) return oneline;
|
|
136
|
+
const lines: string[] = ['map[string]any{'];
|
|
137
|
+
for (const e of rendered) {
|
|
138
|
+
lines.push(`${INDENT}"${e.key}": ${indentContinuationLines(e.value, INDENT)},`);
|
|
139
|
+
}
|
|
140
|
+
lines.push('}');
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function indentContinuationLines(s: string, indent: string): string {
|
|
145
|
+
if (!s.includes('\n')) return s;
|
|
146
|
+
const lines = s.split('\n');
|
|
147
|
+
return lines.map((line, i) => (i === 0 ? line : `${indent}${line}`)).join('\n');
|
|
148
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { rubySnippetEmitter } from './ruby.js';
|
|
2
|
+
export { pythonSnippetEmitter } from './python.js';
|
|
3
|
+
export { phpSnippetEmitter } from './php.js';
|
|
4
|
+
export { goSnippetEmitter } from './go.js';
|
|
5
|
+
export { dotnetSnippetEmitter } from './dotnet.js';
|
|
6
|
+
export { kotlinSnippetEmitter } from './kotlin.js';
|
|
7
|
+
export { rustSnippetEmitter } from './rust.js';
|
|
8
|
+
export { workosSnippetsPlugin } from './plugin.js';
|