@workos/oagen-emitters 0.2.0 → 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/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -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 +11943 -2728
- 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-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- 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 +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- 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 +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- 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 -744
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SdkBehavior } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Node-specific overrides for exception kind names.
|
|
5
|
+
*
|
|
6
|
+
* The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
|
|
7
|
+
* but the Node SDK historically uses different names for some status codes.
|
|
8
|
+
* This map translates the IR kind name to the Node-specific name before
|
|
9
|
+
* appending the 'Exception' suffix.
|
|
10
|
+
*/
|
|
11
|
+
const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
|
|
12
|
+
Authentication: 'Unauthorized',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Fallback status code map when no SDK behavior is provided. */
|
|
16
|
+
const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
|
|
17
|
+
'400': 'BadRequest',
|
|
18
|
+
'401': 'Authentication',
|
|
19
|
+
'403': 'Authorization',
|
|
20
|
+
'404': 'NotFound',
|
|
21
|
+
'409': 'Conflict',
|
|
22
|
+
'422': 'UnprocessableEntity',
|
|
23
|
+
'429': 'RateLimitExceeded',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the status-code-to-exception-class-name map from SDK behavior,
|
|
28
|
+
* applying Node-specific naming overrides.
|
|
29
|
+
*
|
|
30
|
+
* Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
|
|
31
|
+
* because Node uses `UnauthorizedException` instead of `AuthenticationException`.
|
|
32
|
+
*/
|
|
33
|
+
export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
|
|
34
|
+
const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.entries(statusCodeMap).map(([code, kind]) => {
|
|
37
|
+
const nodeKind = NODE_EXCEPTION_KIND_OVERRIDES[kind] ?? kind;
|
|
38
|
+
return [Number(code), `${nodeKind}Exception`];
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/node/tests.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
fieldName,
|
|
6
6
|
wireFieldName,
|
|
7
7
|
fileName,
|
|
8
|
-
|
|
8
|
+
resolveServiceDir,
|
|
9
9
|
servicePropertyName,
|
|
10
10
|
resolveMethodName,
|
|
11
11
|
resolveInterfaceName,
|
|
@@ -15,12 +15,12 @@ import { resolveResourceClassName } from './resources.js';
|
|
|
15
15
|
import {
|
|
16
16
|
assignModelsToServices,
|
|
17
17
|
createServiceDirResolver,
|
|
18
|
-
isServiceCoveredByExisting,
|
|
19
18
|
uncoveredOperations,
|
|
20
19
|
relativeImport,
|
|
21
20
|
isListMetadataModel,
|
|
22
21
|
isListWrapperModel,
|
|
23
22
|
} from './utils.js';
|
|
23
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
24
24
|
|
|
25
25
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
26
26
|
const files: GeneratedFile[] = [];
|
|
@@ -28,21 +28,38 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
28
28
|
// Generate fixture JSON files
|
|
29
29
|
const fixtures = generateFixtures(spec, ctx);
|
|
30
30
|
for (const f of fixtures) {
|
|
31
|
-
files.push({ path: f.path, content: f.content, headerPlacement: 'skip' });
|
|
31
|
+
files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Build model lookup for response field assertions
|
|
35
35
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
36
36
|
|
|
37
|
-
// Generate test files per
|
|
38
|
-
// covered by existing hand-written
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
// Generate test files per mount target — merges all sub-services into one
|
|
38
|
+
// test file. Skip operations already covered by existing hand-written classes.
|
|
39
|
+
const mountGroups = groupByMount(ctx);
|
|
40
|
+
|
|
41
|
+
// Build mount-target → property name map so tests use the same accessor
|
|
42
|
+
// as the generated client, even when the mount target name doesn't match
|
|
43
|
+
// any IR service name directly.
|
|
44
|
+
const mountAccessors = new Map<string, string>();
|
|
45
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
46
|
+
if (!mountAccessors.has(r.mountOn)) {
|
|
47
|
+
mountAccessors.set(r.mountOn, servicePropertyName(r.mountOn));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
52
|
+
mountGroups.size > 0
|
|
53
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
54
|
+
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
55
|
+
|
|
56
|
+
for (const { name: mountName, operations } of testEntries) {
|
|
57
|
+
if (operations.length === 0) continue;
|
|
58
|
+
const mergedService: Service = { name: mountName, operations };
|
|
59
|
+
const ops = uncoveredOperations(mergedService, ctx);
|
|
43
60
|
if (ops.length === 0) continue;
|
|
44
|
-
const testService = ops.length <
|
|
45
|
-
files.push(generateServiceTest(testService, spec, ctx, modelMap));
|
|
61
|
+
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
62
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
// Generate serializer round-trip tests
|
|
@@ -59,11 +76,12 @@ function generateServiceTest(
|
|
|
59
76
|
spec: ApiSpec,
|
|
60
77
|
ctx: EmitterContext,
|
|
61
78
|
modelMap: Map<string, Model>,
|
|
79
|
+
mountAccessors?: Map<string, string>,
|
|
62
80
|
): GeneratedFile {
|
|
63
81
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
64
|
-
const serviceDir =
|
|
82
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
65
83
|
const serviceClass = resolvedName;
|
|
66
|
-
const serviceProp = servicePropertyName(resolvedName);
|
|
84
|
+
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
|
|
67
85
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
68
86
|
|
|
69
87
|
const plans = service.operations.map((op) => ({
|
|
@@ -510,8 +528,9 @@ function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>):
|
|
|
510
528
|
|
|
511
529
|
if (isPaginated) return pathArgs || '';
|
|
512
530
|
if (hasBody) {
|
|
513
|
-
const
|
|
514
|
-
|
|
531
|
+
const payload = buildTestPayload(op, modelMap);
|
|
532
|
+
const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
|
|
533
|
+
return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
|
|
515
534
|
}
|
|
516
535
|
return pathArgs || '';
|
|
517
536
|
}
|
|
@@ -626,10 +645,18 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
|
|
|
626
645
|
}
|
|
627
646
|
|
|
628
647
|
/**
|
|
629
|
-
* Return a JS literal string for the expected fixture value of a
|
|
630
|
-
* Returns null for
|
|
648
|
+
* Return a JS literal string for the expected fixture value of a field.
|
|
649
|
+
* Returns null for types that cannot be deterministically generated.
|
|
650
|
+
* When a modelMap is provided, recursively builds object literals for nested model types.
|
|
651
|
+
* When wire is true, uses snake_case keys for nested model objects (wire format).
|
|
631
652
|
*/
|
|
632
|
-
function fixtureValueForType(
|
|
653
|
+
function fixtureValueForType(
|
|
654
|
+
ref: TypeRef,
|
|
655
|
+
name: string,
|
|
656
|
+
modelName: string,
|
|
657
|
+
modelMap?: Map<string, Model>,
|
|
658
|
+
wire?: boolean,
|
|
659
|
+
): string | null {
|
|
633
660
|
switch (ref.kind) {
|
|
634
661
|
case 'primitive':
|
|
635
662
|
return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
|
|
@@ -646,10 +673,24 @@ function fixtureValueForType(ref: TypeRef, name: string, modelName: string): str
|
|
|
646
673
|
// For arrays of primitives/enums, generate a single-element array assertion.
|
|
647
674
|
// For arrays of models/complex types, return null to skip the assertion —
|
|
648
675
|
// the fixture will have populated items that we can't predict here.
|
|
649
|
-
const itemValue = fixtureValueForType(ref.items, name, modelName);
|
|
676
|
+
const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
|
|
650
677
|
if (itemValue !== null) return `[${itemValue}]`;
|
|
651
678
|
return null;
|
|
652
679
|
}
|
|
680
|
+
case 'model': {
|
|
681
|
+
if (!modelMap) return null;
|
|
682
|
+
const nested = modelMap.get(ref.name);
|
|
683
|
+
if (!nested) return null;
|
|
684
|
+
const requiredFields = nested.fields.filter((f) => f.required);
|
|
685
|
+
const entries: string[] = [];
|
|
686
|
+
for (const field of requiredFields) {
|
|
687
|
+
const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
|
|
688
|
+
if (value === null) return null; // Can't build a complete object
|
|
689
|
+
const key = wire ? wireFieldName(field.name) : fieldName(field.name);
|
|
690
|
+
entries.push(`${key}: ${value}`);
|
|
691
|
+
}
|
|
692
|
+
return `{ ${entries.join(', ')} }`;
|
|
693
|
+
}
|
|
653
694
|
default:
|
|
654
695
|
return null;
|
|
655
696
|
}
|
|
@@ -716,8 +757,8 @@ function buildTestPayload(
|
|
|
716
757
|
if (!model) return null;
|
|
717
758
|
|
|
718
759
|
const fields = model.fields.filter((f) => f.required);
|
|
719
|
-
// Only use
|
|
720
|
-
const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
|
|
760
|
+
// Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
|
|
761
|
+
const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
|
|
721
762
|
|
|
722
763
|
// Only generate a typed payload when ALL required fields have fixture values.
|
|
723
764
|
// A partial payload missing required fields would fail TypeScript type checking.
|
|
@@ -727,11 +768,12 @@ function buildTestPayload(
|
|
|
727
768
|
const snakeEntries: string[] = [];
|
|
728
769
|
|
|
729
770
|
for (const field of usableFields) {
|
|
730
|
-
const
|
|
771
|
+
const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
|
|
772
|
+
const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
|
|
731
773
|
const camelKey = fieldName(field.name);
|
|
732
774
|
const snakeKey = wireFieldName(field.name);
|
|
733
|
-
camelEntries.push(`${camelKey}: ${
|
|
734
|
-
snakeEntries.push(`${snakeKey}: ${
|
|
775
|
+
camelEntries.push(`${camelKey}: ${camelValue}`);
|
|
776
|
+
snakeEntries.push(`${snakeKey}: ${wireValue}`);
|
|
735
777
|
}
|
|
736
778
|
|
|
737
779
|
return {
|
|
@@ -775,7 +817,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
775
817
|
serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
|
|
776
818
|
}
|
|
777
819
|
const resolveDir = (irService: string | undefined) =>
|
|
778
|
-
irService ?
|
|
820
|
+
irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
779
821
|
|
|
780
822
|
// Only generate round-trip tests for models with fields that have serializers generated.
|
|
781
823
|
// Skip list metadata and list wrapper models since their serializers are not emitted.
|
|
@@ -814,7 +856,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
814
856
|
serializerImports.push(
|
|
815
857
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
816
858
|
);
|
|
817
|
-
|
|
859
|
+
const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
860
|
+
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
818
861
|
}
|
|
819
862
|
|
|
820
863
|
for (const imp of serializerImports) {
|
|
@@ -827,7 +870,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
827
870
|
|
|
828
871
|
for (const model of models) {
|
|
829
872
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
830
|
-
const
|
|
873
|
+
const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
874
|
+
const fixtureName = `${camelDomain}Fixture`;
|
|
831
875
|
|
|
832
876
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
833
877
|
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
package/src/node/type-map.ts
CHANGED
|
@@ -122,10 +122,12 @@ function mapWirePrimitive(ref: PrimitiveType): string {
|
|
|
122
122
|
* allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
|
|
123
123
|
*/
|
|
124
124
|
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
125
|
+
const unique = [...new Set(variants)];
|
|
125
126
|
if (ref.compositionKind === 'allOf') {
|
|
126
|
-
return
|
|
127
|
+
return unique.join(' & ');
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
+
if (unique.length === 1) return unique[0];
|
|
130
|
+
return unique.join(' | ');
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** Wrap union/intersection types in parentheses when used as array item type. */
|
package/src/node/utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Model, EmitterContext, Service, Operation
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase } from '@workos/oagen';
|
|
2
3
|
export {
|
|
3
4
|
collectModelRefs,
|
|
4
5
|
collectEnumRefs,
|
|
@@ -7,7 +8,14 @@ export {
|
|
|
7
8
|
collectRequestBodyModels,
|
|
8
9
|
} from '@workos/oagen';
|
|
9
10
|
import { mapTypeRef } from './type-map.js';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
resolveInterfaceName,
|
|
13
|
+
fieldName,
|
|
14
|
+
resolveServiceDir,
|
|
15
|
+
resolveMethodName,
|
|
16
|
+
buildServiceNameMap,
|
|
17
|
+
} from './naming.js';
|
|
18
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
11
19
|
import { assignModelsToServices } from '@workos/oagen';
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -218,7 +226,7 @@ export function createServiceDirResolver(
|
|
|
218
226
|
const modelToService = assignModelsToServices(models, services);
|
|
219
227
|
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
220
228
|
const resolveDir = (irService: string | undefined) =>
|
|
221
|
-
irService ?
|
|
229
|
+
irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
222
230
|
return { modelToService, serviceNameMap, resolveDir };
|
|
223
231
|
}
|
|
224
232
|
|
|
@@ -240,67 +248,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
240
248
|
return false;
|
|
241
249
|
}
|
|
242
250
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
* exactly 2 fields named `before` and `after`, both nullable string.
|
|
246
|
-
*
|
|
247
|
-
* These models are redundant because the SDK already has a shared
|
|
248
|
-
* `ListMetadata` type in `src/common/utils/pagination.ts`.
|
|
249
|
-
*/
|
|
250
|
-
export function isListMetadataModel(model: Model): boolean {
|
|
251
|
-
if (model.fields.length !== 2) return false;
|
|
252
|
-
|
|
253
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
254
|
-
const before = fieldsByName.get('before');
|
|
255
|
-
const after = fieldsByName.get('after');
|
|
256
|
-
|
|
257
|
-
if (!before || !after) return false;
|
|
258
|
-
|
|
259
|
-
return isNullableString(before) && isNullableString(after);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Detect whether a model is a list wrapper — the standard paginated
|
|
264
|
-
* list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
|
|
265
|
-
*
|
|
266
|
-
* These models are redundant because the SDK already has `List<T>` and
|
|
267
|
-
* `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
|
|
268
|
-
* `deserializeList` handles deserialization.
|
|
269
|
-
*/
|
|
270
|
-
export function isListWrapperModel(model: Model): boolean {
|
|
271
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
272
|
-
|
|
273
|
-
// Must have a `data` field that is an array type
|
|
274
|
-
const dataField = fieldsByName.get('data');
|
|
275
|
-
if (!dataField) return false;
|
|
276
|
-
if (dataField.type.kind !== 'array') return false;
|
|
277
|
-
|
|
278
|
-
// Must have a `list_metadata` field (the IR uses snake_case names)
|
|
279
|
-
const listMetadataField = fieldsByName.get('list_metadata');
|
|
280
|
-
if (!listMetadataField) return false;
|
|
281
|
-
|
|
282
|
-
// Optionally has an `object` field with literal value 'list'
|
|
283
|
-
const objectField = fieldsByName.get('object');
|
|
284
|
-
if (objectField) {
|
|
285
|
-
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return true;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
294
|
-
function isNullableString(field: Field): boolean {
|
|
295
|
-
const { type } = field;
|
|
296
|
-
if (type.kind === 'nullable') {
|
|
297
|
-
return type.inner.kind === 'primitive' && type.inner.type === 'string';
|
|
298
|
-
}
|
|
299
|
-
if (type.kind === 'primitive') {
|
|
300
|
-
return type.type === 'string';
|
|
301
|
-
}
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
251
|
+
// Re-export shared model detection utilities
|
|
252
|
+
export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
304
253
|
|
|
305
254
|
/**
|
|
306
255
|
* Compute a structural fingerprint for a model based on its fields.
|
|
@@ -384,6 +333,11 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
384
333
|
* endpoints (e.g., `GET /connections`).
|
|
385
334
|
*/
|
|
386
335
|
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
336
|
+
// A service is "covered" when its mountOn differs from its own name,
|
|
337
|
+
// meaning its operations are mounted on a different (existing) class.
|
|
338
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
339
|
+
if (mountTarget !== toPascalCase(service.name)) return true;
|
|
340
|
+
|
|
387
341
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
388
342
|
if (!overlay || overlay.size === 0) return false;
|
|
389
343
|
if (service.operations.length === 0) return false;
|
|
@@ -405,6 +359,44 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
|
|
|
405
359
|
});
|
|
406
360
|
}
|
|
407
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Check whether a fully-covered service has operations whose overlay-mapped
|
|
364
|
+
* methods are missing from the baseline class. Returns true when at least
|
|
365
|
+
* one operation maps to a method name that the baseline class does not have,
|
|
366
|
+
* meaning the merger needs to add new methods (skipIfExists must be removed).
|
|
367
|
+
*/
|
|
368
|
+
export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
|
|
369
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
370
|
+
if (!baselineClasses) return false;
|
|
371
|
+
|
|
372
|
+
// When a service mounts on a different class (via mount rules), check
|
|
373
|
+
// each operation's resolved method name against the target class directly.
|
|
374
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
375
|
+
if (mountTarget !== toPascalCase(service.name)) {
|
|
376
|
+
const cls = baselineClasses[mountTarget];
|
|
377
|
+
if (!cls) return true; // Target class missing from baseline — treat as absent
|
|
378
|
+
for (const op of service.operations) {
|
|
379
|
+
const method = resolveMethodName(op, service, ctx);
|
|
380
|
+
if (!cls.methods?.[method]) return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Default overlay-based detection
|
|
386
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
387
|
+
if (!overlay) return false;
|
|
388
|
+
|
|
389
|
+
for (const op of service.operations) {
|
|
390
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
391
|
+
const match = overlay.get(httpKey);
|
|
392
|
+
if (!match) continue;
|
|
393
|
+
const cls = baselineClasses[match.className];
|
|
394
|
+
if (!cls) continue;
|
|
395
|
+
if (!cls.methods?.[match.methodName]) return true;
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
408
400
|
/**
|
|
409
401
|
* Return operations in a service that are NOT covered by existing hand-written
|
|
410
402
|
* service classes. For fully uncovered services, returns all operations.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { fieldName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
|
+
import { mapTypeRef } from './type-map.js';
|
|
5
|
+
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate TypeScript wrapper method lines for union split operations.
|
|
9
|
+
*
|
|
10
|
+
* Each wrapper is a typed convenience method that:
|
|
11
|
+
* - Accepts only the exposed params (not the full union body)
|
|
12
|
+
* - Injects constant defaults (e.g., grant_type)
|
|
13
|
+
* - Reads inferred fields from client config (e.g., clientId)
|
|
14
|
+
* - Delegates to the HTTP client with the constructed body
|
|
15
|
+
*/
|
|
16
|
+
export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
17
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
18
|
+
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
22
|
+
lines.push('');
|
|
23
|
+
emitWrapperMethod(lines, resolvedOp, wrapper, ctx);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect response model names referenced by wrappers on a resolved operation.
|
|
31
|
+
* Used by the resource generator to ensure the correct imports are emitted.
|
|
32
|
+
*/
|
|
33
|
+
export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
|
|
34
|
+
const models = new Set<string>();
|
|
35
|
+
for (const wrapper of resolvedOp.wrappers ?? []) {
|
|
36
|
+
if (wrapper.responseModelName) {
|
|
37
|
+
models.add(wrapper.responseModelName);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return models;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emitWrapperMethod(
|
|
44
|
+
lines: string[],
|
|
45
|
+
resolvedOp: ResolvedOperation,
|
|
46
|
+
wrapper: ResolvedWrapper,
|
|
47
|
+
ctx: EmitterContext,
|
|
48
|
+
): void {
|
|
49
|
+
const op = resolvedOp.operation;
|
|
50
|
+
const method = toCamelCase(wrapper.name);
|
|
51
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
52
|
+
|
|
53
|
+
// Build parameter list: path params, then required exposed, then optional exposed
|
|
54
|
+
const paramParts: string[] = [];
|
|
55
|
+
|
|
56
|
+
for (const p of op.pathParams) {
|
|
57
|
+
paramParts.push(`${fieldName(p.name)}: string`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
61
|
+
if (isOptional) continue;
|
|
62
|
+
const tsName = fieldName(paramName);
|
|
63
|
+
const tsType = field ? mapTypeRef(field.type) : 'string';
|
|
64
|
+
paramParts.push(`${tsName}: ${tsType}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
68
|
+
if (!isOptional) continue;
|
|
69
|
+
const tsName = fieldName(paramName);
|
|
70
|
+
const tsType = field ? mapTypeRef(field.type) : 'string';
|
|
71
|
+
paramParts.push(`${tsName}?: ${tsType}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Response type
|
|
75
|
+
const responseTypeName = wrapper.responseModelName ? resolveInterfaceName(wrapper.responseModelName, ctx) : null;
|
|
76
|
+
const wireType = responseTypeName ? wireInterfaceName(responseTypeName) : null;
|
|
77
|
+
const returnType = responseTypeName ?? 'void';
|
|
78
|
+
|
|
79
|
+
// JSDoc
|
|
80
|
+
lines.push(` /** ${formatWrapperDescription(wrapper.name)}. */`);
|
|
81
|
+
|
|
82
|
+
// Method signature
|
|
83
|
+
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
|
|
84
|
+
|
|
85
|
+
// Build body with wire-format (snake_case) keys
|
|
86
|
+
lines.push(' const body: Record<string, unknown> = {');
|
|
87
|
+
|
|
88
|
+
// Constant defaults
|
|
89
|
+
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
90
|
+
lines.push(` ${key}: ${tsLiteral(value)},`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inferred fields from client config
|
|
94
|
+
for (const field of wrapper.inferFromClient) {
|
|
95
|
+
const expr = clientFieldExpression(field);
|
|
96
|
+
lines.push(` ${field}: ${expr},`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Required exposed params (wire-format key, camelCase value)
|
|
100
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
101
|
+
if (isOptional) continue;
|
|
102
|
+
lines.push(` ${paramName}: ${fieldName(paramName)},`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push(' };');
|
|
106
|
+
|
|
107
|
+
// Optional exposed params — add conditionally
|
|
108
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
109
|
+
if (!isOptional) continue;
|
|
110
|
+
const tsName = fieldName(paramName);
|
|
111
|
+
lines.push(` if (${tsName} !== undefined) body.${paramName} = ${tsName};`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Build path expression
|
|
115
|
+
const pathStr = buildPathStr(op);
|
|
116
|
+
|
|
117
|
+
// Make the request
|
|
118
|
+
if (responseTypeName) {
|
|
119
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, body);`);
|
|
120
|
+
lines.push(` return deserialize${responseTypeName}(data);`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, body);`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(' }');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build a path template string from an Operation. */
|
|
129
|
+
function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
|
|
130
|
+
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
131
|
+
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convert a JS value to a TypeScript literal. */
|
|
135
|
+
function tsLiteral(value: string | number | boolean): string {
|
|
136
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
|
|
137
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
138
|
+
return String(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Get the TypeScript expression for reading a client config field. */
|
|
142
|
+
function clientFieldExpression(field: string): string {
|
|
143
|
+
switch (field) {
|
|
144
|
+
case 'client_id':
|
|
145
|
+
return 'this.workos.options.clientId';
|
|
146
|
+
case 'client_secret':
|
|
147
|
+
return 'this.workos.key';
|
|
148
|
+
default:
|
|
149
|
+
return `this.workos.${toCamelCase(field)}`;
|
|
150
|
+
}
|
|
151
|
+
}
|