@workos/oagen-emitters 0.2.1 → 0.4.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 +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- 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 +328 -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 +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- 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 +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -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 +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -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 +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- 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 +179 -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 +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -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 +189 -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 +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -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/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -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 +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -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 +682 -0
- package/test/php/tests.test.ts +185 -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
|
@@ -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
|
@@ -9,18 +9,19 @@ import {
|
|
|
9
9
|
servicePropertyName,
|
|
10
10
|
resolveMethodName,
|
|
11
11
|
resolveInterfaceName,
|
|
12
|
+
wireInterfaceName,
|
|
12
13
|
} from './naming.js';
|
|
13
14
|
import { generateFixtures } from './fixtures.js';
|
|
14
15
|
import { resolveResourceClassName } from './resources.js';
|
|
15
16
|
import {
|
|
16
17
|
assignModelsToServices,
|
|
17
18
|
createServiceDirResolver,
|
|
18
|
-
isServiceCoveredByExisting,
|
|
19
19
|
uncoveredOperations,
|
|
20
20
|
relativeImport,
|
|
21
21
|
isListMetadataModel,
|
|
22
22
|
isListWrapperModel,
|
|
23
23
|
} from './utils.js';
|
|
24
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
24
25
|
|
|
25
26
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
26
27
|
const files: GeneratedFile[] = [];
|
|
@@ -34,15 +35,32 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
34
35
|
// Build model lookup for response field assertions
|
|
35
36
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
36
37
|
|
|
37
|
-
// Generate test files per
|
|
38
|
-
// covered by existing hand-written
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// Generate test files per mount target — merges all sub-services into one
|
|
39
|
+
// test file. Skip operations already covered by existing hand-written classes.
|
|
40
|
+
const mountGroups = groupByMount(ctx);
|
|
41
|
+
|
|
42
|
+
// Build mount-target → property name map so tests use the same accessor
|
|
43
|
+
// as the generated client, even when the mount target name doesn't match
|
|
44
|
+
// any IR service name directly.
|
|
45
|
+
const mountAccessors = new Map<string, string>();
|
|
46
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
47
|
+
if (!mountAccessors.has(r.mountOn)) {
|
|
48
|
+
mountAccessors.set(r.mountOn, servicePropertyName(r.mountOn));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
53
|
+
mountGroups.size > 0
|
|
54
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
55
|
+
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
56
|
+
|
|
57
|
+
for (const { name: mountName, operations } of testEntries) {
|
|
58
|
+
if (operations.length === 0) continue;
|
|
59
|
+
const mergedService: Service = { name: mountName, operations };
|
|
60
|
+
const ops = uncoveredOperations(mergedService, ctx);
|
|
43
61
|
if (ops.length === 0) continue;
|
|
44
|
-
const testService = ops.length <
|
|
45
|
-
files.push(generateServiceTest(testService, spec, ctx, modelMap));
|
|
62
|
+
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
63
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
// Generate serializer round-trip tests
|
|
@@ -59,11 +77,12 @@ function generateServiceTest(
|
|
|
59
77
|
spec: ApiSpec,
|
|
60
78
|
ctx: EmitterContext,
|
|
61
79
|
modelMap: Map<string, Model>,
|
|
80
|
+
mountAccessors?: Map<string, string>,
|
|
62
81
|
): GeneratedFile {
|
|
63
82
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
64
83
|
const serviceDir = resolveServiceDir(resolvedName);
|
|
65
84
|
const serviceClass = resolvedName;
|
|
66
|
-
const serviceProp = servicePropertyName(resolvedName);
|
|
85
|
+
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
|
|
67
86
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
68
87
|
|
|
69
88
|
const plans = service.operations.map((op) => ({
|
|
@@ -733,9 +752,21 @@ function buildTestPayload(
|
|
|
733
752
|
op: Operation,
|
|
734
753
|
modelMap: Map<string, Model>,
|
|
735
754
|
): { camelCaseObj: string; snakeCaseObj: string } | null {
|
|
736
|
-
if (!op.requestBody
|
|
737
|
-
|
|
738
|
-
|
|
755
|
+
if (!op.requestBody) return null;
|
|
756
|
+
|
|
757
|
+
// For discriminated unions, build a payload from the first variant so the
|
|
758
|
+
// generated test produces a value that satisfies the union type. Without
|
|
759
|
+
// this, emitted tests pass `{} as any` for union bodies — fine for older
|
|
760
|
+
// permissive runtimes, but the dispatch switch now throws on unknown
|
|
761
|
+
// discriminator values, so the tests would fail before hitting fetch.
|
|
762
|
+
let model: Model | undefined;
|
|
763
|
+
if (op.requestBody.kind === 'union') {
|
|
764
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
765
|
+
if (!firstVariant || firstVariant.kind !== 'model') return null;
|
|
766
|
+
model = modelMap.get(firstVariant.name);
|
|
767
|
+
} else if (op.requestBody.kind === 'model') {
|
|
768
|
+
model = modelMap.get(op.requestBody.name);
|
|
769
|
+
}
|
|
739
770
|
if (!model) return null;
|
|
740
771
|
|
|
741
772
|
const fields = model.fields.filter((f) => f.required);
|
|
@@ -771,8 +802,14 @@ function buildTestPayload(
|
|
|
771
802
|
* fall back to `{} as any` to bypass type checking for complex required fields.
|
|
772
803
|
*/
|
|
773
804
|
function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
|
|
774
|
-
if (!op.requestBody
|
|
775
|
-
|
|
805
|
+
if (!op.requestBody) return '{} as any';
|
|
806
|
+
let model: Model | undefined;
|
|
807
|
+
if (op.requestBody.kind === 'union') {
|
|
808
|
+
const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
|
|
809
|
+
if (firstVariant && firstVariant.kind === 'model') model = modelMap.get(firstVariant.name);
|
|
810
|
+
} else if (op.requestBody.kind === 'model') {
|
|
811
|
+
model = modelMap.get(op.requestBody.name);
|
|
812
|
+
}
|
|
776
813
|
if (!model) return '{} as any';
|
|
777
814
|
const hasRequiredFields = model.fields.some((f) => f.required);
|
|
778
815
|
return hasRequiredFields ? '{} as any' : '{}';
|
|
@@ -826,6 +863,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
826
863
|
|
|
827
864
|
// Collect imports
|
|
828
865
|
const serializerImports: string[] = [];
|
|
866
|
+
const interfaceImports: string[] = [];
|
|
829
867
|
const fixtureImports: string[] = [];
|
|
830
868
|
|
|
831
869
|
for (const model of models) {
|
|
@@ -833,17 +871,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
833
871
|
const service = modelToService.get(model.name);
|
|
834
872
|
const modelDir = resolveDir(service);
|
|
835
873
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
874
|
+
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
836
875
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
|
|
837
876
|
|
|
838
877
|
serializerImports.push(
|
|
839
878
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
840
879
|
);
|
|
841
|
-
|
|
880
|
+
const wireName = wireInterfaceName(domainName);
|
|
881
|
+
interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
|
|
882
|
+
const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
883
|
+
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
842
884
|
}
|
|
843
885
|
|
|
844
886
|
for (const imp of serializerImports) {
|
|
845
887
|
lines.push(imp);
|
|
846
888
|
}
|
|
889
|
+
for (const imp of interfaceImports) {
|
|
890
|
+
lines.push(imp);
|
|
891
|
+
}
|
|
847
892
|
for (const imp of fixtureImports) {
|
|
848
893
|
lines.push(imp);
|
|
849
894
|
}
|
|
@@ -851,11 +896,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
851
896
|
|
|
852
897
|
for (const model of models) {
|
|
853
898
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
854
|
-
const
|
|
899
|
+
const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
900
|
+
const fixtureName = `${camelDomain}Fixture`;
|
|
855
901
|
|
|
902
|
+
const wireName = wireInterfaceName(domainName);
|
|
856
903
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
857
904
|
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
|
858
|
-
|
|
905
|
+
// Cast the fixture to the wire-type interface — JSON imports are inferred
|
|
906
|
+
// with primitive types (e.g., `string`) that don't satisfy literal-typed
|
|
907
|
+
// response fields (e.g., `type: "enum"`), so the deserialize call needs
|
|
908
|
+
// the explicit cast to compile.
|
|
909
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
859
910
|
lines.push(` const deserialized = deserialize${domainName}(fixture);`);
|
|
860
911
|
lines.push(` const reserialized = serialize${domainName}(deserialized);`);
|
|
861
912
|
lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
|
|
@@ -868,7 +919,6 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
868
919
|
path: testPath,
|
|
869
920
|
content: lines.join('\n'),
|
|
870
921
|
skipIfExists: true,
|
|
871
|
-
integrateTarget: false,
|
|
872
922
|
});
|
|
873
923
|
}
|
|
874
924
|
|
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,4 @@
|
|
|
1
|
-
import type { Model, EmitterContext, Service, Operation
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
export {
|
|
4
4
|
collectModelRefs,
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
resolveServiceDir,
|
|
15
15
|
resolveMethodName,
|
|
16
16
|
buildServiceNameMap,
|
|
17
|
-
SERVICE_COVERED_BY,
|
|
18
17
|
} from './naming.js';
|
|
18
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
19
19
|
import { assignModelsToServices } from '@workos/oagen';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -248,67 +248,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
248
248
|
return false;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
* exactly 2 fields named `before` and `after`, both nullable string.
|
|
254
|
-
*
|
|
255
|
-
* These models are redundant because the SDK already has a shared
|
|
256
|
-
* `ListMetadata` type in `src/common/utils/pagination.ts`.
|
|
257
|
-
*/
|
|
258
|
-
export function isListMetadataModel(model: Model): boolean {
|
|
259
|
-
if (model.fields.length !== 2) return false;
|
|
260
|
-
|
|
261
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
262
|
-
const before = fieldsByName.get('before');
|
|
263
|
-
const after = fieldsByName.get('after');
|
|
264
|
-
|
|
265
|
-
if (!before || !after) return false;
|
|
266
|
-
|
|
267
|
-
return isNullableString(before) && isNullableString(after);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Detect whether a model is a list wrapper — the standard paginated
|
|
272
|
-
* list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
|
|
273
|
-
*
|
|
274
|
-
* These models are redundant because the SDK already has `List<T>` and
|
|
275
|
-
* `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
|
|
276
|
-
* `deserializeList` handles deserialization.
|
|
277
|
-
*/
|
|
278
|
-
export function isListWrapperModel(model: Model): boolean {
|
|
279
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
280
|
-
|
|
281
|
-
// Must have a `data` field that is an array type
|
|
282
|
-
const dataField = fieldsByName.get('data');
|
|
283
|
-
if (!dataField) return false;
|
|
284
|
-
if (dataField.type.kind !== 'array') return false;
|
|
285
|
-
|
|
286
|
-
// Must have a `list_metadata` field (the IR uses snake_case names)
|
|
287
|
-
const listMetadataField = fieldsByName.get('list_metadata');
|
|
288
|
-
if (!listMetadataField) return false;
|
|
289
|
-
|
|
290
|
-
// Optionally has an `object` field with literal value 'list'
|
|
291
|
-
const objectField = fieldsByName.get('object');
|
|
292
|
-
if (objectField) {
|
|
293
|
-
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return true;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
302
|
-
function isNullableString(field: Field): boolean {
|
|
303
|
-
const { type } = field;
|
|
304
|
-
if (type.kind === 'nullable') {
|
|
305
|
-
return type.inner.kind === 'primitive' && type.inner.type === 'string';
|
|
306
|
-
}
|
|
307
|
-
if (type.kind === 'primitive') {
|
|
308
|
-
return type.type === 'string';
|
|
309
|
-
}
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
251
|
+
// Re-export shared model detection utilities
|
|
252
|
+
export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
312
253
|
|
|
313
254
|
/**
|
|
314
255
|
* Compute a structural fingerprint for a model based on its fields.
|
|
@@ -392,8 +333,10 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
392
333
|
* endpoints (e.g., `GET /connections`).
|
|
393
334
|
*/
|
|
394
335
|
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
395
|
-
//
|
|
396
|
-
|
|
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;
|
|
397
340
|
|
|
398
341
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
399
342
|
if (!overlay || overlay.size === 0) return false;
|
|
@@ -426,12 +369,11 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
426
369
|
const baselineClasses = ctx.apiSurface?.classes;
|
|
427
370
|
if (!baselineClasses) return false;
|
|
428
371
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const cls = baselineClasses[targetClassName];
|
|
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];
|
|
435
377
|
if (!cls) return true; // Target class missing from baseline — treat as absent
|
|
436
378
|
for (const op of service.operations) {
|
|
437
379
|
const method = resolveMethodName(op, service, ctx);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { className, servicePropertyName } from './naming.js';
|
|
4
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
|
+
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PHP-specific class-name overrides for non-spec services.
|
|
9
|
+
* If a service id isn't listed here, PascalCase(id) is used.
|
|
10
|
+
*/
|
|
11
|
+
const PHP_NON_SPEC_CLASS_NAMES: Record<string, string> = {
|
|
12
|
+
webhook_verification: 'WebhookVerification',
|
|
13
|
+
session_manager: 'SessionManager',
|
|
14
|
+
pkce: 'PKCEHelper',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Derive PHP class name + property name from a non-spec service id. */
|
|
18
|
+
function phpNonSpecAccessor(id: string): { className: string; propName: string } {
|
|
19
|
+
return {
|
|
20
|
+
className: PHP_NON_SPEC_CLASS_NAMES[id] ?? toPascalCase(id),
|
|
21
|
+
propName:
|
|
22
|
+
id === 'webhook_verification'
|
|
23
|
+
? 'webhookVerification'
|
|
24
|
+
: id === 'session_manager'
|
|
25
|
+
? 'sessionManager'
|
|
26
|
+
: toCamelCase(id),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate the main PHP client class (service wiring only).
|
|
32
|
+
*
|
|
33
|
+
* Static infrastructure (HttpClient, PaginatedResponse, RequestOptions) is
|
|
34
|
+
* now hand-maintained in the target SDK with @oagen-ignore-file.
|
|
35
|
+
*/
|
|
36
|
+
export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
37
|
+
const ns = ctx.namespacePascal;
|
|
38
|
+
const dedupedServices = deduplicateByMount(spec.services, ctx);
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
path: `lib/${ns}.php`,
|
|
43
|
+
content: generateMainClient(spec, dedupedServices, ctx),
|
|
44
|
+
overwriteExisting: true,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a map from IR service name to the client accessor property name.
|
|
51
|
+
*/
|
|
52
|
+
export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
53
|
+
const map = new Map<string, string>();
|
|
54
|
+
for (const service of services) {
|
|
55
|
+
const target = getMountTarget(service, ctx);
|
|
56
|
+
map.set(service.name, servicePropertyName(target));
|
|
57
|
+
map.set(target, servicePropertyName(target));
|
|
58
|
+
}
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
|
|
63
|
+
const seen = new Map<string, { name: string; propName: string }>();
|
|
64
|
+
for (const service of services) {
|
|
65
|
+
const target = getMountTarget(service, ctx);
|
|
66
|
+
if (!seen.has(target)) {
|
|
67
|
+
seen.set(target, {
|
|
68
|
+
name: className(target),
|
|
69
|
+
propName: servicePropertyName(target),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...seen.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function generateMainClient(
|
|
77
|
+
spec: ApiSpec,
|
|
78
|
+
services: { name: string; propName: string }[],
|
|
79
|
+
ctx: EmitterContext,
|
|
80
|
+
): string {
|
|
81
|
+
const ns = ctx.namespacePascal;
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
|
|
84
|
+
// No <?php here — the file header from fileHeader() provides it
|
|
85
|
+
lines.push(`namespace ${ns};`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
// Use imports (sorted case-insensitively for PSR-12)
|
|
89
|
+
const nonSpecAccessors = NON_SPEC_SERVICES.map((s) => phpNonSpecAccessor(s.id));
|
|
90
|
+
const allImports: string[] = [];
|
|
91
|
+
for (const svc of services) {
|
|
92
|
+
allImports.push(`use ${ns}\\Service\\${svc.name};`);
|
|
93
|
+
}
|
|
94
|
+
allImports.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
95
|
+
for (const imp of allImports) {
|
|
96
|
+
lines.push(imp);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
lines.push(`class ${ns}`);
|
|
101
|
+
lines.push('{');
|
|
102
|
+
lines.push(' private static ?string $apiKey = null;');
|
|
103
|
+
lines.push(' private static ?string $clientId = null;');
|
|
104
|
+
lines.push(' private ?HttpClient $httpClient = null;');
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push(' public static function getApiKey(): ?string');
|
|
107
|
+
lines.push(' {');
|
|
108
|
+
lines.push(' return self::$apiKey;');
|
|
109
|
+
lines.push(' }');
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push(' public static function setApiKey(?string $key): void');
|
|
112
|
+
lines.push(' {');
|
|
113
|
+
lines.push(' self::$apiKey = $key;');
|
|
114
|
+
lines.push(' }');
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(' public static function getClientId(): ?string');
|
|
117
|
+
lines.push(' {');
|
|
118
|
+
lines.push(' return self::$clientId;');
|
|
119
|
+
lines.push(' }');
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push(' public static function setClientId(?string $id): void');
|
|
122
|
+
lines.push(' {');
|
|
123
|
+
lines.push(' self::$clientId = $id;');
|
|
124
|
+
lines.push(' }');
|
|
125
|
+
|
|
126
|
+
// Nullable resource properties
|
|
127
|
+
for (const svc of services) {
|
|
128
|
+
lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
|
|
129
|
+
}
|
|
130
|
+
// Non-spec service properties — wrapped in ignore markers so the target
|
|
131
|
+
// SDK can hand-maintain the list. The emitter provides a positional anchor.
|
|
132
|
+
lines.push(' // @oagen-ignore-start — non-spec service properties (hand-maintained)');
|
|
133
|
+
for (const a of nonSpecAccessors) {
|
|
134
|
+
lines.push(` private ?${a.className} $${a.propName} = null;`);
|
|
135
|
+
}
|
|
136
|
+
lines.push(' // @oagen-ignore-end');
|
|
137
|
+
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push(' public function __construct(');
|
|
140
|
+
lines.push(' ?string $apiKey = null,');
|
|
141
|
+
lines.push(' ?string $clientId = null,');
|
|
142
|
+
lines.push(` string $baseUrl = '${spec.baseUrl}',`);
|
|
143
|
+
lines.push(' int $timeout = 60,');
|
|
144
|
+
lines.push(' int $maxRetries = 3,');
|
|
145
|
+
lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
|
|
146
|
+
lines.push(' ?string $userAgent = null,');
|
|
147
|
+
lines.push(' ) {');
|
|
148
|
+
lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
|
|
149
|
+
lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
|
|
150
|
+
lines.push(
|
|
151
|
+
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
|
|
152
|
+
);
|
|
153
|
+
lines.push(' }');
|
|
154
|
+
|
|
155
|
+
// Resource accessors
|
|
156
|
+
for (const svc of services) {
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push(` public function ${svc.propName}(): ${svc.name}`);
|
|
159
|
+
lines.push(' {');
|
|
160
|
+
lines.push(` return $this->${svc.propName} ??= new Service\\${svc.name}($this->httpClient);`);
|
|
161
|
+
lines.push(' }');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Non-spec service accessors — wrapped in ignore markers so the target
|
|
165
|
+
// SDK can hand-maintain these. The emitter provides a positional anchor.
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(' // @oagen-ignore-start — non-spec service accessors (hand-maintained)');
|
|
168
|
+
for (const a of nonSpecAccessors) {
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push(` public function ${a.propName}(): ${a.className}`);
|
|
171
|
+
lines.push(' {');
|
|
172
|
+
lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
|
|
173
|
+
lines.push(' }');
|
|
174
|
+
}
|
|
175
|
+
lines.push(' // @oagen-ignore-end');
|
|
176
|
+
|
|
177
|
+
lines.push('}');
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|