@workos/oagen-emitters 0.14.1 → 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 +9 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-DRGwxN88.mjs → plugin-BbSmT2kj.mjs} +125 -40
- package/dist/plugin-BbSmT2kj.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/go/fixtures.ts +6 -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 +13 -3
- package/src/node/index.ts +14 -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 +6 -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/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
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-BbSmT2kj.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
package/src/go/fixtures.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
2
|
import { fileName, fieldName } from './naming.js';
|
|
3
3
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
4
|
+
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Prefix mapping for generating realistic ID fixture values.
|
|
@@ -34,9 +35,12 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
34
35
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
35
36
|
const files: { path: string; content: string }[] = [];
|
|
36
37
|
|
|
38
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
39
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
40
|
+
|
|
37
41
|
for (const model of spec.models) {
|
|
38
|
-
if (isListMetadataModel(model)) continue;
|
|
39
|
-
if (isListWrapperModel(model)) continue;
|
|
42
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
43
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
40
44
|
|
|
41
45
|
const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
|
|
42
46
|
|
package/src/go/models.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isListWrapperModel,
|
|
10
10
|
isListMetadataModel,
|
|
11
11
|
collectNonPaginatedResponseModelNames,
|
|
12
|
+
collectReferencedListMetadataModels,
|
|
12
13
|
} from '../shared/model-utils.js';
|
|
13
14
|
export { isListWrapperModel, isListMetadataModel };
|
|
14
15
|
|
|
@@ -92,12 +93,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
92
93
|
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
93
94
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
94
95
|
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
96
|
+
// A `ListMetadata`-shape model referenced by a surviving non-paginated
|
|
97
|
+
// wrapper (e.g. vault's `VersionListResponse`) must still be emitted —
|
|
98
|
+
// otherwise the wrapper's struct references a type that was never declared.
|
|
99
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
100
|
+
const skipAsListMetadata = (m: Model): boolean => isListMetadataModel(m) && !listMetadataNeeded.has(m.name);
|
|
95
101
|
|
|
96
102
|
// Build structural hash for deduplication
|
|
97
103
|
const modelHashMap = new Map<string, string>();
|
|
98
104
|
const hashGroups = new Map<string, string[]>();
|
|
99
105
|
for (const model of models) {
|
|
100
|
-
if (skipAsListWrapper(model) ||
|
|
106
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
101
107
|
if (requestBodyOnly.has(model.name)) continue;
|
|
102
108
|
const hash = structuralHash(model);
|
|
103
109
|
modelHashMap.set(model.name, hash);
|
|
@@ -121,7 +127,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
121
127
|
|
|
122
128
|
const batchedAliases = new Set<string>();
|
|
123
129
|
for (const model of models) {
|
|
124
|
-
if (skipAsListWrapper(model) ||
|
|
130
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
125
131
|
if (requestBodyOnly.has(model.name)) continue;
|
|
126
132
|
|
|
127
133
|
const structName = className(model.name);
|
package/src/kotlin/models.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isListWrapperModel,
|
|
7
7
|
isListMetadataModel,
|
|
8
8
|
collectNonPaginatedResponseModelNames,
|
|
9
|
+
collectReferencedListMetadataModels,
|
|
9
10
|
} from '../shared/model-utils.js';
|
|
10
11
|
|
|
11
12
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
@@ -67,13 +68,18 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
67
68
|
// code references them by name and pagination iterators don't unwrap them.
|
|
68
69
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
69
70
|
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
71
|
+
// A `ListMetadata`-shape model referenced by a surviving non-paginated
|
|
72
|
+
// wrapper (e.g. vault's `VersionListResponse`) must still emit a data class
|
|
73
|
+
// — otherwise the wrapper's class references a type that was never declared.
|
|
74
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
75
|
+
const skipAsListMetadata = (m: Model): boolean => isListMetadataModel(m) && !listMetadataNeeded.has(m.name);
|
|
70
76
|
|
|
71
77
|
// Deduplication: identical structures become typealiases.
|
|
72
78
|
// Pass 1: hash without nested-alias resolution.
|
|
73
79
|
modelAliasMap = null;
|
|
74
80
|
const hashGroupsPass1 = new Map<string, string[]>();
|
|
75
81
|
for (const model of models) {
|
|
76
|
-
if (skipAsListWrapper(model) ||
|
|
82
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
77
83
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
78
84
|
const hash = structuralHash(model);
|
|
79
85
|
if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
|
|
@@ -96,7 +102,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
96
102
|
modelAliasMap = aliasOf;
|
|
97
103
|
const hashGroupsPass2 = new Map<string, string[]>();
|
|
98
104
|
for (const model of models) {
|
|
99
|
-
if (skipAsListWrapper(model) ||
|
|
105
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
100
106
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
101
107
|
if (aliasOf.has(model.name)) continue; // already aliased in pass 1
|
|
102
108
|
const hash = structuralHash(model);
|
|
@@ -115,7 +121,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
for (const model of models) {
|
|
118
|
-
if (skipAsListWrapper(model) ||
|
|
124
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
119
125
|
const typeName = className(model.name);
|
|
120
126
|
|
|
121
127
|
// Parent of a discriminated union: emit a sealed class.
|
|
@@ -152,7 +158,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
152
158
|
// mapping so Jackson can deserialize directly to the correct concrete type.
|
|
153
159
|
const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
|
|
154
160
|
for (const model of models) {
|
|
155
|
-
if (skipAsListWrapper(model) ||
|
|
161
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
156
162
|
if (aliasOf.has(model.name)) continue;
|
|
157
163
|
if (!isEventEnvelopeModel(model)) continue;
|
|
158
164
|
const eventField = model.fields.find((f) => f.name === 'event');
|
package/src/kotlin/resources.ts
CHANGED
|
@@ -11,7 +11,12 @@ import type {
|
|
|
11
11
|
} from '@workos/oagen';
|
|
12
12
|
import { planOperation } from '@workos/oagen';
|
|
13
13
|
import { mapTypeRef, mapTypeRefOptional, implicitImportsFor } from './type-map.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
isListWrapperModel,
|
|
16
|
+
isListMetadataModel,
|
|
17
|
+
collectNonPaginatedResponseModelNames,
|
|
18
|
+
collectReferencedListMetadataModels,
|
|
19
|
+
} from '../shared/model-utils.js';
|
|
15
20
|
import { enumCanonicalMap } from './enums.js';
|
|
16
21
|
import {
|
|
17
22
|
className,
|
|
@@ -1124,6 +1129,12 @@ function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterCon
|
|
|
1124
1129
|
const mapped = mapTypeRef(ref);
|
|
1125
1130
|
for (const imp of implicitImportsFor(mapped)) imports.add(imp);
|
|
1126
1131
|
|
|
1132
|
+
// Match the model-emission gate: a `ListMetadata`-shape model that survives
|
|
1133
|
+
// emission (because a non-paginated wrapper still references it) needs its
|
|
1134
|
+
// import here too.
|
|
1135
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
1136
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(ctx.spec.models, nonPaginatedRefs);
|
|
1137
|
+
|
|
1127
1138
|
walk(ref, (r) => {
|
|
1128
1139
|
if (r.kind === 'enum') {
|
|
1129
1140
|
// When an enum is aliased, import the canonical class instead of the alias.
|
|
@@ -1132,7 +1143,11 @@ function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterCon
|
|
|
1132
1143
|
}
|
|
1133
1144
|
if (r.kind === 'model') {
|
|
1134
1145
|
const referenced = ctx.spec.models.find((m) => m.name === r.name);
|
|
1135
|
-
if (referenced
|
|
1146
|
+
if (referenced) {
|
|
1147
|
+
const skipWrapper = isListWrapperModel(referenced) && !nonPaginatedRefs.has(referenced.name);
|
|
1148
|
+
const skipMetadata = isListMetadataModel(referenced) && !listMetadataNeeded.has(referenced.name);
|
|
1149
|
+
if (skipWrapper || skipMetadata) return;
|
|
1150
|
+
}
|
|
1136
1151
|
imports.add(`com.workos.models.${className(r.name)}`);
|
|
1137
1152
|
}
|
|
1138
1153
|
});
|
package/src/node/fixtures.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { wireFieldName, fileName, resolveServiceDir } from './naming.js';
|
|
3
3
|
import { resolveResourceClassName, resolveResourceDir } from './resources.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createServiceDirResolver,
|
|
6
|
+
assignModelsToServices,
|
|
7
|
+
isListMetadataModel,
|
|
8
|
+
isListWrapperModel,
|
|
9
|
+
collectNonPaginatedResponseModelNames,
|
|
10
|
+
collectReferencedListMetadataModels,
|
|
11
|
+
} from './utils.js';
|
|
5
12
|
|
|
6
13
|
export const ID_PREFIXES: Record<string, string> = {
|
|
7
14
|
Connection: 'conn_',
|
|
@@ -75,11 +82,14 @@ export function generateFixtures(
|
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
86
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
87
|
+
|
|
78
88
|
const seenFixturePaths = new Set<string>();
|
|
79
89
|
for (const model of spec.models) {
|
|
80
90
|
if (!fixtureReachable.has(model.name)) continue;
|
|
81
|
-
if (isListMetadataModel(model)) continue;
|
|
82
|
-
if (isListWrapperModel(model)) continue;
|
|
91
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
92
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
83
93
|
|
|
84
94
|
const service = modelToService.get(model.name);
|
|
85
95
|
const dirName = resolveDir(service);
|
package/src/node/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
setBaselineSerializedNames,
|
|
24
24
|
setBaselineInterfaceNames,
|
|
25
25
|
setAdoptedModelNames,
|
|
26
|
+
setStructurallyRenamedDomainNames,
|
|
26
27
|
resolveInterfaceName,
|
|
27
28
|
} from './naming.js';
|
|
28
29
|
import { withNodeOperationOverrides } from './node-overrides.js';
|
|
@@ -108,6 +109,19 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
108
109
|
// Pass an empty map; type-map will fall back to emitting the symbol name.
|
|
109
110
|
setInlineEnumUnions(new Map());
|
|
110
111
|
setAdoptedModelNames(computeAdoptedModelNames(ctx, surface));
|
|
112
|
+
|
|
113
|
+
// Pre-compute which domain names the resolver reaches via structural
|
|
114
|
+
// rename so `wireInterfaceName` can tell `AuditLogSchemaJson` →
|
|
115
|
+
// `AuditLogSchemaResponse` (real single-form case) apart from
|
|
116
|
+
// `CreateDataKeyResponse` → `CreateDataKeyResponse` (fresh IR model whose
|
|
117
|
+
// own name already ends in `Response`).
|
|
118
|
+
const renamed = new Set<string>();
|
|
119
|
+
for (const model of ctx.spec.models) {
|
|
120
|
+
const resolved = resolveInterfaceName(model.name, ctx);
|
|
121
|
+
if (resolved !== model.name) renamed.add(resolved);
|
|
122
|
+
}
|
|
123
|
+
setStructurallyRenamedDomainNames(renamed);
|
|
124
|
+
|
|
111
125
|
return surface;
|
|
112
126
|
}
|
|
113
127
|
|
package/src/node/models.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
isListMetadataModel,
|
|
25
25
|
isListWrapperModel,
|
|
26
26
|
collectNonPaginatedResponseModelNames,
|
|
27
|
+
collectReferencedListMetadataModels,
|
|
27
28
|
buildDeduplicationMap,
|
|
28
29
|
relativeImport,
|
|
29
30
|
modelHasNewFields,
|
|
@@ -214,11 +215,19 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
214
215
|
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
215
216
|
// code references them by name and pagination iterators don't unwrap them.
|
|
216
217
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
218
|
+
|
|
219
|
+
// ListMetadata-shape models are usually subsumed by the SDK's shared
|
|
220
|
+
// pagination wrapper, so we blanket-skip them. But a non-paginated
|
|
221
|
+
// wrapper like vault's `VersionListResponse` keeps the reference live
|
|
222
|
+
// (`list_metadata: ListMetadata`), and skipping the emission leaves the
|
|
223
|
+
// wrapper's interface importing from a file that was never written.
|
|
224
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
225
|
+
|
|
217
226
|
for (const originalModel of models) {
|
|
218
227
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
219
228
|
if (!reachableModels.has(model.name)) continue;
|
|
220
229
|
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
221
|
-
if (isListMetadataModel(model)) continue;
|
|
230
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
222
231
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
223
232
|
if (discriminatedSkip?.has(model.name)) continue;
|
|
224
233
|
const service = modelToService.get(model.name);
|
|
@@ -517,36 +526,43 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
517
526
|
}
|
|
518
527
|
lines.push('');
|
|
519
528
|
|
|
520
|
-
// Wire/response interface
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
529
|
+
// Wire/response interface — skip when the wire name collapsed onto the
|
|
530
|
+
// domain name (single-form structural-rename case, e.g. IR `Object` →
|
|
531
|
+
// `ReadObjectResponse`). Emitting the second declaration would either
|
|
532
|
+
// produce a literal duplicate `export interface ReadObjectResponse`
|
|
533
|
+
// pair or, after TypeScript's silent declaration merge, leave the
|
|
534
|
+
// call site with `import type { ReadObjectResponse, ReadObjectResponse }`.
|
|
535
|
+
if (responseName !== domainName) {
|
|
536
|
+
const seenWireFields = new Set<string>();
|
|
537
|
+
if (model.fields.length === 0) {
|
|
538
|
+
lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
539
|
+
} else {
|
|
540
|
+
lines.push(`export interface ${responseName}${typeParams} {`);
|
|
541
|
+
for (const field of model.fields) {
|
|
542
|
+
const wireField = wireFieldName(field.name);
|
|
543
|
+
if (seenWireFields.has(wireField)) continue;
|
|
544
|
+
seenWireFields.add(wireField);
|
|
545
|
+
const baselineField = baselineResponse?.fields?.[wireField];
|
|
546
|
+
if (
|
|
547
|
+
baselineField &&
|
|
548
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
549
|
+
baselineFieldCompatible(baselineField, field)
|
|
550
|
+
) {
|
|
551
|
+
const opt = baselineField.optional ? '?' : '';
|
|
552
|
+
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
553
|
+
} else {
|
|
554
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
555
|
+
// Same baseline-optional preservation as the domain side. The
|
|
556
|
+
// wire interface's optional flag drives test-fixture shape, so
|
|
557
|
+
// flipping it on regen breaks every fixture that omitted the
|
|
558
|
+
// field assuming it was optional.
|
|
559
|
+
const baselineSaysOptional = baselineField?.optional === true;
|
|
560
|
+
const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
561
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
562
|
+
}
|
|
547
563
|
}
|
|
564
|
+
lines.push('}');
|
|
548
565
|
}
|
|
549
|
-
lines.push('}');
|
|
550
566
|
}
|
|
551
567
|
|
|
552
568
|
// Preserve inline types from existing file
|
|
@@ -739,12 +755,16 @@ export function generateSerializers(
|
|
|
739
755
|
|
|
740
756
|
const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
741
757
|
const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
758
|
+
|
|
759
|
+
// Mirror the interface-emission gate (see `generateModels`).
|
|
760
|
+
const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
|
|
761
|
+
|
|
742
762
|
const eligibleModels: Model[] = [];
|
|
743
763
|
for (const originalModel of models) {
|
|
744
764
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
745
765
|
if (!serializerReachable.has(model.name)) continue;
|
|
746
766
|
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
747
|
-
if (isListMetadataModel(model)) continue;
|
|
767
|
+
if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
|
|
748
768
|
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
749
769
|
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
750
770
|
const service = modelToService.get(model.name);
|
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
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
|
@@ -46,12 +46,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
46
46
|
// emitted — the resource code references them by name and SyncPage doesn't
|
|
47
47
|
// wrap them.
|
|
48
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);
|
|
49
53
|
|
|
50
54
|
for (const model of models) {
|
|
51
55
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
52
56
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
53
57
|
// Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
|
|
54
|
-
if (isListMetadataModel(model)) continue;
|
|
58
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
55
59
|
|
|
56
60
|
const service = modelToService.get(model.name);
|
|
57
61
|
const dirName = resolveDir(service);
|
|
@@ -875,5 +879,6 @@ import {
|
|
|
875
879
|
isListMetadataModel,
|
|
876
880
|
isListWrapperModel,
|
|
877
881
|
collectNonPaginatedResponseModelNames,
|
|
882
|
+
collectReferencedListMetadataModels,
|
|
878
883
|
} from '../shared/model-utils.js';
|
|
879
884
|
export { isListMetadataModel, isListWrapperModel };
|
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
|
|
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|