@workos/oagen-emitters 0.14.1 → 0.14.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.
@@ -1,5 +1,5 @@
1
1
  import type { Model, Field, TypeRef, Enum, Service } from '@workos/oagen';
2
- import { toSnakeCase, toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
2
+ import { collectFieldDependencies, toSnakeCase, toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  // @ts-ignore -- js-yaml has no type declarations in this project
@@ -72,6 +72,41 @@ export function isListMetadataModel(model: Model): boolean {
72
72
  return isNullableString(before) && isNullableString(after);
73
73
  }
74
74
 
75
+ /**
76
+ * Compute the `ListMetadata`-shape model names that must still be emitted
77
+ * because some surviving wrapper references them.
78
+ *
79
+ * Each language emitter blanket-skips `isListMetadataModel` models on the
80
+ * assumption that the SDK's shared pagination wrapper subsumes them. That
81
+ * is correct for paginated list envelopes (the iterator unwraps the
82
+ * envelope and `list_metadata` is handled at runtime), but a *non-paginated*
83
+ * wrapper like vault's `VersionListResponse` still has a
84
+ * `list_metadata: ListMetadata` field — and skipping emission of
85
+ * `ListMetadata` leaves the wrapper's interface importing from a file that
86
+ * was never written.
87
+ *
88
+ * Pass the same `nonPaginatedRefs` set the emitter uses for its own
89
+ * wrapper-survival decision so the two answers stay in sync.
90
+ */
91
+ export function collectReferencedListMetadataModels(models: Model[], nonPaginatedRefs: Set<string>): Set<string> {
92
+ const listMetadataNames = new Set<string>();
93
+ for (const m of models) {
94
+ if (isListMetadataModel(m)) listMetadataNames.add(m.name);
95
+ }
96
+ if (listMetadataNames.size === 0) return new Set();
97
+
98
+ const referenced = new Set<string>();
99
+ for (const model of models) {
100
+ if (isListMetadataModel(model)) continue;
101
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
102
+ const deps = collectFieldDependencies(model);
103
+ for (const dep of deps.models) {
104
+ if (listMetadataNames.has(dep)) referenced.add(dep);
105
+ }
106
+ }
107
+ return referenced;
108
+ }
109
+
75
110
  /** Check if a field type is nullable string (nullable<string> or just string). */
76
111
  function isNullableString(field: Field): boolean {
77
112
  if (field.type.kind === 'primitive' && field.type.type === 'string') return true;
@@ -1,7 +1,13 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import type { EmitterContext } from '@workos/oagen';
3
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
- import { resolveInterfaceName, setAdoptedModelNames } from '../../src/node/naming.js';
4
+ import {
5
+ resolveInterfaceName,
6
+ setAdoptedModelNames,
7
+ setBaselineInterfaceNames,
8
+ setStructurallyRenamedDomainNames,
9
+ wireInterfaceName,
10
+ } from '../../src/node/naming.js';
5
11
 
6
12
  const ctx: EmitterContext = {
7
13
  namespace: 'workos',
@@ -19,6 +25,8 @@ const ctx: EmitterContext = {
19
25
 
20
26
  afterEach(() => {
21
27
  setAdoptedModelNames(new Set());
28
+ setBaselineInterfaceNames(new Set());
29
+ setStructurallyRenamedDomainNames(new Set());
22
30
  });
23
31
 
24
32
  describe('resolveInterfaceName', () => {
@@ -75,3 +83,39 @@ describe('resolveInterfaceName', () => {
75
83
  expect(result).toBe('CreateM2MApplication');
76
84
  });
77
85
  });
86
+
87
+ describe('wireInterfaceName', () => {
88
+ it('emits *Wire for a fresh `*Response`-named IR model with an empty baseline', () => {
89
+ expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
90
+ });
91
+
92
+ it('returns *Wire even when a prior buggy regen poisoned the baseline with the bare name', () => {
93
+ // Reproduces the vault regression: `CreateDataKeyResponse` is its own IR
94
+ // name (no structural rename), so the resolver does not flag it. A prior
95
+ // broken emission wrote `export interface CreateDataKeyResponse { ... }`
96
+ // twice into the file, and the baseline now contains the bare name.
97
+ // Without the rename signal, `wireInterfaceName` must still pick `*Wire`
98
+ // — otherwise the duplicate emission perpetuates across regens.
99
+ setBaselineInterfaceNames(new Set(['CreateDataKeyResponse']));
100
+ expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
101
+ });
102
+
103
+ it('uses *Wire when baseline already has a separate `*Wire` companion', () => {
104
+ setBaselineInterfaceNames(new Set(['DecryptResponse', 'DecryptResponseWire']));
105
+ expect(wireInterfaceName('DecryptResponse')).toBe('DecryptResponseWire');
106
+ });
107
+
108
+ it('returns the bare name when a structural rename mapped a differently-named IR model onto a baseline `*Response`', () => {
109
+ // The legitimate single-form case the heuristic was designed for:
110
+ // `AuditLogSchemaJson` → `AuditLogSchemaResponse` via structural match,
111
+ // and the baseline owns just the one `AuditLogSchemaResponse` interface
112
+ // representing the wire shape.
113
+ setBaselineInterfaceNames(new Set(['AuditLogSchemaResponse']));
114
+ setStructurallyRenamedDomainNames(new Set(['AuditLogSchemaResponse']));
115
+ expect(wireInterfaceName('AuditLogSchemaResponse')).toBe('AuditLogSchemaResponse');
116
+ });
117
+
118
+ it('falls back to `${name}Response` for non-`*Response` IR names', () => {
119
+ expect(wireInterfaceName('Organization')).toBe('OrganizationResponse');
120
+ });
121
+ });
@@ -268,4 +268,73 @@ describe('node test generation ownership', () => {
268
268
  fs.rmSync(tmpRoot, { recursive: true, force: true });
269
269
  }
270
270
  });
271
+
272
+ it('asserts toBeNull() for nullable fields whose example is null', () => {
273
+ // When the spec gives `example: null` on a nullable field — common for
274
+ // optional date-times like `last_used_at` — the previous emitter would
275
+ // emit `expect(x.toISOString()).toBe(null)`, which both blows up at
276
+ // runtime on a null `x` and never matches when `x` is a Date.
277
+ const secretModel: Model = {
278
+ name: 'Secret',
279
+ fields: [
280
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true, example: 'sec_1' },
281
+ {
282
+ name: 'last_used_at',
283
+ type: {
284
+ kind: 'nullable',
285
+ inner: { kind: 'primitive', type: 'string', format: 'date-time' },
286
+ },
287
+ required: true,
288
+ example: null,
289
+ },
290
+ {
291
+ name: 'created_at',
292
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
293
+ required: true,
294
+ example: '2026-01-15T12:00:00.000Z',
295
+ },
296
+ ],
297
+ };
298
+
299
+ const showOp = {
300
+ name: 'showSecret',
301
+ httpMethod: 'get' as const,
302
+ path: '/secrets/{id}',
303
+ pathParams: [{ name: 'id', type: { kind: 'primitive' as const, type: 'string' as const }, required: true }],
304
+ queryParams: [],
305
+ headerParams: [],
306
+ response: { kind: 'model' as const, name: 'Secret' },
307
+ errors: [],
308
+ injectIdempotencyKey: false,
309
+ };
310
+ const secretService: Service = { name: 'Secrets', operations: [showOp] };
311
+ const secretSpec: ApiSpec = {
312
+ ...spec,
313
+ models: [secretModel],
314
+ services: [secretService],
315
+ };
316
+ const tmpRoot = createTrackedSdkRoot();
317
+ try {
318
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'secrets', 'fixtures'), { recursive: true });
319
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
320
+
321
+ const result = nodeEmitter.generateTests!(secretSpec, {
322
+ ...ctx,
323
+ spec: secretSpec,
324
+ outputDir: tmpRoot,
325
+ emitterOptions: { ownedServices: ['Secrets'], regenerateOwnedTests: true },
326
+ } as EmitterContext);
327
+
328
+ const testFile = result.find((f) => f.path === 'src/secrets/secrets.spec.ts');
329
+ expect(testFile).toBeDefined();
330
+ const content = testFile!.content;
331
+ // Null example on a nullable date-time → toBeNull(), not `.toISOString().toBe(null)`.
332
+ expect(content).toContain('expect(result.lastUsedAt).toBeNull();');
333
+ expect(content).not.toContain('lastUsedAt.toISOString()).toBe(null)');
334
+ // Non-null date-time examples still go through `.toISOString()`.
335
+ expect(content).toContain("expect(result.createdAt.toISOString()).toBe('2026-01-15T12:00:00.000Z');");
336
+ } finally {
337
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
338
+ }
339
+ });
271
340
  });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Model } from '@workos/oagen';
