@workos/oagen-emitters 0.19.0 → 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 +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BXDPA9pJ.mjs → plugin-DXIciTnN.mjs} +535 -96
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/tests.ts +1 -1
- 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/tests.ts +4 -2
- package/src/kotlin/models.ts +36 -6
- package/src/kotlin/tests.ts +36 -1
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +118 -34
- package/src/python/tests.ts +28 -9
- package/src/ruby/tests.ts +35 -2
- package/src/rust/enums.ts +29 -15
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +26 -8
- package/src/rust/tests.ts +1 -1
- package/src/shared/resolved-ops.ts +57 -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/dist/plugin-BXDPA9pJ.mjs.map +0 -1
package/src/python/tests.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
lookupResolved,
|
|
31
31
|
buildHiddenParams,
|
|
32
32
|
collectGroupedParamNames,
|
|
33
|
+
isModelInScope,
|
|
34
|
+
fileExistsAfterRun,
|
|
33
35
|
} from '../shared/resolved-ops.js';
|
|
34
36
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
35
37
|
import { pythonLiteral } from './wrappers.js';
|
|
@@ -100,7 +102,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
100
102
|
const files: GeneratedFile[] = [];
|
|
101
103
|
|
|
102
104
|
// Generate fixture JSON files
|
|
103
|
-
const fixtures = generateFixtures(spec);
|
|
105
|
+
const fixtures = generateFixtures(spec, ctx);
|
|
104
106
|
for (const fixture of fixtures) {
|
|
105
107
|
files.push({
|
|
106
108
|
path: fixture.path,
|
|
@@ -1461,22 +1463,39 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1461
1463
|
|
|
1462
1464
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
1463
1465
|
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
1464
|
-
const models = spec.models.filter(
|
|
1465
|
-
(m) =>
|
|
1466
|
-
!(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
|
|
1467
|
-
!(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
|
|
1468
|
-
!requestOnlyModelNames.has(m.name),
|
|
1469
|
-
);
|
|
1470
|
-
if (models.length === 0) return null;
|
|
1471
1466
|
|
|
1472
1467
|
// The round-trip test imports models from their *natural* (pre-relocation)
|
|
1473
1468
|
// service so existing callers keep working — those imports resolve via the
|
|
1474
1469
|
// BC re-exports that the model emitter writes into each service barrel.
|
|
1475
|
-
const
|
|
1470
|
+
const placement = computeSchemaPlacement(spec, ctx);
|
|
1471
|
+
const modelToService = placement.originalModelToService;
|
|
1472
|
+
// The per-model FILE is written to the RELOCATED dir; use it to compute the
|
|
1473
|
+
// gate's relPath so it lines up with the prior manifest.
|
|
1474
|
+
const relocatedModelToService = placement.modelToService;
|
|
1476
1475
|
const roundTripDirMap = buildMountDirMap(ctx);
|
|
1477
1476
|
const resolveDir = (irService: string | undefined) =>
|
|
1478
1477
|
irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
|
|
1479
1478
|
|
|
1479
|
+
// Scoped runs: the round-trip test must only reference a model whose per-model
|
|
1480
|
+
// file exists on disk after the run (in-scope, or already present from the
|
|
1481
|
+
// prior manifest). A brand-new out-of-scope model is excluded — its file is
|
|
1482
|
+
// never emitted, so importing it would raise ModuleNotFoundError. A full run
|
|
1483
|
+
// keeps every model (fileExistsAfterRun ⇒ true).
|
|
1484
|
+
const modelFileExists = (name: string): boolean => {
|
|
1485
|
+
const dir = resolveDir(relocatedModelToService.get(name));
|
|
1486
|
+
const path = `src/${ctx.namespace}/${dir}/models/${fileName(name)}.py`;
|
|
1487
|
+
return fileExistsAfterRun(path, isModelInScope(name, ctx), ctx);
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
const models = spec.models.filter(
|
|
1491
|
+
(m) =>
|
|
1492
|
+
!(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
|
|
1493
|
+
!(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
|
|
1494
|
+
!requestOnlyModelNames.has(m.name) &&
|
|
1495
|
+
modelFileExists(m.name),
|
|
1496
|
+
);
|
|
1497
|
+
if (models.length === 0) return null;
|
|
1498
|
+
|
|
1480
1499
|
const lines: string[] = [];
|
|
1481
1500
|
lines.push('"""Model round-trip tests: from_dict(to_dict()) preserves data."""');
|
|
1482
1501
|
lines.push('');
|
package/src/ruby/tests.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { assignModelsToServices } from '@workos/oagen';
|
|
2
3
|
import {
|
|
3
4
|
className,
|
|
4
5
|
fileName,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
resolveMethodName,
|
|
11
12
|
buildExportedClassNameSet,
|
|
12
13
|
resolveServiceTarget,
|
|
14
|
+
buildMountDirMap,
|
|
13
15
|
} from './naming.js';
|
|
14
16
|
import {
|
|
15
17
|
buildResolvedLookup,
|
|
@@ -17,8 +19,11 @@ import {
|
|
|
17
19
|
lookupResolved,
|
|
18
20
|
buildHiddenParams,
|
|
19
21
|
collectBodyFieldTypes,
|
|
22
|
+
isModelInScope,
|
|
23
|
+
fileExistsAfterRun,
|
|
20
24
|
} from '../shared/resolved-ops.js';
|
|
21
25
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
26
|
+
import { classifyUnassignedModel } from './models.js';
|
|
22
27
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
23
28
|
import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js';
|
|
24
29
|
|
|
@@ -203,7 +208,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
203
208
|
});
|
|
204
209
|
}
|
|
205
210
|
|
|
206
|
-
files.push(generateModelRoundTripTest(spec));
|
|
211
|
+
files.push(generateModelRoundTripTest(spec, ctx));
|
|
207
212
|
|
|
208
213
|
return files;
|
|
209
214
|
}
|
|
@@ -212,8 +217,19 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
212
217
|
* Emit test/workos/model_round_trip_test.rb that round-trips every non-wrapper
|
|
213
218
|
* model through `.new(json)` and `.to_json`, asserting the result is a Hash and
|
|
214
219
|
* that required fields appear with the seeded values.
|
|
220
|
+
*
|
|
221
|
+
* This is a whole-suite AGGREGATE: it references `WorkOS::<Class>` for every
|
|
222
|
+
* model. Under a scoped (`--services`) run only the selected services' per-model
|
|
223
|
+
* `.rb` files are (re)written, so a test referencing a brand-new out-of-scope
|
|
224
|
+
* model whose file was never emitted would raise `NameError: uninitialized
|
|
225
|
+
* constant`. Each per-model test is therefore gated by {@link fileExistsAfterRun}
|
|
226
|
+
* on the SAME path `models.ts` writes (`lib/workos/<dir>/<file>.rb`): emit only
|
|
227
|
+
* when that file will exist on disk after the run — in-scope (emitted this run)
|
|
228
|
+
* OR already present from a prior run (`priorTargetManifestPaths`). Brand-new
|
|
229
|
+
* out-of-scope models are excluded; renamed/removed-but-on-disk models are
|
|
230
|
+
* retained. A full run (scoping inert) keeps every model.
|
|
215
231
|
*/
|
|
216
|
-
function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
|
|
232
|
+
function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
217
233
|
const lines: string[] = [];
|
|
218
234
|
lines.push(`require 'test_helper'`);
|
|
219
235
|
lines.push('');
|
|
@@ -223,7 +239,24 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
|
|
|
223
239
|
const enumNames = new Set(spec.enums.map((e) => e.name));
|
|
224
240
|
const emitted = new Set<string>();
|
|
225
241
|
|
|
242
|
+
// Resolve each model's per-model file path EXACTLY as models.ts does, so the
|
|
243
|
+
// scope gate keys on the same path the per-model FILE writer uses.
|
|
244
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
|
|
245
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
246
|
+
const dirFor = (modelName: string): string => {
|
|
247
|
+
const service = modelToService.get(modelName);
|
|
248
|
+
if (!service) return classifyUnassignedModel(modelName);
|
|
249
|
+
return mountDirMap.get(service) ?? classifyUnassignedModel(modelName);
|
|
250
|
+
};
|
|
251
|
+
|
|
226
252
|
for (const model of models) {
|
|
253
|
+
// Skip models whose per-model `.rb` file will NOT exist on disk after this
|
|
254
|
+
// run (brand-new + out-of-scope under `--services`). Without this gate the
|
|
255
|
+
// aggregate would reference an undefined constant. The path mirrors
|
|
256
|
+
// models.ts's `lib/workos/${dirFor}/${fileName}.rb`.
|
|
257
|
+
const modelFilePath = `lib/workos/${dirFor(model.name)}/${fileName(model.name)}.rb`;
|
|
258
|
+
if (!fileExistsAfterRun(modelFilePath, isModelInScope(model.name, ctx), ctx)) continue;
|
|
259
|
+
|
|
227
260
|
// Avoid duplicate test names when two IR model names collapse to the same
|
|
228
261
|
// snake_case file name (we use the file name as the test suffix).
|
|
229
262
|
const fileBase = fileName(model.name);
|
package/src/rust/enums.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { typeName, moduleName, variantName } from './naming.js';
|
|
3
|
-
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
3
|
+
import { isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Generate one Rust source file per enum under `src/enums/`, plus a
|
|
@@ -28,20 +28,34 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
28
28
|
const mod = moduleName(e.name);
|
|
29
29
|
if (seen.has(mod)) continue;
|
|
30
30
|
seen.add(mod);
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
// enum
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
|
|
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
|
+
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
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,7 +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 } from '../shared/resolved-ops.js';
|
|
5
|
+
import { isModelInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
|
|
6
6
|
|
|
7
7
|
const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
|
|
8
8
|
|
|
@@ -33,27 +33,45 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
|
|
|
33
33
|
const mod = moduleName(model.name);
|
|
34
34
|
if (seen.has(mod)) continue;
|
|
35
35
|
seen.add(mod);
|
|
36
|
-
// The barrel (`src/models/mod.rs`) must declare every model's module so
|
|
37
|
-
// Rust compiles even in a scoped run — `moduleNames` is collected from the
|
|
38
|
-
// FULL model set regardless of scope.
|
|
39
|
-
moduleNames.push(mod);
|
|
40
36
|
|
|
41
37
|
// renderModel registers inline unions into `registry` as a side effect, and
|
|
42
38
|
// `_unions.rs` is rendered later (in generateClient) from that registry — so
|
|
43
39
|
// it MUST run for every model, even out-of-scope ones, or scoped runs drop
|
|
44
40
|
// unions. Compute content unconditionally; only the per-model `.rs` FILE write
|
|
45
|
-
// is scoped (FR-1.4)
|
|
46
|
-
//
|
|
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);
|
|
47
44
|
const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
|
|
48
45
|
const path = hintPath ?? `src/models/${mod}.rs`;
|
|
49
46
|
const content = renderModel(model, registry, taggedVariantFields.get(model.name));
|
|
50
|
-
if (
|
|
47
|
+
if (inScope) {
|
|
51
48
|
files.push({
|
|
52
49
|
path,
|
|
53
50
|
content,
|
|
54
51
|
overwriteExisting: true,
|
|
55
52
|
});
|
|
56
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
|
+
}
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
// Always include the unions module in the barrel so downstream stages
|
package/src/rust/tests.ts
CHANGED
|
@@ -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',
|
|
@@ -141,6 +141,63 @@ export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
|
|
|
141
141
|
return !scope || scope.has(enumName);
|
|
142
142
|
}
|
|
143
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
|
+
|
|
144
201
|
/**
|
|
145
202
|
* Get the mount target for an IR service.
|
|
146
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
|
+
});
|