@workos/oagen-emitters 0.3.0 → 0.5.0
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className, deprecationMessage, escapeCsAttributeString, humanize } from './naming.js';
|
|
4
|
+
import { setEnumAliases, setSingleValueEnumNames } from './type-map.js';
|
|
5
|
+
import { enrichModelsFromSpec } from '../shared/model-utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate C# enum definitions from IR Enum definitions.
|
|
9
|
+
* Each enum becomes a separate .cs file. Structurally-identical enums are
|
|
10
|
+
* deduplicated: only the canonical (alphabetically-first) name is emitted,
|
|
11
|
+
* and every reference to a duplicate enum is rewritten to the canonical one
|
|
12
|
+
* by `mapTypeRef` via `setEnumAliases`.
|
|
13
|
+
*/
|
|
14
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
15
|
+
if (enums.length === 0) return [];
|
|
16
|
+
|
|
17
|
+
// Publish the alias map + single-value enum set so model/options/wrapper
|
|
18
|
+
// emitters all resolve duplicate enum references to the canonical name and
|
|
19
|
+
// rewrite 1-value enum refs to `string`.
|
|
20
|
+
const aliasOf = collectEnumAliasOf(enums);
|
|
21
|
+
setEnumAliases(aliasOf);
|
|
22
|
+
setSingleValueEnumNames(enums.filter((e) => e.values.length === 1).map((e) => e.name));
|
|
23
|
+
diagnoseDivergentEnums(enums);
|
|
24
|
+
|
|
25
|
+
// Collect all enum names actually referenced by models and operations so we
|
|
26
|
+
// can suppress orphan enums that would otherwise be emitted but never used.
|
|
27
|
+
const referencedEnums = collectReferencedEnumNames(ctx);
|
|
28
|
+
|
|
29
|
+
const files: GeneratedFile[] = [];
|
|
30
|
+
|
|
31
|
+
for (const enumDef of enums) {
|
|
32
|
+
const typeName = className(enumDef.name);
|
|
33
|
+
|
|
34
|
+
// Skip duplicate enums — their references are retargeted to the canonical.
|
|
35
|
+
if (aliasOf.has(enumDef.name)) continue;
|
|
36
|
+
|
|
37
|
+
// Skip empty and single-value enums — the single-value case is a discriminator
|
|
38
|
+
// masquerading as an enum, and mapTypeRef rewrites such refs to `string` with
|
|
39
|
+
// a const initializer on the owning property.
|
|
40
|
+
if (enumDef.values.length <= 1) continue;
|
|
41
|
+
|
|
42
|
+
// Skip orphan enums that are not referenced by any model field or operation
|
|
43
|
+
// parameter. Resolve aliases so that a canonical enum is kept if any of its
|
|
44
|
+
// aliases are referenced.
|
|
45
|
+
const isReferenced =
|
|
46
|
+
referencedEnums.has(enumDef.name) ||
|
|
47
|
+
[...aliasOf.entries()].some(([alias, canon]) => canon === enumDef.name && referencedEnums.has(alias));
|
|
48
|
+
if (!isReferenced) continue;
|
|
49
|
+
|
|
50
|
+
// Deduplicate values
|
|
51
|
+
const seenValues = new Set<string>();
|
|
52
|
+
const uniqueValues: typeof enumDef.values = [];
|
|
53
|
+
for (const v of enumDef.values) {
|
|
54
|
+
const vs = String(v.value);
|
|
55
|
+
if (!seenValues.has(vs)) {
|
|
56
|
+
seenValues.add(vs);
|
|
57
|
+
uniqueValues.push(v);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
lines.push(`namespace ${ctx.namespacePascal}`);
|
|
63
|
+
lines.push('{');
|
|
64
|
+
lines.push(' using System.Runtime.Serialization;');
|
|
65
|
+
lines.push(' using Newtonsoft.Json;');
|
|
66
|
+
lines.push(' using STJS = System.Text.Json.Serialization;');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(` /// <summary>Represents ${humanize(enumDef.name)} values.</summary>`);
|
|
69
|
+
lines.push(' [JsonConverter(typeof(WorkOSNewtonsoftStringEnumConverter))]');
|
|
70
|
+
lines.push(' [STJS.JsonConverter(typeof(WorkOSStringEnumConverterFactory))]');
|
|
71
|
+
lines.push(` public enum ${typeName}`);
|
|
72
|
+
lines.push(' {');
|
|
73
|
+
// Unknown sentinel as first member (value 0) for forward-compatibility
|
|
74
|
+
lines.push(` [EnumMember(Value = "unknown")]`);
|
|
75
|
+
lines.push(` Unknown,`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
|
|
78
|
+
const usedNames = new Set<string>();
|
|
79
|
+
usedNames.add('Unknown');
|
|
80
|
+
// Track used EnumMember wire values to avoid duplicates (sentinel uses "unknown")
|
|
81
|
+
const usedWireValues = new Set<string>();
|
|
82
|
+
usedWireValues.add('unknown');
|
|
83
|
+
for (let i = 0; i < uniqueValues.length; i++) {
|
|
84
|
+
const v = uniqueValues[i];
|
|
85
|
+
// Skip values whose wire representation collides with the sentinel
|
|
86
|
+
if (usedWireValues.has(String(v.value))) continue;
|
|
87
|
+
usedWireValues.add(String(v.value));
|
|
88
|
+
let memberName = className(String(v.value));
|
|
89
|
+
// Avoid collision with the type itself or previously used names
|
|
90
|
+
if (memberName === typeName || usedNames.has(memberName)) {
|
|
91
|
+
let suffix = 2;
|
|
92
|
+
while (usedNames.has(`${memberName}${suffix}`)) suffix++;
|
|
93
|
+
memberName = `${memberName}${suffix}`;
|
|
94
|
+
}
|
|
95
|
+
usedNames.add(memberName);
|
|
96
|
+
|
|
97
|
+
if (v.description) {
|
|
98
|
+
lines.push(` /// <summary>${escapeXml(v.description)}</summary>`);
|
|
99
|
+
}
|
|
100
|
+
if (v.deprecated) {
|
|
101
|
+
const msg = escapeCsAttributeString(deprecationMessage(v.description, 'value'));
|
|
102
|
+
lines.push(` [System.Obsolete("${msg}")]`);
|
|
103
|
+
}
|
|
104
|
+
lines.push(` [EnumMember(Value = "${v.value}")]`);
|
|
105
|
+
const comma = i < uniqueValues.length - 1 ? ',' : ',';
|
|
106
|
+
lines.push(` ${memberName}${comma}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push(' }');
|
|
110
|
+
lines.push('}');
|
|
111
|
+
|
|
112
|
+
files.push({
|
|
113
|
+
path: `Enums/${typeName}.cs`,
|
|
114
|
+
content: lines.join('\n'),
|
|
115
|
+
overwriteExisting: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function escapeXml(s: string): string {
|
|
123
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Populate the module-level enum alias resolver from the given spec's enums.
|
|
128
|
+
* Call from every emitter entrypoint that uses `mapTypeRef` so enum
|
|
129
|
+
* references resolve to their canonical names regardless of which emitter
|
|
130
|
+
* phase runs first.
|
|
131
|
+
*/
|
|
132
|
+
export function primeEnumAliases(enums: Enum[]): void {
|
|
133
|
+
setEnumAliases(collectEnumAliasOf(enums));
|
|
134
|
+
setSingleValueEnumNames(enums.filter((e) => e.values.length === 1).map((e) => e.name));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Warn when two enums share a trailing stem (e.g., `ConnectionType`) but
|
|
139
|
+
* carry *contradictory* wire values — each side has values the other lacks.
|
|
140
|
+
* Pure subset relationships (event-specific payloads that narrow a shared
|
|
141
|
+
* base set — the usual shape of WorkOS event enums) are legitimate and not
|
|
142
|
+
* flagged. Only true divergences, which typically indicate spec drift, are
|
|
143
|
+
* reported.
|
|
144
|
+
*/
|
|
145
|
+
export function diagnoseDivergentEnums(enums: Enum[]): void {
|
|
146
|
+
const byStem = new Map<string, Enum[]>();
|
|
147
|
+
for (const e of enums) {
|
|
148
|
+
if (e.values.length < 2) continue;
|
|
149
|
+
const stem = trailingPascalStem(e.name);
|
|
150
|
+
if (!stem) continue;
|
|
151
|
+
if (!byStem.has(stem)) byStem.set(stem, []);
|
|
152
|
+
byStem.get(stem)!.push(e);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [stem, group] of byStem) {
|
|
156
|
+
if (group.length < 2) continue;
|
|
157
|
+
// Skip generic event-payload stems (`DataState`, `DataStatus`,
|
|
158
|
+
// `DataActorSource`, …). Many unrelated event subtypes expose a
|
|
159
|
+
// `data.{state,status}` field whose allowed values are scoped per
|
|
160
|
+
// event; grouping them by the generic `Data*` suffix surfaces
|
|
161
|
+
// false-positive divergences, not real drift.
|
|
162
|
+
if (stem.startsWith('Data')) continue;
|
|
163
|
+
// Identical value sets are handled by the alias/dedupe pass.
|
|
164
|
+
const distinctSigs = new Set(group.map(valueSignature));
|
|
165
|
+
if (distinctSigs.size === 1) continue;
|
|
166
|
+
// Only warn when some pair is *mutually exclusive*: each enum has a
|
|
167
|
+
// value the other doesn't. Subsets are not drift.
|
|
168
|
+
if (!hasMutuallyExclusivePair(group)) continue;
|
|
169
|
+
const summary = group.map((e) => `${e.name}[${e.values.length}]`).join(', ');
|
|
170
|
+
console.warn(`[oagen:dotnet] Divergent enums sharing stem "${stem}": ${summary}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* True if some pair in the group both:
|
|
176
|
+
* (a) shares enough values to plausibly be the same concept, AND
|
|
177
|
+
* (b) has values exclusive to each side (i.e., is not a subset/equal).
|
|
178
|
+
* Sharing the trailing stem alone is insufficient — `DataStatus` shows up on
|
|
179
|
+
* unrelated domains (connection link state vs. membership state) that only
|
|
180
|
+
* coincidentally overlap on a sentinel like `unknown`. Require a non-trivial
|
|
181
|
+
* intersection before treating two enums as the same logical enum.
|
|
182
|
+
*/
|
|
183
|
+
function hasMutuallyExclusivePair(group: Enum[]): boolean {
|
|
184
|
+
// Compare case-insensitively so cosmetic drift like `GitHubOAuth` vs
|
|
185
|
+
// `GithubOAuth` — an intentional historical spelling carried forward by
|
|
186
|
+
// the spec — doesn't register as divergence.
|
|
187
|
+
const valueSets = group.map((e) => new Set(e.values.map((v) => String(v.value).toLowerCase())));
|
|
188
|
+
for (let i = 0; i < valueSets.length; i++) {
|
|
189
|
+
for (let j = i + 1; j < valueSets.length; j++) {
|
|
190
|
+
const a = valueSets[i];
|
|
191
|
+
const b = valueSets[j];
|
|
192
|
+
// Concept-similarity gate: two enums are treated as the same logical
|
|
193
|
+
// enum only when they share >= 3 values AND those shared values
|
|
194
|
+
// dominate both sides (>= 50% of the larger set). A handful of
|
|
195
|
+
// shared sentinels (`unknown`, `pending`) recurs across unrelated
|
|
196
|
+
// domains (`AuthMethod` on Radar assessments vs. sessions: only
|
|
197
|
+
// `password`/`passkey`/`sso`/`unknown` overlap out of ~15 total), and
|
|
198
|
+
// that sparse overlap shouldn't count as drift.
|
|
199
|
+
let shared = 0;
|
|
200
|
+
for (const v of a) {
|
|
201
|
+
if (b.has(v)) shared++;
|
|
202
|
+
}
|
|
203
|
+
if (shared < 3) continue;
|
|
204
|
+
const larger = Math.max(a.size, b.size);
|
|
205
|
+
if (larger === 0 || shared / larger < 0.5) continue;
|
|
206
|
+
// Both sides must have values the other lacks — a subset is legitimate
|
|
207
|
+
// narrowing, not drift.
|
|
208
|
+
let aHasExtra = false;
|
|
209
|
+
for (const v of a) {
|
|
210
|
+
if (!b.has(v)) {
|
|
211
|
+
aHasExtra = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!aHasExtra) continue;
|
|
216
|
+
for (const v of b) {
|
|
217
|
+
if (!a.has(v)) return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function trailingPascalStem(name: string): string | null {
|
|
225
|
+
// Extract the last two PascalCase segments so that `SSOConnectionType`
|
|
226
|
+
// and `ConnectionFindResponseConnectionType` both map to `ConnectionType`.
|
|
227
|
+
const segments = name.match(/[A-Z]+[a-z0-9]*/g);
|
|
228
|
+
if (!segments || segments.length < 2) return null;
|
|
229
|
+
return segments.slice(-2).join('');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function valueSignature(e: Enum): string {
|
|
233
|
+
return [...e.values]
|
|
234
|
+
.map((v) => String(v.value))
|
|
235
|
+
.sort()
|
|
236
|
+
.join('|');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
|
|
240
|
+
const hashGroups = new Map<string, string[]>();
|
|
241
|
+
for (const enumDef of enums) {
|
|
242
|
+
const hash = [...enumDef.values]
|
|
243
|
+
.map((v) => String(v.value))
|
|
244
|
+
.sort()
|
|
245
|
+
.join('|');
|
|
246
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
247
|
+
hashGroups.get(hash)!.push(enumDef.name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const aliasOf = new Map<string, string>();
|
|
251
|
+
for (const [, names] of hashGroups) {
|
|
252
|
+
if (names.length <= 1) continue;
|
|
253
|
+
const sorted = [...names].sort();
|
|
254
|
+
const canonical = sorted[0];
|
|
255
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
256
|
+
aliasOf.set(sorted[i], canonical);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return aliasOf;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Collect all enum names referenced anywhere in the spec — model fields,
|
|
264
|
+
* operation params, request bodies, and responses. Used to prune orphan
|
|
265
|
+
* enums that the IR extracted from the spec but that no generated code
|
|
266
|
+
* actually references.
|
|
267
|
+
*/
|
|
268
|
+
function collectReferencedEnumNames(ctx: EmitterContext): Set<string> {
|
|
269
|
+
const refs = new Set<string>();
|
|
270
|
+
const collect = (ref: any) => {
|
|
271
|
+
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
272
|
+
};
|
|
273
|
+
// Walk the enriched models (which include synthetic fields from oneOf
|
|
274
|
+
// flattening) so synthetic enums are also counted as referenced.
|
|
275
|
+
const enrichedModels = enrichModelsFromSpec(ctx.spec.models);
|
|
276
|
+
for (const model of enrichedModels) {
|
|
277
|
+
for (const field of model.fields) {
|
|
278
|
+
collect(field.type);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const service of ctx.spec.services) {
|
|
282
|
+
for (const op of service.operations) {
|
|
283
|
+
if (op.requestBody) collect(op.requestBody);
|
|
284
|
+
if (op.response) collect(op.response);
|
|
285
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
286
|
+
collect(p.type);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return refs;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Get the canonical enum name if the given enum is an alias. */
|
|
294
|
+
export function resolveEnumName(name: string, enums: Enum[]): string {
|
|
295
|
+
const aliasOf = collectEnumAliasOf(enums);
|
|
296
|
+
return aliasOf.get(name) ? className(aliasOf.get(name)!) : className(name);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
|
|
300
|
+
const enumToService = new Map<string, string>();
|
|
301
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
302
|
+
|
|
303
|
+
for (const service of services) {
|
|
304
|
+
for (const op of service.operations) {
|
|
305
|
+
const refs = new Set<string>();
|
|
306
|
+
const collect = (ref: any) => {
|
|
307
|
+
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
308
|
+
};
|
|
309
|
+
if (op.requestBody) collect(op.requestBody);
|
|
310
|
+
collect(op.response);
|
|
311
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
312
|
+
collect(p.type);
|
|
313
|
+
}
|
|
314
|
+
for (const name of refs) {
|
|
315
|
+
if (enumNames.has(name) && !enumToService.has(name)) {
|
|
316
|
+
enumToService.set(name, service.name);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return enumToService;
|
|
323
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
import { fixtureFileName, fieldName } from './naming.js';
|
|
3
|
+
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prefix mapping for generating realistic ID fixture values.
|
|
7
|
+
*/
|
|
8
|
+
export const ID_PREFIXES: Record<string, string> = {
|
|
9
|
+
Connection: 'conn_',
|
|
10
|
+
Organization: 'org_',
|
|
11
|
+
OrganizationMembership: 'om_',
|
|
12
|
+
User: 'user_',
|
|
13
|
+
Directory: 'directory_',
|
|
14
|
+
DirectoryGroup: 'dir_grp_',
|
|
15
|
+
DirectoryUser: 'dir_usr_',
|
|
16
|
+
Invitation: 'inv_',
|
|
17
|
+
Session: 'session_',
|
|
18
|
+
AuthenticationFactor: 'auth_factor_',
|
|
19
|
+
EmailVerification: 'email_verification_',
|
|
20
|
+
MagicAuth: 'magic_auth_',
|
|
21
|
+
PasswordReset: 'password_reset_',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate JSON fixture files for test data.
|
|
26
|
+
*/
|
|
27
|
+
export function generateFixtures(spec: {
|
|
28
|
+
models: Model[];
|
|
29
|
+
enums: Enum[];
|
|
30
|
+
services: any[];
|
|
31
|
+
}): { path: string; content: string }[] {
|
|
32
|
+
if (spec.models.length === 0) return [];
|
|
33
|
+
|
|
34
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
35
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
36
|
+
const files: { path: string; content: string }[] = [];
|
|
37
|
+
|
|
38
|
+
for (const model of spec.models) {
|
|
39
|
+
if (isListMetadataModel(model)) continue;
|
|
40
|
+
if (isListWrapperModel(model)) continue;
|
|
41
|
+
|
|
42
|
+
const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
|
|
43
|
+
|
|
44
|
+
files.push({
|
|
45
|
+
path: `testdata/${fixtureFileName(model.name)}.json`,
|
|
46
|
+
content: JSON.stringify(fixture, null, 2),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Generate null-field variant for models with nullable/optional fields
|
|
50
|
+
const hasNullableFields = model.fields.some((f) => !f.required || f.type.kind === 'nullable');
|
|
51
|
+
if (hasNullableFields && model.fields.length > 0) {
|
|
52
|
+
const nullFixture: Record<string, any> = {};
|
|
53
|
+
for (const field of model.fields) {
|
|
54
|
+
if (!field.required || field.type.kind === 'nullable') {
|
|
55
|
+
nullFixture[field.name] = null;
|
|
56
|
+
} else {
|
|
57
|
+
nullFixture[field.name] = fixture[field.name] ?? null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
files.push({
|
|
61
|
+
path: `testdata/${fixtureFileName(model.name)}_nulls.json`,
|
|
62
|
+
content: JSON.stringify(nullFixture, null, 2),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generate list fixtures for paginated responses
|
|
68
|
+
for (const service of spec.services) {
|
|
69
|
+
for (const op of service.operations) {
|
|
70
|
+
if (op.pagination) {
|
|
71
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
72
|
+
if (itemModel) {
|
|
73
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
74
|
+
if (unwrapped) itemModel = unwrapped;
|
|
75
|
+
if (itemModel.fields.length === 0) continue;
|
|
76
|
+
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
77
|
+
const listFixture = {
|
|
78
|
+
data: [fixture],
|
|
79
|
+
list_metadata: {
|
|
80
|
+
before: null,
|
|
81
|
+
after: null,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
files.push({
|
|
85
|
+
path: `testdata/list_${fixtureFileName(itemModel.name)}.json`,
|
|
86
|
+
content: JSON.stringify(listFixture, null, 2),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generate empty list fixtures for paginated responses
|
|
94
|
+
for (const service of spec.services) {
|
|
95
|
+
for (const op of service.operations) {
|
|
96
|
+
if (op.pagination) {
|
|
97
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
98
|
+
if (itemModel) {
|
|
99
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
100
|
+
if (unwrapped) itemModel = unwrapped;
|
|
101
|
+
const emptyFixture = {
|
|
102
|
+
data: [],
|
|
103
|
+
list_metadata: {
|
|
104
|
+
before: null,
|
|
105
|
+
after: null,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
files.push({
|
|
109
|
+
path: `testdata/list_empty_${fixtureFileName(itemModel.name)}.json`,
|
|
110
|
+
content: JSON.stringify(emptyFixture, null, 2),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Deduplicate fixtures by path (keep last-written for each path)
|
|
118
|
+
const byPath = new Map<string, { path: string; content: string }>();
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
byPath.set(f.path, f);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [...byPath.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
127
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
128
|
+
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
129
|
+
if (dataField && hasListMetadata && dataField.type.kind === 'array') {
|
|
130
|
+
const itemType = dataField.type.items;
|
|
131
|
+
if (itemType.kind === 'model') {
|
|
132
|
+
return modelMap.get(itemType.name) ?? null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function generateModelFixture(
|
|
139
|
+
model: Model,
|
|
140
|
+
modelMap: Map<string, Model>,
|
|
141
|
+
enumMap: Map<string, Enum>,
|
|
142
|
+
): Record<string, any> {
|
|
143
|
+
const fixture: Record<string, any> = {};
|
|
144
|
+
|
|
145
|
+
const seenFieldNames = new Set<string>();
|
|
146
|
+
const deduplicatedFields = model.fields.filter((f) => {
|
|
147
|
+
const csName = fieldName(f.name);
|
|
148
|
+
if (seenFieldNames.has(csName)) return false;
|
|
149
|
+
seenFieldNames.add(csName);
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
for (const field of deduplicatedFields) {
|
|
154
|
+
const wireName = field.name;
|
|
155
|
+
if (field.example !== undefined) {
|
|
156
|
+
fixture[wireName] = field.example;
|
|
157
|
+
} else {
|
|
158
|
+
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return fixture;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function generateFieldValue(
|
|
166
|
+
ref: TypeRef,
|
|
167
|
+
fName: string,
|
|
168
|
+
modelName: string,
|
|
169
|
+
modelMap: Map<string, Model>,
|
|
170
|
+
enumMap: Map<string, Enum>,
|
|
171
|
+
): any {
|
|
172
|
+
switch (ref.kind) {
|
|
173
|
+
case 'primitive':
|
|
174
|
+
return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
|
|
175
|
+
case 'literal':
|
|
176
|
+
return ref.value;
|
|
177
|
+
case 'enum': {
|
|
178
|
+
const e = enumMap.get(ref.name);
|
|
179
|
+
return e?.values[0]?.value ?? 'unknown';
|
|
180
|
+
}
|
|
181
|
+
case 'model': {
|
|
182
|
+
const nested = modelMap.get(ref.name);
|
|
183
|
+
if (nested) return generateModelFixture(nested, modelMap, enumMap);
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
case 'array': {
|
|
187
|
+
if (ref.items.kind === 'enum') {
|
|
188
|
+
const e = enumMap.get(ref.items.name);
|
|
189
|
+
if (e && e.values.length > 0) {
|
|
190
|
+
return e.values.map((v) => v.value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
|
|
194
|
+
return [item];
|
|
195
|
+
}
|
|
196
|
+
case 'nullable':
|
|
197
|
+
return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
|
|
198
|
+
case 'union':
|
|
199
|
+
if (ref.variants.length > 0) {
|
|
200
|
+
return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
case 'map':
|
|
204
|
+
return {
|
|
205
|
+
key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
211
|
+
switch (type) {
|
|
212
|
+
case 'string':
|
|
213
|
+
if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
214
|
+
if (format === 'date') return '2023-01-01';
|
|
215
|
+
if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
216
|
+
if (name === 'id') {
|
|
217
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
218
|
+
return `${prefix}01234`;
|
|
219
|
+
}
|
|
220
|
+
if (name.includes('id')) return `${name}_01234`;
|
|
221
|
+
if (name.includes('email')) return 'test@example.com';
|
|
222
|
+
if (name.includes('url') || name.includes('uri')) return 'https://example.com';
|
|
223
|
+
if (name.includes('name')) return 'Test';
|
|
224
|
+
return `test_${name}`;
|
|
225
|
+
case 'integer':
|
|
226
|
+
return 1;
|
|
227
|
+
case 'number':
|
|
228
|
+
return 1.0;
|
|
229
|
+
case 'boolean':
|
|
230
|
+
return true;
|
|
231
|
+
case 'unknown':
|
|
232
|
+
return {};
|
|
233
|
+
default:
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|