3
+ import {
4
+ collectReferencedListMetadataModels,
5
+ isListMetadataModel,
6
+ isListWrapperModel,
7
+ } from '../../src/shared/model-utils.js';
8
+
9
+ const listMetadataModel: Model = {
10
+ name: 'ListMetadata',
11
+ fields: [
12
+ {
13
+ name: 'before',
14
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
15
+ required: false,
16
+ },
17
+ {
18
+ name: 'after',
19
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
20
+ required: false,
21
+ },
22
+ ],
23
+ };
24
+
25
+ describe('isListMetadataModel', () => {
26
+ it('matches a two-field nullable-string before/after shape', () => {
27
+ expect(isListMetadataModel(listMetadataModel)).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('collectReferencedListMetadataModels', () => {
32
+ it('returns nothing when no surviving wrapper references the model', () => {
33
+ // A paginated list wrapper that the SDK pagination machinery unwraps —
34
+ // not in `nonPaginatedRefs`, so it counts as skipped.
35
+ const paginatedWrapper: Model = {
36
+ name: 'OrgList',
37
+ fields: [
38
+ { name: 'data', type: { kind: 'array', items: { kind: 'model', name: 'Org' } }, required: true },
39
+ { name: 'list_metadata', type: { kind: 'model', name: 'ListMetadata' }, required: true },
40
+ ],
41
+ };
42
+ const result = collectReferencedListMetadataModels([listMetadataModel, paginatedWrapper], new Set());
43
+ expect(result.size).toBe(0);
44
+ });
45
+
46
+ it('flags the ListMetadata model when a non-paginated wrapper still references it', () => {
47
+ // `VersionListResponse` is shaped like a list envelope but the operation
48
+ // has no pagination params, so it survives the wrapper skip — and its
49
+ // `list_metadata` field needs the `ListMetadata` interface on disk.
50
+ const versionWrapper: Model = {
51
+ name: 'VersionListResponse',
52
+ fields: [
53
+ { name: 'data', type: { kind: 'array', items: { kind: 'model', name: 'Version' } }, required: true },
54
+ { name: 'list_metadata', type: { kind: 'model', name: 'ListMetadata' }, required: true },
55
+ ],
56
+ };
57
+ const result = collectReferencedListMetadataModels(
58
+ [listMetadataModel, versionWrapper],
59
+ new Set(['VersionListResponse']),
60
+ );
61
+ expect(result.has('ListMetadata')).toBe(true);
62
+ });
63
+
64
+ it('flags the ListMetadata model when a non-wrapper IR model references it directly', () => {
65
+ // Defensive: should anything else point at a `ListMetadata`-shape model
66
+ // as a regular field, we still need the file.
67
+ const customModel: Model = {
68
+ name: 'Custom',
69
+ fields: [{ name: 'meta', type: { kind: 'model', name: 'ListMetadata' }, required: true }],
70
+ };
71
+ const result = collectReferencedListMetadataModels([listMetadataModel, customModel], new Set());
72
+ expect(result.has('ListMetadata')).toBe(true);
73
+ });
74
+
75
+ it('returns an empty set when no ListMetadata-shape model exists in the IR', () => {
76
+ const regular: Model = {
77
+ name: 'Foo',
78
+ fields: [{ name: 'bar', type: { kind: 'primitive', type: 'string' }, required: true }],
79
+ };
80
+ const result = collectReferencedListMetadataModels([regular], new Set());
81
+ expect(result.size).toBe(0);
82
+ });
83
+ });
84
+
85
+ describe('isListWrapperModel + isListMetadataModel — sanity', () => {
86
+ it('does not classify a wrapper as a metadata model', () => {
87
+ const wrapper: Model = {
88
+ name: 'OrgList',
89
+ fields: [
90
+ { name: 'data', type: { kind: 'array', items: { kind: 'model', name: 'Org' } }, required: true },
91
+ { name: 'list_metadata', type: { kind: 'model', name: 'ListMetadata' }, required: true },
92
+ ],
93
+ };
94
+ expect(isListWrapperModel(wrapper)).toBe(true);
95
+ expect(isListMetadataModel(wrapper)).toBe(false);
96
+ });
97
+ });