@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-DRGwxN88.mjs → plugin-D0qLBiGv.mjs} +166 -40
- package/dist/plugin-D0qLBiGv.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/go/fixtures.ts +22 -2
- package/src/go/models.ts +8 -2
- package/src/kotlin/models.ts +10 -4
- package/src/kotlin/resources.ts +17 -2
- package/src/node/fixtures.ts +30 -3
- package/src/node/index.ts +19 -0
- package/src/node/models.ts +50 -30
- package/src/node/naming.ts +25 -1
- package/src/node/resources.ts +9 -1
- package/src/node/tests.ts +8 -1
- package/src/node/utils.ts +1 -0
- package/src/python/fixtures.ts +22 -2
- package/src/python/models.ts +6 -1
- package/src/python/tests.ts +7 -1
- package/src/ruby/parameter-groups.ts +4 -2
- package/src/ruby/rbi.ts +1 -0
- package/src/rust/fixtures.ts +16 -0
- package/src/shared/model-utils.ts +36 -1
- package/test/node/naming.test.ts +45 -1
- package/test/node/tests.test.ts +69 -0
- package/test/shared/model-utils.test.ts +97 -0
- package/dist/plugin-DRGwxN88.mjs.map +0 -1
|
@@ -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;
|
package/test/node/naming.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/test/node/tests.test.ts
CHANGED
|
@@ -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
|
+
});
|