@workos/oagen-emitters 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
package/src/node/fixtures.ts
CHANGED
|
@@ -48,6 +48,7 @@ export function generateFixtures(
|
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
const files: { path: string; content: string }[] = [];
|
|
50
50
|
|
|
51
|
+
const seenFixturePaths = new Set<string>();
|
|
51
52
|
for (const model of spec.models) {
|
|
52
53
|
// Skip redundant list-metadata and list-wrapper models (handled by shared types)
|
|
53
54
|
if (isListMetadataModel(model)) continue;
|
|
@@ -55,10 +56,17 @@ export function generateFixtures(
|
|
|
55
56
|
|
|
56
57
|
const service = modelToService.get(model.name);
|
|
57
58
|
const dirName = resolveDir(service);
|
|
59
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.fixture.json`;
|
|
60
|
+
|
|
61
|
+
// After noise suffix stripping, multiple models may resolve to the same
|
|
62
|
+
// fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
|
|
63
|
+
if (seenFixturePaths.has(fixturePath)) continue;
|
|
64
|
+
seenFixturePaths.add(fixturePath);
|
|
65
|
+
|
|
58
66
|
const fixture = generateModelFixture(model, modelMap, enumMap);
|
|
59
67
|
|
|
60
68
|
files.push({
|
|
61
|
-
path:
|
|
69
|
+
path: fixturePath,
|
|
62
70
|
content: JSON.stringify(fixture, null, 2),
|
|
63
71
|
});
|
|
64
72
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -11,14 +11,11 @@ import type {
|
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { generateModelsAndSerializers } from './models.js';
|
|
15
15
|
import { generateEnums } from './enums.js';
|
|
16
|
-
import { generateSerializers } from './serializers.js';
|
|
17
16
|
import { generateResources } from './resources.js';
|
|
18
17
|
import { generateClient } from './client.js';
|
|
19
18
|
import { generateErrors } from './errors.js';
|
|
20
|
-
import { generateConfig } from './config.js';
|
|
21
|
-
import { generateCommon } from './common.js';
|
|
22
19
|
import { generateTests } from './tests.js';
|
|
23
20
|
import { generateManifest } from './manifest.js';
|
|
24
21
|
|
|
@@ -36,7 +33,7 @@ export const nodeEmitter: Emitter = {
|
|
|
36
33
|
language: 'node',
|
|
37
34
|
|
|
38
35
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
39
|
-
return ensureTrailingNewlines(
|
|
36
|
+
return ensureTrailingNewlines(generateModelsAndSerializers(models, ctx));
|
|
40
37
|
},
|
|
41
38
|
|
|
42
39
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -55,10 +52,6 @@ export const nodeEmitter: Emitter = {
|
|
|
55
52
|
return ensureTrailingNewlines(generateErrors(ctx));
|
|
56
53
|
},
|
|
57
54
|
|
|
58
|
-
generateConfig(_ctx: EmitterContext): GeneratedFile[] {
|
|
59
|
-
return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
|
|
60
|
-
},
|
|
61
|
-
|
|
62
55
|
generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
|
|
63
56
|
// TypeScript uses inline types — no separate type signature files needed
|
|
64
57
|
return [];
|
package/src/node/models.ts
CHANGED
|
@@ -14,8 +14,16 @@ import {
|
|
|
14
14
|
isListMetadataModel,
|
|
15
15
|
isListWrapperModel,
|
|
16
16
|
buildDeduplicationMap,
|
|
17
|
+
relativeImport,
|
|
17
18
|
} from './utils.js';
|
|
18
19
|
import { assignEnumsToServices } from './enums.js';
|
|
20
|
+
import {
|
|
21
|
+
renderSerializerTypeParams,
|
|
22
|
+
buildSerializerImports,
|
|
23
|
+
buildSkipFormatFields,
|
|
24
|
+
shouldSkipSerializeForModel,
|
|
25
|
+
emitSerializerBody,
|
|
26
|
+
} from './field-plan.js';
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Detect baseline interfaces that are generic (have type parameters) even though
|
|
@@ -58,26 +66,21 @@ function enrichGenericDefaultsFromBaseline(
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
69
|
+
export function generateModels(models: Model[], ctx: EmitterContext, shared?: SharedModelContext): GeneratedFile[] {
|
|
62
70
|
if (models.length === 0) return [];
|
|
63
71
|
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// (e.g., Profile<CustomAttributesType>). Detect these by checking if any
|
|
73
|
-
// field type contains a PascalCase name that isn't a known model, enum, or builtin.
|
|
74
|
-
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
72
|
+
const {
|
|
73
|
+
modelToService,
|
|
74
|
+
resolveDir,
|
|
75
|
+
useStringDates,
|
|
76
|
+
dedup: sharedDedup,
|
|
77
|
+
genericDefaults: sharedDefaults,
|
|
78
|
+
} = shared ?? buildSharedContext(models, ctx);
|
|
79
|
+
const genericDefaults = sharedDefaults;
|
|
75
80
|
const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
|
|
76
81
|
const wireTypeRefOpts = { genericDefaults };
|
|
77
82
|
const files: GeneratedFile[] = [];
|
|
78
|
-
|
|
79
|
-
// Detect structurally identical or same-name models — emit type aliases for duplicates
|
|
80
|
-
const dedup = buildDeduplicationMap(models, ctx);
|
|
83
|
+
const dedup = sharedDedup;
|
|
81
84
|
|
|
82
85
|
for (const model of models) {
|
|
83
86
|
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
@@ -90,14 +93,28 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
90
93
|
// emit a type alias instead of a full interface.
|
|
91
94
|
const canonicalName = dedup.get(model.name);
|
|
92
95
|
if (canonicalName) {
|
|
93
|
-
const domainName = resolveInterfaceName(model.name, ctx);
|
|
94
|
-
const responseName = wireInterfaceName(domainName);
|
|
95
|
-
const canonDomainName = resolveInterfaceName(canonicalName, ctx);
|
|
96
|
-
const canonResponseName = wireInterfaceName(canonDomainName);
|
|
97
96
|
const service = modelToService.get(model.name);
|
|
98
97
|
const dirName = resolveDir(service);
|
|
98
|
+
|
|
99
|
+
// Skip typeAlias resolution for dedup models. The canonical file may
|
|
100
|
+
// be preserved (skipIfExists) and still export its raw name, so the
|
|
101
|
+
// import names must match the raw exports, not resolved aliases.
|
|
102
|
+
const skipTA = { skipTypeAlias: true };
|
|
103
|
+
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
104
|
+
const responseName = wireInterfaceName(domainName);
|
|
105
|
+
const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
|
|
106
|
+
const canonResponseName = wireInterfaceName(canonDomainName);
|
|
107
|
+
|
|
99
108
|
const canonService = modelToService.get(canonicalName);
|
|
100
109
|
const canonDir = resolveDir(canonService);
|
|
110
|
+
|
|
111
|
+
// After noise suffix stripping (e.g., "OrganizationDto" → "Organization"),
|
|
112
|
+
// the alias and canonical may resolve to the same file path or the same
|
|
113
|
+
// type names. Skip — the canonical file already provides the types.
|
|
114
|
+
const aliasPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
115
|
+
const canonPath = `src/${canonDir}/interfaces/${fileName(canonicalName)}.interface.ts`;
|
|
116
|
+
if (aliasPath === canonPath) continue;
|
|
117
|
+
if (domainName === canonDomainName) continue;
|
|
101
118
|
const canonRelPath =
|
|
102
119
|
canonDir === dirName
|
|
103
120
|
? `./${fileName(canonicalName)}.interface`
|
|
@@ -109,7 +126,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
109
126
|
`export type ${responseName} = ${canonResponseName};`,
|
|
110
127
|
];
|
|
111
128
|
files.push({
|
|
112
|
-
path:
|
|
129
|
+
path: aliasPath,
|
|
113
130
|
content: aliasLines.join('\n'),
|
|
114
131
|
skipIfExists: true,
|
|
115
132
|
});
|
|
@@ -118,7 +135,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
118
135
|
|
|
119
136
|
const service = modelToService.get(model.name);
|
|
120
137
|
const dirName = resolveDir(service);
|
|
121
|
-
|
|
138
|
+
// If this model is a dedup canonical (other models alias to it), skip
|
|
139
|
+
// typeAlias resolution so the file exports the raw name. Dedup aliases
|
|
140
|
+
// import using the raw name to stay consistent with preserved files.
|
|
141
|
+
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
142
|
+
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
122
143
|
const responseName = wireInterfaceName(domainName);
|
|
123
144
|
const deps = collectFieldDependencies(model);
|
|
124
145
|
const lines: string[] = [];
|
|
@@ -448,3 +469,139 @@ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>):
|
|
|
448
469
|
});
|
|
449
470
|
return `<${params.join(', ')}>`;
|
|
450
471
|
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Shared context — computed once and reused by interface + serializer passes
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
interface SharedModelContext {
|
|
478
|
+
modelToService: Map<string, string>;
|
|
479
|
+
resolveDir: (irService: string | undefined) => string;
|
|
480
|
+
useStringDates: boolean;
|
|
481
|
+
dedup: Map<string, string>;
|
|
482
|
+
genericDefaults: Map<string, string>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
486
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
487
|
+
const useStringDates = detectStringDateConvention(models, ctx);
|
|
488
|
+
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
489
|
+
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
490
|
+
const dedup = buildDeduplicationMap(models, ctx);
|
|
491
|
+
return { modelToService, resolveDir, useStringDates, dedup, genericDefaults };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Serializer file generation (moved from serializers.ts)
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Generate serializer files for all models.
|
|
500
|
+
* Can accept pre-computed shared context to avoid duplicating work
|
|
501
|
+
* when called alongside generateModels.
|
|
502
|
+
*/
|
|
503
|
+
export function generateSerializers(
|
|
504
|
+
models: Model[],
|
|
505
|
+
ctx: EmitterContext,
|
|
506
|
+
shared?: SharedModelContext,
|
|
507
|
+
): GeneratedFile[] {
|
|
508
|
+
if (models.length === 0) return [];
|
|
509
|
+
|
|
510
|
+
const { modelToService, resolveDir, useStringDates, dedup } = shared ?? buildSharedContext(models, ctx);
|
|
511
|
+
const files: GeneratedFile[] = [];
|
|
512
|
+
const skippedSerializeModels = new Set<string>();
|
|
513
|
+
|
|
514
|
+
for (const model of models) {
|
|
515
|
+
if (isListMetadataModel(model)) continue;
|
|
516
|
+
if (isListWrapperModel(model)) continue;
|
|
517
|
+
|
|
518
|
+
// Deduplication: for structurally identical models, re-export the canonical serializer
|
|
519
|
+
const canonicalName = dedup.get(model.name);
|
|
520
|
+
if (canonicalName) {
|
|
521
|
+
const service = modelToService.get(model.name);
|
|
522
|
+
const dirName = resolveDir(service);
|
|
523
|
+
// Skip typeAlias resolution for dedup serializers (same reason as interfaces).
|
|
524
|
+
const skipTA = { skipTypeAlias: true };
|
|
525
|
+
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
526
|
+
const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
|
|
527
|
+
|
|
528
|
+
const canonService = modelToService.get(canonicalName);
|
|
529
|
+
const canonDir = resolveDir(canonService);
|
|
530
|
+
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
531
|
+
const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
|
|
532
|
+
|
|
533
|
+
// After noise suffix stripping, alias and canonical may resolve to the
|
|
534
|
+
// same serializer path or the same function names. Skip — the canonical
|
|
535
|
+
// serializer already provides the functions.
|
|
536
|
+
if (serializerPath === canonSerializerPath) continue;
|
|
537
|
+
if (domainName === canonDomainName) continue;
|
|
538
|
+
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
539
|
+
files.push({
|
|
540
|
+
path: serializerPath,
|
|
541
|
+
content: `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`,
|
|
542
|
+
overwriteExisting: true,
|
|
543
|
+
});
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const service = modelToService.get(model.name);
|
|
548
|
+
const dirName = resolveDir(service);
|
|
549
|
+
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
550
|
+
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
551
|
+
const responseName = wireInterfaceName(domainName);
|
|
552
|
+
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
553
|
+
const typeParams = renderSerializerTypeParams(model, ctx);
|
|
554
|
+
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
555
|
+
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
556
|
+
|
|
557
|
+
const skipFormatFields = buildSkipFormatFields(model, useStringDates, baselineDomain);
|
|
558
|
+
const shouldSkipSerialize = shouldSkipSerializeForModel(
|
|
559
|
+
model,
|
|
560
|
+
baselineResponse,
|
|
561
|
+
baselineDomain,
|
|
562
|
+
dedup,
|
|
563
|
+
skippedSerializeModels,
|
|
564
|
+
ctx,
|
|
565
|
+
);
|
|
566
|
+
if (shouldSkipSerialize) {
|
|
567
|
+
skippedSerializeModels.add(model.name);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const sctx = { modelToService, resolveDir, useStringDates, dedup, skippedSerializeModels, ctx };
|
|
571
|
+
const lines = [
|
|
572
|
+
...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
|
|
573
|
+
...emitSerializerBody(
|
|
574
|
+
model,
|
|
575
|
+
domainName,
|
|
576
|
+
responseName,
|
|
577
|
+
typeParams,
|
|
578
|
+
baselineDomain,
|
|
579
|
+
baselineResponse,
|
|
580
|
+
skipFormatFields,
|
|
581
|
+
shouldSkipSerialize,
|
|
582
|
+
ctx,
|
|
583
|
+
),
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
files.push({
|
|
587
|
+
path: serializerPath,
|
|
588
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return files;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Combined generation — single shared context, two output streams
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Generate both interface files and serializer files in a single pass
|
|
601
|
+
* with shared context computation.
|
|
602
|
+
*/
|
|
603
|
+
export function generateModelsAndSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
604
|
+
if (models.length === 0) return [];
|
|
605
|
+
const shared = buildSharedContext(models, ctx);
|
|
606
|
+
return [...generateModels(models, ctx, shared), ...generateSerializers(models, ctx, shared)];
|
|
607
|
+
}
|
package/src/node/naming.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
|
|
4
|
+
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
5
|
+
|
|
6
|
+
/** Strip spec-noise suffixes (e.g., "Dto") from an IR name. */
|
|
7
|
+
export function stripNoiseSuffixes(name: string): string {
|
|
8
|
+
return name.replace(/Dto$/i, '');
|
|
9
|
+
}
|
|
3
10
|
|
|
4
11
|
/** PascalCase class/interface name. */
|
|
5
12
|
export function className(name: string): string {
|
|
6
|
-
return toPascalCase(name);
|
|
13
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
/** kebab-case file name (without extension). */
|
|
10
17
|
export function fileName(name: string): string {
|
|
11
|
-
return toKebabCase(name);
|
|
18
|
+
return toKebabCase(stripUrnPrefix(name));
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
/** camelCase method name. */
|
|
@@ -67,134 +74,65 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* Key: "HTTP_METHOD /path", Value: camelCase method name.
|
|
73
|
-
*/
|
|
74
|
-
const METHOD_NAME_OVERRIDES: Record<string, string> = {
|
|
75
|
-
'POST /portal/generate_link': 'generatePortalLink',
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Explicit service directory overrides. Maps a resolved PascalCase service name
|
|
80
|
-
* to a target directory (kebab-case). Use this when the spec's tag grouping
|
|
81
|
-
* does not match the desired SDK directory layout and the spec cannot be changed.
|
|
82
|
-
*/
|
|
83
|
-
const SERVICE_DIR_OVERRIDES: Record<string, string> = {
|
|
84
|
-
ApplicationClientSecrets: 'workos-connect',
|
|
85
|
-
Applications: 'workos-connect',
|
|
86
|
-
Connections: 'sso',
|
|
87
|
-
Directories: 'directory-sync',
|
|
88
|
-
DirectoryGroups: 'directory-sync',
|
|
89
|
-
DirectoryUsers: 'directory-sync',
|
|
90
|
-
FeatureFlagsTargets: 'feature-flags',
|
|
91
|
-
MultiFactorAuth: 'mfa',
|
|
92
|
-
MultiFactorAuthChallenges: 'mfa',
|
|
93
|
-
OrganizationsApiKeys: 'organizations',
|
|
94
|
-
WebhooksEndpoints: 'webhooks',
|
|
95
|
-
UserManagementAuthentication: 'user-management',
|
|
96
|
-
UserManagementCorsOrigins: 'user-management',
|
|
97
|
-
UserManagementDataProviders: 'user-management',
|
|
98
|
-
UserManagementInvitations: 'user-management',
|
|
99
|
-
UserManagementJWTTemplate: 'user-management',
|
|
100
|
-
UserManagementMagicAuth: 'user-management',
|
|
101
|
-
UserManagementMultiFactorAuthentication: 'user-management',
|
|
102
|
-
UserManagementOrganizationMembership: 'user-management',
|
|
103
|
-
UserManagementRedirectUris: 'user-management',
|
|
104
|
-
UserManagementSessionTokens: 'user-management',
|
|
105
|
-
UserManagementUsers: 'user-management',
|
|
106
|
-
UserManagementUsersAuthorizedApplications: 'user-management',
|
|
107
|
-
WorkOSConnect: 'workos-connect',
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Maps a service (by PascalCase name) to the existing hand-written class that
|
|
112
|
-
* already covers its endpoints. When a service appears here:
|
|
113
|
-
* - `resolveClassName` returns the target class (so generated code merges in)
|
|
114
|
-
* - `isServiceCoveredByExisting` returns true
|
|
115
|
-
* - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
|
|
116
|
-
* so new endpoints are added to the existing class rather than silently dropped
|
|
117
|
-
*/
|
|
118
|
-
export const SERVICE_COVERED_BY: Record<string, string> = {
|
|
119
|
-
Connections: 'SSO',
|
|
120
|
-
Directories: 'DirectorySync',
|
|
121
|
-
DirectoryGroups: 'DirectorySync',
|
|
122
|
-
DirectoryUsers: 'DirectorySync',
|
|
123
|
-
FeatureFlagsTargets: 'FeatureFlags',
|
|
124
|
-
MultiFactorAuth: 'Mfa',
|
|
125
|
-
MultiFactorAuthChallenges: 'Mfa',
|
|
126
|
-
OrganizationsApiKeys: 'Organizations',
|
|
127
|
-
UserManagementAuthentication: 'UserManagement',
|
|
128
|
-
UserManagementInvitations: 'UserManagement',
|
|
129
|
-
UserManagementMagicAuth: 'UserManagement',
|
|
130
|
-
UserManagementMultiFactorAuthentication: 'UserManagement',
|
|
131
|
-
UserManagementOrganizationMembership: 'UserManagement',
|
|
132
|
-
UserManagementUsers: 'UserManagement',
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Explicit class name overrides. Maps the default PascalCase service name
|
|
137
|
-
* to the desired SDK class name when toPascalCase produces the wrong casing.
|
|
138
|
-
*/
|
|
139
|
-
const CLASS_NAME_OVERRIDES: Record<string, string> = {
|
|
140
|
-
WorkosConnect: 'WorkOSConnect',
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Resolve the output directory for a service, checking overrides first.
|
|
145
|
-
* Falls back to the standard kebab-case conversion.
|
|
77
|
+
* Resolve the output directory for a service.
|
|
78
|
+
* Mount rules already handle directory placement, so this is a simple kebab-case conversion.
|
|
146
79
|
*/
|
|
147
80
|
export function resolveServiceDir(resolvedServiceName: string): string {
|
|
148
|
-
return
|
|
81
|
+
return serviceDirName(resolvedServiceName);
|
|
149
82
|
}
|
|
150
83
|
|
|
151
|
-
/** Resolve the SDK method name for an operation,
|
|
84
|
+
/** Resolve the SDK method name for an operation, using resolved operations first. */
|
|
152
85
|
export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
86
|
+
const lookup = buildResolvedLookup(ctx);
|
|
87
|
+
const resolved = lookupMethodName(op, lookup);
|
|
88
|
+
if (resolved) return toCamelCase(resolved);
|
|
89
|
+
// Fallback to overlay, then spec-derived
|
|
153
90
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
154
|
-
const override = METHOD_NAME_OVERRIDES[httpKey];
|
|
155
|
-
if (override) return override;
|
|
156
91
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
157
|
-
if (existing)
|
|
158
|
-
// Fix: when the path ends with a path parameter (single-resource operation)
|
|
159
|
-
// and the overlay method name is plural, prefer the singular form.
|
|
160
|
-
// E.g., getUsers → getUser when path is /user_management/users/{id}
|
|
161
|
-
const isSingleResource = /\/\{[^}]+\}$/.test(op.path);
|
|
162
|
-
if (isSingleResource && existing.methodName.endsWith('s') && !existing.methodName.endsWith('ss')) {
|
|
163
|
-
const singular = existing.methodName.slice(0, -1);
|
|
164
|
-
// Only singularize if it looks like a typical pluralization (ends in 's')
|
|
165
|
-
// and the spec-derived name agrees it should be singular
|
|
166
|
-
const specDerived = toCamelCase(op.name);
|
|
167
|
-
if (specDerived === singular || specDerived.endsWith(singular.slice(singular.length - 4))) {
|
|
168
|
-
return singular;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return existing.methodName;
|
|
172
|
-
}
|
|
92
|
+
if (existing) return existing.methodName;
|
|
173
93
|
return toCamelCase(op.name);
|
|
174
94
|
}
|
|
175
95
|
|
|
176
|
-
/** Resolve the SDK class name for a service,
|
|
96
|
+
/** Resolve the SDK class name for a service, using resolved ops mountOn as canonical. */
|
|
177
97
|
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
178
|
-
//
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
// to find the existing class name
|
|
98
|
+
// Use resolved ops mountOn as canonical class name
|
|
99
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
100
|
+
if (r.service.name === service.name) return r.mountOn;
|
|
101
|
+
}
|
|
102
|
+
// Fallback to overlay
|
|
184
103
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
185
104
|
for (const op of service.operations) {
|
|
186
105
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
187
106
|
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
188
|
-
if (existing) return
|
|
107
|
+
if (existing) return existing.className;
|
|
189
108
|
}
|
|
190
109
|
}
|
|
191
|
-
|
|
192
|
-
return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
|
|
110
|
+
return toPascalCase(service.name);
|
|
193
111
|
}
|
|
194
112
|
|
|
195
|
-
/** Resolve the interface name for a model, checking overlay first.
|
|
196
|
-
|
|
113
|
+
/** Resolve the interface name for a model, checking overlay first.
|
|
114
|
+
*
|
|
115
|
+
* @param opts.skipTypeAlias - When true, skip apiSurface typeAlias resolution.
|
|
116
|
+
* Use this for dedup models to ensure the file exports match the import
|
|
117
|
+
* names (preserved files export the raw name, not the resolved alias).
|
|
118
|
+
*/
|
|
119
|
+
export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: { skipTypeAlias?: boolean }): string {
|
|
197
120
|
const existing = ctx.overlayLookup?.interfaceByName?.get(name);
|
|
198
121
|
if (existing) return existing;
|
|
199
|
-
|
|
122
|
+
|
|
123
|
+
// If the model name is a type alias that points to a canonical interface,
|
|
124
|
+
// use the canonical name. This prevents the merger from generating unused
|
|
125
|
+
// backward-compat aliases (e.g., `type FlagOwner = FeatureFlagOwner`).
|
|
126
|
+
if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
|
|
127
|
+
const alias = ctx.apiSurface.typeAliases[name] as { value?: string } | undefined;
|
|
128
|
+
if (alias?.value && ctx.apiSurface.interfaces?.[alias.value]) {
|
|
129
|
+
return alias.value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Strip spec-noise suffixes (e.g., "Dto") only for models without a
|
|
134
|
+
// baseline. When an overlay exists (Scenario A), the overlay check above
|
|
135
|
+
// handles existing models. New models (no overlay entry) get clean names.
|
|
136
|
+
const cleaned = ctx.apiSurface ? name : stripNoiseSuffixes(name);
|
|
137
|
+
return toPascalCase(stripUrnPrefix(cleaned));
|
|
200
138
|
}
|