@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/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/dotnet/models.ts
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
} from './naming.js';
|
|
21
21
|
|
|
22
22
|
// Import and re-export shared model detection utilities
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
isListWrapperModel,
|
|
25
|
+
isListMetadataModel,
|
|
26
|
+
collectNonPaginatedResponseModelNames,
|
|
27
|
+
} from '../shared/model-utils.js';
|
|
24
28
|
export { isListWrapperModel, isListMetadataModel };
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -74,6 +78,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
74
78
|
// (see workos-dotnet#248 with `CreateUserApiKey` /
|
|
75
79
|
// `UserManagementCreateApiKeyOptions`). Skip emission for those.
|
|
76
80
|
const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
|
|
81
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
82
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
83
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
84
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
85
|
+
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
77
86
|
|
|
78
87
|
// Build a lookup of base model field C# names → C# types for inheritance.
|
|
79
88
|
// Variant models skip inherited fields and use `new` for type-divergent ones.
|
|
@@ -81,9 +90,12 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
81
90
|
if (discCtx) {
|
|
82
91
|
for (const model of models) {
|
|
83
92
|
if (discCtx.discriminatorBases.has(model.name)) {
|
|
93
|
+
const baseClassName = modelClassName(model.name);
|
|
84
94
|
const fieldMap = new Map<string, string>();
|
|
85
95
|
for (const field of model.fields) {
|
|
86
|
-
|
|
96
|
+
let csName = fieldName(field.name);
|
|
97
|
+
if (csName === baseClassName) csName = `${csName}Value`;
|
|
98
|
+
fieldMap.set(csName, mapTypeRef(field.type));
|
|
87
99
|
}
|
|
88
100
|
baseFieldLookup.set(model.name, fieldMap);
|
|
89
101
|
}
|
|
@@ -91,7 +103,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
for (const model of models) {
|
|
94
|
-
if (
|
|
106
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
95
107
|
if (requestBodyOnlyNames.has(model.name)) continue;
|
|
96
108
|
|
|
97
109
|
const csClassName = modelClassName(model.name);
|
|
@@ -104,7 +116,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
104
116
|
const fieldTypes = model.fields.map((f) => mapTypeRef(f.type));
|
|
105
117
|
const needsCollections = fieldTypes.some((t) => t.startsWith('List<') || t.startsWith('Dictionary<'));
|
|
106
118
|
const needsSystem = fieldTypes.some((t) => t.includes('DateTimeOffset'));
|
|
107
|
-
|
|
119
|
+
// Required enums need JsonProperty / STJS; a field whose PascalCase name
|
|
120
|
+
// collides with the enclosing class needs the same imports for the wire-
|
|
121
|
+
// name override emitted below.
|
|
122
|
+
const hasClassNameCollision = model.fields.some((f) => fieldName(f.name) === csClassName);
|
|
123
|
+
const needsJsonAttrs = hasClassNameCollision || model.fields.some((f) => f.required && isEnumRef(f.type));
|
|
108
124
|
|
|
109
125
|
lines.push(`namespace ${ctx.namespacePascal}`);
|
|
110
126
|
lines.push('{');
|
|
@@ -154,7 +170,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
154
170
|
// Deduplicate fields by C# property name
|
|
155
171
|
const seenFieldNames = new Set<string>();
|
|
156
172
|
for (const field of model.fields) {
|
|
157
|
-
|
|
173
|
+
// CS0542: a property can't share its enclosing class's name. Spec schemas
|
|
174
|
+
// like `Error.error` PascalCase into `Error.Error`, so suffix with `Value`
|
|
175
|
+
// when that happens. Track the rename so we emit an explicit
|
|
176
|
+
// `[JsonProperty]` attribute below — the SnakeCaseLower naming policy
|
|
177
|
+
// would otherwise serialize `ErrorValue` as `error_value`, not `error`.
|
|
178
|
+
let csFieldName = fieldName(field.name);
|
|
179
|
+
const collidesWithClassName = csFieldName === csClassName;
|
|
180
|
+
if (collidesWithClassName) csFieldName = `${csFieldName}Value`;
|
|
158
181
|
if (seenFieldNames.has(csFieldName)) continue;
|
|
159
182
|
seenFieldNames.add(csFieldName);
|
|
160
183
|
|
|
@@ -234,7 +257,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
234
257
|
}
|
|
235
258
|
|
|
236
259
|
const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
|
|
237
|
-
lines.push(
|
|
260
|
+
lines.push(
|
|
261
|
+
...emitJsonPropertyAttributes(field.name, { isRequiredEnum, explicitWireName: collidesWithClassName }),
|
|
262
|
+
);
|
|
238
263
|
// Discriminated-union-typed field: attach the variant-dispatching converter
|
|
239
264
|
// so Newtonsoft picks the right subtype on deserialization. The converter
|
|
240
265
|
// name is keyed off the first IR variant model name (matches how
|
package/src/dotnet/type-map.ts
CHANGED
|
@@ -156,7 +156,24 @@ export function isEnumRef(ref: TypeRef): boolean {
|
|
|
156
156
|
* omission so the API returns a clear `missing required field` error instead
|
|
157
157
|
* of a confusing 422.
|
|
158
158
|
*/
|
|
159
|
-
export function emitJsonPropertyAttributes(
|
|
159
|
+
export function emitJsonPropertyAttributes(
|
|
160
|
+
wireName: string,
|
|
161
|
+
options: { isRequiredEnum?: boolean; explicitWireName?: boolean } = {},
|
|
162
|
+
): string[] {
|
|
163
|
+
// When the C# property name doesn't naturally map back to the wire name via
|
|
164
|
+
// the SnakeCaseLower naming policy (e.g. we suffixed the property to avoid a
|
|
165
|
+
// CS0542 class/member collision like `Error.Error` → `Error.ErrorValue`), the
|
|
166
|
+
// caller pins the wire name explicitly so JSON round-trip still works.
|
|
167
|
+
if (options.explicitWireName) {
|
|
168
|
+
if (options.isRequiredEnum) {
|
|
169
|
+
return [
|
|
170
|
+
` [JsonProperty("${wireName}", DefaultValueHandling = DefaultValueHandling.Ignore)]`,
|
|
171
|
+
` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
|
|
172
|
+
` [STJS.JsonPropertyName("${wireName}")]`,
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
return [` [JsonProperty("${wireName}")]`, ` [STJS.JsonPropertyName("${wireName}")]`];
|
|
176
|
+
}
|
|
160
177
|
if (options.isRequiredEnum) {
|
|
161
178
|
return [
|
|
162
179
|
` [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`,
|
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
|
@@ -5,7 +5,12 @@ import { className, fieldName } from './naming.js';
|
|
|
5
5
|
import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
|
|
6
6
|
|
|
7
7
|
// Import and re-export shared model detection utilities
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isListWrapperModel,
|
|
10
|
+
isListMetadataModel,
|
|
11
|
+
collectNonPaginatedResponseModelNames,
|
|
12
|
+
collectReferencedListMetadataModels,
|
|
13
|
+
} from '../shared/model-utils.js';
|
|
9
14
|
export { isListWrapperModel, isListMetadataModel };
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -83,12 +88,22 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
83
88
|
lines.push('');
|
|
84
89
|
|
|
85
90
|
const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
|
|
91
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
92
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
93
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
94
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
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);
|
|
86
101
|
|
|
87
102
|
// Build structural hash for deduplication
|
|
88
103
|
const modelHashMap = new Map<string, string>();
|
|
89
104
|
const hashGroups = new Map<string, string[]>();
|
|
90
105
|
for (const model of models) {
|
|
91
|
-
if (
|
|
106
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
92
107
|
if (requestBodyOnly.has(model.name)) continue;
|
|
93
108
|
const hash = structuralHash(model);
|
|
94
109
|
modelHashMap.set(model.name, hash);
|
|
@@ -112,7 +127,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
112
127
|
|
|
113
128
|
const batchedAliases = new Set<string>();
|
|
114
129
|
for (const model of models) {
|
|
115
|
-
if (
|
|
130
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
116
131
|
if (requestBodyOnly.has(model.name)) continue;
|
|
117
132
|
|
|
118
133
|
const structName = className(model.name);
|
package/src/kotlin/models.ts
CHANGED
|
@@ -2,7 +2,12 @@ import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@work
|
|
|
2
2
|
import { mapTypeRef, discriminatedUnions } from './type-map.js';
|
|
3
3
|
import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
|
|
4
4
|
import { enumCanonicalMap } from './enums.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isListWrapperModel,
|
|
7
|
+
isListMetadataModel,
|
|
8
|
+
collectNonPaginatedResponseModelNames,
|
|
9
|
+
collectReferencedListMetadataModels,
|
|
10
|
+
} from '../shared/model-utils.js';
|
|
6
11
|
|
|
7
12
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
8
13
|
const MODELS_PACKAGE = 'com.workos.models';
|
|
@@ -47,7 +52,7 @@ function promoteFieldType(f: Field): Field {
|
|
|
47
52
|
* List wrappers (`{ data, list_metadata }`) and the shared `ListMetadata`
|
|
48
53
|
* model are skipped — the hand-maintained runtime provides [Page]/[ListMetadata].
|
|
49
54
|
*/
|
|
50
|
-
export function generateModels(models: Model[],
|
|
55
|
+
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
51
56
|
if (models.length === 0) return [];
|
|
52
57
|
|
|
53
58
|
// First pass: call mapTypeRef on every model field so discriminator info is
|
|
@@ -58,12 +63,23 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
58
63
|
|
|
59
64
|
const files: GeneratedFile[] = [];
|
|
60
65
|
|
|
66
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
67
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
68
|
+
// code references them by name and pagination iterators don't unwrap them.
|
|
69
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
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);
|
|
76
|
+
|
|
61
77
|
// Deduplication: identical structures become typealiases.
|
|
62
78
|
// Pass 1: hash without nested-alias resolution.
|
|
63
79
|
modelAliasMap = null;
|
|
64
80
|
const hashGroupsPass1 = new Map<string, string[]>();
|
|
65
81
|
for (const model of models) {
|
|
66
|
-
if (
|
|
82
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
67
83
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
68
84
|
const hash = structuralHash(model);
|
|
69
85
|
if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
|
|
@@ -86,7 +102,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
86
102
|
modelAliasMap = aliasOf;
|
|
87
103
|
const hashGroupsPass2 = new Map<string, string[]>();
|
|
88
104
|
for (const model of models) {
|
|
89
|
-
if (
|
|
105
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
90
106
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
91
107
|
if (aliasOf.has(model.name)) continue; // already aliased in pass 1
|
|
92
108
|
const hash = structuralHash(model);
|
|
@@ -105,7 +121,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
for (const model of models) {
|
|
108
|
-
if (
|
|
124
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
109
125
|
const typeName = className(model.name);
|
|
110
126
|
|
|
111
127
|
// Parent of a discriminated union: emit a sealed class.
|
|
@@ -142,7 +158,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
142
158
|
// mapping so Jackson can deserialize directly to the correct concrete type.
|
|
143
159
|
const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
|
|
144
160
|
for (const model of models) {
|
|
145
|
-
if (
|
|
161
|
+
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
146
162
|
if (aliasOf.has(model.name)) continue;
|
|
147
163
|
if (!isEventEnvelopeModel(model)) continue;
|
|
148
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';
|
|
@@ -38,6 +39,24 @@ import { fileName } from './naming.js';
|
|
|
38
39
|
*/
|
|
39
40
|
const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Paths the node emitter has produced so far in this ctx, accumulated across
|
|
44
|
+
* `applyLiveSurface` calls. Drives `carryForwardManagedFiles` so files in the
|
|
45
|
+
* prior manifest that we did not re-emit this run still land in the new
|
|
46
|
+
* manifest as "still managed" — without that, the orchestrator's prune diff
|
|
47
|
+
* treats every untouched autogen file as stale.
|
|
48
|
+
*/
|
|
49
|
+
const emittedPathsCache = new WeakMap<EmitterContext, Set<string>>();
|
|
50
|
+
|
|
51
|
+
function getEmittedPaths(ctx: EmitterContext): Set<string> {
|
|
52
|
+
let set = emittedPathsCache.get(ctx);
|
|
53
|
+
if (!set) {
|
|
54
|
+
set = new Set();
|
|
55
|
+
emittedPathsCache.set(ctx, set);
|
|
56
|
+
}
|
|
57
|
+
return set;
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
function getSurface(ctx: EmitterContext): LiveSurface {
|
|
42
61
|
let surface = surfaceCache.get(ctx);
|
|
43
62
|
if (surface) return surface;
|
|
@@ -90,6 +109,19 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
90
109
|
// Pass an empty map; type-map will fall back to emitting the symbol name.
|
|
91
110
|
setInlineEnumUnions(new Map());
|
|
92
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
|
+
|
|
93
125
|
return surface;
|
|
94
126
|
}
|
|
95
127
|
|
|
@@ -115,8 +147,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
115
147
|
* `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
|
|
116
148
|
* with no `--target` step they would otherwise land as untracked cruft.
|
|
117
149
|
*
|
|
118
|
-
* Note:
|
|
119
|
-
*
|
|
150
|
+
* Note: the carry-forward step in `generateTests` re-declares prior-manifest
|
|
151
|
+
* paths we didn't touch this run, so the orchestrator's prune diff stays
|
|
152
|
+
* accurate without needing `--no-prune` at the call site. See
|
|
153
|
+
* `carryForwardManagedFiles` below.
|
|
120
154
|
*/
|
|
121
155
|
/**
|
|
122
156
|
* `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
|
|
@@ -355,6 +389,55 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
|
|
|
355
389
|
}
|
|
356
390
|
out.push(f);
|
|
357
391
|
}
|
|
392
|
+
const emitted = getEmittedPaths(ctx);
|
|
393
|
+
for (const f of out) emitted.add(f.path);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Re-declare prior-manifest paths that we did not emit this run so manifest
|
|
399
|
+
* pruning can tell "intentionally removed" from "untouched but still managed."
|
|
400
|
+
*
|
|
401
|
+
* The node emitter only outputs files it actually wants to write each run —
|
|
402
|
+
* untouched-but-up-to-date autogen files don't come back through any
|
|
403
|
+
* `generateXxx` method. Without this carry-forward, the orchestrator's
|
|
404
|
+
* `prevManifest.files − currentEmission` diff treats every such file as stale
|
|
405
|
+
* and prunes the whole tree on a regen. That's why `scripts/sdk-generate.sh`
|
|
406
|
+
* historically paired the node emitter with `--no-prune` — at the cost of
|
|
407
|
+
* never pruning legitimately-removed files (e.g. an enum file orphaned by a
|
|
408
|
+
* `schemaNameTransform` rename like `RadarAction` → `RadarListAction`).
|
|
409
|
+
*
|
|
410
|
+
* The carry-forward entry uses `skipIfExists: true`, so writer.ts skips the
|
|
411
|
+
* write and only ensures the header is present (no-op for files that already
|
|
412
|
+
* have it). The path still lands in `outputEmittedPaths` and therefore in the
|
|
413
|
+
* new manifest, which restores correct prune semantics.
|
|
414
|
+
*
|
|
415
|
+
* Files dropped from the carry-forward set:
|
|
416
|
+
* - Not on disk anymore (file was hand-deleted — let prune confirm absence).
|
|
417
|
+
* - `@oagen-ignore-file` protected (user has explicitly taken ownership).
|
|
418
|
+
* - `.ts` files that no longer carry the auto-gen header (user has taken
|
|
419
|
+
* ownership in-place; the next prune cycle will clear the manifest entry).
|
|
420
|
+
*/
|
|
421
|
+
function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
|
|
422
|
+
const priorPaths = ctx.priorTargetManifestPaths;
|
|
423
|
+
if (!priorPaths || priorPaths.size === 0) return [];
|
|
424
|
+
|
|
425
|
+
const emitted = getEmittedPaths(ctx);
|
|
426
|
+
const out: GeneratedFile[] = [];
|
|
427
|
+
for (const relPath of priorPaths) {
|
|
428
|
+
if (emitted.has(relPath)) continue;
|
|
429
|
+
if (!surface.files.has(relPath)) continue;
|
|
430
|
+
if (surface.protectedFiles.has(relPath)) continue;
|
|
431
|
+
if (relPath.endsWith('.ts') && !surface.autogenFiles.has(relPath)) continue;
|
|
432
|
+
|
|
433
|
+
out.push({
|
|
434
|
+
path: relPath,
|
|
435
|
+
content: '',
|
|
436
|
+
skipIfExists: true,
|
|
437
|
+
headerPlacement: 'skip',
|
|
438
|
+
});
|
|
439
|
+
emitted.add(relPath);
|
|
440
|
+
}
|
|
358
441
|
return out;
|
|
359
442
|
}
|
|
360
443
|
|
|
@@ -440,11 +523,16 @@ export const nodeEmitter: Emitter = {
|
|
|
440
523
|
|
|
441
524
|
// Test specs and fixtures are hand-maintained except for explicitly-owned
|
|
442
525
|
// service directories.
|
|
526
|
+
//
|
|
527
|
+
// This is also the last `generateXxx` hook in `generateAllFiles`, so it's
|
|
528
|
+
// where we tack on the carry-forward set — see `carryForwardManagedFiles`.
|
|
443
529
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
444
530
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
445
|
-
if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
|
|
446
531
|
const surface = getSurface(nodeCtx);
|
|
447
|
-
|
|
532
|
+
const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
|
|
533
|
+
? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
|
|
534
|
+
: [];
|
|
535
|
+
return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
|
|
448
536
|
},
|
|
449
537
|
|
|
450
538
|
// No operations map needed — the manifest belongs to the staging+target flow,
|
package/src/node/models.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
createServiceDirResolver,
|
|
24
24
|
isListMetadataModel,
|
|
25
25
|
isListWrapperModel,
|
|
26
|
+
collectNonPaginatedResponseModelNames,
|
|
27
|
+
collectReferencedListMetadataModels,
|
|
26
28
|
buildDeduplicationMap,
|
|
27
29
|
relativeImport,
|
|
28
30
|
modelHasNewFields,
|
|
@@ -209,12 +211,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
214
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
215
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
216
|
+
// code references them by name and pagination iterators don't unwrap them.
|
|
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
|
+
|
|
212
226
|
for (const originalModel of models) {
|
|
213
227
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
214
228
|
if (!reachableModels.has(model.name)) continue;
|
|
215
229
|
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
216
|
-
if (isListMetadataModel(model)) continue;
|
|
217
|
-
if (isListWrapperModel(model)) continue;
|
|
230
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
231
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
218
232
|
if (discriminatedSkip?.has(model.name)) continue;
|
|
219
233
|
const service = modelToService.get(model.name);
|
|
220
234
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
@@ -512,36 +526,43 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
512
526
|
}
|
|
513
527
|
lines.push('');
|
|
514
528
|
|
|
515
|
-
// Wire/response interface
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
}
|
|
542
563
|
}
|
|
564
|
+
lines.push('}');
|
|
543
565
|
}
|
|
544
|
-
lines.push('}');
|
|
545
566
|
}
|
|
546
567
|
|
|
547
568
|
// Preserve inline types from existing file
|
|
@@ -733,13 +754,18 @@ export function generateSerializers(
|
|
|
733
754
|
}
|
|
734
755
|
|
|
735
756
|
const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
757
|
+
const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
758
|
+
|
|
759
|
+
// Mirror the interface-emission gate (see `generateModels`).
|
|
760
|
+
const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
|
|
761
|
+
|
|
736
762
|
const eligibleModels: Model[] = [];
|
|
737
763
|
for (const originalModel of models) {
|
|
738
764
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
739
765
|
if (!serializerReachable.has(model.name)) continue;
|
|
740
766
|
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
741
|
-
if (isListMetadataModel(model)) continue;
|
|
742
|
-
if (isListWrapperModel(model)) continue;
|
|
767
|
+
if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
|
|
768
|
+
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
743
769
|
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
744
770
|
const service = modelToService.get(model.name);
|
|
745
771
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|