@workos/oagen-emitters 0.14.0 → 0.14.1
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-BxVeu2v9.mjs → plugin-DRGwxN88.mjs} +148 -31
- package/dist/plugin-DRGwxN88.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/models.ts +12 -3
- package/src/kotlin/models.ts +16 -6
- package/src/node/index.ts +78 -4
- package/src/node/models.ts +8 -2
- package/src/node/utils.ts +5 -1
- package/src/php/models.ts +11 -2
- package/src/python/models.ts +12 -2
- package/src/python/resources.ts +8 -2
- package/src/ruby/index.ts +3 -3
- package/src/ruby/models.ts +13 -3
- package/src/rust/models.ts +5 -1
- package/src/rust/resources.ts +4 -1
- package/src/shared/model-utils.ts +35 -3
- package/test/rust/models.test.ts +3 -3
- 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-DRGwxN88.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/models.ts
CHANGED
|
@@ -5,7 +5,11 @@ 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
|
+
} from '../shared/model-utils.js';
|
|
9
13
|
export { isListWrapperModel, isListMetadataModel };
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -83,12 +87,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
83
87
|
lines.push('');
|
|
84
88
|
|
|
85
89
|
const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
|
|
90
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
91
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
92
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
93
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
94
|
+
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
86
95
|
|
|
87
96
|
// Build structural hash for deduplication
|
|
88
97
|
const modelHashMap = new Map<string, string>();
|
|
89
98
|
const hashGroups = new Map<string, string[]>();
|
|
90
99
|
for (const model of models) {
|
|
91
|
-
if (
|
|
100
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
92
101
|
if (requestBodyOnly.has(model.name)) continue;
|
|
93
102
|
const hash = structuralHash(model);
|
|
94
103
|
modelHashMap.set(model.name, hash);
|
|
@@ -112,7 +121,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
112
121
|
|
|
113
122
|
const batchedAliases = new Set<string>();
|
|
114
123
|
for (const model of models) {
|
|
115
|
-
if (
|
|
124
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
116
125
|
if (requestBodyOnly.has(model.name)) continue;
|
|
117
126
|
|
|
118
127
|
const structName = className(model.name);
|
package/src/kotlin/models.ts
CHANGED
|
@@ -2,7 +2,11 @@ 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
|
+
} from '../shared/model-utils.js';
|
|
6
10
|
|
|
7
11
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
8
12
|
const MODELS_PACKAGE = 'com.workos.models';
|
|
@@ -47,7 +51,7 @@ function promoteFieldType(f: Field): Field {
|
|
|
47
51
|
* List wrappers (`{ data, list_metadata }`) and the shared `ListMetadata`
|
|
48
52
|
* model are skipped — the hand-maintained runtime provides [Page]/[ListMetadata].
|
|
49
53
|
*/
|
|
50
|
-
export function generateModels(models: Model[],
|
|
54
|
+
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
51
55
|
if (models.length === 0) return [];
|
|
52
56
|
|
|
53
57
|
// First pass: call mapTypeRef on every model field so discriminator info is
|
|
@@ -58,12 +62,18 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
58
62
|
|
|
59
63
|
const files: GeneratedFile[] = [];
|
|
60
64
|
|
|
65
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
66
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
67
|
+
// code references them by name and pagination iterators don't unwrap them.
|
|
68
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
69
|
+
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
70
|
+
|
|
61
71
|
// Deduplication: identical structures become typealiases.
|
|
62
72
|
// Pass 1: hash without nested-alias resolution.
|
|
63
73
|
modelAliasMap = null;
|
|
64
74
|
const hashGroupsPass1 = new Map<string, string[]>();
|
|
65
75
|
for (const model of models) {
|
|
66
|
-
if (
|
|
76
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
67
77
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
68
78
|
const hash = structuralHash(model);
|
|
69
79
|
if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
|
|
@@ -86,7 +96,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
86
96
|
modelAliasMap = aliasOf;
|
|
87
97
|
const hashGroupsPass2 = new Map<string, string[]>();
|
|
88
98
|
for (const model of models) {
|
|
89
|
-
if (
|
|
99
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
90
100
|
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
91
101
|
if (aliasOf.has(model.name)) continue; // already aliased in pass 1
|
|
92
102
|
const hash = structuralHash(model);
|
|
@@ -105,7 +115,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
for (const model of models) {
|
|
108
|
-
if (
|
|
118
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
109
119
|
const typeName = className(model.name);
|
|
110
120
|
|
|
111
121
|
// Parent of a discriminated union: emit a sealed class.
|
|
@@ -142,7 +152,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
142
152
|
// mapping so Jackson can deserialize directly to the correct concrete type.
|
|
143
153
|
const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
|
|
144
154
|
for (const model of models) {
|
|
145
|
-
if (
|
|
155
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
146
156
|
if (aliasOf.has(model.name)) continue;
|
|
147
157
|
if (!isEventEnvelopeModel(model)) continue;
|
|
148
158
|
const eventField = model.fields.find((f) => f.name === 'event');
|
package/src/node/index.ts
CHANGED
|
@@ -38,6 +38,24 @@ import { fileName } from './naming.js';
|
|
|
38
38
|
*/
|
|
39
39
|
const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Paths the node emitter has produced so far in this ctx, accumulated across
|
|
43
|
+
* `applyLiveSurface` calls. Drives `carryForwardManagedFiles` so files in the
|
|
44
|
+
* prior manifest that we did not re-emit this run still land in the new
|
|
45
|
+
* manifest as "still managed" — without that, the orchestrator's prune diff
|
|
46
|
+
* treats every untouched autogen file as stale.
|
|
47
|
+
*/
|
|
48
|
+
const emittedPathsCache = new WeakMap<EmitterContext, Set<string>>();
|
|
49
|
+
|
|
50
|
+
function getEmittedPaths(ctx: EmitterContext): Set<string> {
|
|
51
|
+
let set = emittedPathsCache.get(ctx);
|
|
52
|
+
if (!set) {
|
|
53
|
+
set = new Set();
|
|
54
|
+
emittedPathsCache.set(ctx, set);
|
|
55
|
+
}
|
|
56
|
+
return set;
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
function getSurface(ctx: EmitterContext): LiveSurface {
|
|
42
60
|
let surface = surfaceCache.get(ctx);
|
|
43
61
|
if (surface) return surface;
|
|
@@ -115,8 +133,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
115
133
|
* `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
|
|
116
134
|
* with no `--target` step they would otherwise land as untracked cruft.
|
|
117
135
|
*
|
|
118
|
-
* Note:
|
|
119
|
-
*
|
|
136
|
+
* Note: the carry-forward step in `generateTests` re-declares prior-manifest
|
|
137
|
+
* paths we didn't touch this run, so the orchestrator's prune diff stays
|
|
138
|
+
* accurate without needing `--no-prune` at the call site. See
|
|
139
|
+
* `carryForwardManagedFiles` below.
|
|
120
140
|
*/
|
|
121
141
|
/**
|
|
122
142
|
* `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
|
|
@@ -355,6 +375,55 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
|
|
|
355
375
|
}
|
|
356
376
|
out.push(f);
|
|
357
377
|
}
|
|
378
|
+
const emitted = getEmittedPaths(ctx);
|
|
379
|
+
for (const f of out) emitted.add(f.path);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Re-declare prior-manifest paths that we did not emit this run so manifest
|
|
385
|
+
* pruning can tell "intentionally removed" from "untouched but still managed."
|
|
386
|
+
*
|
|
387
|
+
* The node emitter only outputs files it actually wants to write each run —
|
|
388
|
+
* untouched-but-up-to-date autogen files don't come back through any
|
|
389
|
+
* `generateXxx` method. Without this carry-forward, the orchestrator's
|
|
390
|
+
* `prevManifest.files − currentEmission` diff treats every such file as stale
|
|
391
|
+
* and prunes the whole tree on a regen. That's why `scripts/sdk-generate.sh`
|
|
392
|
+
* historically paired the node emitter with `--no-prune` — at the cost of
|
|
393
|
+
* never pruning legitimately-removed files (e.g. an enum file orphaned by a
|
|
394
|
+
* `schemaNameTransform` rename like `RadarAction` → `RadarListAction`).
|
|
395
|
+
*
|
|
396
|
+
* The carry-forward entry uses `skipIfExists: true`, so writer.ts skips the
|
|
397
|
+
* write and only ensures the header is present (no-op for files that already
|
|
398
|
+
* have it). The path still lands in `outputEmittedPaths` and therefore in the
|
|
399
|
+
* new manifest, which restores correct prune semantics.
|
|
400
|
+
*
|
|
401
|
+
* Files dropped from the carry-forward set:
|
|
402
|
+
* - Not on disk anymore (file was hand-deleted — let prune confirm absence).
|
|
403
|
+
* - `@oagen-ignore-file` protected (user has explicitly taken ownership).
|
|
404
|
+
* - `.ts` files that no longer carry the auto-gen header (user has taken
|
|
405
|
+
* ownership in-place; the next prune cycle will clear the manifest entry).
|
|
406
|
+
*/
|
|
407
|
+
function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
|
|
408
|
+
const priorPaths = ctx.priorTargetManifestPaths;
|
|
409
|
+
if (!priorPaths || priorPaths.size === 0) return [];
|
|
410
|
+
|
|
411
|
+
const emitted = getEmittedPaths(ctx);
|
|
412
|
+
const out: GeneratedFile[] = [];
|
|
413
|
+
for (const relPath of priorPaths) {
|
|
414
|
+
if (emitted.has(relPath)) continue;
|
|
415
|
+
if (!surface.files.has(relPath)) continue;
|
|
416
|
+
if (surface.protectedFiles.has(relPath)) continue;
|
|
417
|
+
if (relPath.endsWith('.ts') && !surface.autogenFiles.has(relPath)) continue;
|
|
418
|
+
|
|
419
|
+
out.push({
|
|
420
|
+
path: relPath,
|
|
421
|
+
content: '',
|
|
422
|
+
skipIfExists: true,
|
|
423
|
+
headerPlacement: 'skip',
|
|
424
|
+
});
|
|
425
|
+
emitted.add(relPath);
|
|
426
|
+
}
|
|
358
427
|
return out;
|
|
359
428
|
}
|
|
360
429
|
|
|
@@ -440,11 +509,16 @@ export const nodeEmitter: Emitter = {
|
|
|
440
509
|
|
|
441
510
|
// Test specs and fixtures are hand-maintained except for explicitly-owned
|
|
442
511
|
// service directories.
|
|
512
|
+
//
|
|
513
|
+
// This is also the last `generateXxx` hook in `generateAllFiles`, so it's
|
|
514
|
+
// where we tack on the carry-forward set — see `carryForwardManagedFiles`.
|
|
443
515
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
444
516
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
445
|
-
if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
|
|
446
517
|
const surface = getSurface(nodeCtx);
|
|
447
|
-
|
|
518
|
+
const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
|
|
519
|
+
? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
|
|
520
|
+
: [];
|
|
521
|
+
return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
|
|
448
522
|
},
|
|
449
523
|
|
|
450
524
|
// No operations map needed — the manifest belongs to the staging+target flow,
|
package/src/node/models.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
createServiceDirResolver,
|
|
24
24
|
isListMetadataModel,
|
|
25
25
|
isListWrapperModel,
|
|
26
|
+
collectNonPaginatedResponseModelNames,
|
|
26
27
|
buildDeduplicationMap,
|
|
27
28
|
relativeImport,
|
|
28
29
|
modelHasNewFields,
|
|
@@ -209,12 +210,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
213
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
214
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
215
|
+
// code references them by name and pagination iterators don't unwrap them.
|
|
216
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
212
217
|
for (const originalModel of models) {
|
|
213
218
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
214
219
|
if (!reachableModels.has(model.name)) continue;
|
|
215
220
|
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
216
221
|
if (isListMetadataModel(model)) continue;
|
|
217
|
-
if (isListWrapperModel(model)) continue;
|
|
222
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
218
223
|
if (discriminatedSkip?.has(model.name)) continue;
|
|
219
224
|
const service = modelToService.get(model.name);
|
|
220
225
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
@@ -733,13 +738,14 @@ export function generateSerializers(
|
|
|
733
738
|
}
|
|
734
739
|
|
|
735
740
|
const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
741
|
+
const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
736
742
|
const eligibleModels: Model[] = [];
|
|
737
743
|
for (const originalModel of models) {
|
|
738
744
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
739
745
|
if (!serializerReachable.has(model.name)) continue;
|
|
740
746
|
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
741
747
|
if (isListMetadataModel(model)) continue;
|
|
742
|
-
if (isListWrapperModel(model)) continue;
|
|
748
|
+
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
743
749
|
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
744
750
|
const service = modelToService.get(model.name);
|
|
745
751
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
package/src/node/utils.ts
CHANGED
|
@@ -277,7 +277,11 @@ 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
|
+
} from '../shared/model-utils.js';
|
|
281
285
|
|
|
282
286
|
function modelFingerprint(model: Model): string {
|
|
283
287
|
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/models.ts
CHANGED
|
@@ -41,9 +41,15 @@ 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
|
+
|
|
44
50
|
for (const model of models) {
|
|
45
51
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
46
|
-
if (isListWrapperModel(model)) continue;
|
|
52
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
47
53
|
// Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
|
|
48
54
|
if (isListMetadataModel(model)) continue;
|
|
49
55
|
|
|
@@ -865,5 +871,9 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
865
871
|
}
|
|
866
872
|
|
|
867
873
|
// Import and re-export shared model detection utilities
|
|
868
|
-
import {
|
|
874
|
+
import {
|
|
875
|
+
isListMetadataModel,
|
|
876
|
+
isListWrapperModel,
|
|
877
|
+
collectNonPaginatedResponseModelNames,
|
|
878
|
+
} from '../shared/model-utils.js';
|
|
869
879
|
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/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);
|
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 { 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'`.
|
|
@@ -503,7 +524,7 @@ export function detectDiscriminators(models: Model[]): Model[] {
|
|
|
503
524
|
* Returns a new array of enriched models (original models are not mutated).
|
|
504
525
|
* Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
|
|
505
526
|
*/
|
|
506
|
-
export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
527
|
+
export function enrichModelsFromSpec(models: Model[], enums: Enum[] = []): Model[] {
|
|
507
528
|
const spec = loadRawSpec();
|
|
508
529
|
if (!spec) {
|
|
509
530
|
_lastSyntheticEnums = [];
|
|
@@ -518,6 +539,17 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
518
539
|
collector.usedNames.add(m.name);
|
|
519
540
|
collector.usedNames.add(toSnakeCase(m.name));
|
|
520
541
|
}
|
|
542
|
+
// Seed existing IR enum names too. The parser already emits inline enums
|
|
543
|
+
// like `DataIntegrationAccessTokenResponseError` (PascalCase); without this
|
|
544
|
+
// seed, the synthetic path would emit a sibling enum named
|
|
545
|
+
// `DataIntegrationAccessTokenResponse_error`, and language emitters that
|
|
546
|
+
// PascalCase-normalize identifiers (Ruby, Go, PHP, Python) would collapse
|
|
547
|
+
// both onto the same class/file path — the second overwrites the first
|
|
548
|
+
// with a `X = X` self-alias.
|
|
549
|
+
for (const e of enums) {
|
|
550
|
+
collector.usedNames.add(e.name);
|
|
551
|
+
collector.usedNames.add(toSnakeCase(e.name));
|
|
552
|
+
}
|
|
521
553
|
|
|
522
554
|
const enriched = models.map((model) => {
|
|
523
555
|
const rawSchema = lookupRawSchema(model.name);
|
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
|
});
|