@workos/oagen-emitters 0.18.1 → 0.18.3

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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-CtU_wbid.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-1ckLMpgo.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -1,6 +1,7 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
  import { fixtureFileName, fieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
+ import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
4
5
 
5
6
  /**
6
7
  * Prefix mapping for generating realistic ID fixture values.
@@ -35,9 +36,19 @@ export function generateFixtures(spec: {
35
36
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
36
37
  const files: { path: string; content: string }[] = [];
37
38
 
39
+ // List-wrappers are normally represented only by the per-operation
40
+ // `list_<item>.json` fixtures generated from paginated operations below. But
41
+ // a wrapper returned by a NON-paginated operation (e.g.
42
+ // `PUT /authorization/groups/{id}/role_assignments` -> GroupRoleAssignmentList)
43
+ // is emitted as a real model (see models.ts) and its generated test references
44
+ // `testdata/<type>.json` (tests.ts). Emit that envelope fixture too, mirroring
45
+ // the non-wrapper `VersionListResponse` precedent — otherwise the test loads a
46
+ // file that was never written.
47
+ const nonPaginatedWrapperRefs = collectNonPaginatedResponseModelNames(spec.services);
48
+
38
49
  for (const model of spec.models) {
39
50
  if (isListMetadataModel(model)) continue;
40
- if (isListWrapperModel(model)) continue;
51
+ if (isListWrapperModel(model) && !nonPaginatedWrapperRefs.has(model.name)) continue;
41
52
 
42
53
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
43
54
 
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
  }
@@ -279,6 +279,24 @@ function operationHasOptionsInput(op: Operation, plan: OperationPlan, resolvedOp
279
279
  );
280
280
  }
281
281
 
282
+ // True only when the type is a SINGLE closed object literal (`{ ... }`), not
283
+ // the head of a compound type such as `{ ... } & X` or `{ ... } | Y`. Counting
284
+ // brace depth locates the literal's matching close brace (handling nesting like
285
+ // `{ a: { b: string } }`); any non-whitespace after it means the type is not a
286
+ // pure literal and must be preserved verbatim rather than replaced by a named
287
+ // request interface.
288
+ function isClosedObjectLiteral(type: string): boolean {
289
+ const t = type.trim();
290
+ if (!t.startsWith('{')) return false;
291
+ let depth = 0;
292
+ for (let i = 0; i < t.length; i++) {
293
+ const ch = t[i];
294
+ if (ch === '{') depth++;
295
+ else if (ch === '}' && --depth === 0) return i === t.length - 1;
296
+ }
297
+ return false;
298
+ }
299
+
282
300
  function optionsObjectInfo(
283
301
  service: Service,
284
302
  method: string,
@@ -289,7 +307,35 @@ function optionsObjectInfo(
289
307
  resolvedOp?: ResolvedOperation,
290
308
  ): OptionsObjectParam | undefined {
291
309
  const baseline = optionsObjectParam(baselineMethod);
292
- if (baseline) return baseline;
310
+ if (baseline) {
311
+ // A baseline param whose type is a pure inline object literal
312
+ // (e.g. `{ intent?: GenerateLinkIntent; organization: string; ... }`)
313
+ // cannot be referenced as a named import and routinely diverges from the
314
+ // spec-derived request interface that the generated serializer expects —
315
+ // field renames (`adminEmails` -> `itContactEmails`), widened enums
316
+ // (`string` vs `'GoogleSAML'`), differing optionality — which makes the
317
+ // generated `serialize${Model}(payload)` call fail to typecheck. When the
318
+ // operation owns a named request-body model, adopt that interface so the
319
+ // method signature, the serializer, and the request model all agree.
320
+ // Named baseline types (`CreateOrganizationApiKeyOptions`) and compound
321
+ // intersections (`X & { ... }` or `{ ... } & X`) are still preserved
322
+ // verbatim — only a single, closed object literal is eligible for adoption.
323
+ if (
324
+ isClosedObjectLiteral(baseline.type) &&
325
+ isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx))
326
+ ) {
327
+ const body = extractRequestBodyType(op, ctx);
328
+ if (body?.kind === 'model') {
329
+ return {
330
+ name: 'options',
331
+ type: resolveInterfaceName(body.name, ctx),
332
+ optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
333
+ generated: false,
334
+ };
335
+ }
336
+ }
337
+ return baseline;
338
+ }
293
339
 
294
340
  const overrideType = operationOverrideFor(ctx, op)?.optionsType;
295
341
  if (overrideType) {
@@ -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,147 @@ 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
- // When the baseline surface reports that literal text as the param "type name",
899
- // the emitter must keep it inline in the signature and must NOT slugify it into
900
- // an interface filename or emit a named import of a brace-expression.
901
- it('keeps the literal type inline and never imports it', () => {
902
- const literalType = '{ intent: GenerateLinkIntent; organization: string; returnUrl?: string }';
903
- const service: Service = {
904
- name: 'AdminPortal',
905
- operations: [
906
- {
907
- name: 'generateLink',
908
- httpMethod: 'post',
909
- path: '/portal/generate_link',
910
- pathParams: [],
911
- queryParams: [],
912
- headerParams: [],
913
- requestBody: { kind: 'model', name: 'GenerateLinkBody' },
914
- response: { kind: 'model', name: 'PortalLink' },
915
- errors: [],
916
- injectIdempotencyKey: false,
917
- },
918
- ],
919
- };
920
- const spec: ApiSpec = {
921
- ...emptySpec,
922
- services: [service],
923
- models: [
924
- {
925
- name: 'GenerateLinkBody',
926
- fields: [
927
- { name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
928
- { name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
929
- ],
930
- },
931
- {
932
- name: 'PortalLink',
933
- fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
934
- },
935
- ],
936
- };
937
- const ctxWithBaseline: EmitterContext = {
938
- ...ctx,
939
- spec,
940
- emitterOptions: { ownedServices: ['AdminPortal'] },
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[], paramType: string = literalType): 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: paramType, passingStyle: 'options_object' }],
937
+ returnType: 'Promise<{ link: string }>',
938
+ async: true,
939
+ },
940
+ ],
955
941
  },
956
942
  },
957
- } as any,
958
- };
943
+ },
944
+ } as any,
945
+ });
959
946
 
960
- const result = generateResources([service], ctxWithBaseline);
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 literal type stays inline in the method signature.
966
- expect(content).toContain(`async generateLink(options: ${literalType})`);
967
- // No named import of a brace-expression…
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
+ });
1010
+
1011
+ it('keeps a `{ ... } & X` compound intersection inline even with a named request model', () => {
1012
+ // The adoption guard targets a PURE object literal. A baseline param that
1013
+ // LEADS with a literal but is actually a compound intersection
1014
+ // (`{ ... } & WithMetadata`) carries the hand-authored `& X` portion, which
1015
+ // a named-interface swap would silently drop. It must be preserved verbatim
1016
+ // even though the operation has a single named request-body model.
1017
+ const compoundType = `${literalType} & WithMetadata`;
1018
+ const service = adminPortalService({ kind: 'model', name: 'GenerateLinkBody' });
1019
+ const models = [
1020
+ {
1021
+ name: 'GenerateLinkBody',
1022
+ fields: [
1023
+ { name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
1024
+ { name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
1025
+ ],
1026
+ },
1027
+ { name: 'PortalLink', fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }] },
1028
+ ];
1029
+
1030
+ const result = generateResources([service], baselineCtx(service, models, compoundType));
1031
+ const content = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts')!.content;
1032
+
1033
+ // The compound type (including the `& WithMetadata` tail) survives intact;
1034
+ // it is NOT replaced by the named `GenerateLinkBody` interface.
1035
+ expect(content).toContain(`async generateLink(options: ${compoundType})`);
1036
+ expect(content).not.toContain('async generateLink(options: GenerateLinkBody)');
1037
+ expect(content).not.toContain('import type { {');
1038
+ });
974
1039
  });
975
1040
 
976
1041
  describe('@oagen-ignore region method filtering', () => {