@workos/oagen-emitters 0.17.0 → 0.18.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.
@@ -303,9 +303,26 @@ function optionsObjectInfo(
303
303
 
304
304
  if (!operationHasOptionsInput(op, plan, resolvedOp)) return undefined;
305
305
 
306
+ const resolvedService = resolveResourceClassName(service, ctx);
307
+ let optionsType = methodOptionsName(method, resolvedService);
308
+ // A bare `${Method}Options` name (e.g. `GetGroupOptions`) can collide with a
309
+ // same-named baseline type owned by a DIFFERENT service (for example the
310
+ // Groups module's `GetGroupOptions`, shaped `{ organizationId, groupId }`).
311
+ // Reusing it by name would import a foreign shape. When the existing
312
+ // declaration lives in another service directory, qualify the name with the
313
+ // service (mirroring the `list` -> `${Service}ListOptions` rule) so this
314
+ // service generates and imports its own options type.
315
+ if (method !== 'list') {
316
+ const baselineFile = baselineTypeSourceFile(ctx, optionsType);
317
+ const baselineDir = baselineFile?.match(/^src\/([^/]+)\//)?.[1];
318
+ if (baselineDir && baselineDir !== resolveResourceDir(service, ctx)) {
319
+ optionsType = `${toPascalCase(resolvedService)}${toPascalCase(method)}Options`;
320
+ }
321
+ }
322
+
306
323
  return {
307
324
  name: 'options',
308
- type: methodOptionsName(method, resolveResourceClassName(service, ctx)),
325
+ type: optionsType,
309
326
  optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
310
327
  generated: true,
311
328
  };
@@ -327,6 +344,18 @@ function isValidTypeIdentifier(name: string): boolean {
327
344
  return /^[A-Za-z_$][\w$]*$/.test(name);
328
345
  }
329
346
 
347
+ /**
348
+ * Extract candidate named type references from a compound type expression such
349
+ * as `GetAccessTokenOptions & { provider: string }`. PascalCase tokens are type
350
+ * names worth importing; lowercase tokens are property keys or primitives and
351
+ * are skipped. The caller only imports the ones that resolve to a known source
352
+ * file, so unrecognized PascalCase tokens (e.g. `Date`, `Record`) are harmless.
353
+ */
354
+ function extractNamedTypeRefs(typeExpr: string): string[] {
355
+ const matches = typeExpr.match(/\b[A-Z][A-Za-z0-9_$]*\b/g) ?? [];
356
+ return [...new Set(matches)];
357
+ }
358
+
330
359
  function autoPaginatableItemType(returnType: string | undefined): string | undefined {
331
360
  // Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
332
361
  // item types are extracted even when the hand-written code predates AutoPaginatable.
@@ -964,16 +993,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
964
993
 
965
994
  const importedTypeNames = new Set<string>();
966
995
  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;
970
- if (importedTypeNames.has(optionType)) continue;
971
- importedTypeNames.add(optionType);
972
- const sourceFile = baselineTypeSourceFile(ctx, optionType);
973
- const relPath = sourceFile
974
- ? relativeImport(resourcePath, sourceFile)
975
- : `./interfaces/${fileName(optionType)}.interface`;
976
- lines.push(`import type { ${optionType} } from '${relPath}';`);
996
+ if (isValidTypeIdentifier(optionType)) {
997
+ if (importedTypeNames.has(optionType)) continue;
998
+ importedTypeNames.add(optionType);
999
+ const sourceFile = baselineTypeSourceFile(ctx, optionType);
1000
+ const relPath = sourceFile
1001
+ ? relativeImport(resourcePath, sourceFile)
1002
+ : `./interfaces/${fileName(optionType)}.interface`;
1003
+ lines.push(`import type { ${optionType} } from '${relPath}';`);
1004
+ continue;
1005
+ }
1006
+ // Compound option types (e.g. a baseline `GetAccessTokenOptions & { provider:
1007
+ // string }`) keep their inline object literal inline, but the named type(s)
1008
+ // they reference must still be imported. Only import names that resolve to a
1009
+ // known source file — inline literals, primitives, and property keys have no
1010
+ // importable source and are skipped.
1011
+ for (const typeName of extractNamedTypeRefs(optionType)) {
1012
+ if (importedTypeNames.has(typeName)) continue;
1013
+ const sourceFile = baselineTypeSourceFile(ctx, typeName) ?? liveSurfaceInterfacePath(typeName);
1014
+ if (!sourceFile) continue;
1015
+ importedTypeNames.add(typeName);
1016
+ lines.push(`import type { ${typeName} } from '${relativeImport(resourcePath, sourceFile)}';`);
1017
+ }
977
1018
  }
978
1019
  for (const typeName of returnTypeImports) {
979
1020
  if (importedTypeNames.has(typeName)) continue;
@@ -1170,7 +1211,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
1170
1211
  const baselineMethod = baselineMethodFor(service, method, ctx);
1171
1212
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolved);
1172
1213
  if (plan.isPaginated) {
1173
- const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
1214
+ // Skip deprecated query params: the typed options interface omits them
1215
+ // (the curated baseline drops deprecated params), so the wire serializer
1216
+ // must not reference a field that does not exist on the options type.
1217
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name) && !p.deprecated);
1174
1218
  if (extraParams.length > 0) {
1175
1219
  const optionsName = optionInfo?.type ?? paginatedOptionsName(method, resolvedName);
1176
1220
 
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 = plan.isPaginated
674
- ? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
675
- : op.queryParams;
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);
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import {
5
+ detectDiscriminatedShape,
6
+ generateDiscriminatedFiles,
7
+ type DiscriminatedPlan,
8
+ } from '../../src/node/discriminated-models.js';
9
+
10
+ const emptySpec: ApiSpec = {
11
+ name: 'Test',
12
+ version: '1.0.0',
13
+ baseUrl: '',
14
+ services: [],
15
+ models: [],
16
+ enums: [],
17
+ sdk: defaultSdkBehavior(),
18
+ };
19
+
20
+ const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
21
+
22
+ // A pure `oneOf` discriminated by a boolean `active` const — the Pipes token
23
+ // response shape that previously collapsed into a flat all-optional interface.
24
+ // `RawSchema` is internal to the emitter; the loose typing mirrors the raw
25
+ // `components.schemas` shape `detectDiscriminatedShape` consumes at runtime.
26
+ const rawSchemas: Record<string, any> = {
27
+ DataIntegrationAccessTokenResponse: {
28
+ oneOf: [
29
+ {
30
+ type: 'object',
31
+ properties: {
32
+ active: { type: 'boolean', const: true },
33
+ access_token: {
34
+ type: 'object',
35
+ properties: {
36
+ object: { type: 'string', const: 'access_token' },
37
+ access_token: { type: 'string' },
38
+ expires_at: { type: ['string', 'null'], format: 'date-time' },
39
+ scopes: { type: 'array', items: { type: 'string' } },
40
+ missing_scopes: { type: 'array', items: { type: 'string' } },
41
+ },
42
+ required: ['object', 'access_token', 'expires_at', 'scopes', 'missing_scopes'],
43
+ },
44
+ },
45
+ required: ['active', 'access_token'],
46
+ },
47
+ {
48
+ type: 'object',
49
+ properties: {
50
+ active: { type: 'boolean', const: false },
51
+ error: { type: 'string', enum: ['not_installed', 'needs_reauthorization'] },
52
+ },
53
+ required: ['active', 'error'],
54
+ },
55
+ ],
56
+ },
57
+ };
58
+
59
+ describe('detectDiscriminatedShape — pure oneOf with boolean discriminator', () => {
60
+ it('detects a two-variant inline union keyed on the boolean `active`', () => {
61
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas);
62
+ expect(shape).not.toBeNull();
63
+ expect(shape!.inlineUnion).toBe(true);
64
+ expect(shape!.discriminatorProperty).toBe('active');
65
+ expect(shape!.variants).toHaveLength(2);
66
+ expect(shape!.variants.map((v) => v.discriminatorValue).sort()).toEqual(['false', 'true']);
67
+ expect(shape!.variants.every((v) => v.discriminatorIsBoolean)).toBe(true);
68
+ });
69
+
70
+ it('emits a discriminated union interface (not a flat optional bag)', () => {
71
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
72
+ const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap: new Map() };
73
+ const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
74
+
75
+ const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
76
+ expect(iface).toBeDefined();
77
+ // Union alias, two variants, boolean discriminator (unquoted), required fields.
78
+ expect(iface.content).toContain('export type DataIntegrationAccessTokenResponse =');
79
+ expect(iface.content).toContain('active: true');
80
+ expect(iface.content).toContain('active: false');
81
+ expect(iface.content).toContain('accessToken: DataIntegrationAccessTokenResponseAccessToken');
82
+ expect(iface.content).toContain("error: 'not_installed' | 'needs_reauthorization'");
83
+ // No optional discriminator — narrowing must work.
84
+ expect(iface.content).not.toContain('active?: true');
85
+
86
+ const ser = files.find((f) => f.path.endsWith('.serializer.ts'))!;
87
+ expect(ser.content).toContain('switch (response.active)');
88
+ expect(ser.content).toContain('case true:');
89
+ expect(ser.content).toContain('case false:');
90
+ });
91
+
92
+ it('resolves a cross-service inline-object dep to a relative import path', () => {
93
+ // The nested `access_token` object is a synthetic IR model. Its dep is
94
+ // carried in snake form (`Parent_field`) but keyed in depDirMap under the
95
+ // PascalCase IR name. When that model resolves to a different service dir,
96
+ // collectImports must emit a cross-service path rather than defaulting to
97
+ // a same-dir import.
98
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
99
+ const depDirMap = new Map<string, string>([['DataIntegrationAccessTokenResponseAccessToken', 'connect']]);
100
+ const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap };
101
+ const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
102
+
103
+ const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
104
+ expect(iface.content).toContain(
105
+ "from '../../connect/interfaces/data-integration-access-token-response-access-token.interface'",
106
+ );
107
+ });
108
+ });