@workos/oagen-emitters 0.12.4 → 0.13.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-nmiHN7Ko.mjs → plugin-B9F2jmwy.mjs} +1432 -1203
- package/dist/plugin-B9F2jmwy.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/kotlin/client.ts +3 -2
- package/src/kotlin/naming.ts +34 -0
- package/src/kotlin/resources.ts +5 -3
- package/src/kotlin/tests.ts +12 -3
- package/src/node/client.ts +11 -2
- package/src/node/field-plan.ts +27 -13
- package/src/node/manifest.ts +5 -3
- package/src/node/models.ts +52 -6
- package/src/node/naming.ts +24 -0
- package/src/node/resources.ts +21 -9
- package/src/node/tests.ts +52 -9
- package/src/php/client.ts +3 -2
- package/src/php/naming.ts +22 -0
- package/src/php/resources.ts +3 -2
- package/src/python/client.ts +16 -5
- package/src/python/naming.ts +25 -0
- package/src/python/resources.ts +4 -1
- package/src/ruby/client.ts +18 -5
- package/src/ruby/manifest.ts +5 -0
- package/src/ruby/naming.ts +30 -0
- package/src/ruby/rbi.ts +15 -8
- package/src/ruby/resources.ts +10 -3
- package/src/ruby/tests.ts +22 -5
- package/src/shared/service-name-collision.ts +56 -0
- package/test/node/models.test.ts +56 -0
- package/test/node/tests.test.ts +57 -0
- package/dist/plugin-nmiHN7Ko.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-B9F2jmwy.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/oagen-emitters",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "WorkOS' oagen emitters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "WorkOS",
|
|
@@ -40,20 +40,20 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@commitlint/cli": "^21.0.1",
|
|
42
42
|
"@commitlint/config-conventional": "^21.0.1",
|
|
43
|
-
"@types/node": "^25.9.
|
|
43
|
+
"@types/node": "^25.9.1",
|
|
44
44
|
"husky": "^9.1.7",
|
|
45
|
-
"oxfmt": "^0.
|
|
46
|
-
"oxlint": "^1.
|
|
45
|
+
"oxfmt": "^0.51.0",
|
|
46
|
+
"oxlint": "^1.66.0",
|
|
47
47
|
"prettier": "^3.8.3",
|
|
48
48
|
"tsdown": "^0.22.0",
|
|
49
|
-
"tsx": "^4.22.
|
|
49
|
+
"tsx": "^4.22.3",
|
|
50
50
|
"typescript": "^6.0.3",
|
|
51
|
-
"vitest": "^4.1.
|
|
51
|
+
"vitest": "^4.1.7"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.10.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@workos/oagen": "^0.19.
|
|
57
|
+
"@workos/oagen": "^0.19.5"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/kotlin/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveApiClassName, packageSegment, servicePropertyName, buildExportedClassNameSet } from './naming.js';
|
|
3
3
|
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
4
4
|
|
|
5
5
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
@@ -23,8 +23,9 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
|
|
|
23
23
|
|
|
24
24
|
const imports = new Set<string>();
|
|
25
25
|
const accessorLines: string[] = [];
|
|
26
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
26
27
|
for (const mount of targets) {
|
|
27
|
-
const apiCls =
|
|
28
|
+
const apiCls = resolveApiClassName(mount, exportedClasses);
|
|
28
29
|
const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
|
|
29
30
|
imports.add(fqn);
|
|
30
31
|
const prop = servicePropertyName(mount);
|
package/src/kotlin/naming.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { Operation, Service, EmitterContext, TypeRef } from '@workos/oagen';
|
|
2
|
+
import {
|
|
3
|
+
buildExportedClassNameSet as buildExportedClassNameSetShared,
|
|
4
|
+
resolveServiceTarget as resolveServiceTargetShared,
|
|
5
|
+
} from '../shared/service-name-collision.js';
|
|
2
6
|
import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
|
|
3
7
|
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
8
|
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
@@ -73,6 +77,36 @@ export function apiClassName(name: string): string {
|
|
|
73
77
|
return className(name);
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the Kotlin service class name with the collision suffix applied
|
|
82
|
+
* when needed. Wraps `apiClassName` so callers don't need to thread the
|
|
83
|
+
* exported-classes set through unrelated emission logic.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveApiClassName(name: string, exportedClasses: Set<string>): string {
|
|
86
|
+
return apiClassName(resolveServiceTarget(name, exportedClasses));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the set of model + enum class names exported by the SDK. Used to
|
|
91
|
+
* detect collisions with operation-client class names — a colliding service
|
|
92
|
+
* gets a `Service` suffix appended.
|
|
93
|
+
*/
|
|
94
|
+
export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
|
|
95
|
+
return buildExportedClassNameSetShared(ctx, className);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a service's mount-target identifier, appending `Service` on
|
|
100
|
+
* collision with an exported model/enum class. Kotlin sees the collision
|
|
101
|
+
* when a service class shares a simple name with an imported model class
|
|
102
|
+
* (e.g. `com.workos.models.OrganizationMembership` vs
|
|
103
|
+
* `com.workos.organizationmembership.OrganizationMembership`) — the file's
|
|
104
|
+
* local declaration shadows the import for unqualified references.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
|
|
107
|
+
return resolveServiceTargetShared(target, exportedClasses, className);
|
|
108
|
+
}
|
|
109
|
+
|
|
76
110
|
/** Accessor property exposed on the WorkOS client (camelCase). */
|
|
77
111
|
export function servicePropertyName(name: string): string {
|
|
78
112
|
return toCamelCase(name);
|
package/src/kotlin/resources.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { enumCanonicalMap } from './enums.js';
|
|
|
16
16
|
import {
|
|
17
17
|
className,
|
|
18
18
|
propertyName,
|
|
19
|
-
|
|
19
|
+
resolveApiClassName,
|
|
20
20
|
packageSegment,
|
|
21
21
|
resolveMethodName,
|
|
22
22
|
ktLiteral,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
escapeReserved,
|
|
25
25
|
humanize,
|
|
26
26
|
maybeShortenEnumParamDescription,
|
|
27
|
+
buildExportedClassNameSet,
|
|
27
28
|
} from './naming.js';
|
|
28
29
|
import {
|
|
29
30
|
buildResolvedLookup,
|
|
@@ -96,13 +97,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
96
97
|
|
|
97
98
|
const files: GeneratedFile[] = [];
|
|
98
99
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
100
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
99
101
|
|
|
100
102
|
for (const [mountName, group] of mountGroups) {
|
|
101
103
|
const classCode = generateApiClass(mountName, group.operations, ctx, resolvedLookup);
|
|
102
104
|
if (!classCode) continue;
|
|
103
105
|
const pkg = packageSegment(mountName);
|
|
104
106
|
files.push({
|
|
105
|
-
path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${
|
|
107
|
+
path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${resolveApiClassName(mountName, exportedClasses)}.kt`,
|
|
106
108
|
content: classCode,
|
|
107
109
|
overwriteExisting: true,
|
|
108
110
|
});
|
|
@@ -118,7 +120,7 @@ function generateApiClass(
|
|
|
118
120
|
resolvedLookup: Map<string, ResolvedOperation>,
|
|
119
121
|
): string | null {
|
|
120
122
|
if (operations.length === 0) return null;
|
|
121
|
-
const apiClass =
|
|
123
|
+
const apiClass = resolveApiClassName(mountName, buildExportedClassNameSet(ctx));
|
|
122
124
|
const pkg = `com.workos.${packageSegment(mountName)}`;
|
|
123
125
|
|
|
124
126
|
const imports = new Set<string>();
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -10,7 +10,15 @@ import type {
|
|
|
10
10
|
ResolvedWrapper,
|
|
11
11
|
} from '@workos/oagen';
|
|
12
12
|
import { planOperation } from '@workos/oagen';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
resolveApiClassName,
|
|
15
|
+
packageSegment,
|
|
16
|
+
resolveMethodName,
|
|
17
|
+
ktStringLiteral,
|
|
18
|
+
className,
|
|
19
|
+
propertyName,
|
|
20
|
+
buildExportedClassNameSet,
|
|
21
|
+
} from './naming.js';
|
|
14
22
|
import { mapTypeRef } from './type-map.js';
|
|
15
23
|
import {
|
|
16
24
|
groupByMount,
|
|
@@ -70,12 +78,13 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
70
78
|
const mountGroups = groupByMount(ctx);
|
|
71
79
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
72
80
|
|
|
81
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
73
82
|
for (const [mountName, group] of mountGroups) {
|
|
74
83
|
const content = generateServiceTestClass(mountName, group.operations, ctx, resolvedLookup);
|
|
75
84
|
if (!content) continue;
|
|
76
85
|
const pkg = packageSegment(mountName);
|
|
77
86
|
files.push({
|
|
78
|
-
path: `${TEST_PREFIX}com/workos/${pkg}/${
|
|
87
|
+
path: `${TEST_PREFIX}com/workos/${pkg}/${resolveApiClassName(mountName, exportedClasses)}Test.kt`,
|
|
79
88
|
content,
|
|
80
89
|
overwriteExisting: true,
|
|
81
90
|
});
|
|
@@ -203,7 +212,7 @@ function generateServiceTestClass(
|
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
const pkg = packageSegment(mountName);
|
|
206
|
-
const apiCls =
|
|
215
|
+
const apiCls = resolveApiClassName(mountName, buildExportedClassNameSet(ctx));
|
|
207
216
|
|
|
208
217
|
// If any operation would emit a disabled placeholder test, preregister
|
|
209
218
|
// the `Disabled` import before we serialize the header.
|
package/src/node/client.ts
CHANGED
|
@@ -2,7 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fileName,
|
|
7
|
+
servicePropertyName,
|
|
8
|
+
resolveInterfaceName,
|
|
9
|
+
wireInterfaceName,
|
|
10
|
+
resolveServiceName,
|
|
11
|
+
} from './naming.js';
|
|
6
12
|
import { isInlineEnum } from './type-map.js';
|
|
7
13
|
import {
|
|
8
14
|
docComment,
|
|
@@ -89,7 +95,10 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
89
95
|
for (const service of spec.services) {
|
|
90
96
|
if (coveredServices.has(service.name)) continue;
|
|
91
97
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
92
|
-
|
|
98
|
+
// Accessor name uses the un-suffixed service name so that the public API
|
|
99
|
+
// stays `client.organizationMembership` even when the class itself was
|
|
100
|
+
// renamed to `OrganizationMembershipService` to avoid a model collision.
|
|
101
|
+
const propName = servicePropertyName(resolveServiceName(service, ctx));
|
|
93
102
|
if (existingProps.has(propName)) continue;
|
|
94
103
|
// Propagate `@deprecated` from the service class to the property so
|
|
95
104
|
// IDEs surface the strikethrough at `workos.xyz` access sites, not
|
package/src/node/field-plan.ts
CHANGED
|
@@ -666,6 +666,10 @@ interface SerializerContext {
|
|
|
666
666
|
resolveDir: (irService: string | undefined) => string;
|
|
667
667
|
dedup: Map<string, string>;
|
|
668
668
|
skippedSerializeModels: Set<string>;
|
|
669
|
+
/** Models reachable from any response — anything outside this set is
|
|
670
|
+
* request-only and won't have a `deserialize<X>` emitted. `undefined`
|
|
671
|
+
* means "no usage info available, assume deserialize exists". */
|
|
672
|
+
responseReachableModels: Set<string> | undefined;
|
|
669
673
|
ctx: EmitterContext;
|
|
670
674
|
}
|
|
671
675
|
|
|
@@ -723,6 +727,10 @@ export function buildSerializerImports(
|
|
|
723
727
|
const canon = sctx.dedup.get(dep);
|
|
724
728
|
const depSkipSerialize =
|
|
725
729
|
sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
|
|
730
|
+
const depSkipDeserialize =
|
|
731
|
+
sctx.responseReachableModels !== undefined &&
|
|
732
|
+
!sctx.responseReachableModels.has(dep) &&
|
|
733
|
+
(canon == null || !sctx.responseReachableModels.has(canon));
|
|
726
734
|
|
|
727
735
|
// Decide whether this serializer is reachable at runtime:
|
|
728
736
|
// - file on disk → honor what it exports (hasDeser/hasSer)
|
|
@@ -759,8 +767,11 @@ export function buildSerializerImports(
|
|
|
759
767
|
continue;
|
|
760
768
|
}
|
|
761
769
|
|
|
770
|
+
if (depSkipSerialize && depSkipDeserialize) continue;
|
|
762
771
|
if (depSkipSerialize) {
|
|
763
772
|
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
773
|
+
} else if (depSkipDeserialize) {
|
|
774
|
+
lines.push(`import { serialize${depName} } from '${rel}';`);
|
|
764
775
|
} else {
|
|
765
776
|
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
766
777
|
}
|
|
@@ -821,27 +832,30 @@ export function emitSerializerBody(
|
|
|
821
832
|
baselineResponse: BaselineInterface | undefined,
|
|
822
833
|
skipFormatFields: Set<string>,
|
|
823
834
|
shouldSkipSerialize: boolean,
|
|
835
|
+
shouldSkipDeserialize: boolean,
|
|
824
836
|
ctx: EmitterContext,
|
|
825
837
|
): string[] {
|
|
826
838
|
const lines: string[] = [];
|
|
827
839
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
840
|
+
if (!shouldSkipDeserialize) {
|
|
841
|
+
const seenDeserFields = new Set<string>();
|
|
842
|
+
const deserParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
843
|
+
lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
|
|
844
|
+
lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
|
|
845
|
+
lines.push(`): ${domainName}${typeParams.usage} => ({`);
|
|
846
|
+
for (const field of model.fields) {
|
|
847
|
+
const domain = fieldName(field.name);
|
|
848
|
+
if (seenDeserFields.has(domain)) continue;
|
|
849
|
+
seenDeserFields.add(domain);
|
|
850
|
+
const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
|
|
851
|
+
if (!plan.skip) lines.push(plan.line);
|
|
852
|
+
}
|
|
853
|
+
lines.push('});');
|
|
839
854
|
}
|
|
840
|
-
lines.push('});');
|
|
841
855
|
|
|
842
856
|
if (!shouldSkipSerialize) {
|
|
857
|
+
if (!shouldSkipDeserialize) lines.push('');
|
|
843
858
|
const serParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
844
|
-
lines.push('');
|
|
845
859
|
lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
|
|
846
860
|
lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
|
|
847
861
|
lines.push(`): ${responseName}${typeParams.usage} => ({`);
|
package/src/node/manifest.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
|
|
2
|
-
import { resolveMethodName, servicePropertyName } from './naming.js';
|
|
3
|
-
import { resolveResourceClassName } from './resources.js';
|
|
2
|
+
import { resolveMethodName, servicePropertyName, resolveServiceName } from './naming.js';
|
|
4
3
|
import { buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
5
4
|
|
|
6
5
|
export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
|
|
@@ -8,7 +7,10 @@ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): Operatio
|
|
|
8
7
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
9
8
|
|
|
10
9
|
for (const service of spec.services) {
|
|
11
|
-
|
|
10
|
+
// Accessor name reflects the un-suffixed service mount target so the
|
|
11
|
+
// manifest matches `client.organizationMembership` (not the suffixed
|
|
12
|
+
// class name used to dodge model collisions).
|
|
13
|
+
const serviceProp = servicePropertyName(resolveServiceName(service, ctx));
|
|
12
14
|
for (const op of service.operations) {
|
|
13
15
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
14
16
|
const method = resolveMethodName(op, service, ctx);
|
package/src/node/models.ts
CHANGED
|
@@ -61,6 +61,10 @@ interface SharedModelContext {
|
|
|
61
61
|
interface GeneratedResourceModelUsage {
|
|
62
62
|
interfaceRoots: Set<string>;
|
|
63
63
|
serializerRoots: Set<string>;
|
|
64
|
+
/** Models that are directly used as a request body. Drive `serialize<X>`. */
|
|
65
|
+
requestRoots: Set<string>;
|
|
66
|
+
/** Models that are directly used as a response body. Drive `deserialize<X>`. */
|
|
67
|
+
responseRoots: Set<string>;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
@@ -655,6 +659,14 @@ export function generateSerializers(
|
|
|
655
659
|
const serializerEligibleModels = resourceUsage
|
|
656
660
|
? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
|
|
657
661
|
: undefined;
|
|
662
|
+
// Models reachable from any response — only these need a `deserialize<X>`.
|
|
663
|
+
// A model used solely as a request body (e.g. `CreateWebhookEndpoint`)
|
|
664
|
+
// would otherwise emit a deserializer with a partial response shape that
|
|
665
|
+
// silently misbehaves if called. Undefined means "no resource usage info,
|
|
666
|
+
// emit both halves" (standalone generation, smoke tests).
|
|
667
|
+
const responseReachableModels = resourceUsage
|
|
668
|
+
? expandModelRoots(resourceUsage.responseRoots, projectedByName)
|
|
669
|
+
: undefined;
|
|
658
670
|
|
|
659
671
|
const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
660
672
|
|
|
@@ -765,9 +777,19 @@ export function generateSerializers(
|
|
|
765
777
|
if (domainName === canonDomainName) continue;
|
|
766
778
|
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
767
779
|
const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
780
|
+
const canonSkipDeserialize =
|
|
781
|
+
responseReachableModels !== undefined &&
|
|
782
|
+
!responseReachableModels.has(canonicalName) &&
|
|
783
|
+
!responseReachableModels.has(model.name);
|
|
784
|
+
if (canonSkipSerialize && canonSkipDeserialize) continue;
|
|
785
|
+
const parts: string[] = [];
|
|
786
|
+
if (!canonSkipDeserialize) {
|
|
787
|
+
parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
|
|
788
|
+
}
|
|
789
|
+
if (!canonSkipSerialize) {
|
|
790
|
+
parts.push(`serialize${canonDomainName} as serialize${domainName}`);
|
|
791
|
+
}
|
|
792
|
+
const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
|
|
771
793
|
files.push({
|
|
772
794
|
path: serializerPath,
|
|
773
795
|
content: reexportContent,
|
|
@@ -787,8 +809,21 @@ export function generateSerializers(
|
|
|
787
809
|
|
|
788
810
|
const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
|
|
789
811
|
const shouldSkipSerialize = skippedSerializeModels.has(model.name);
|
|
790
|
-
|
|
791
|
-
|
|
812
|
+
// Skip `deserialize<X>` when the model never appears as a response (and
|
|
813
|
+
// we have usage info to verify that — `undefined` means "emit both halves
|
|
814
|
+
// conservatively"). Cuts unused, partially-typed deserializers like
|
|
815
|
+
// `deserializeCreateWebhookEndpoint` from request-body-only models.
|
|
816
|
+
const shouldSkipDeserialize = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
|
|
817
|
+
if (shouldSkipSerialize && shouldSkipDeserialize) continue;
|
|
818
|
+
|
|
819
|
+
const sctx = {
|
|
820
|
+
modelToService,
|
|
821
|
+
resolveDir,
|
|
822
|
+
dedup,
|
|
823
|
+
skippedSerializeModels,
|
|
824
|
+
responseReachableModels,
|
|
825
|
+
ctx,
|
|
826
|
+
};
|
|
792
827
|
const lines = [
|
|
793
828
|
...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
|
|
794
829
|
...emitSerializerBody(
|
|
@@ -800,6 +835,7 @@ export function generateSerializers(
|
|
|
800
835
|
baselineResponse,
|
|
801
836
|
skipFormatFields,
|
|
802
837
|
shouldSkipSerialize,
|
|
838
|
+
shouldSkipDeserialize,
|
|
803
839
|
ctx,
|
|
804
840
|
),
|
|
805
841
|
];
|
|
@@ -812,6 +848,10 @@ export function generateSerializers(
|
|
|
812
848
|
}
|
|
813
849
|
|
|
814
850
|
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
|
851
|
+
// Surface the response-reachable set so the serializer-roundtrip test
|
|
852
|
+
// generator can fall back to a deserialize-skipped path for request-only
|
|
853
|
+
// models (where `deserialize<X>` was deliberately not emitted).
|
|
854
|
+
(ctx as any)._responseReachableModels = responseReachableModels;
|
|
815
855
|
|
|
816
856
|
// Emit a `serializers/index.ts` barrel per directory that received serializer
|
|
817
857
|
// files in this pass. Mirrors the per-service `interfaces/index.ts` barrel so
|
|
@@ -882,6 +922,8 @@ function buildGeneratedResourceModelUsage(
|
|
|
882
922
|
const modelMap = new Map(models.map((model) => [model.name, model]));
|
|
883
923
|
const interfaceRoots = new Set<string>();
|
|
884
924
|
const serializerRoots = new Set<string>();
|
|
925
|
+
const requestRoots = new Set<string>();
|
|
926
|
+
const responseRoots = new Set<string>();
|
|
885
927
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
886
928
|
const mountGroups = groupByMount(ctx);
|
|
887
929
|
const services: Service[] =
|
|
@@ -924,16 +966,19 @@ function buildGeneratedResourceModelUsage(
|
|
|
924
966
|
if (unwrapped) itemName = unwrapped.name;
|
|
925
967
|
interfaceRoots.add(itemName);
|
|
926
968
|
serializerRoots.add(itemName);
|
|
969
|
+
responseRoots.add(itemName);
|
|
927
970
|
}
|
|
928
971
|
} else if (plan.responseModelName) {
|
|
929
972
|
interfaceRoots.add(plan.responseModelName);
|
|
930
973
|
serializerRoots.add(plan.responseModelName);
|
|
974
|
+
responseRoots.add(plan.responseModelName);
|
|
931
975
|
}
|
|
932
976
|
|
|
933
977
|
const bodyInfo = extractRequestBodyModels(op, ctx);
|
|
934
978
|
for (const name of bodyInfo) {
|
|
935
979
|
interfaceRoots.add(name);
|
|
936
980
|
serializerRoots.add(name);
|
|
981
|
+
requestRoots.add(name);
|
|
937
982
|
}
|
|
938
983
|
|
|
939
984
|
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
@@ -945,6 +990,7 @@ function buildGeneratedResourceModelUsage(
|
|
|
945
990
|
for (const name of collectWrapperResponseModels(resolved)) {
|
|
946
991
|
interfaceRoots.add(name);
|
|
947
992
|
serializerRoots.add(name);
|
|
993
|
+
responseRoots.add(name);
|
|
948
994
|
}
|
|
949
995
|
for (const wrapper of resolved.wrappers ?? []) {
|
|
950
996
|
for (const { field } of resolveWrapperParams(wrapper, ctx)) {
|
|
@@ -955,7 +1001,7 @@ function buildGeneratedResourceModelUsage(
|
|
|
955
1001
|
}
|
|
956
1002
|
}
|
|
957
1003
|
|
|
958
|
-
return { interfaceRoots, serializerRoots };
|
|
1004
|
+
return { interfaceRoots, serializerRoots, requestRoots, responseRoots };
|
|
959
1005
|
}
|
|
960
1006
|
|
|
961
1007
|
function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
|
package/src/node/naming.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
|
|
4
4
|
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
5
|
+
import {
|
|
6
|
+
buildExportedClassNameSet as buildExportedClassNameSetShared,
|
|
7
|
+
resolveServiceTarget as resolveServiceTargetShared,
|
|
8
|
+
} from '../shared/service-name-collision.js';
|
|
5
9
|
|
|
6
10
|
/** Strip spec-noise suffixes (e.g., "Dto") from an IR name. */
|
|
7
11
|
export function stripNoiseSuffixes(name: string): string {
|
|
@@ -117,6 +121,26 @@ export function resolveServiceName(service: Service, ctx: EmitterContext): strin
|
|
|
117
121
|
return resolveClassName(service, ctx);
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Build the set of model + enum class names exported by the SDK. Used to
|
|
126
|
+
* detect collisions with operation-client class names — a colliding service
|
|
127
|
+
* gets a `Service` suffix appended.
|
|
128
|
+
*/
|
|
129
|
+
export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
|
|
130
|
+
return buildExportedClassNameSetShared(ctx, className);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a service's mount-target identifier, appending `Service` on
|
|
135
|
+
* collision with an exported model/enum class. The result feeds `className`
|
|
136
|
+
* and `fileName` so both the `export class` declaration and its file name
|
|
137
|
+
* stay aligned (e.g. `OrganizationMembershipService` /
|
|
138
|
+
* `organization-membership-service.ts`).
|
|
139
|
+
*/
|
|
140
|
+
export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
|
|
141
|
+
return resolveServiceTargetShared(target, exportedClasses, className);
|
|
142
|
+
}
|
|
143
|
+
|
|
120
144
|
/**
|
|
121
145
|
* Build a map from IR service name -> resolved service name.
|
|
122
146
|
*/
|
package/src/node/resources.ts
CHANGED
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
resolveInterfaceName,
|
|
54
54
|
resolveServiceName,
|
|
55
55
|
wireInterfaceName,
|
|
56
|
+
buildExportedClassNameSet,
|
|
57
|
+
resolveServiceTarget,
|
|
56
58
|
} from './naming.js';
|
|
57
59
|
import {
|
|
58
60
|
docComment,
|
|
@@ -102,21 +104,31 @@ export function hasCompatibleConstructor(className: string, ctx: EmitterContext)
|
|
|
102
104
|
*/
|
|
103
105
|
export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
|
|
104
106
|
const overlayName = resolveServiceName(service, ctx);
|
|
107
|
+
let base: string;
|
|
105
108
|
if (hasCompatibleConstructor(overlayName, ctx)) {
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
109
|
+
base = overlayName;
|
|
110
|
+
} else {
|
|
111
|
+
// Incompatible constructor — fall back to IR name, with `Endpoints` suffix
|
|
112
|
+
// if it would collide with the overlay name.
|
|
113
|
+
const irName = toPascalCase(service.name);
|
|
114
|
+
base = irName === overlayName ? `${irName}Endpoints` : irName;
|
|
115
|
+
}
|
|
116
|
+
// Cross-language `Service` suffix when the chosen class name would shadow
|
|
117
|
+
// an exported model/enum (e.g. tag `OrganizationMembership` + schema
|
|
118
|
+
// `OrganizationMembership`).
|
|
119
|
+
return resolveServiceTarget(base, buildExportedClassNameSet(ctx));
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
export function resolveResourceDir(service: Service, ctx: EmitterContext): string {
|
|
117
123
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
118
124
|
if (resolvedName === 'WebhooksEndpoints') return 'webhooks';
|
|
119
|
-
|
|
125
|
+
// Drop the collision-`Service` suffix when picking the directory so the
|
|
126
|
+
// resource and its model-interfaces share a folder (e.g.
|
|
127
|
+
// `organization-membership/` houses both `OrganizationMembershipService`
|
|
128
|
+
// and `OrganizationMembership`'s interface files).
|
|
129
|
+
const overlayName = resolveServiceName(service, ctx);
|
|
130
|
+
const dirBase = resolvedName === `${overlayName}Service` ? overlayName : resolvedName;
|
|
131
|
+
return resolveServiceDir(dirBase);
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
/** Standard pagination query params handled by PaginationOptions — not imported individually. */
|
package/src/node/tests.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
servicePropertyName,
|
|
12
12
|
resolveMethodName,
|
|
13
13
|
resolveInterfaceName,
|
|
14
|
+
resolveServiceName,
|
|
14
15
|
wireInterfaceName,
|
|
15
16
|
} from './naming.js';
|
|
16
17
|
import { generateFixtures } from './fixtures.js';
|
|
@@ -142,7 +143,9 @@ function generateServiceTest(
|
|
|
142
143
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
143
144
|
const serviceDir = resolveResourceDir(service, ctx);
|
|
144
145
|
const serviceClass = resolvedName;
|
|
145
|
-
|
|
146
|
+
// Accessor stays un-suffixed so `client.organizationMembership` resolves even
|
|
147
|
+
// when the class was renamed to dodge a model-name collision.
|
|
148
|
+
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
|
|
146
149
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
147
150
|
|
|
148
151
|
const plans = service.operations.map((op) => ({
|
|
@@ -838,18 +841,33 @@ function buildTestPayload(
|
|
|
838
841
|
}
|
|
839
842
|
if (!model) return null;
|
|
840
843
|
|
|
841
|
-
const
|
|
844
|
+
const requiredFields = model.fields.filter((f) => f.required);
|
|
842
845
|
// Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
846
|
+
const usableRequired = requiredFields.filter(
|
|
847
|
+
(f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
let chosenFields: typeof model.fields;
|
|
851
|
+
if (requiredFields.length > 0) {
|
|
852
|
+
// Only generate a typed payload when ALL required fields have fixture values.
|
|
853
|
+
// A partial payload missing required fields would fail TypeScript type checking.
|
|
854
|
+
if (usableRequired.length < requiredFields.length) return null;
|
|
855
|
+
chosenFields = usableRequired;
|
|
856
|
+
} else {
|
|
857
|
+
// All-optional model (e.g. PATCH `Update<X>` bodies). Pick the first few
|
|
858
|
+
// optional fields with available fixture values so the test asserts the
|
|
859
|
+
// wire format instead of falling back to `expect(fetchBody()).toBeDefined()`.
|
|
860
|
+
const usableOptional = model.fields.filter(
|
|
861
|
+
(f) => !f.required && fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
|
|
862
|
+
);
|
|
863
|
+
if (usableOptional.length === 0) return null;
|
|
864
|
+
chosenFields = usableOptional.slice(0, 2);
|
|
865
|
+
}
|
|
848
866
|
|
|
849
867
|
const camelEntries: string[] = [];
|
|
850
868
|
const snakeEntries: string[] = [];
|
|
851
869
|
|
|
852
|
-
for (const field of
|
|
870
|
+
for (const field of chosenFields) {
|
|
853
871
|
const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
|
|
854
872
|
const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
|
|
855
873
|
const camelKey = fieldName(field.name);
|
|
@@ -935,6 +953,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
935
953
|
// Use the skipped-serialize set computed by the serializer generator.
|
|
936
954
|
// It's stashed on the context during generateSerializers.
|
|
937
955
|
const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
|
|
956
|
+
// Response-reachable models — anything outside this set is request-only
|
|
957
|
+
// and has no `deserialize<X>` to test. `undefined` means "no usage info,
|
|
958
|
+
// assume deserialize exists" (standalone generation, smoke tests).
|
|
959
|
+
const responseReachableModels: Set<string> | undefined = (ctx as any)._responseReachableModels;
|
|
938
960
|
|
|
939
961
|
// Group eligible models by service directory for one test file per service
|
|
940
962
|
const modelsByDir = new Map<string, Model[]>();
|
|
@@ -958,6 +980,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
958
980
|
const interfaceImports: string[] = [];
|
|
959
981
|
const fixtureImports: string[] = [];
|
|
960
982
|
const deserializeOnlyModels = new Set<string>();
|
|
983
|
+
const serializeOnlyModels = new Set<string>();
|
|
961
984
|
|
|
962
985
|
for (const model of models) {
|
|
963
986
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -966,13 +989,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
966
989
|
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
967
990
|
const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
968
991
|
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
|
|
969
|
-
|
|
992
|
+
// Request-only models (e.g. `CreateWebhookEndpoint`) have no
|
|
993
|
+
// `deserialize<X>` emitted, so they can only be tested via serialize.
|
|
994
|
+
// This check has to come first: a hand-owned fixture would otherwise
|
|
995
|
+
// route through the deserialize-only branch, which then imports a
|
|
996
|
+
// function that doesn't exist.
|
|
997
|
+
const isRequestOnly = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
|
|
998
|
+
const deserializeOnly =
|
|
999
|
+
!isRequestOnly && (serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx));
|
|
1000
|
+
const serializeOnly = isRequestOnly;
|
|
970
1001
|
if (deserializeOnly) deserializeOnlyModels.add(model.name);
|
|
1002
|
+
if (serializeOnly) serializeOnlyModels.add(model.name);
|
|
971
1003
|
|
|
972
1004
|
if (deserializeOnly) {
|
|
973
1005
|
serializerImports.push(
|
|
974
1006
|
`import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
975
1007
|
);
|
|
1008
|
+
} else if (serializeOnly) {
|
|
1009
|
+
serializerImports.push(`import { serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`);
|
|
976
1010
|
} else {
|
|
977
1011
|
serializerImports.push(
|
|
978
1012
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
@@ -1009,6 +1043,15 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
1009
1043
|
lines.push(' expect(deserialized).toBeDefined();');
|
|
1010
1044
|
lines.push(' });');
|
|
1011
1045
|
lines.push('});');
|
|
1046
|
+
} else if (serializeOnlyModels.has(model.name)) {
|
|
1047
|
+
// Serialize-only test for request-body-only models without a deserializer.
|
|
1048
|
+
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
1049
|
+
lines.push(" it('serializes correctly', () => {");
|
|
1050
|
+
lines.push(` const fixture = ${fixtureName} as ${wireName};`);
|
|
1051
|
+
lines.push(` const serialized = serialize${domainName}(fixture as any);`);
|
|
1052
|
+
lines.push(' expect(serialized).toBeDefined();');
|
|
1053
|
+
lines.push(' });');
|
|
1054
|
+
lines.push('});');
|
|
1012
1055
|
} else {
|
|
1013
1056
|
// Round-trip test
|
|
1014
1057
|
lines.push(`describe('${domainName}Serializer', () => {`);
|