@workos/oagen-emitters 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-B9F2jmwy.mjs → plugin-DRGwxN88.mjs} +754 -49
- package/dist/plugin-DRGwxN88.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/dotnet/models.ts +31 -6
- package/src/dotnet/type-map.ts +18 -1
- package/src/go/models.ts +12 -3
- package/src/kotlin/models.ts +16 -6
- package/src/node/discriminated-models.ts +735 -0
- package/src/node/index.ts +134 -9
- package/src/node/models.ts +23 -3
- package/src/node/node-overrides.ts +49 -6
- package/src/node/utils.ts +5 -1
- package/src/php/index.ts +25 -2
- package/src/php/models.ts +11 -2
- package/src/python/models.ts +12 -2
- package/src/python/resources.ts +8 -2
- package/src/ruby/index.ts +27 -2
- package/src/ruby/models.ts +13 -3
- package/src/rust/index.ts +26 -2
- package/src/rust/models.ts +5 -1
- package/src/rust/resources.ts +4 -1
- package/src/shared/model-utils.ts +49 -7
- package/test/rust/models.test.ts +3 -3
- package/dist/plugin-B9F2jmwy.mjs.map +0 -1
package/src/node/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { generateEnums as generateEnumFiles } from './enums.js';
|
|
|
16
16
|
import { generateResources, resolveResourceClassName, resolveResourceDir } from './resources.js';
|
|
17
17
|
import { generateClient } from './client.js';
|
|
18
18
|
import { generateTests as generateTestFiles } from './tests.js';
|
|
19
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
20
|
+
import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
|
|
19
21
|
import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
|
|
20
22
|
import {
|
|
21
23
|
setBaselineSerializedNames,
|
|
@@ -36,6 +38,24 @@ import { fileName } from './naming.js';
|
|
|
36
38
|
*/
|
|
37
39
|
const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Paths the node emitter has produced so far in this ctx, accumulated across
|
|
43
|
+
* `applyLiveSurface` calls. Drives `carryForwardManagedFiles` so files in the
|
|
44
|
+
* prior manifest that we did not re-emit this run still land in the new
|
|
45
|
+
* manifest as "still managed" — without that, the orchestrator's prune diff
|
|
46
|
+
* treats every untouched autogen file as stale.
|
|
47
|
+
*/
|
|
48
|
+
const emittedPathsCache = new WeakMap<EmitterContext, Set<string>>();
|
|
49
|
+
|
|
50
|
+
function getEmittedPaths(ctx: EmitterContext): Set<string> {
|
|
51
|
+
let set = emittedPathsCache.get(ctx);
|
|
52
|
+
if (!set) {
|
|
53
|
+
set = new Set();
|
|
54
|
+
emittedPathsCache.set(ctx, set);
|
|
55
|
+
}
|
|
56
|
+
return set;
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
function getSurface(ctx: EmitterContext): LiveSurface {
|
|
40
60
|
let surface = surfaceCache.get(ctx);
|
|
41
61
|
if (surface) return surface;
|
|
@@ -113,8 +133,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
113
133
|
* `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
|
|
114
134
|
* with no `--target` step they would otherwise land as untracked cruft.
|
|
115
135
|
*
|
|
116
|
-
* Note:
|
|
117
|
-
*
|
|
136
|
+
* Note: the carry-forward step in `generateTests` re-declares prior-manifest
|
|
137
|
+
* paths we didn't touch this run, so the orchestrator's prune diff stays
|
|
138
|
+
* accurate without needing `--no-prune` at the call site. See
|
|
139
|
+
* `carryForwardManagedFiles` below.
|
|
118
140
|
*/
|
|
119
141
|
/**
|
|
120
142
|
* `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
|
|
@@ -214,7 +236,10 @@ function computeOwnedServiceDirs(ctx: EmitterContext): Set<string> {
|
|
|
214
236
|
const mountGroups = groupByMount(ctx);
|
|
215
237
|
const services =
|
|
216
238
|
mountGroups.size > 0
|
|
217
|
-
? [...mountGroups].map(([name, group]) => ({
|
|
239
|
+
? [...mountGroups].map(([name, group]) => ({
|
|
240
|
+
name,
|
|
241
|
+
operations: group.operations,
|
|
242
|
+
}))
|
|
218
243
|
: ctx.spec.services;
|
|
219
244
|
const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
220
245
|
|
|
@@ -233,7 +258,10 @@ function computeAdoptedServiceDirs(ctx: EmitterContext, surface: LiveSurface): S
|
|
|
233
258
|
const mountGroups = groupByMount(ctx);
|
|
234
259
|
const services =
|
|
235
260
|
mountGroups.size > 0
|
|
236
|
-
? [...mountGroups].map(([name, group]) => ({
|
|
261
|
+
? [...mountGroups].map(([name, group]) => ({
|
|
262
|
+
name,
|
|
263
|
+
operations: group.operations,
|
|
264
|
+
}))
|
|
237
265
|
: ctx.spec.services;
|
|
238
266
|
const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
239
267
|
|
|
@@ -347,22 +375,110 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
|
|
|
347
375
|
}
|
|
348
376
|
out.push(f);
|
|
349
377
|
}
|
|
378
|
+
const emitted = getEmittedPaths(ctx);
|
|
379
|
+
for (const f of out) emitted.add(f.path);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Re-declare prior-manifest paths that we did not emit this run so manifest
|
|
385
|
+
* pruning can tell "intentionally removed" from "untouched but still managed."
|
|
386
|
+
*
|
|
387
|
+
* The node emitter only outputs files it actually wants to write each run —
|
|
388
|
+
* untouched-but-up-to-date autogen files don't come back through any
|
|
389
|
+
* `generateXxx` method. Without this carry-forward, the orchestrator's
|
|
390
|
+
* `prevManifest.files − currentEmission` diff treats every such file as stale
|
|
391
|
+
* and prunes the whole tree on a regen. That's why `scripts/sdk-generate.sh`
|
|
392
|
+
* historically paired the node emitter with `--no-prune` — at the cost of
|
|
393
|
+
* never pruning legitimately-removed files (e.g. an enum file orphaned by a
|
|
394
|
+
* `schemaNameTransform` rename like `RadarAction` → `RadarListAction`).
|
|
395
|
+
*
|
|
396
|
+
* The carry-forward entry uses `skipIfExists: true`, so writer.ts skips the
|
|
397
|
+
* write and only ensures the header is present (no-op for files that already
|
|
398
|
+
* have it). The path still lands in `outputEmittedPaths` and therefore in the
|
|
399
|
+
* new manifest, which restores correct prune semantics.
|
|
400
|
+
*
|
|
401
|
+
* Files dropped from the carry-forward set:
|
|
402
|
+
* - Not on disk anymore (file was hand-deleted — let prune confirm absence).
|
|
403
|
+
* - `@oagen-ignore-file` protected (user has explicitly taken ownership).
|
|
404
|
+
* - `.ts` files that no longer carry the auto-gen header (user has taken
|
|
405
|
+
* ownership in-place; the next prune cycle will clear the manifest entry).
|
|
406
|
+
*/
|
|
407
|
+
function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
|
|
408
|
+
const priorPaths = ctx.priorTargetManifestPaths;
|
|
409
|
+
if (!priorPaths || priorPaths.size === 0) return [];
|
|
410
|
+
|
|
411
|
+
const emitted = getEmittedPaths(ctx);
|
|
412
|
+
const out: GeneratedFile[] = [];
|
|
413
|
+
for (const relPath of priorPaths) {
|
|
414
|
+
if (emitted.has(relPath)) continue;
|
|
415
|
+
if (!surface.files.has(relPath)) continue;
|
|
416
|
+
if (surface.protectedFiles.has(relPath)) continue;
|
|
417
|
+
if (relPath.endsWith('.ts') && !surface.autogenFiles.has(relPath)) continue;
|
|
418
|
+
|
|
419
|
+
out.push({
|
|
420
|
+
path: relPath,
|
|
421
|
+
content: '',
|
|
422
|
+
skipIfExists: true,
|
|
423
|
+
headerPlacement: 'skip',
|
|
424
|
+
});
|
|
425
|
+
emitted.add(relPath);
|
|
426
|
+
}
|
|
350
427
|
return out;
|
|
351
428
|
}
|
|
352
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Flatten oneOf / allOf+oneOf variant fields from the raw spec onto each
|
|
432
|
+
* model. `enrichModelsFromSpec` produces (a) extra optional fields on models
|
|
433
|
+
* whose schema is `allOf [base, oneOf [...]]`, and (b) synthetic models /
|
|
434
|
+
* enums for inline objects encountered inside variants (e.g. the inline
|
|
435
|
+
* `redirect_uris` item shape on `ConnectApplication`).
|
|
436
|
+
*
|
|
437
|
+
* Node, like Go / Kotlin / .NET, emits flat interfaces rather than a sum
|
|
438
|
+
* type, so on `enrichModelsFromSpec`-marked discriminated bases we restore
|
|
439
|
+
* the original IR fields — otherwise the base interface would be empty.
|
|
440
|
+
* A future change can emit a real TS discriminated union; for now the goal
|
|
441
|
+
* is parity with the other flat-emit languages so every variant field is
|
|
442
|
+
* at least reachable.
|
|
443
|
+
*/
|
|
444
|
+
function enrichModelsForNode(models: Model[]): Model[] {
|
|
445
|
+
const enriched = enrichModelsFromSpec(models);
|
|
446
|
+
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
447
|
+
return enriched.map((m) => {
|
|
448
|
+
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
449
|
+
const original = originalByName.get(m.name);
|
|
450
|
+
if (original && original.fields.length > 0) {
|
|
451
|
+
return { ...m, fields: original.fields };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return m;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
353
458
|
export const nodeEmitter: Emitter = {
|
|
354
459
|
language: 'node',
|
|
355
460
|
|
|
356
461
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
357
462
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
358
463
|
const surface = getSurface(nodeCtx);
|
|
359
|
-
|
|
464
|
+
const enriched = enrichModelsForNode(models);
|
|
465
|
+
// Detect `allOf [base, oneOf [variant, …]]` schemas and hand them off
|
|
466
|
+
// to the discriminated-models module. Leave the model in the standard
|
|
467
|
+
// pipeline's input so its field-type deps stay reachable, but stash the
|
|
468
|
+
// name set on ctx so models.ts skips emitting an interface/serializer —
|
|
469
|
+
// the discriminated module owns those paths instead.
|
|
470
|
+
const discPlans = planDiscriminatedModels(enriched, nodeCtx);
|
|
471
|
+
(nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = new Set(discPlans.keys());
|
|
472
|
+
const standardFiles = generateModelsAndSerializers(enriched, nodeCtx);
|
|
473
|
+
const discFiles = generateDiscriminatedFiles(discPlans, nodeCtx);
|
|
474
|
+
return applyLiveSurface([...standardFiles, ...discFiles], nodeCtx, surface);
|
|
360
475
|
},
|
|
361
476
|
|
|
362
477
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
363
478
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
364
479
|
const surface = getSurface(nodeCtx);
|
|
365
|
-
|
|
480
|
+
const syntheticEnums = getSyntheticEnums();
|
|
481
|
+
return applyLiveSurface(generateEnumFiles([...enums, ...syntheticEnums], nodeCtx), nodeCtx, surface);
|
|
366
482
|
},
|
|
367
483
|
|
|
368
484
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -374,7 +490,11 @@ export const nodeEmitter: Emitter = {
|
|
|
374
490
|
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
375
491
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
376
492
|
const surface = getSurface(nodeCtx);
|
|
377
|
-
|
|
493
|
+
// `nodeCtx.spec` has the synthetic models that `enrichModelsFromSpec`
|
|
494
|
+
// produced (e.g. inline-object item types like `ConnectApplicationRedirectUri`).
|
|
495
|
+
// The `spec` param is the engine's pre-enrichment spec, so the barrel
|
|
496
|
+
// generator would miss those synthetic interfaces. Use the enriched one.
|
|
497
|
+
return applyLiveSurface(generateClient(nodeCtx.spec, nodeCtx), nodeCtx, surface);
|
|
378
498
|
},
|
|
379
499
|
|
|
380
500
|
// workos-node ships its own exception hierarchy under src/common/exceptions/.
|
|
@@ -389,11 +509,16 @@ export const nodeEmitter: Emitter = {
|
|
|
389
509
|
|
|
390
510
|
// Test specs and fixtures are hand-maintained except for explicitly-owned
|
|
391
511
|
// service directories.
|
|
512
|
+
//
|
|
513
|
+
// This is also the last `generateXxx` hook in `generateAllFiles`, so it's
|
|
514
|
+
// where we tack on the carry-forward set — see `carryForwardManagedFiles`.
|
|
392
515
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
393
516
|
const nodeCtx = withNodeOperationOverrides(ctx);
|
|
394
|
-
if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
|
|
395
517
|
const surface = getSurface(nodeCtx);
|
|
396
|
-
|
|
518
|
+
const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
|
|
519
|
+
? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
|
|
520
|
+
: [];
|
|
521
|
+
return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
|
|
397
522
|
},
|
|
398
523
|
|
|
399
524
|
// No operations map needed — the manifest belongs to the staging+target flow,
|
package/src/node/models.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
createServiceDirResolver,
|
|
24
24
|
isListMetadataModel,
|
|
25
25
|
isListWrapperModel,
|
|
26
|
+
collectNonPaginatedResponseModelNames,
|
|
26
27
|
buildDeduplicationMap,
|
|
27
28
|
relativeImport,
|
|
28
29
|
modelHasNewFields,
|
|
@@ -130,6 +131,13 @@ function isSupportedFieldType(
|
|
|
130
131
|
// silently dropped on first emission because the target interface
|
|
131
132
|
// (`UserObject` under the adopted `connect/` dir) hasn't landed yet.
|
|
132
133
|
if (isAdoptedModelName(ref.name)) return true;
|
|
134
|
+
// Synthetic models produced by `enrichModelsFromSpec` (e.g. the
|
|
135
|
+
// inline-object item type for `ConnectApplication.redirect_uris`)
|
|
136
|
+
// are added to the models list passed into this generation pass —
|
|
137
|
+
// and hence into `shared.modelToService` — but won't yet exist on
|
|
138
|
+
// disk or in `apiSurface`. Accept them so their parent field
|
|
139
|
+
// survives field-projection.
|
|
140
|
+
if (shared.modelToService.has(ref.name)) return true;
|
|
133
141
|
const relPath = `src/${shared.resolveDir(shared.modelToService.get(ref.name))}/interfaces/${fileName(ref.name)}.interface.ts`;
|
|
134
142
|
return liveSurfaceHasManagedFile(relPath);
|
|
135
143
|
}
|
|
@@ -201,12 +209,18 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
201
209
|
}
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
213
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
214
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
215
|
+
// code references them by name and pagination iterators don't unwrap them.
|
|
216
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
204
217
|
for (const originalModel of models) {
|
|
205
218
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
206
219
|
if (!reachableModels.has(model.name)) continue;
|
|
207
220
|
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
208
221
|
if (isListMetadataModel(model)) continue;
|
|
209
|
-
if (isListWrapperModel(model)) continue;
|
|
222
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
223
|
+
if (discriminatedSkip?.has(model.name)) continue;
|
|
210
224
|
const service = modelToService.get(model.name);
|
|
211
225
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
212
226
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
|
|
@@ -723,13 +737,16 @@ export function generateSerializers(
|
|
|
723
737
|
}
|
|
724
738
|
}
|
|
725
739
|
|
|
740
|
+
const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
|
|
741
|
+
const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
726
742
|
const eligibleModels: Model[] = [];
|
|
727
743
|
for (const originalModel of models) {
|
|
728
744
|
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
729
745
|
if (!serializerReachable.has(model.name)) continue;
|
|
730
746
|
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
731
747
|
if (isListMetadataModel(model)) continue;
|
|
732
|
-
if (isListWrapperModel(model)) continue;
|
|
748
|
+
if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
|
|
749
|
+
if (discriminatedSerializerSkip?.has(model.name)) continue;
|
|
733
750
|
const service = modelToService.get(model.name);
|
|
734
751
|
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
735
752
|
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
|
|
@@ -928,7 +945,10 @@ function buildGeneratedResourceModelUsage(
|
|
|
928
945
|
const mountGroups = groupByMount(ctx);
|
|
929
946
|
const services: Service[] =
|
|
930
947
|
mountGroups.size > 0
|
|
931
|
-
? [...mountGroups].map(([name, group]) => ({
|
|
948
|
+
? [...mountGroups].map(([name, group]) => ({
|
|
949
|
+
name,
|
|
950
|
+
operations: group.operations,
|
|
951
|
+
}))
|
|
932
952
|
: ctx.spec.services;
|
|
933
953
|
|
|
934
954
|
for (const service of services) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { EmitterContext, ResolvedOperation } from '@workos/oagen';
|
|
1
|
+
import type { EmitterContext, Model, ResolvedOperation } from '@workos/oagen';
|
|
2
|
+
import { enrichModelsFromSpec } from '../shared/model-utils.js';
|
|
2
3
|
|
|
3
4
|
type OperationOverride = {
|
|
4
5
|
methodName?: string;
|
|
@@ -42,17 +43,52 @@ function operationKey(resolved: ResolvedOperation): string {
|
|
|
42
43
|
return `${resolved.operation.httpMethod.toUpperCase()} ${resolved.operation.path}`;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Apply oneOf / allOf+oneOf enrichment (flattening variant fields onto the
|
|
48
|
+
* parent model, plus synthetic models/enums for inline shapes) so the rest
|
|
49
|
+
* of the Node emitter sees a richer `spec.models`.
|
|
50
|
+
*
|
|
51
|
+
* Without this, `ConnectApplication` (and any other `allOf [base, oneOf [...]]`
|
|
52
|
+
* schema whose first variant is itself wrapped in `allOf`) loses every
|
|
53
|
+
* non-M2M field — the IR parser's discriminator detection silently skips
|
|
54
|
+
* variants whose properties live behind another `allOf`. Mirrors what the
|
|
55
|
+
* Go / Kotlin / .NET emitters already do.
|
|
56
|
+
*
|
|
57
|
+
* Discriminated bases produced by `enrichModelsFromSpec` get their original
|
|
58
|
+
* fields restored — Node emits flat interfaces today, not TS sum types, so
|
|
59
|
+
* an empty base would otherwise drop the common fields.
|
|
60
|
+
*/
|
|
61
|
+
function enrichSpecModels(models: readonly Model[]): Model[] {
|
|
62
|
+
const enriched = enrichModelsFromSpec(models as Model[]);
|
|
63
|
+
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
64
|
+
return enriched.map((m) => {
|
|
65
|
+
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
66
|
+
const original = originalByName.get(m.name);
|
|
67
|
+
if (original && original.fields.length > 0) {
|
|
68
|
+
return { ...m, fields: original.fields };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return m;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext {
|
|
46
76
|
const cached = contextCache.get(ctx);
|
|
47
77
|
if (cached) return cached;
|
|
48
78
|
|
|
79
|
+
const enrichedModels = enrichSpecModels(ctx.spec.models);
|
|
80
|
+
const specChanged =
|
|
81
|
+
enrichedModels.length !== ctx.spec.models.length || enrichedModels.some((m, i) => m !== ctx.spec.models[i]);
|
|
82
|
+
const enrichedSpec = specChanged ? { ...ctx.spec, models: enrichedModels } : ctx.spec;
|
|
83
|
+
|
|
49
84
|
const resolvedOperations = ctx.resolvedOperations;
|
|
50
85
|
if (!resolvedOperations?.length) {
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
const next = specChanged ? { ...ctx, spec: enrichedSpec } : ctx;
|
|
87
|
+
contextCache.set(ctx, next);
|
|
88
|
+
return next;
|
|
53
89
|
}
|
|
54
90
|
|
|
55
|
-
let
|
|
91
|
+
let opsChanged = false;
|
|
56
92
|
const nextResolved = resolvedOperations.map((resolved) => {
|
|
57
93
|
const override = OPERATION_OVERRIDES[operationKey(resolved)];
|
|
58
94
|
if (!override) return resolved;
|
|
@@ -63,7 +99,7 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
|
|
|
63
99
|
return resolved;
|
|
64
100
|
}
|
|
65
101
|
|
|
66
|
-
|
|
102
|
+
opsChanged = true;
|
|
67
103
|
return {
|
|
68
104
|
...resolved,
|
|
69
105
|
methodName,
|
|
@@ -71,7 +107,14 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
|
|
|
71
107
|
};
|
|
72
108
|
});
|
|
73
109
|
|
|
74
|
-
const next =
|
|
110
|
+
const next =
|
|
111
|
+
opsChanged || specChanged
|
|
112
|
+
? {
|
|
113
|
+
...ctx,
|
|
114
|
+
...(opsChanged ? { resolvedOperations: nextResolved } : {}),
|
|
115
|
+
...(specChanged ? { spec: enrichedSpec } : {}),
|
|
116
|
+
}
|
|
117
|
+
: ctx;
|
|
75
118
|
contextCache.set(ctx, next);
|
|
76
119
|
return next;
|
|
77
120
|
}
|
package/src/node/utils.ts
CHANGED
|
@@ -277,7 +277,11 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
277
277
|
return false;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
export {
|
|
280
|
+
export {
|
|
281
|
+
isListMetadataModel,
|
|
282
|
+
isListWrapperModel,
|
|
283
|
+
collectNonPaginatedResponseModelNames,
|
|
284
|
+
} from '../shared/model-utils.js';
|
|
281
285
|
|
|
282
286
|
function modelFingerprint(model: Model): string {
|
|
283
287
|
const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
|
package/src/php/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
|
|
|
18
18
|
import { generateTests } from './tests.js';
|
|
19
19
|
import { buildOperationsMap } from './manifest.js';
|
|
20
20
|
import { initializeEnumDedup } from './naming.js';
|
|
21
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
21
22
|
|
|
22
23
|
/** Initialize enum deduplication from spec data. */
|
|
23
24
|
function ensureNamingInitialized(ctx: EmitterContext): void {
|
|
@@ -34,17 +35,39 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
34
35
|
return files;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Flatten oneOf / allOf+oneOf variant fields onto each base model and pull
|
|
40
|
+
* in synthetic models / enums for inline variant shapes. PHP emits flat
|
|
41
|
+
* classes (no sum types), so a discriminated base whose IR fields the
|
|
42
|
+
* parser stripped (post-allOf-aware detection) gets its original fields
|
|
43
|
+
* restored to avoid silently dropping variant data.
|
|
44
|
+
*/
|
|
45
|
+
function enrichModelsForPhp(models: Model[]): Model[] {
|
|
46
|
+
const enriched = enrichModelsFromSpec(models);
|
|
47
|
+
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
48
|
+
return enriched.map((m) => {
|
|
49
|
+
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
50
|
+
const original = originalByName.get(m.name);
|
|
51
|
+
if (original && original.fields.length > 0) {
|
|
52
|
+
return { ...m, fields: original.fields };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return m;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
export const phpEmitter: Emitter = {
|
|
38
60
|
language: 'php',
|
|
39
61
|
|
|
40
62
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
41
63
|
ensureNamingInitialized(ctx);
|
|
42
|
-
return ensureTrailingNewlines(generateModels(models, ctx));
|
|
64
|
+
return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
|
|
43
65
|
},
|
|
44
66
|
|
|
45
67
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
46
68
|
ensureNamingInitialized(ctx);
|
|
47
|
-
|
|
69
|
+
const syntheticEnums = getSyntheticEnums();
|
|
70
|
+
return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
|
|
48
71
|
},
|
|
49
72
|
|
|
50
73
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
package/src/php/models.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { className, enumClassName, fieldName } from './naming.js';
|
|
|
4
4
|
import { phpDocComment } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// Import and re-export shared model detection utilities
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isListMetadataModel,
|
|
9
|
+
isListWrapperModel,
|
|
10
|
+
collectNonPaginatedResponseModelNames,
|
|
11
|
+
} from '../shared/model-utils.js';
|
|
8
12
|
export { isListMetadataModel, isListWrapperModel };
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -32,9 +36,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
32
36
|
overwriteExisting: true,
|
|
33
37
|
});
|
|
34
38
|
|
|
39
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
40
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
41
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
42
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
43
|
+
|
|
35
44
|
for (const model of models) {
|
|
36
45
|
if (isListMetadataModel(model)) continue;
|
|
37
|
-
if (isListWrapperModel(model)) continue;
|
|
46
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
38
47
|
const name = className(model.name);
|
|
39
48
|
const lines: string[] = [];
|
|
40
49
|
|
package/src/python/models.ts
CHANGED
|
@@ -41,9 +41,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
41
41
|
// oneOf enrichment collide with existing IR models in snake_case.
|
|
42
42
|
const emittedFilePaths = new Set<string>();
|
|
43
43
|
|
|
44
|
+
// Wrappers referenced as a non-paginated operation response (e.g.
|
|
45
|
+
// `VersionListResponse` for `GET /vault/v1/kv/{id}/versions`) must still be
|
|
46
|
+
// emitted — the resource code references them by name and SyncPage doesn't
|
|
47
|
+
// wrap them.
|
|
48
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
49
|
+
|
|
44
50
|
for (const model of models) {
|
|
45
51
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
46
|
-
if (isListWrapperModel(model)) continue;
|
|
52
|
+
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
47
53
|
// Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
|
|
48
54
|
if (isListMetadataModel(model)) continue;
|
|
49
55
|
|
|
@@ -865,5 +871,9 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
865
871
|
}
|
|
866
872
|
|
|
867
873
|
// Import and re-export shared model detection utilities
|
|
868
|
-
import {
|
|
874
|
+
import {
|
|
875
|
+
isListMetadataModel,
|
|
876
|
+
isListWrapperModel,
|
|
877
|
+
collectNonPaginatedResponseModelNames,
|
|
878
|
+
} from '../shared/model-utils.js';
|
|
869
879
|
export { isListMetadataModel, isListWrapperModel };
|
package/src/python/resources.ts
CHANGED
|
@@ -1053,8 +1053,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1053
1053
|
|
|
1054
1054
|
for (const op of allOperations) {
|
|
1055
1055
|
const plan = planOperation(op);
|
|
1056
|
-
if (plan.responseModelName
|
|
1057
|
-
|
|
1056
|
+
if (plan.responseModelName) {
|
|
1057
|
+
// List-wrapper responses are normally replaced by SyncPage on paginated
|
|
1058
|
+
// ops, so the wrapper itself is never referenced. On non-paginated ops
|
|
1059
|
+
// (e.g. `GET /vault/v1/kv/{id}/versions` → `VersionListResponse`) the
|
|
1060
|
+
// resource method still returns the wrapper by name and must import it.
|
|
1061
|
+
if (!listWrapperNames.has(plan.responseModelName) || !plan.isPaginated) {
|
|
1062
|
+
modelImports.add(plan.responseModelName);
|
|
1063
|
+
}
|
|
1058
1064
|
}
|
|
1059
1065
|
if (op.requestBody?.kind === 'model') {
|
|
1060
1066
|
const requestBodyRef = op.requestBody;
|
package/src/ruby/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { generateClient } from './client.js';
|
|
|
15
15
|
import { generateTests } from './tests.js';
|
|
16
16
|
import { buildOperationsMap } from './manifest.js';
|
|
17
17
|
import { generateRbiFiles } from './rbi.js';
|
|
18
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
18
19
|
|
|
19
20
|
/** Ensure every generated file's content ends with a trailing newline. */
|
|
20
21
|
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
@@ -26,16 +27,40 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
26
27
|
return files;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Flatten oneOf / allOf+oneOf variant fields onto each base model and pick
|
|
32
|
+
* up the synthetic models / enums `enrichModelsFromSpec` produces for inline
|
|
33
|
+
* variant shapes. Ruby emits flat hash-backed models, not sum types, so a
|
|
34
|
+
* discriminated base whose IR fields were stripped (the new EventSchema-
|
|
35
|
+
* style behaviour after the parser learned to walk allOf-wrapped variants)
|
|
36
|
+
* has its original fields restored — otherwise `ConnectApplication`-style
|
|
37
|
+
* bases would silently lose every variant field they had previously.
|
|
38
|
+
*/
|
|
39
|
+
function enrichModelsForRuby(models: Model[], enums: Enum[]): Model[] {
|
|
40
|
+
const enriched = enrichModelsFromSpec(models, enums);
|
|
41
|
+
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
42
|
+
return enriched.map((m) => {
|
|
43
|
+
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
44
|
+
const original = originalByName.get(m.name);
|
|
45
|
+
if (original && original.fields.length > 0) {
|
|
46
|
+
return { ...m, fields: original.fields };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return m;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
export const rubyEmitter: Emitter = {
|
|
30
54
|
language: 'ruby',
|
|
31
55
|
|
|
32
56
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
33
|
-
const modelFiles = generateModels(models, ctx);
|
|
57
|
+
const modelFiles = generateModels(enrichModelsForRuby(models, ctx.spec.enums), ctx);
|
|
34
58
|
return ensureTrailingNewlines(modelFiles);
|
|
35
59
|
},
|
|
36
60
|
|
|
37
61
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
38
|
-
|
|
62
|
+
const syntheticEnums = getSyntheticEnums();
|
|
63
|
+
return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
|
|
39
64
|
},
|
|
40
65
|
|
|
41
66
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
package/src/ruby/models.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
2
|
import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
|
|
3
3
|
import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isListWrapperModel,
|
|
6
|
+
isListMetadataModel,
|
|
7
|
+
collectNonPaginatedResponseModelNames,
|
|
8
|
+
} from '../shared/model-utils.js';
|
|
5
9
|
|
|
6
10
|
/** Folder under lib/workos/ for models not owned by any service. */
|
|
7
11
|
export const SHARED_MODEL_DIR = 'shared';
|
|
@@ -77,11 +81,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
77
81
|
|
|
78
82
|
const files: GeneratedFile[] = [];
|
|
79
83
|
|
|
84
|
+
// Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
|
|
85
|
+
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
86
|
+
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
87
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
88
|
+
const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
|
|
89
|
+
|
|
80
90
|
// Dedup identical models (by recursive structural hash).
|
|
81
91
|
const recursiveHashes = buildRecursiveHashMap(models, enumNames);
|
|
82
92
|
const hashGroups = new Map<string, string[]>();
|
|
83
93
|
for (const m of models) {
|
|
84
|
-
if (
|
|
94
|
+
if (skipAsListWrapper(m) || isListMetadataModel(m)) continue;
|
|
85
95
|
const h = recursiveHashes.get(m.name) ?? '';
|
|
86
96
|
if (!hashGroups.has(h)) hashGroups.set(h, []);
|
|
87
97
|
hashGroups.get(h)!.push(m.name);
|
|
@@ -95,7 +105,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
for (const model of models) {
|
|
98
|
-
if (
|
|
108
|
+
if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
|
|
99
109
|
|
|
100
110
|
const cls = className(model.name);
|
|
101
111
|
const file = fileName(model.name);
|
package/src/rust/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { generateClient } from './client.js';
|
|
|
16
16
|
import { generateTests } from './tests.js';
|
|
17
17
|
import { buildOperationsMap } from './manifest.js';
|
|
18
18
|
import { UnionRegistry } from './type-map.js';
|
|
19
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Shared per-emit registry that collects synthesised oneOf-style unions
|
|
@@ -34,16 +35,39 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
34
35
|
return files;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Flatten oneOf / allOf+oneOf variant fields onto each base model and pull
|
|
40
|
+
* in synthetic models / enums for inline variant shapes. Rust emits flat
|
|
41
|
+
* structs (a synthesised enum-union from `UnionRegistry` exists, but the
|
|
42
|
+
* field-on-base pattern is what matches `ConnectApplication` today). A
|
|
43
|
+
* discriminated base whose IR fields the parser stripped gets its original
|
|
44
|
+
* fields restored to avoid losing variant data.
|
|
45
|
+
*/
|
|
46
|
+
function enrichModelsForRust(models: Model[]): Model[] {
|
|
47
|
+
const enriched = enrichModelsFromSpec(models);
|
|
48
|
+
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
49
|
+
return enriched.map((m) => {
|
|
50
|
+
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
51
|
+
const original = originalByName.get(m.name);
|
|
52
|
+
if (original && original.fields.length > 0) {
|
|
53
|
+
return { ...m, fields: original.fields };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return m;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
export const rustEmitter: Emitter = {
|
|
38
61
|
language: 'rust',
|
|
39
62
|
|
|
40
63
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
41
64
|
unionRegistry.reset();
|
|
42
|
-
return ensureTrailingNewlines(generateModels(models, ctx, unionRegistry));
|
|
65
|
+
return ensureTrailingNewlines(generateModels(enrichModelsForRust(models), ctx, unionRegistry));
|
|
43
66
|
},
|
|
44
67
|
|
|
45
68
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
-
|
|
69
|
+
const syntheticEnums = getSyntheticEnums();
|
|
70
|
+
return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
|
|
47
71
|
},
|
|
48
72
|
|
|
49
73
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|