@workos/oagen-emitters 0.14.0 → 0.14.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 +18 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BxVeu2v9.mjs → plugin-BbSmT2kj.mjs} +266 -64
- package/dist/plugin-BbSmT2kj.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/models.ts +31 -6
- package/src/dotnet/type-map.ts +18 -1
- package/src/go/fixtures.ts +6 -2
- package/src/go/models.ts +18 -3
- package/src/kotlin/models.ts +22 -6
- package/src/kotlin/resources.ts +17 -2
- package/src/node/fixtures.ts +13 -3
- package/src/node/index.ts +92 -4
- package/src/node/models.ts +58 -32
- 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 +6 -1
- package/src/php/models.ts +11 -2
- package/src/python/fixtures.ts +6 -2
- package/src/python/models.ts +18 -3
- package/src/python/resources.ts +8 -2
- package/src/python/tests.ts +7 -1
- package/src/ruby/index.ts +3 -3
- package/src/ruby/models.ts +13 -3
- package/src/ruby/parameter-groups.ts +4 -2
- package/src/ruby/rbi.ts +1 -0
- package/src/rust/models.ts +5 -1
- package/src/rust/resources.ts +4 -1
- package/src/shared/model-utils.ts +70 -3
- package/test/node/naming.test.ts +45 -1
- package/test/node/tests.test.ts +69 -0
- package/test/rust/models.test.ts +3 -3
- package/test/shared/model-utils.test.ts +97 -0
- package/dist/plugin-BxVeu2v9.mjs.map +0 -1
package/src/node/naming.ts
CHANGED
|
@@ -73,6 +73,23 @@ export function isAdoptedModelName(name: string): boolean {
|
|
|
73
73
|
return adoptedModelNames.has(name);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Domain names that `resolveInterfaceName` reached via a structural rename
|
|
78
|
+
* — the resolved name differs from the IR model's own name. `wireInterfaceName`
|
|
79
|
+
* consults this set to decide whether to fire the "single-form wire" case:
|
|
80
|
+
* that case is *only* meant for structurally-renamed models, where the
|
|
81
|
+
* baseline owns a `*Response` interface representing the wire shape with no
|
|
82
|
+
* separate `*Wire` companion. Without this signal, a freshly-emitted model
|
|
83
|
+
* whose IR name already ends in `Response` (e.g. `CreateDataKeyResponse`)
|
|
84
|
+
* would land in the same case as soon as a prior buggy regen wrote the
|
|
85
|
+
* baseline — producing two `export interface CreateDataKeyResponse { ... }`
|
|
86
|
+
* declarations in the same file.
|
|
87
|
+
*/
|
|
88
|
+
let structurallyRenamedDomainNames: Set<string> = new Set();
|
|
89
|
+
export function setStructurallyRenamedDomainNames(names: Set<string>): void {
|
|
90
|
+
structurallyRenamedDomainNames = names;
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
/**
|
|
77
94
|
* Wire/response interface name.
|
|
78
95
|
*
|
|
@@ -97,7 +114,14 @@ export function wireInterfaceName(domainName: string): string {
|
|
|
97
114
|
if (domainName.endsWith('Response')) {
|
|
98
115
|
const wireForm = `${domainName}Wire`;
|
|
99
116
|
if (baselineInterfaceNames.has(wireForm)) return wireForm;
|
|
100
|
-
|
|
117
|
+
// Single-form case (#3 in the docstring): only fire when the resolver
|
|
118
|
+
// structurally renamed an IR model to this baseline name. Otherwise a
|
|
119
|
+
// fresh `CreateDataKeyResponse`-style IR model would collapse onto its
|
|
120
|
+
// own name as soon as one buggy regen wrote `CreateDataKeyResponse` to
|
|
121
|
+
// the baseline, perpetuating the duplicate-interface emission.
|
|
122
|
+
if (structurallyRenamedDomainNames.has(domainName) && baselineInterfaceNames.has(domainName)) {
|
|
123
|
+
return domainName;
|
|
124
|
+
}
|
|
101
125
|
return wireForm;
|
|
102
126
|
}
|
|
103
127
|
return `${domainName}Response`;
|
package/src/node/resources.ts
CHANGED
|
@@ -698,7 +698,15 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
698
698
|
? `./interfaces/${fileName(name)}.interface`
|
|
699
699
|
: `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
700
700
|
if (usedWireTypes.has(resolved)) {
|
|
701
|
-
|
|
701
|
+
const wireName = wireInterfaceName(resolved);
|
|
702
|
+
// When the wire name collapsed onto the domain name (single-form
|
|
703
|
+
// structural-rename emission), import once — otherwise we ship
|
|
704
|
+
// `import type { ReadObjectResponse, ReadObjectResponse }`.
|
|
705
|
+
if (wireName === resolved) {
|
|
706
|
+
lines.push(`import type { ${resolved} } from '${relPath}';`);
|
|
707
|
+
} else {
|
|
708
|
+
lines.push(`import type { ${resolved}, ${wireName} } from '${relPath}';`);
|
|
709
|
+
}
|
|
702
710
|
} else {
|
|
703
711
|
lines.push(`import type { ${resolved} } from '${relPath}';`);
|
|
704
712
|
}
|
package/src/node/tests.ts
CHANGED
|
@@ -682,7 +682,14 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
|
|
|
682
682
|
const fieldAccessor = isDateTime ? `${accessor}.${domainField}.toISOString()` : `${accessor}.${domainField}`;
|
|
683
683
|
// When a field has an example value, use it as the expected assertion value
|
|
684
684
|
if (field.example !== undefined) {
|
|
685
|
-
|
|
685
|
+
// A null example on a nullable field must assert `toBeNull()` on the
|
|
686
|
+
// raw value — calling `.toISOString()` on null throws at runtime, and
|
|
687
|
+
// `.toBe(null)` against any non-null value never matches.
|
|
688
|
+
if (field.example === null) {
|
|
689
|
+
assertions.push(`expect(${accessor}.${domainField}).toBeNull();`);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (typeof field.example === 'object') {
|
|
686
693
|
// Objects and arrays need toEqual with JSON serialization
|
|
687
694
|
assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
|
|
688
695
|
} else {
|
package/src/node/utils.ts
CHANGED
|
@@ -277,7 +277,12 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
277
277
|
return false;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
export {
|
|
280
|
+
export {
|
|
281
|
+
isListMetadataModel,
|
|
282
|
+
isListWrapperModel,
|
|
283
|
+
collectNonPaginatedResponseModelNames,
|
|
284
|
+
collectReferencedListMetadataModels,
|
|
285
|
+
} from '../shared/model-utils.js';
|
|
281
286
|
|
|
282
287
|
function modelFingerprint(model: Model): string {
|
|
283
288
|
const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
|
package/src/php/models.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { className, enumClassName, fieldName } from './naming.js';
|
|
|
4
4
|
import { phpDocComment } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// Import and re-export shared model detection utilities
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isListMetadataModel,
|
|
9
|
+
isListWrapperModel,
|
|
10
|
+
collectNonPaginatedResponseModelNames,
|
|
11
|
+
} from '../shared/model-utils.js';
|
|
8
12
|
export { isListMetadataModel, isListWrapperModel };
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -32,9 +36,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
32
36
|
overwriteExisting: true,
|
|
33
37
|
});
|
|
34
38
|
|
|
39
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
40
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
41
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
42
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
43
|
+
|
|
35
44
|
for (const model of models) {
|
|
36
45
|
if (isListMetadataModel(model)) continue;
|
|
37
|
-
if (isListWrapperModel(model)) continue;
|
|
46
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
38
47
|
const name = className(model.name);
|
|
39
48
|
const lines: string[] = [];
|
|
40
49
|
|
package/src/python/fixtures.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
|
2
2
|
|
|
3
3
|
import { fileName, fieldName } from './naming.js';
|
|
4
4
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
5
|
+
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Prefix mapping for generating realistic ID fixture values.
|
|
@@ -36,9 +37,12 @@ export function generateFixtures(spec: {
|
|
|
36
37
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
37
38
|
const files: { path: string; content: string }[] = [];
|
|
38
39
|
|
|
40
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
41
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
42
|
+
|
|
39
43
|
for (const model of spec.models) {
|
|
40
|
-
if (isListMetadataModel(model)) continue;
|
|
41
|
-
if (isListWrapperModel(model)) continue;
|
|
44
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
45
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
42
46
|
// Skip models with no fields — these are typically discriminated unions
|
|
43
47
|
// with hand-maintained @oagen-ignore overrides; generated empty fixtures
|
|
44
48
|
// would not match the override's required fields.
|
package/src/python/models.ts
CHANGED
|
@@ -41,11 +41,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
41
41
|
// oneOf enrichment collide with existing IR models in snake_case.
|
|
42
42
|
const emittedFilePaths = new Set<string>();
|
|
43
43
|
|
|
44
|
+
// Wrappers referenced as a non-paginated operation response (e.g.
|
|
45
|
+
// `VersionListResponse` for `GET /vault/v1/kv/{id}/versions`) must still be
|
|
46
|
+
// emitted — the resource code references them by name and SyncPage doesn't
|
|
47
|
+
// wrap them.
|
|
48
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
49
|
+
// ListMetadata-shape models referenced by a surviving non-paginated wrapper
|
|
50
|
+
// (e.g. vault's `VersionListResponse`) must still emit a dataclass —
|
|
51
|
+
// otherwise the wrapper's module imports a class that was never written.
|
|
52
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
53
|
+
|
|
44
54
|
for (const model of models) {
|
|
45
55
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
46
|
-
if (isListWrapperModel(model)) continue;
|
|
56
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
47
57
|
// Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
|
|
48
|
-
if (isListMetadataModel(model)) continue;
|
|
58
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
49
59
|
|
|
50
60
|
const service = modelToService.get(model.name);
|
|
51
61
|
const dirName = resolveDir(service);
|
|
@@ -865,5 +875,10 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
865
875
|
}
|
|
866
876
|
|
|
867
877
|
// Import and re-export shared model detection utilities
|
|
868
|
-
import {
|
|
878
|
+
import {
|
|
879
|
+
isListMetadataModel,
|
|
880
|
+
isListWrapperModel,
|
|
881
|
+
collectNonPaginatedResponseModelNames,
|
|
882
|
+
collectReferencedListMetadataModels,
|
|
883
|
+
} from '../shared/model-utils.js';
|
|
869
884
|
export { isListMetadataModel, isListWrapperModel };
|
package/src/python/resources.ts
CHANGED
|
@@ -1053,8 +1053,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1053
1053
|
|
|
1054
1054
|
for (const op of allOperations) {
|
|
1055
1055
|
const plan = planOperation(op);
|
|
1056
|
-
if (plan.responseModelName
|
|
1057
|
-
|
|
1056
|
+
if (plan.responseModelName) {
|
|
1057
|
+
// List-wrapper responses are normally replaced by SyncPage on paginated
|
|
1058
|
+
// ops, so the wrapper itself is never referenced. On non-paginated ops
|
|
1059
|
+
// (e.g. `GET /vault/v1/kv/{id}/versions` → `VersionListResponse`) the
|
|
1060
|
+
// resource method still returns the wrapper by name and must import it.
|
|
1061
|
+
if (!listWrapperNames.has(plan.responseModelName) || !plan.isPaginated) {
|
|
1062
|
+
modelImports.add(plan.responseModelName);
|
|
1063
|
+
}
|
|
1058
1064
|
}
|
|
1059
1065
|
if (op.requestBody?.kind === 'model') {
|
|
1060
1066
|
const requestBodyRef = op.requestBody;
|
package/src/python/tests.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { resolveResourceClassName, bodyParamName } from './resources.js';
|
|
|
22
22
|
import { buildServiceAccessPaths } from './client.js';
|
|
23
23
|
import { generateFixtures, generateModelFixture } from './fixtures.js';
|
|
24
24
|
import { isListWrapperModel, isListMetadataModel } from './models.js';
|
|
25
|
+
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
25
26
|
import {
|
|
26
27
|
groupByMount,
|
|
27
28
|
buildResolvedLookup,
|
|
@@ -1396,8 +1397,13 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1396
1397
|
// A model is request-only if it's used as a request body but never as a response
|
|
1397
1398
|
for (const name of responseModelNames) requestOnlyModelNames.delete(name);
|
|
1398
1399
|
|
|
1400
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
1401
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
1399
1402
|
const models = spec.models.filter(
|
|
1400
|
-
(m) =>
|
|
1403
|
+
(m) =>
|
|
1404
|
+
!(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
|
|
1405
|
+
!(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
|
|
1406
|
+
!requestOnlyModelNames.has(m.name),
|
|
1401
1407
|
);
|
|
1402
1408
|
if (models.length === 0) return null;
|
|
1403
1409
|
|
package/src/ruby/index.ts
CHANGED
|
@@ -36,8 +36,8 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
36
36
|
* has its original fields restored — otherwise `ConnectApplication`-style
|
|
37
37
|
* bases would silently lose every variant field they had previously.
|
|
38
38
|
*/
|
|
39
|
-
function enrichModelsForRuby(models: Model[]): Model[] {
|
|
40
|
-
const enriched = enrichModelsFromSpec(models);
|
|
39
|
+
function enrichModelsForRuby(models: Model[], enums: Enum[]): Model[] {
|
|
40
|
+
const enriched = enrichModelsFromSpec(models, enums);
|
|
41
41
|
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
42
42
|
return enriched.map((m) => {
|
|
43
43
|
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
@@ -54,7 +54,7 @@ export const rubyEmitter: Emitter = {
|
|
|
54
54
|
language: 'ruby',
|
|
55
55
|
|
|
56
56
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
57
|
-
const modelFiles = generateModels(enrichModelsForRuby(models), ctx);
|
|
57
|
+
const modelFiles = generateModels(enrichModelsForRuby(models, ctx.spec.enums), ctx);
|
|
58
58
|
return ensureTrailingNewlines(modelFiles);
|
|
59
59
|
},
|
|
60
60
|
|
package/src/ruby/models.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
2
|
import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
|
|
3
3
|
import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isListWrapperModel,
|
|
6
|
+
isListMetadataModel,
|
|
7
|
+
collectNonPaginatedResponseModelNames,
|
|
8
|
+
} from '../shared/model-utils.js';
|
|
5
9
|
|
|
6
10
|
/** Folder under lib/workos/ for models not owned by any service. */
|
|
7
11
|
export const SHARED_MODEL_DIR = 'shared';
|
|
@@ -77,11 +81,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
77
81
|
|
|
78
82
|
const files: GeneratedFile[] = [];
|
|
79
83
|
|
|
84
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
85
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
86
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
87
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
88
|
+
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
89
|
+
|
|
80
90
|
// Dedup identical models (by recursive structural hash).
|
|
81
91
|
const recursiveHashes = buildRecursiveHashMap(models, enumNames);
|
|
82
92
|
const hashGroups = new Map<string, string[]>();
|
|
83
93
|
for (const m of models) {
|
|
84
|
-
if (
|
|
94
|
+
if (skipAsListWrapper(m) || isListMetadataModel(m)) continue;
|
|
85
95
|
const h = recursiveHashes.get(m.name) ?? '';
|
|
86
96
|
if (!hashGroups.has(h)) hashGroups.set(h, []);
|
|
87
97
|
hashGroups.get(h)!.push(m.name);
|
|
@@ -95,7 +105,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
for (const model of models) {
|
|
98
|
-
if (
|
|
108
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
99
109
|
|
|
100
110
|
const cls = className(model.name);
|
|
101
111
|
const file = fileName(model.name);
|
|
@@ -29,8 +29,10 @@ function mapSorbetType(ref: TypeRef): string {
|
|
|
29
29
|
return `WorkOS::${className(ref.name)}`;
|
|
30
30
|
case 'enum':
|
|
31
31
|
return 'String';
|
|
32
|
-
case 'nullable':
|
|
33
|
-
|
|
32
|
+
case 'nullable': {
|
|
33
|
+
const inner = mapSorbetType(ref.inner);
|
|
34
|
+
return inner === 'T.untyped' ? inner : `T.nilable(${inner})`;
|
|
35
|
+
}
|
|
34
36
|
case 'literal':
|
|
35
37
|
if (typeof ref.value === 'string') return 'String';
|
|
36
38
|
if (ref.value === null) return 'NilClass';
|
package/src/ruby/rbi.ts
CHANGED
|
@@ -316,6 +316,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
316
316
|
|
|
317
317
|
/** Unwrap T.nilable(...) if already wrapped, to avoid double-wrapping. */
|
|
318
318
|
function unwrapNilable(type: string): string {
|
|
319
|
+
if (type === 'T.untyped') return type;
|
|
319
320
|
const match = type.match(/^T\.nilable\((.+)\)$/);
|
|
320
321
|
return match ? match[1] : type;
|
|
321
322
|
}
|
package/src/rust/models.ts
CHANGED
|
@@ -140,7 +140,11 @@ function renderField(field: Field, rustField: string, modelName: string, registr
|
|
|
140
140
|
function renderModelsBarrel(modules: string[]): string {
|
|
141
141
|
const sorted = [...new Set(modules)].sort();
|
|
142
142
|
const lines: string[] = [];
|
|
143
|
-
|
|
143
|
+
// Declare the modules privately so `pub use crate::models::*` in lib.rs only
|
|
144
|
+
// re-exports the struct names, not the module names themselves. Otherwise a
|
|
145
|
+
// module like `models::organization_membership` collides with the same-named
|
|
146
|
+
// `resources::organization_membership` when both barrels are glob-re-exported.
|
|
147
|
+
for (const m of sorted) lines.push(`mod ${m};`);
|
|
144
148
|
lines.push('');
|
|
145
149
|
for (const m of sorted) lines.push(`pub use ${m}::*;`);
|
|
146
150
|
return lines.join('\n') + '\n';
|
package/src/rust/resources.ts
CHANGED
|
@@ -1317,7 +1317,10 @@ function renderResourcesBarrel(exports: { module: string; struct: string }[]): s
|
|
|
1317
1317
|
unique.sort((a, b) => a.module.localeCompare(b.module));
|
|
1318
1318
|
|
|
1319
1319
|
const lines: string[] = [];
|
|
1320
|
-
|
|
1320
|
+
// Declare modules privately — see the matching comment in `models.ts`.
|
|
1321
|
+
// `pub mod resources::organization_membership` would collide with the
|
|
1322
|
+
// same-named module re-exported via `pub use models::*` in lib.rs.
|
|
1323
|
+
for (const { module } of unique) lines.push(`mod ${module};`);
|
|
1321
1324
|
lines.push('');
|
|
1322
1325
|
for (const { module, struct } of unique) lines.push(`pub use ${module}::${struct};`);
|
|
1323
1326
|
return lines.join('\n') + '\n';
|
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
-
import { toSnakeCase, toUpperSnakeCase } from '@workos/oagen';
|
|
1
|
+
import type { Model, Field, TypeRef, Enum, Service } 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
|
|
6
6
|
import { load as yamlLoad } from 'js-yaml';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Collect model names referenced as the return type of any non-paginated
|
|
10
|
+
* operation. The list-wrapper skip rule below assumes a wrapper is always
|
|
11
|
+
* replaced by the SDK's pagination machinery — but a few endpoints
|
|
12
|
+
* (e.g. `GET /vault/v1/kv/{id}/versions`) have a list-envelope response
|
|
13
|
+
* shape with no pagination params, so the parser leaves them as a plain
|
|
14
|
+
* model reference. We must still emit those wrappers as regular models;
|
|
15
|
+
* otherwise the generated resource code references an undefined name.
|
|
16
|
+
*/
|
|
17
|
+
export function collectNonPaginatedResponseModelNames(services: Service[]): Set<string> {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const service of services) {
|
|
20
|
+
for (const op of service.operations) {
|
|
21
|
+
if (op.pagination) continue;
|
|
22
|
+
walkTypeRef(op.response, { model: (r) => names.add(r.name) });
|
|
23
|
+
for (const sr of op.successResponses ?? []) walkTypeRef(sr.type, { model: (r) => names.add(r.name) });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
/**
|
|
9
30
|
* Detect whether a model is a list wrapper -- the standard paginated
|
|
10
31
|
* list envelope with `data` (array), `list_metadata`, and optionally `object: 'list'`.
|
|
@@ -51,6 +72,41 @@ export function isListMetadataModel(model: Model): boolean {
|
|
|
51
72
|
return isNullableString(before) && isNullableString(after);
|
|
52
73
|
}
|
|
53
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
|
+
|
|
54
110
|
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
55
111
|
function isNullableString(field: Field): boolean {
|
|
56
112
|
if (field.type.kind === 'primitive' && field.type.type === 'string') return true;
|
|
@@ -503,7 +559,7 @@ export function detectDiscriminators(models: Model[]): Model[] {
|
|
|
503
559
|
* Returns a new array of enriched models (original models are not mutated).
|
|
504
560
|
* Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
|
|
505
561
|
*/
|
|
506
|
-
export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
562
|
+
export function enrichModelsFromSpec(models: Model[], enums: Enum[] = []): Model[] {
|
|
507
563
|
const spec = loadRawSpec();
|
|
508
564
|
if (!spec) {
|
|
509
565
|
_lastSyntheticEnums = [];
|
|
@@ -518,6 +574,17 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
518
574
|
collector.usedNames.add(m.name);
|
|
519
575
|
collector.usedNames.add(toSnakeCase(m.name));
|
|
520
576
|
}
|
|
577
|
+
// Seed existing IR enum names too. The parser already emits inline enums
|
|
578
|
+
// like `DataIntegrationAccessTokenResponseError` (PascalCase); without this
|
|
579
|
+
// seed, the synthetic path would emit a sibling enum named
|
|
580
|
+
// `DataIntegrationAccessTokenResponse_error`, and language emitters that
|
|
581
|
+
// PascalCase-normalize identifiers (Ruby, Go, PHP, Python) would collapse
|
|
582
|
+
// both onto the same class/file path — the second overwrites the first
|
|
583
|
+
// with a `X = X` self-alias.
|
|
584
|
+
for (const e of enums) {
|
|
585
|
+
collector.usedNames.add(e.name);
|
|
586
|
+
collector.usedNames.add(toSnakeCase(e.name));
|
|
587
|
+
}
|
|
521
588
|
|
|
522
589
|
const enriched = models.map((model) => {
|
|
523
590
|
const rawSchema = lookupRawSchema(model.name);
|
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
|
});
|
package/test/rust/models.test.ts
CHANGED
|
@@ -110,7 +110,7 @@ describe('rust/models', () => {
|
|
|
110
110
|
const event = files.find((f) => f.path === 'src/models/event.rs')!;
|
|
111
111
|
expect(event.content).toContain('pub payload: EventPayloadOneOf,');
|
|
112
112
|
const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
|
|
113
|
-
expect(barrel.content).toContain('
|
|
113
|
+
expect(barrel.content).toContain('mod _unions;');
|
|
114
114
|
// The _unions.rs file is rendered by generateClient (the final structural
|
|
115
115
|
// pass) so resource-side body unions can join the same registry.
|
|
116
116
|
const clientFiles = generateClient(emptySpec, ctx, registry);
|
|
@@ -170,8 +170,8 @@ describe('rust/models', () => {
|
|
|
170
170
|
];
|
|
171
171
|
const files = generateModels(models, ctx, new UnionRegistry());
|
|
172
172
|
const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
|
|
173
|
-
expect(barrel.content).toContain('
|
|
174
|
-
expect(barrel.content).toContain('
|
|
173
|
+
expect(barrel.content).toContain('mod alpha;');
|
|
174
|
+
expect(barrel.content).toContain('mod beta;');
|
|
175
175
|
expect(barrel.content).toContain('pub use alpha::*;');
|
|
176
176
|
});
|
|
177
177
|
});
|