@workos/oagen-emitters 0.18.0 → 0.18.2
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 +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-DAa-HsN5.mjs → plugin-bqfwowQ3.mjs} +61 -12
- package/dist/plugin-bqfwowQ3.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/node/client.ts +2 -2
- package/src/node/enums.ts +23 -0
- package/src/node/models.ts +44 -5
- package/src/node/options.ts +23 -0
- package/src/node/resources.ts +50 -3
- package/src/node/tests.ts +5 -3
- package/test/node/enums.test.ts +138 -2
- package/test/node/resources.test.ts +101 -65
- package/dist/plugin-DAa-HsN5.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-bqfwowQ3.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
package/src/node/client.ts
CHANGED
|
@@ -179,7 +179,7 @@ function exportedNamesForSource(ctx: EmitterContext, sourceFile: string): string
|
|
|
179
179
|
*/
|
|
180
180
|
function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
181
181
|
const files: GeneratedFile[] = [];
|
|
182
|
-
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
182
|
+
const { modelToService, resolveDir, serviceNameMap } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
183
183
|
const enumToService = assignEnumsToServices(spec.enums, spec.services, spec.models, ctx);
|
|
184
184
|
|
|
185
185
|
// Group interface files by directory, tracking exported symbol names
|
|
@@ -190,7 +190,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
190
190
|
const dirSymbols = new Map<string, Set<string>>();
|
|
191
191
|
const ownedDirNames = new Set<string>();
|
|
192
192
|
for (const service of spec.services) {
|
|
193
|
-
if (isNodeOwnedService(ctx, service.name)) {
|
|
193
|
+
if (isNodeOwnedService(ctx, service.name, serviceNameMap.get(service.name))) {
|
|
194
194
|
const dir = resolveDir(service.name);
|
|
195
195
|
ownedDirNames.add(dir);
|
|
196
196
|
// Ensure owned directories always get a barrel entry, even if no
|
package/src/node/enums.ts
CHANGED
|
@@ -186,5 +186,28 @@ export function assignEnumsToServices(
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// A shared enum that already has a canonical declaration under `src/common/`
|
|
190
|
+
// must stay there. `common` is a shared module that owned-service
|
|
191
|
+
// regeneration never overwrites, so it is always the source of truth for
|
|
192
|
+
// these names. Without this, owning a service that references such an enum
|
|
193
|
+
// would emit a SECOND copy into the owned module's `interfaces/` dir (via the
|
|
194
|
+
// owned-service exception in `generateEnums`) while the `common` copy
|
|
195
|
+
// remains, and `src/index.ts` re-exports both barrels — a duplicate
|
|
196
|
+
// `export *` (TS2308). Unassigning the enum makes every consumer
|
|
197
|
+
// (`generateEnums`, model imports, barrels) resolve it to `common`.
|
|
198
|
+
if (ctx) {
|
|
199
|
+
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
200
|
+
const toUnassign: string[] = [];
|
|
201
|
+
for (const [name, service] of enumToService) {
|
|
202
|
+
if (!isNodeOwnedService(ctx, service, serviceNameMap.get(service))) continue;
|
|
203
|
+
const home =
|
|
204
|
+
(ctx.apiSurface?.enums?.[name] as any)?.sourceFile ??
|
|
205
|
+
(ctx.apiSurface?.typeAliases?.[name] as any)?.sourceFile ??
|
|
206
|
+
liveSurfaceInterfacePath(name);
|
|
207
|
+
if (home && home.startsWith('src/common/')) toUnassign.push(name);
|
|
208
|
+
}
|
|
209
|
+
for (const name of toUnassign) enumToService.delete(name);
|
|
210
|
+
}
|
|
211
|
+
|
|
189
212
|
return enumToService;
|
|
190
213
|
}
|
package/src/node/models.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
wireInterfaceName,
|
|
12
12
|
resolveMethodName,
|
|
13
13
|
isAdoptedModelName,
|
|
14
|
+
buildServiceNameMap,
|
|
14
15
|
} from './naming.js';
|
|
15
16
|
import {
|
|
16
17
|
collectFieldDependencies,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
hasDateTimeConversion,
|
|
43
44
|
} from './field-plan.js';
|
|
44
45
|
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
|
|
45
|
-
import { isNodeOwnedService } from './options.js';
|
|
46
|
+
import { isNodeOwnedService, isHandOwnedType } from './options.js';
|
|
46
47
|
import { unwrapListModel } from './fixtures.js';
|
|
47
48
|
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
48
49
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
@@ -244,6 +245,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
244
245
|
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
245
246
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
246
247
|
if (discriminatedSkip?.has(model.name)) continue;
|
|
248
|
+
// Hand-owned types are declared in a hand-written file (e.g. generics the
|
|
249
|
+
// spec cannot express). Never generate an interface for them; references
|
|
250
|
+
// route to the existing declaration via its baseline source file.
|
|
251
|
+
if (
|
|
252
|
+
isHandOwnedType(ctx, model.name) ||
|
|
253
|
+
isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
|
|
254
|
+
)
|
|
255
|
+
continue;
|
|
247
256
|
const service = modelToService.get(model.name);
|
|
248
257
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
249
258
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
|
|
@@ -342,6 +351,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
342
351
|
const crossServiceImports = new Map<string, { name: string; relPath: string }>();
|
|
343
352
|
const unresolvableNames = new Set<string>();
|
|
344
353
|
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx);
|
|
354
|
+
// Resolve mounted/owned service names (e.g. IR `Directories` -> `DirectorySync`)
|
|
355
|
+
// so owned-service enum-import planning matches `generateEnums`, which keys
|
|
356
|
+
// ownership off the resolved name (see enums.ts).
|
|
357
|
+
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
345
358
|
const resolvedEnumNames = new Map<string, string>();
|
|
346
359
|
for (const e of ctx.spec.enums) {
|
|
347
360
|
resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
|
|
@@ -375,7 +388,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
375
388
|
// even when the baseline declares the name elsewhere (usually in
|
|
376
389
|
// the very file being overwritten), so import planning must
|
|
377
390
|
// target the canonical path to agree with that emission.
|
|
378
|
-
const eEnumIsOwned = isNodeOwnedService(ctx, eService);
|
|
391
|
+
const eEnumIsOwned = isNodeOwnedService(ctx, eService, eService ? serviceNameMap.get(eService) : undefined);
|
|
379
392
|
const bSrc = eEnumIsOwned ? undefined : ((bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile);
|
|
380
393
|
const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
|
|
381
394
|
const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
@@ -453,7 +466,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
453
466
|
const baselineEnum = ctx.apiSurface?.enums?.[dep];
|
|
454
467
|
const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
|
|
455
468
|
const depService = enumToService.get(dep);
|
|
456
|
-
const depEnumIsOwned = isNodeOwnedService(
|
|
469
|
+
const depEnumIsOwned = isNodeOwnedService(
|
|
470
|
+
ctx,
|
|
471
|
+
depService,
|
|
472
|
+
depService ? serviceNameMap.get(depService) : undefined,
|
|
473
|
+
);
|
|
457
474
|
// Fall back to the live-surface declaration path: `generateEnums` skips
|
|
458
475
|
// emission when the enum is already declared elsewhere in the SDK (same
|
|
459
476
|
// fallback, see enums.ts), so the import must follow that declaration —
|
|
@@ -835,6 +852,12 @@ export function generateSerializers(
|
|
|
835
852
|
if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
|
|
836
853
|
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
837
854
|
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
855
|
+
// Hand-owned types keep their hand-written serializer (see generateModels).
|
|
856
|
+
if (
|
|
857
|
+
isHandOwnedType(ctx, model.name) ||
|
|
858
|
+
isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
|
|
859
|
+
)
|
|
860
|
+
continue;
|
|
838
861
|
const service = modelToService.get(model.name);
|
|
839
862
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
840
863
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
|
|
@@ -979,12 +1002,28 @@ export function generateSerializers(
|
|
|
979
1002
|
}
|
|
980
1003
|
const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
|
|
981
1004
|
for (const [dir, stems] of serializersByDir) {
|
|
982
|
-
if (liveRootForBarrel
|
|
1005
|
+
if (liveRootForBarrel) {
|
|
1006
|
+
const dirIsOwned = isNodeOwnedService(ctx, dir);
|
|
983
1007
|
const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
|
|
984
1008
|
try {
|
|
985
1009
|
for (const entry of fs.readdirSync(serializersDir)) {
|
|
986
1010
|
if (!entry.endsWith('.serializer.ts')) continue;
|
|
987
|
-
|
|
1011
|
+
const stem = entry.replace(/\.serializer\.ts$/, '');
|
|
1012
|
+
if (stems.has(stem)) continue;
|
|
1013
|
+
// Owned directories are otherwise fully regenerated. Only preserve a
|
|
1014
|
+
// hand-written serializer when it belongs to a hand-owned type (i.e.
|
|
1015
|
+
// exports `(de)serialize<HandOwnedType>`); stale auto-generated files
|
|
1016
|
+
// should be pruned and unrelated hand-written files must not collide
|
|
1017
|
+
// with generated exports.
|
|
1018
|
+
if (dirIsOwned) {
|
|
1019
|
+
const content = fs.readFileSync(path.join(serializersDir, entry), 'utf-8');
|
|
1020
|
+
if (/auto-generated by oagen/i.test(content.slice(0, 400))) continue;
|
|
1021
|
+
const serializerFns = [
|
|
1022
|
+
...content.matchAll(/export\s+(?:const|function)\s+((?:de)?serialize[A-Za-z0-9_]+)/g),
|
|
1023
|
+
].map((m) => m[1].replace(/^(?:de)?serialize/, ''));
|
|
1024
|
+
if (!serializerFns.some((typeName) => isHandOwnedType(ctx, typeName))) continue;
|
|
1025
|
+
}
|
|
1026
|
+
stems.add(stem);
|
|
988
1027
|
}
|
|
989
1028
|
} catch {
|
|
990
1029
|
// Directory doesn't exist yet — only this-pass serializers will appear.
|
package/src/node/options.ts
CHANGED
|
@@ -38,6 +38,17 @@ export interface NodeEmitterOptions {
|
|
|
38
38
|
* without affecting other languages.
|
|
39
39
|
*/
|
|
40
40
|
operationOverrides?: Record<string, OperationOverride>;
|
|
41
|
+
/**
|
|
42
|
+
* Type/model names whose hand-written declarations are authoritative even
|
|
43
|
+
* inside an owned service. The emitter will NOT generate an interface or
|
|
44
|
+
* serializer for these names; instead it treats them like a baseline type,
|
|
45
|
+
* routing imports and barrel exports to the existing hand-written file.
|
|
46
|
+
*
|
|
47
|
+
* Use this to keep hand-owned generics the OpenAPI spec cannot express
|
|
48
|
+
* (for example `DirectoryUserWithGroups<TCustomAttributes>`) while still
|
|
49
|
+
* letting oagen own the rest of the service.
|
|
50
|
+
*/
|
|
51
|
+
handOwnedTypes?: string[];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
|
|
@@ -67,3 +78,15 @@ export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string |
|
|
|
67
78
|
name !== undefined ? ownedLookupNames(name).some((candidate) => owned.has(normalizeServiceName(candidate))) : false,
|
|
68
79
|
);
|
|
69
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when `name` is a hand-owned type (see {@link NodeEmitterOptions.handOwnedTypes}).
|
|
84
|
+
* Hand-owned types are never generated; the emitter defers to the existing
|
|
85
|
+
* hand-written declaration and routes imports/barrel exports to it.
|
|
86
|
+
*/
|
|
87
|
+
export function isHandOwnedType(ctx: EmitterContext, name: string | undefined): boolean {
|
|
88
|
+
if (name === undefined) return false;
|
|
89
|
+
const configured = nodeOptions(ctx).handOwnedTypes;
|
|
90
|
+
if (!configured || configured.length === 0) return false;
|
|
91
|
+
return configured.includes(name);
|
|
92
|
+
}
|
package/src/node/resources.ts
CHANGED
|
@@ -289,7 +289,34 @@ function optionsObjectInfo(
|
|
|
289
289
|
resolvedOp?: ResolvedOperation,
|
|
290
290
|
): OptionsObjectParam | undefined {
|
|
291
291
|
const baseline = optionsObjectParam(baselineMethod);
|
|
292
|
-
if (baseline)
|
|
292
|
+
if (baseline) {
|
|
293
|
+
// A baseline param whose type is a pure inline object literal
|
|
294
|
+
// (e.g. `{ intent?: GenerateLinkIntent; organization: string; ... }`)
|
|
295
|
+
// cannot be referenced as a named import and routinely diverges from the
|
|
296
|
+
// spec-derived request interface that the generated serializer expects —
|
|
297
|
+
// field renames (`adminEmails` -> `itContactEmails`), widened enums
|
|
298
|
+
// (`string` vs `'GoogleSAML'`), differing optionality — which makes the
|
|
299
|
+
// generated `serialize${Model}(payload)` call fail to typecheck. When the
|
|
300
|
+
// operation owns a named request-body model, adopt that interface so the
|
|
301
|
+
// method signature, the serializer, and the request model all agree.
|
|
302
|
+
// Named baseline types (`CreateOrganizationApiKeyOptions`) and compound
|
|
303
|
+
// intersections (`X & { ... }`) are still preserved verbatim.
|
|
304
|
+
if (
|
|
305
|
+
baseline.type.trimStart().startsWith('{') &&
|
|
306
|
+
isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx))
|
|
307
|
+
) {
|
|
308
|
+
const body = extractRequestBodyType(op, ctx);
|
|
309
|
+
if (body?.kind === 'model') {
|
|
310
|
+
return {
|
|
311
|
+
name: 'options',
|
|
312
|
+
type: resolveInterfaceName(body.name, ctx),
|
|
313
|
+
optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
|
|
314
|
+
generated: false,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return baseline;
|
|
319
|
+
}
|
|
293
320
|
|
|
294
321
|
const overrideType = operationOverrideFor(ctx, op)?.optionsType;
|
|
295
322
|
if (overrideType) {
|
|
@@ -303,9 +330,26 @@ function optionsObjectInfo(
|
|
|
303
330
|
|
|
304
331
|
if (!operationHasOptionsInput(op, plan, resolvedOp)) return undefined;
|
|
305
332
|
|
|
333
|
+
const resolvedService = resolveResourceClassName(service, ctx);
|
|
334
|
+
let optionsType = methodOptionsName(method, resolvedService);
|
|
335
|
+
// A bare `${Method}Options` name (e.g. `GetGroupOptions`) can collide with a
|
|
336
|
+
// same-named baseline type owned by a DIFFERENT service (for example the
|
|
337
|
+
// Groups module's `GetGroupOptions`, shaped `{ organizationId, groupId }`).
|
|
338
|
+
// Reusing it by name would import a foreign shape. When the existing
|
|
339
|
+
// declaration lives in another service directory, qualify the name with the
|
|
340
|
+
// service (mirroring the `list` -> `${Service}ListOptions` rule) so this
|
|
341
|
+
// service generates and imports its own options type.
|
|
342
|
+
if (method !== 'list') {
|
|
343
|
+
const baselineFile = baselineTypeSourceFile(ctx, optionsType);
|
|
344
|
+
const baselineDir = baselineFile?.match(/^src\/([^/]+)\//)?.[1];
|
|
345
|
+
if (baselineDir && baselineDir !== resolveResourceDir(service, ctx)) {
|
|
346
|
+
optionsType = `${toPascalCase(resolvedService)}${toPascalCase(method)}Options`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
306
350
|
return {
|
|
307
351
|
name: 'options',
|
|
308
|
-
type:
|
|
352
|
+
type: optionsType,
|
|
309
353
|
optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
|
|
310
354
|
generated: true,
|
|
311
355
|
};
|
|
@@ -1194,7 +1238,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
1194
1238
|
const baselineMethod = baselineMethodFor(service, method, ctx);
|
|
1195
1239
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolved);
|
|
1196
1240
|
if (plan.isPaginated) {
|
|
1197
|
-
|
|
1241
|
+
// Skip deprecated query params: the typed options interface omits them
|
|
1242
|
+
// (the curated baseline drops deprecated params), so the wire serializer
|
|
1243
|
+
// must not reference a field that does not exist on the options type.
|
|
1244
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name) && !p.deprecated);
|
|
1198
1245
|
if (extraParams.length > 0) {
|
|
1199
1246
|
const optionsName = optionInfo?.type ?? paginatedOptionsName(method, resolvedName);
|
|
1200
1247
|
|
package/src/node/tests.ts
CHANGED
|
@@ -670,9 +670,11 @@ function buildOptionsObjectTestArg(
|
|
|
670
670
|
if (plan.isPaginated) {
|
|
671
671
|
entries.push("order: 'desc'");
|
|
672
672
|
}
|
|
673
|
-
const queryParams =
|
|
674
|
-
|
|
675
|
-
|
|
673
|
+
const queryParams = (
|
|
674
|
+
plan.isPaginated
|
|
675
|
+
? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
|
|
676
|
+
: op.queryParams
|
|
677
|
+
).filter((param) => !param.deprecated);
|
|
676
678
|
for (const param of queryParams) {
|
|
677
679
|
const localName = fieldName(param.name);
|
|
678
680
|
const value = queryParamTestValue(param, modelMap);
|
package/test/node/enums.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import type { EmitterContext, ApiSpec, Enum, Model } from '@workos/oagen';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Enum, Model, Service } from '@workos/oagen';
|
|
3
3
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
-
import { generateEnums } from '../../src/node/enums.js';
|
|
4
|
+
import { generateEnums, assignEnumsToServices } from '../../src/node/enums.js';
|
|
5
5
|
import { nodeEmitter } from '../../src/node/index.js';
|
|
6
6
|
import { emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
|
|
7
7
|
import * as fs from 'node:fs';
|
|
@@ -195,6 +195,142 @@ describe('assignEnumsToServices owned-service dependency reassignment', () => {
|
|
|
195
195
|
});
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
describe('assignEnumsToServices common-home unassignment under ownership', () => {
|
|
199
|
+
// Inverse of the reassignment case above: an enum referenced by an OWNED
|
|
200
|
+
// service is first-reference-assigned into that service, but its canonical
|
|
201
|
+
// declaration already lives under `src/common/`. The unassignment guard
|
|
202
|
+
// must drop it back to `common` so the owned-service exception in
|
|
203
|
+
// `generateEnums` does not emit a SECOND copy alongside the existing
|
|
204
|
+
// `common` one (a duplicate `export *` / TS2308).
|
|
205
|
+
const connectionType: Enum = {
|
|
206
|
+
name: 'ConnectionType',
|
|
207
|
+
values: [
|
|
208
|
+
{ name: 'GOOGLE_SAML', value: 'GoogleSAML' },
|
|
209
|
+
{ name: 'OKTA_SAML', value: 'OktaSAML' },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
const ssoService: Service = {
|
|
213
|
+
name: 'SSO',
|
|
214
|
+
operations: [
|
|
215
|
+
{
|
|
216
|
+
name: 'listConnections',
|
|
217
|
+
httpMethod: 'get',
|
|
218
|
+
path: '/connections',
|
|
219
|
+
pathParams: [],
|
|
220
|
+
queryParams: [
|
|
221
|
+
{
|
|
222
|
+
name: 'connectionType',
|
|
223
|
+
type: { kind: 'enum', name: 'ConnectionType', values: ['GoogleSAML', 'OktaSAML'] },
|
|
224
|
+
required: false,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
headerParams: [],
|
|
228
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
229
|
+
errors: [],
|
|
230
|
+
injectIdempotencyKey: false,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const makeCtx = (overrides: Partial<EmitterContext>): EmitterContext =>
|
|
236
|
+
({
|
|
237
|
+
...ctx,
|
|
238
|
+
spec: { ...emptySpec, enums: [connectionType], services: [ssoService] },
|
|
239
|
+
emitterOptions: { ownedServices: ['SSO'] },
|
|
240
|
+
...overrides,
|
|
241
|
+
}) as EmitterContext;
|
|
242
|
+
|
|
243
|
+
it('unassigns an owned-service enum whose apiSurface sourceFile is under src/common/', () => {
|
|
244
|
+
const ctxOwned = makeCtx({
|
|
245
|
+
apiSurface: {
|
|
246
|
+
classes: {},
|
|
247
|
+
interfaces: {},
|
|
248
|
+
typeAliases: {},
|
|
249
|
+
exports: {},
|
|
250
|
+
enums: { ConnectionType: { sourceFile: 'src/common/interfaces/connection-type.interface.ts' } },
|
|
251
|
+
},
|
|
252
|
+
} as unknown as Partial<EmitterContext>);
|
|
253
|
+
|
|
254
|
+
const map = assignEnumsToServices([connectionType], [ssoService], [], ctxOwned);
|
|
255
|
+
// Unassigned → resolves to `common`, not the owned `SSO` directory.
|
|
256
|
+
expect(map.has('ConnectionType')).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('unassigns via the typeAliases sourceFile lookup (literal-union baseline form)', () => {
|
|
260
|
+
const ctxOwned = makeCtx({
|
|
261
|
+
apiSurface: {
|
|
262
|
+
classes: {},
|
|
263
|
+
interfaces: {},
|
|
264
|
+
enums: {},
|
|
265
|
+
exports: {},
|
|
266
|
+
typeAliases: {
|
|
267
|
+
ConnectionType: {
|
|
268
|
+
value: "'GoogleSAML' | 'OktaSAML'",
|
|
269
|
+
sourceFile: 'src/common/interfaces/connection-type.interface.ts',
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
} as unknown as Partial<EmitterContext>);
|
|
274
|
+
|
|
275
|
+
const map = assignEnumsToServices([connectionType], [ssoService], [], ctxOwned);
|
|
276
|
+
expect(map.has('ConnectionType')).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('falls back to the live-surface interface path when no apiSurface entry exists', () => {
|
|
280
|
+
// Guards the `liveSurfaceInterfacePath(name)` fallback: with no apiSurface,
|
|
281
|
+
// the canonical home is only discoverable through the scanned live surface.
|
|
282
|
+
const surface = emptyLiveSurface();
|
|
283
|
+
surface.files.add('src/workos.ts');
|
|
284
|
+
surface.interfaces.set('ConnectionType', {
|
|
285
|
+
filePath: 'src/common/interfaces/connection-type.interface.ts',
|
|
286
|
+
fields: new Set(),
|
|
287
|
+
});
|
|
288
|
+
setActiveLiveSurface(surface);
|
|
289
|
+
try {
|
|
290
|
+
const map = assignEnumsToServices([connectionType], [ssoService], [], makeCtx({}));
|
|
291
|
+
expect(map.has('ConnectionType')).toBe(false);
|
|
292
|
+
} finally {
|
|
293
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('keeps an owned-service enum whose canonical home is NOT under src/common/', () => {
|
|
298
|
+
// The guard is specific to `src/common/`: an enum that genuinely belongs to
|
|
299
|
+
// the owned service must stay assigned there.
|
|
300
|
+
const ctxOwned = makeCtx({
|
|
301
|
+
apiSurface: {
|
|
302
|
+
classes: {},
|
|
303
|
+
interfaces: {},
|
|
304
|
+
typeAliases: {},
|
|
305
|
+
exports: {},
|
|
306
|
+
enums: { ConnectionType: { sourceFile: 'src/sso/interfaces/connection-type.interface.ts' } },
|
|
307
|
+
},
|
|
308
|
+
} as unknown as Partial<EmitterContext>);
|
|
309
|
+
|
|
310
|
+
const map = assignEnumsToServices([connectionType], [ssoService], [], ctxOwned);
|
|
311
|
+
expect(map.get('ConnectionType')).toBe('SSO');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('does not unassign for a non-owned service even when the home is under src/common/', () => {
|
|
315
|
+
// The unassignment only applies under ownership — a non-owned service keeps
|
|
316
|
+
// its first-reference assignment regardless of where the enum's home is.
|
|
317
|
+
const ctxNotOwned = {
|
|
318
|
+
...ctx,
|
|
319
|
+
spec: { ...emptySpec, enums: [connectionType], services: [ssoService] },
|
|
320
|
+
apiSurface: {
|
|
321
|
+
classes: {},
|
|
322
|
+
interfaces: {},
|
|
323
|
+
typeAliases: {},
|
|
324
|
+
exports: {},
|
|
325
|
+
enums: { ConnectionType: { sourceFile: 'src/common/interfaces/connection-type.interface.ts' } },
|
|
326
|
+
},
|
|
327
|
+
} as unknown as EmitterContext;
|
|
328
|
+
|
|
329
|
+
const map = assignEnumsToServices([connectionType], [ssoService], [], ctxNotOwned);
|
|
330
|
+
expect(map.get('ConnectionType')).toBe('SSO');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
198
334
|
describe('owned-service enum emission under the live-surface skip', () => {
|
|
199
335
|
function ownedDomainSpec(enums: Enum[], models: Model[]): ApiSpec {
|
|
200
336
|
return {
|
|
@@ -895,82 +895,118 @@ describe('paginated list methods and path params (AutoPaginatable typing)', () =
|
|
|
895
895
|
describe('inline object-literal baseline parameter types', () => {
|
|
896
896
|
// The hand-written workos-node AdminPortal method uses an inline object-literal
|
|
897
897
|
// parameter TYPE (`generateLink({ ... }: { intent: GenerateLinkIntent; ... })`).
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
},
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
apiSurface: {
|
|
942
|
-
classes: {
|
|
943
|
-
AdminPortal: {
|
|
944
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
945
|
-
methods: {
|
|
946
|
-
generateLink: [
|
|
947
|
-
{
|
|
948
|
-
name: 'generateLink',
|
|
949
|
-
params: [{ name: 'options', type: literalType, passingStyle: 'options_object' }],
|
|
950
|
-
returnType: 'Promise<{ link: string }>',
|
|
951
|
-
async: true,
|
|
952
|
-
},
|
|
953
|
-
],
|
|
954
|
-
},
|
|
898
|
+
// For an OWNED service, that inline literal frequently diverges from the
|
|
899
|
+
// spec-derived request interface the generated serializer expects (field
|
|
900
|
+
// renames, widened enums, differing optionality), so the emitter adopts the
|
|
901
|
+
// named request-body interface instead of preserving the literal — while
|
|
902
|
+
// still never slugifying the literal text into an interface filename or
|
|
903
|
+
// emitting a named import of a brace-expression.
|
|
904
|
+
const literalType = '{ intent: GenerateLinkIntent; organization: string; returnUrl?: string }';
|
|
905
|
+
|
|
906
|
+
const adminPortalService = (requestBody: any): Service => ({
|
|
907
|
+
name: 'AdminPortal',
|
|
908
|
+
operations: [
|
|
909
|
+
{
|
|
910
|
+
name: 'generateLink',
|
|
911
|
+
httpMethod: 'post',
|
|
912
|
+
path: '/portal/generate_link',
|
|
913
|
+
pathParams: [],
|
|
914
|
+
queryParams: [],
|
|
915
|
+
headerParams: [],
|
|
916
|
+
requestBody,
|
|
917
|
+
response: { kind: 'model', name: 'PortalLink' },
|
|
918
|
+
errors: [],
|
|
919
|
+
injectIdempotencyKey: false,
|
|
920
|
+
},
|
|
921
|
+
],
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const baselineCtx = (service: Service, models: any[]): EmitterContext => ({
|
|
925
|
+
...ctx,
|
|
926
|
+
spec: { ...emptySpec, services: [service], models },
|
|
927
|
+
emitterOptions: { ownedServices: ['AdminPortal'] },
|
|
928
|
+
apiSurface: {
|
|
929
|
+
classes: {
|
|
930
|
+
AdminPortal: {
|
|
931
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
932
|
+
methods: {
|
|
933
|
+
generateLink: [
|
|
934
|
+
{
|
|
935
|
+
name: 'generateLink',
|
|
936
|
+
params: [{ name: 'options', type: literalType, passingStyle: 'options_object' }],
|
|
937
|
+
returnType: 'Promise<{ link: string }>',
|
|
938
|
+
async: true,
|
|
939
|
+
},
|
|
940
|
+
],
|
|
955
941
|
},
|
|
956
942
|
},
|
|
957
|
-
}
|
|
958
|
-
}
|
|
943
|
+
},
|
|
944
|
+
} as any,
|
|
945
|
+
});
|
|
959
946
|
|
|
960
|
-
|
|
947
|
+
it('adopts the named request-body interface instead of the inline literal', () => {
|
|
948
|
+
const service = adminPortalService({ kind: 'model', name: 'GenerateLinkBody' });
|
|
949
|
+
const models = [
|
|
950
|
+
{
|
|
951
|
+
name: 'GenerateLinkBody',
|
|
952
|
+
fields: [
|
|
953
|
+
{ name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
954
|
+
{ name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
955
|
+
],
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'PortalLink',
|
|
959
|
+
fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
960
|
+
},
|
|
961
|
+
];
|
|
962
|
+
|
|
963
|
+
const result = generateResources([service], baselineCtx(service, models));
|
|
961
964
|
const resourceFile = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts');
|
|
962
965
|
expect(resourceFile).toBeDefined();
|
|
963
966
|
const content = resourceFile!.content;
|
|
964
967
|
|
|
965
|
-
// The
|
|
966
|
-
|
|
967
|
-
|
|
968
|
+
// The named request interface replaces the inline literal in the signature,
|
|
969
|
+
// so the method param, the request model, and `serialize*` all agree.
|
|
970
|
+
expect(content).toContain('async generateLink(options: GenerateLinkBody)');
|
|
971
|
+
expect(content).toContain('serializeGenerateLinkBody(payload)');
|
|
972
|
+
// The inline literal is gone from the signature.
|
|
973
|
+
expect(content).not.toContain(`options: ${literalType}`);
|
|
974
|
+
// No named import of a brace-expression, and no import path / interface
|
|
975
|
+
// file derived from slugifying the literal type's text.
|
|
968
976
|
expect(content).not.toContain('import type { {');
|
|
969
|
-
// …and no import path derived from slugifying the literal type's text.
|
|
970
977
|
expect(content).not.toContain('intent-generate-link-intent');
|
|
971
|
-
// No interface file is emitted for the literal type either.
|
|
972
978
|
expect(result.some((f) => f.path.includes('intent-generate-link-intent'))).toBe(false);
|
|
973
979
|
});
|
|
980
|
+
|
|
981
|
+
it('keeps the literal inline when there is no single named request model', () => {
|
|
982
|
+
// A union request body has no single named interface to adopt, so the
|
|
983
|
+
// emitter must fall back to preserving the inline literal (and must still
|
|
984
|
+
// never slugify it into an import).
|
|
985
|
+
const service = adminPortalService({
|
|
986
|
+
kind: 'union',
|
|
987
|
+
variants: [
|
|
988
|
+
{ kind: 'model', name: 'SsoLinkBody' },
|
|
989
|
+
{ kind: 'model', name: 'DsyncLinkBody' },
|
|
990
|
+
],
|
|
991
|
+
});
|
|
992
|
+
const models = [
|
|
993
|
+
{
|
|
994
|
+
name: 'SsoLinkBody',
|
|
995
|
+
fields: [{ name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
name: 'DsyncLinkBody',
|
|
999
|
+
fields: [{ name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1000
|
+
},
|
|
1001
|
+
{ name: 'PortalLink', fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }] },
|
|
1002
|
+
];
|
|
1003
|
+
|
|
1004
|
+
const result = generateResources([service], baselineCtx(service, models));
|
|
1005
|
+
const content = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts')!.content;
|
|
1006
|
+
|
|
1007
|
+
expect(content).toContain(`async generateLink(options: ${literalType})`);
|
|
1008
|
+
expect(content).not.toContain('import type { {');
|
|
1009
|
+
});
|
|
974
1010
|
});
|
|
975
1011
|
|
|
976
1012
|
describe('@oagen-ignore region method filtering', () => {
|