@workos/oagen-emitters 0.18.4 → 0.19.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 +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +4 -4
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/models.ts +11 -5
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +4 -4
- package/src/go/enums.ts +91 -18
- package/src/go/fixtures.ts +25 -3
- package/src/go/flat-merge.ts +253 -0
- package/src/go/models.ts +85 -20
- package/src/go/resources.ts +3 -3
- package/src/go/tests.ts +7 -5
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/models.ts +53 -11
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +38 -3
- package/src/node/enums.ts +8 -5
- package/src/node/models.ts +29 -21
- package/src/node/resources.ts +12 -1
- package/src/node/tests.ts +7 -2
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +11 -3
- package/src/php/models.ts +11 -5
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +6 -3
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +138 -45
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +31 -12
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/models.ts +23 -12
- package/src/ruby/rbi.ts +17 -6
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +37 -4
- package/src/rust/enums.ts +29 -7
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +37 -6
- package/src/rust/resources.ts +8 -1
- package/src/rust/tests.ts +3 -3
- package/src/shared/resolved-ops.ts +104 -0
- package/test/dotnet/scoped-aggregates.test.ts +247 -0
- package/test/go/scoping.test.ts +324 -0
- package/test/kotlin/models.test.ts +74 -0
- package/test/kotlin/tests.test.ts +33 -0
- package/test/python/scoped-aggregates.test.ts +205 -0
- package/test/ruby/tests.test.ts +130 -0
- package/test/rust/fixtures.test.ts +13 -7
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-Cciic50q.mjs.map +0 -1
package/src/rust/enums.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { typeName, moduleName, variantName } from './naming.js';
|
|
3
|
+
import { isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Generate one Rust source file per enum under `src/enums/`, plus a
|
|
@@ -17,7 +18,7 @@ import { typeName, moduleName, variantName } from './naming.js';
|
|
|
17
18
|
* variant and re-serialize as the canonical wire string.
|
|
18
19
|
* - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
|
|
19
20
|
*/
|
|
20
|
-
export function generateEnums(enums: Enum[],
|
|
21
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
21
22
|
const files: GeneratedFile[] = [];
|
|
22
23
|
const seen = new Set<string>();
|
|
23
24
|
const moduleNames: string[] = [];
|
|
@@ -27,13 +28,34 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
27
28
|
const mod = moduleName(e.name);
|
|
28
29
|
if (seen.has(mod)) continue;
|
|
29
30
|
seen.add(mod);
|
|
30
|
-
moduleNames.push(mod);
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
// Only the per-enum `.rs` FILE write is scoped (FR-1.4); an out-of-scope
|
|
33
|
+
// enum's existing `.rs` file stays untouched on disk.
|
|
34
|
+
const inScope = isEnumInScope(e.name, ctx);
|
|
35
|
+
const path = `src/enums/${mod}.rs`;
|
|
36
|
+
if (inScope) {
|
|
37
|
+
files.push({
|
|
38
|
+
path,
|
|
39
|
+
content: renderEnum(e),
|
|
40
|
+
overwriteExisting: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Declare the module only if its file will exist on disk after this run, so
|
|
45
|
+
// a scoped run never declares a brand-new out-of-scope enum it doesn't emit.
|
|
46
|
+
if (fileExistsAfterRun(path, inScope, ctx)) {
|
|
47
|
+
moduleNames.push(mod);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scoped runs: retain barrel entries for enum files still on disk (prior
|
|
52
|
+
// manifest) that the current spec no longer produces (e.g. renamed for
|
|
53
|
+
// another service), since out-of-scope code may still reference them.
|
|
54
|
+
for (const base of priorManifestBasenames(ctx, 'src/enums', '.rs', new Set(['mod']))) {
|
|
55
|
+
if (!seen.has(base)) {
|
|
56
|
+
seen.add(base);
|
|
57
|
+
moduleNames.push(base);
|
|
58
|
+
}
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
files.push({
|
package/src/rust/fixtures.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef } from '@workos/oagen';
|
|
1
|
+
import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { walkTypeRef } from '@workos/oagen';
|
|
3
3
|
import { moduleName } from './naming.js';
|
|
4
|
+
import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Generate JSON test fixture files under `tests/fixtures/`. The Rust tests
|
|
7
8
|
* pull these in via `include_str!` so no I/O is required at test time.
|
|
9
|
+
*
|
|
10
|
+
* Scoped runs only emit a fixture for a model whose file will exist on disk
|
|
11
|
+
* after the run (in-scope, or already present from a prior run). Emitting a
|
|
12
|
+
* fixture for a brand-new out-of-scope model would add a stray file for a model
|
|
13
|
+
* the SDK can't even reference yet; in-scope tests only `include_str!` fixtures
|
|
14
|
+
* for in-scope models, so gating here is safe.
|
|
8
15
|
*/
|
|
9
|
-
export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
|
|
16
|
+
export function generateFixtures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
10
17
|
const files: GeneratedFile[] = [];
|
|
11
18
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
12
19
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
@@ -17,8 +24,10 @@ export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
|
|
|
17
24
|
if (model.fields.length === 0) continue;
|
|
18
25
|
seen.add(model.name);
|
|
19
26
|
|
|
20
|
-
const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
|
|
21
27
|
const path = `tests/fixtures/${moduleName(model.name)}.json`;
|
|
28
|
+
if (!fileExistsAfterRun(path, isModelInScope(model.name, ctx), ctx)) continue;
|
|
29
|
+
|
|
30
|
+
const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
|
|
22
31
|
files.push({
|
|
23
32
|
path,
|
|
24
33
|
content: JSON.stringify(fixture, null, 2) + '\n',
|
package/src/rust/models.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Model, EmitterContext, GeneratedFile, Field, TypeRef } from '@work
|
|
|
2
2
|
import { typeName, domainFieldName, moduleName } from './naming.js';
|
|
3
3
|
import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
|
|
4
4
|
import { applySecretRedaction } from './secret.js';
|
|
5
|
+
import { isModelInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
|
|
7
8
|
|
|
@@ -32,15 +33,45 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
|
|
|
32
33
|
const mod = moduleName(model.name);
|
|
33
34
|
if (seen.has(mod)) continue;
|
|
34
35
|
seen.add(mod);
|
|
35
|
-
moduleNames.push(mod);
|
|
36
36
|
|
|
37
|
+
// renderModel registers inline unions into `registry` as a side effect, and
|
|
38
|
+
// `_unions.rs` is rendered later (in generateClient) from that registry — so
|
|
39
|
+
// it MUST run for every model, even out-of-scope ones, or scoped runs drop
|
|
40
|
+
// unions. Compute content unconditionally; only the per-model `.rs` FILE write
|
|
41
|
+
// is scoped (FR-1.4) — an out-of-scope model's existing `.rs` file stays
|
|
42
|
+
// untouched on disk.
|
|
43
|
+
const inScope = isModelInScope(model.name, ctx);
|
|
37
44
|
const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
|
|
38
45
|
const path = hintPath ?? `src/models/${mod}.rs`;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const content = renderModel(model, registry, taggedVariantFields.get(model.name));
|
|
47
|
+
if (inScope) {
|
|
48
|
+
files.push({
|
|
49
|
+
path,
|
|
50
|
+
content,
|
|
51
|
+
overwriteExisting: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Declare the module only if its `.rs` file will exist on disk after this
|
|
56
|
+
// run: in-scope (emitted just now) or already present from a prior run. A
|
|
57
|
+
// scoped run must NOT declare a brand-new out-of-scope model whose file it
|
|
58
|
+
// never emits — that dangling `mod` is what broke the build. A full run
|
|
59
|
+
// declares every module (fileExistsAfterRun ⇒ true when scoping is inert).
|
|
60
|
+
if (fileExistsAfterRun(path, inScope, ctx)) {
|
|
61
|
+
moduleNames.push(mod);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Scoped runs: retain barrel entries for model files still on disk (prior
|
|
66
|
+
// manifest) that the current spec no longer produces — e.g. a model renamed
|
|
67
|
+
// for another service. Out-of-scope code we did not regenerate may still
|
|
68
|
+
// reference them, so dropping the `mod` would break the build. De-duped
|
|
69
|
+
// against the modules declared above; a full run yields nothing here.
|
|
70
|
+
for (const base of priorManifestBasenames(ctx, 'src/models', '.rs', new Set([UNIONS_MODULE, 'mod']))) {
|
|
71
|
+
if (!seen.has(base)) {
|
|
72
|
+
seen.add(base);
|
|
73
|
+
moduleNames.push(base);
|
|
74
|
+
}
|
|
44
75
|
}
|
|
45
76
|
|
|
46
77
|
// Always include the unions module in the barrel so downstream stages
|
package/src/rust/resources.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { fieldName, domainFieldName, methodName, typeName, moduleName, variantNa
|
|
|
14
14
|
import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
|
|
15
15
|
import { applySecretRedaction } from './secret.js';
|
|
16
16
|
import { parsePathTemplate } from '../shared/path-template.js';
|
|
17
|
-
import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
|
|
17
|
+
import { groupByMount, buildResolvedLookup, isMountInScope } from '../shared/resolved-ops.js';
|
|
18
18
|
import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -32,7 +32,14 @@ export function generateResources(_services: Service[], ctx: EmitterContext, reg
|
|
|
32
32
|
if (group.operations.length === 0) continue;
|
|
33
33
|
const basename = moduleName(mountName);
|
|
34
34
|
const struct = mountStructName(mountName);
|
|
35
|
+
// The barrel (`src/resources/mod.rs`) must list every mount's module so
|
|
36
|
+
// Rust compiles even in a scoped run — `exports` is collected from the
|
|
37
|
+
// FULL groupByMount set regardless of scope.
|
|
35
38
|
exports.push({ module: basename, struct });
|
|
39
|
+
// Only the per-service resource `.rs` FILE write is scoped. In a scoped
|
|
40
|
+
// run we skip emitting files for out-of-scope mounts, but the barrel above
|
|
41
|
+
// still references their modules (their existing `.rs` files stay on disk).
|
|
42
|
+
if (!isMountInScope(mountName, ctx)) continue;
|
|
36
43
|
files.push({
|
|
37
44
|
path: `src/resources/${basename}.rs`,
|
|
38
45
|
content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
|
package/src/rust/tests.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
TypeRef,
|
|
12
12
|
} from '@workos/oagen';
|
|
13
13
|
import { methodName, moduleName, typeName } from './naming.js';
|
|
14
|
-
import {
|
|
14
|
+
import { scopedMountGroups } from '../shared/resolved-ops.js';
|
|
15
15
|
import { exampleFor, generateFixtures } from './fixtures.js';
|
|
16
16
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
17
17
|
import { isInlineEnvelopeList } from './resources.js';
|
|
@@ -35,7 +35,7 @@ import { isInlineEnvelopeList } from './resources.js';
|
|
|
35
35
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
36
36
|
const files: GeneratedFile[] = [];
|
|
37
37
|
|
|
38
|
-
files.push(...generateFixtures(spec));
|
|
38
|
+
files.push(...generateFixtures(spec, ctx));
|
|
39
39
|
|
|
40
40
|
files.push({
|
|
41
41
|
path: 'tests/common/mod.rs',
|
|
@@ -43,7 +43,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
43
43
|
overwriteExisting: true,
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
const groups =
|
|
46
|
+
const groups = scopedMountGroups(ctx);
|
|
47
47
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
|
|
@@ -94,6 +94,110 @@ export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
|
|
|
94
94
|
return groups;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Like {@link groupByMount}, but for a scoped (`--services`) run returns ONLY the
|
|
99
|
+
* mount groups the run selected (`ctx.scopedServices`, POST-MOUNT names). When
|
|
100
|
+
* scoping is inactive the full set is returned unchanged.
|
|
101
|
+
*
|
|
102
|
+
* Use this for PER-SERVICE resource/test emission. Do NOT use it for
|
|
103
|
+
* aggregate/barrel files (Rust `mod.rs`, Ruby `client.rbi`, the root client) —
|
|
104
|
+
* those must continue to list every service, so they keep calling
|
|
105
|
+
* {@link groupByMount} over the full set; otherwise a scoped run would drop
|
|
106
|
+
* sibling modules and break the build/type-check.
|
|
107
|
+
*/
|
|
108
|
+
export function scopedMountGroups(ctx: EmitterContext): Map<string, MountGroup> {
|
|
109
|
+
const groups = groupByMount(ctx);
|
|
110
|
+
const scope = ctx.scopedServices;
|
|
111
|
+
if (!scope || scope.size === 0) return groups;
|
|
112
|
+
return new Map([...groups].filter(([mountName]) => scope.has(mountName)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when a POST-MOUNT service name should be emitted in the current run.
|
|
117
|
+
* Inactive scoping (no `ctx.scopedServices`) ⇒ everything is in scope. Use this
|
|
118
|
+
* for inline per-service gates (e.g. manifest loops keyed by `getMountTarget`).
|
|
119
|
+
*/
|
|
120
|
+
export function isMountInScope(mountName: string, ctx: EmitterContext): boolean {
|
|
121
|
+
const scope = ctx.scopedServices;
|
|
122
|
+
return !scope || scope.size === 0 || scope.has(mountName);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* True when a MODEL's per-model FILE should be written in the current run (FR-1.4).
|
|
127
|
+
* A scoped run sets `ctx.scopedModelNames` to the models reachable from the
|
|
128
|
+
* selected services; out-of-scope models are left untouched on disk. Inactive
|
|
129
|
+
* scoping ⇒ everything is in scope. NOTE: gate only the per-model FILE write —
|
|
130
|
+
* the model must still appear in barrels/indexes (built from the full set) so the
|
|
131
|
+
* untouched on-disk file stays importable.
|
|
132
|
+
*/
|
|
133
|
+
export function isModelInScope(modelName: string, ctx: EmitterContext): boolean {
|
|
134
|
+
const scope = ctx.scopedModelNames;
|
|
135
|
+
return !scope || scope.has(modelName);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Like {@link isModelInScope} but for an ENUM's per-enum file (`ctx.scopedEnumNames`). */
|
|
139
|
+
export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
|
|
140
|
+
const scope = ctx.scopedEnumNames;
|
|
141
|
+
return !scope || scope.has(enumName);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when a scoped (`--services`) run is active. */
|
|
145
|
+
export function isScopedRun(ctx: EmitterContext): boolean {
|
|
146
|
+
return !!ctx.scopedServices && ctx.scopedServices.size > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Barrel/index inclusion gate for one item (model, enum, fixture) under a
|
|
151
|
+
* scoped run. A barrel/index may only reference an item whose per-item FILE
|
|
152
|
+
* EXISTS ON DISK after the run — otherwise the reference dangles and the SDK
|
|
153
|
+
* fails to compile (the bug this guards: a brand-new model belonging to an
|
|
154
|
+
* out-of-scope service gets declared in `mod.rs`/`__init__.py` but its source
|
|
155
|
+
* file is never emitted). That on-disk set is:
|
|
156
|
+
* in-scope items (freshly emitted this run) ∪ items already on disk
|
|
157
|
+
* (recorded in the prior manifest, left untouched because scoped runs never
|
|
158
|
+
* prune).
|
|
159
|
+
* Inactive scoping ⇒ always true (a full run emits and declares everything).
|
|
160
|
+
*
|
|
161
|
+
* `relPath` is the per-item file path the emitter writes (e.g.
|
|
162
|
+
* `src/models/foo.rs`); `inScope` is the per-item scope predicate result
|
|
163
|
+
* (`isModelInScope` / `isEnumInScope`).
|
|
164
|
+
*/
|
|
165
|
+
export function fileExistsAfterRun(relPath: string, inScope: boolean, ctx: EmitterContext): boolean {
|
|
166
|
+
if (!isScopedRun(ctx)) return true;
|
|
167
|
+
return inScope || (ctx.priorTargetManifestPaths?.has(relPath) ?? false);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-item basenames recorded in the prior manifest directly under `dir` (e.g.
|
|
172
|
+
* `src/models`) with extension `ext` (e.g. `.rs`), EXCLUDING `reserved` names
|
|
173
|
+
* (barrels such as `mod`, `_unions`, `__init__`). A scoped run uses this to
|
|
174
|
+
* RETAIN barrel declarations for items that were renamed/removed from the spec
|
|
175
|
+
* but whose files still sit on disk — and may still be referenced by
|
|
176
|
+
* out-of-scope code the scoped run did not regenerate (e.g. a stale resource
|
|
177
|
+
* file). Returns `[]` for a full run or when no prior manifest is available;
|
|
178
|
+
* the caller is responsible for de-duping against items it already emitted.
|
|
179
|
+
*/
|
|
180
|
+
export function priorManifestBasenames(
|
|
181
|
+
ctx: EmitterContext,
|
|
182
|
+
dir: string,
|
|
183
|
+
ext: string,
|
|
184
|
+
reserved: Set<string> = new Set(),
|
|
185
|
+
): string[] {
|
|
186
|
+
if (!isScopedRun(ctx)) return [];
|
|
187
|
+
const paths = ctx.priorTargetManifestPaths;
|
|
188
|
+
if (!paths) return [];
|
|
189
|
+
const prefix = dir.endsWith('/') ? dir : `${dir}/`;
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const p of paths) {
|
|
192
|
+
if (!p.startsWith(prefix) || !p.endsWith(ext)) continue;
|
|
193
|
+
const base = p.slice(prefix.length, p.length - ext.length);
|
|
194
|
+
if (base.includes('/')) continue; // direct children only
|
|
195
|
+
if (reserved.has(base)) continue;
|
|
196
|
+
out.push(base);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
97
201
|
/**
|
|
98
202
|
* Get the mount target for an IR service.
|
|
99
203
|
* Checks the first resolved operation that belongs to this service.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { dotnetEmitter } from '../../src/dotnet/index.js';
|
|
3
|
+
import { generateTests } from '../../src/dotnet/tests.js';
|
|
4
|
+
import { discriminatedUnions } from '../../src/dotnet/type-map.js';
|
|
5
|
+
import { primeEnumAliases } from '../../src/dotnet/enums.js';
|
|
6
|
+
import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
|
|
7
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scoped (`--services`) runs emit per-model `Entities/*.cs` only for in-scope
|
|
11
|
+
* models, but the polymorphic-dispatch aggregates (discriminated-union JSON
|
|
12
|
+
* converters, per-model test fixtures) were previously built from the FULL
|
|
13
|
+
* spec. A brand-new out-of-scope variant would then be referenced by a
|
|
14
|
+
* converter whose `.cs` file is never emitted (CS0246). These tests assert the
|
|
15
|
+
* `fileExistsAfterRun` gate excludes brand-new out-of-scope items while
|
|
16
|
+
* retaining renamed/removed-but-still-on-disk ones.
|
|
17
|
+
*/
|
|
18
|
+
const emptySpec: ApiSpec = {
|
|
19
|
+
name: 'Test',
|
|
20
|
+
version: '1.0.0',
|
|
21
|
+
baseUrl: '',
|
|
22
|
+
services: [],
|
|
23
|
+
models: [],
|
|
24
|
+
enums: [],
|
|
25
|
+
sdk: defaultSdkBehavior(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('dotnet/scoped aggregates', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
// discriminatedUnions is module-global and accumulates across mapTypeRef
|
|
31
|
+
// calls; clear it so each test sees only the unions it registers.
|
|
32
|
+
discriminatedUnions.clear();
|
|
33
|
+
primeEnumAliases([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('gates the discriminated-union converter to variants whose .cs exists after a scoped run', () => {
|
|
37
|
+
// Parent model has a field typed as a discriminated union over three
|
|
38
|
+
// event-payload variants:
|
|
39
|
+
// - UserCreated: in-scope this run (emitted now)
|
|
40
|
+
// - OrganizationDomainStandAlone: NOT in scope, but on disk (prior run) → retained
|
|
41
|
+
// - SessionReauthenticated: brand-new, NOT in scope, NOT on disk → excluded
|
|
42
|
+
const models: Model[] = [
|
|
43
|
+
{
|
|
44
|
+
name: 'WebhookEnvelope',
|
|
45
|
+
fields: [
|
|
46
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
47
|
+
{
|
|
48
|
+
name: 'payload',
|
|
49
|
+
type: {
|
|
50
|
+
kind: 'union',
|
|
51
|
+
variants: [
|
|
52
|
+
{ kind: 'model', name: 'UserCreated' },
|
|
53
|
+
{ kind: 'model', name: 'OrganizationDomainStandAlone' },
|
|
54
|
+
{ kind: 'model', name: 'SessionReauthenticated' },
|
|
55
|
+
],
|
|
56
|
+
discriminator: {
|
|
57
|
+
property: 'event',
|
|
58
|
+
mapping: {
|
|
59
|
+
'user.created': 'UserCreated',
|
|
60
|
+
'organization_domain.verified': 'OrganizationDomainStandAlone',
|
|
61
|
+
'session.reauthenticated': 'SessionReauthenticated',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: true,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'UserCreated',
|
|
71
|
+
fields: [{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'OrganizationDomainStandAlone',
|
|
75
|
+
fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'SessionReauthenticated',
|
|
79
|
+
fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const ctx: EmitterContext = {
|
|
84
|
+
namespace: 'workos',
|
|
85
|
+
namespacePascal: 'WorkOS',
|
|
86
|
+
spec: { ...emptySpec, models },
|
|
87
|
+
// Scoped to the WebhookEnvelope + UserCreated surface only.
|
|
88
|
+
scopedServices: new Set(['Webhooks']),
|
|
89
|
+
scopedModelNames: new Set(['WebhookEnvelope', 'UserCreated']),
|
|
90
|
+
// OrganizationDomainStandAlone exists on disk from a prior full run; the
|
|
91
|
+
// brand-new SessionReauthenticated does not.
|
|
92
|
+
priorTargetManifestPaths: new Set([
|
|
93
|
+
'src/WorkOS.net/Entities/WebhookEnvelope.cs',
|
|
94
|
+
'src/WorkOS.net/Entities/UserCreated.cs',
|
|
95
|
+
'src/WorkOS.net/Entities/OrganizationDomainStandAlone.cs',
|
|
96
|
+
]),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const files = dotnetEmitter.generateModels!(models, ctx);
|
|
100
|
+
const converter = files.find((f) => f.path.includes('DiscriminatorConverter.cs'));
|
|
101
|
+
expect(converter).toBeDefined();
|
|
102
|
+
const content = converter!.content;
|
|
103
|
+
|
|
104
|
+
// In-scope variant is dispatched.
|
|
105
|
+
expect(content).toContain('case "user.created": return jObject.ToObject<UserCreated>(serializer);');
|
|
106
|
+
// Renamed/removed-but-on-disk variant is retained.
|
|
107
|
+
expect(content).toContain(
|
|
108
|
+
'case "organization_domain.verified": return jObject.ToObject<OrganizationDomainStandAlone>(serializer);',
|
|
109
|
+
);
|
|
110
|
+
// Brand-new out-of-scope variant is EXCLUDED (its .cs is never emitted → CS0246).
|
|
111
|
+
expect(content).not.toContain('SessionReauthenticated');
|
|
112
|
+
expect(content).not.toContain('session.reauthenticated');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('emits all converter variants on a full (unscoped) run', () => {
|
|
116
|
+
const models: Model[] = [
|
|
117
|
+
{
|
|
118
|
+
name: 'WebhookEnvelope',
|
|
119
|
+
fields: [
|
|
120
|
+
{
|
|
121
|
+
name: 'payload',
|
|
122
|
+
type: {
|
|
123
|
+
kind: 'union',
|
|
124
|
+
variants: [
|
|
125
|
+
{ kind: 'model', name: 'UserCreated' },
|
|
126
|
+
{ kind: 'model', name: 'SessionReauthenticated' },
|
|
127
|
+
],
|
|
128
|
+
discriminator: {
|
|
129
|
+
property: 'event',
|
|
130
|
+
mapping: {
|
|
131
|
+
'user.created': 'UserCreated',
|
|
132
|
+
'session.reauthenticated': 'SessionReauthenticated',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required: true,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'UserCreated',
|
|
142
|
+
fields: [{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'SessionReauthenticated',
|
|
146
|
+
fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// No scopedServices → full run; every variant is dispatched.
|
|
151
|
+
const ctx: EmitterContext = {
|
|
152
|
+
namespace: 'workos',
|
|
153
|
+
namespacePascal: 'WorkOS',
|
|
154
|
+
spec: { ...emptySpec, models },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const files = dotnetEmitter.generateModels!(models, ctx);
|
|
158
|
+
const converter = files.find((f) => f.path.includes('DiscriminatorConverter.cs'))!;
|
|
159
|
+
expect(converter.content).toContain('ToObject<UserCreated>');
|
|
160
|
+
expect(converter.content).toContain('ToObject<SessionReauthenticated>');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('gates per-model fixtures to models whose file exists after a scoped run', () => {
|
|
164
|
+
const models: Model[] = [
|
|
165
|
+
{
|
|
166
|
+
name: 'OrganizationMembership',
|
|
167
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'OrganizationDomainStandAlone',
|
|
171
|
+
fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'SessionReauthenticated',
|
|
175
|
+
fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const services: Service[] = [
|
|
180
|
+
{
|
|
181
|
+
name: 'OrganizationMemberships',
|
|
182
|
+
operations: [
|
|
183
|
+
{
|
|
184
|
+
name: 'getOrganizationMembership',
|
|
185
|
+
httpMethod: 'get',
|
|
186
|
+
path: '/organization_memberships/{id}',
|
|
187
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
188
|
+
queryParams: [],
|
|
189
|
+
headerParams: [],
|
|
190
|
+
response: { kind: 'model', name: 'OrganizationMembership' },
|
|
191
|
+
errors: [],
|
|
192
|
+
injectIdempotencyKey: false,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const spec: ApiSpec = { ...emptySpec, services, models };
|
|
199
|
+
const ctx: EmitterContext = {
|
|
200
|
+
namespace: 'workos',
|
|
201
|
+
namespacePascal: 'WorkOS',
|
|
202
|
+
spec,
|
|
203
|
+
scopedServices: new Set(['OrganizationMemberships']),
|
|
204
|
+
scopedModelNames: new Set(['OrganizationMembership']),
|
|
205
|
+
// The renamed/removed model is on disk; the brand-new one is not.
|
|
206
|
+
priorTargetManifestPaths: new Set([
|
|
207
|
+
'test/WorkOSTests/testdata/organization_membership.json',
|
|
208
|
+
'test/WorkOSTests/testdata/organization_domain_stand_alone.json',
|
|
209
|
+
]),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const files = generateTests(spec, ctx);
|
|
213
|
+
const paths = files.map((f) => f.path);
|
|
214
|
+
|
|
215
|
+
// In-scope model fixture is emitted.
|
|
216
|
+
expect(paths).toContain('testdata/organization_membership.json');
|
|
217
|
+
// Renamed/removed-but-on-disk fixture is retained.
|
|
218
|
+
expect(paths).toContain('testdata/organization_domain_stand_alone.json');
|
|
219
|
+
// Brand-new out-of-scope fixture is EXCLUDED (stray file for an
|
|
220
|
+
// unreferenceable model).
|
|
221
|
+
expect(paths.some((p) => p.includes('session_reauthenticated'))).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('emits all per-model fixtures on a full (unscoped) run', () => {
|
|
225
|
+
const models: Model[] = [
|
|
226
|
+
{
|
|
227
|
+
name: 'OrganizationMembership',
|
|
228
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'SessionReauthenticated',
|
|
232
|
+
fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
const spec: ApiSpec = { ...emptySpec, models };
|
|
236
|
+
const ctx: EmitterContext = {
|
|
237
|
+
namespace: 'workos',
|
|
238
|
+
namespacePascal: 'WorkOS',
|
|
239
|
+
spec,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const files = generateTests(spec, ctx);
|
|
243
|
+
const paths = files.map((f) => f.path);
|
|
244
|
+
expect(paths).toContain('testdata/organization_membership.json');
|
|
245
|
+
expect(paths.some((p) => p.includes('session_reauthenticated'))).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|