@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/src/php/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
3
|
-
import { className, servicePropertyName } from './naming.js';
|
|
3
|
+
import { className, servicePropertyName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
|
|
4
4
|
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
5
|
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
6
6
|
|
|
@@ -61,11 +61,12 @@ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext
|
|
|
61
61
|
|
|
62
62
|
function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
|
|
63
63
|
const seen = new Map<string, { name: string; propName: string }>();
|
|
64
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
64
65
|
for (const service of services) {
|
|
65
66
|
const target = getMountTarget(service, ctx);
|
|
66
67
|
if (!seen.has(target)) {
|
|
67
68
|
seen.set(target, {
|
|
68
|
-
name: className(target),
|
|
69
|
+
name: className(resolveServiceTarget(target, exportedClasses)),
|
|
69
70
|
propName: servicePropertyName(target),
|
|
70
71
|
});
|
|
71
72
|
}
|
package/src/php/naming.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
|
|
4
4
|
import { stripUrnPrefix, applyAcronymFixes } 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
|
/** Namespace grouping result (shared with client.ts). */
|
|
7
11
|
export interface NamespaceGroup {
|
|
@@ -90,6 +94,24 @@ export function className(name: string): string {
|
|
|
90
94
|
return result;
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Build the set of model + enum class names exported by the SDK. Used to
|
|
99
|
+
* detect collisions with operation-client class names — a colliding service
|
|
100
|
+
* gets a `Service` suffix appended.
|
|
101
|
+
*/
|
|
102
|
+
export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
|
|
103
|
+
return buildExportedClassNameSetShared(ctx, className);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a service's mount-target identifier, appending `Service` on
|
|
108
|
+
* collision with an exported model/enum class. Used in `\Service\…` files
|
|
109
|
+
* to avoid `use WorkOS\Resource\X; class X` PHP fatal errors.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
|
|
112
|
+
return resolveServiceTargetShared(target, exportedClasses, className);
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/** PascalCase file name (without extension) — PSR-4 convention. */
|
|
94
116
|
export function fileName(name: string): string {
|
|
95
117
|
return className(name);
|
package/src/php/resources.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
|
|
2
2
|
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
4
|
-
import { className, fieldName, resolveMethodName } from './naming.js';
|
|
4
|
+
import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
|
|
5
5
|
import { isListWrapperModel } from './models.js';
|
|
6
6
|
import {
|
|
7
7
|
groupByMount,
|
|
@@ -43,9 +43,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
43
43
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
44
44
|
: services.map((s) => ({ name: className(s.name), operations: s.operations }));
|
|
45
45
|
|
|
46
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
46
47
|
for (const { name: mountName, operations } of entries) {
|
|
47
48
|
if (operations.length === 0) continue;
|
|
48
|
-
const resourceName = className(mountName);
|
|
49
|
+
const resourceName = className(resolveServiceTarget(mountName, exportedClasses));
|
|
49
50
|
const mergedService: Service = { name: mountName, operations };
|
|
50
51
|
const lines: string[] = [];
|
|
51
52
|
|
package/src/python/client.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
className,
|
|
5
|
+
resolveServiceDir,
|
|
6
|
+
servicePropertyName,
|
|
7
|
+
buildMountDirMap,
|
|
8
|
+
dirToModule,
|
|
9
|
+
buildExportedClassNameSet,
|
|
10
|
+
resolveServiceTarget,
|
|
11
|
+
} from './naming.js';
|
|
4
12
|
import { resolveResourceClassName, collectParameterGroupClassNames } from './resources.js';
|
|
5
13
|
import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
|
|
6
14
|
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
@@ -149,9 +157,10 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
149
157
|
|
|
150
158
|
// Import resource classes (both sync and async)
|
|
151
159
|
const serviceDirMap = buildMountDirMap(ctx);
|
|
160
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
152
161
|
for (const service of topLevelServices) {
|
|
153
162
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
154
|
-
const clsName = className(resolvedName);
|
|
163
|
+
const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
|
|
155
164
|
const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
|
|
156
165
|
const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
|
|
157
166
|
if (importLine.length > 88) {
|
|
@@ -185,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
185
194
|
const generatedProps = new Set<string>();
|
|
186
195
|
for (const service of topLevelServices) {
|
|
187
196
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
188
|
-
const clsName = className(resolvedName);
|
|
197
|
+
const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
|
|
189
198
|
const prop = servicePropertyName(resolvedName);
|
|
190
199
|
const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
191
200
|
lines.push('');
|
|
@@ -208,7 +217,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
208
217
|
const asyncGeneratedProps = new Set<string>();
|
|
209
218
|
for (const service of topLevelServices) {
|
|
210
219
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
211
|
-
const clsName = className(resolvedName);
|
|
220
|
+
const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
|
|
212
221
|
const prop = servicePropertyName(resolvedName);
|
|
213
222
|
const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
214
223
|
lines.push('');
|
|
@@ -255,6 +264,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
255
264
|
const files: GeneratedFile[] = [];
|
|
256
265
|
const topLevel = deduplicateByMount(spec.services, ctx);
|
|
257
266
|
const serviceDirMap = buildMountDirMap(ctx);
|
|
267
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
258
268
|
|
|
259
269
|
// Build a map from mount target -> operations so we can discover parameter
|
|
260
270
|
// group dataclasses that need re-exporting from __init__.py.
|
|
@@ -262,6 +272,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
262
272
|
|
|
263
273
|
for (const service of topLevel) {
|
|
264
274
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
275
|
+
const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
|
|
265
276
|
const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
|
|
266
277
|
const lines: string[] = [];
|
|
267
278
|
|
|
@@ -274,7 +285,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
274
285
|
// public re-exports. Otherwise consumers importing
|
|
275
286
|
// `from workos.user_management import RoleSingle` get a private-import
|
|
276
287
|
// warning under strict mode. Models barrel uses the same convention.
|
|
277
|
-
const resourceImports = [
|
|
288
|
+
const resourceImports = [clsName, `Async${clsName}`, ...groupClassNames];
|
|
278
289
|
const aliasedImports = resourceImports.map((n) => `${n} as ${n}`);
|
|
279
290
|
lines.push(`from ._resource import ${aliasedImports.join(', ')}`);
|
|
280
291
|
lines.push('from .models import *');
|
package/src/python/naming.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
4
|
import { stripUrnPrefix, applyAcronymFixes } 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
|
/**
|
|
7
11
|
* Python class names that collide with builtins or typing imports.
|
|
@@ -136,6 +140,27 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
|
|
|
136
140
|
return toSnakeCase(op.name);
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Build the set of model + enum class names exported by the SDK. Used to
|
|
145
|
+
* detect collisions with operation-client class names — a colliding service
|
|
146
|
+
* gets a `Service` suffix appended.
|
|
147
|
+
*/
|
|
148
|
+
export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
|
|
149
|
+
return buildExportedClassNameSetShared(ctx, className);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a service's mount-target identifier, appending `Service` on
|
|
154
|
+
* collision with an exported model/enum class. Feeds `className`/`fileName`
|
|
155
|
+
* so the class declaration, file, and any qualified references stay aligned.
|
|
156
|
+
*
|
|
157
|
+
* Accessor names (`servicePropertyName`) intentionally use the RAW target —
|
|
158
|
+
* `client.organization_membership` reads better than the suffixed form.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
|
|
161
|
+
return resolveServiceTargetShared(target, exportedClasses, className);
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
/** Resolve the SDK class name for a service, using resolved operations' mountOn. */
|
|
140
165
|
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
141
166
|
// Use resolved ops mountOn as canonical class name (flat pattern like PHP)
|
package/src/python/resources.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
buildMountDirMap,
|
|
24
24
|
dirToModule,
|
|
25
25
|
relativeImportPrefix,
|
|
26
|
+
buildExportedClassNameSet,
|
|
27
|
+
resolveServiceTarget,
|
|
26
28
|
} from './naming.js';
|
|
27
29
|
import {
|
|
28
30
|
buildResolvedLookup,
|
|
@@ -1012,10 +1014,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1012
1014
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
1013
1015
|
: services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
|
|
1014
1016
|
|
|
1017
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
1015
1018
|
for (const { name: mountName, operations: allOperations } of entries) {
|
|
1016
1019
|
if (allOperations.length === 0) continue;
|
|
1017
1020
|
const dirName = moduleName(mountName);
|
|
1018
|
-
const resourceClassName = className(mountName);
|
|
1021
|
+
const resourceClassName = className(resolveServiceTarget(mountName, exportedClasses));
|
|
1019
1022
|
const importPrefix = relativeImportPrefix(dirName);
|
|
1020
1023
|
|
|
1021
1024
|
const lines: string[] = [];
|
package/src/ruby/client.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Service, Model, Enum } from '@workos/oagen';
|
|
2
2
|
import { assignModelsToServices } from '@workos/oagen';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
servicePropertyName,
|
|
5
|
+
resolveClassName,
|
|
6
|
+
className,
|
|
7
|
+
fileName,
|
|
8
|
+
buildMountDirMap,
|
|
9
|
+
buildExportedClassNameSet,
|
|
10
|
+
resolveServiceTarget,
|
|
11
|
+
} from './naming.js';
|
|
4
12
|
import { classifyUnassignedModel } from './models.js';
|
|
5
13
|
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
6
14
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
@@ -89,8 +97,12 @@ function buildInflectionMap(spec: ApiSpec, ctx: EmitterContext): Map<string, str
|
|
|
89
97
|
|
|
90
98
|
inflections.set('workos', 'WorkOS');
|
|
91
99
|
|
|
100
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
92
101
|
for (const service of buildTopLevelServices(spec, ctx)) {
|
|
93
|
-
const target =
|
|
102
|
+
const target = resolveServiceTarget(
|
|
103
|
+
getMountTarget(service, ctx) || resolveClassName(service, ctx),
|
|
104
|
+
exportedClasses,
|
|
105
|
+
);
|
|
94
106
|
const cls = className(target);
|
|
95
107
|
const file = fileName(target);
|
|
96
108
|
if (rubyCamelize(file) !== cls) inflections.set(file, cls);
|
|
@@ -203,10 +215,11 @@ function generateClientClass(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
203
215
|
lines.push(' class Client < BaseClient');
|
|
204
216
|
|
|
205
217
|
const topLevelServices = buildTopLevelServices(spec, ctx);
|
|
218
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
206
219
|
for (const service of topLevelServices) {
|
|
207
|
-
const
|
|
208
|
-
const cls = className(
|
|
209
|
-
const prop = servicePropertyName(
|
|
220
|
+
const rawTarget = getMountTarget(service, ctx) || resolveClassName(service, ctx);
|
|
221
|
+
const cls = className(resolveServiceTarget(rawTarget, exportedClasses));
|
|
222
|
+
const prop = servicePropertyName(rawTarget);
|
|
210
223
|
lines.push('');
|
|
211
224
|
lines.push(` def ${prop}`);
|
|
212
225
|
lines.push(` @${prop} ||= WorkOS::${cls}.new(self)`);
|
package/src/ruby/manifest.ts
CHANGED
|
@@ -7,9 +7,14 @@ import { servicePropertyName } from './naming.js';
|
|
|
7
7
|
* Uses each resolved operation's actual mountOn (not the service default) so
|
|
8
8
|
* operations remounted via operationHints land on the correct service prop.
|
|
9
9
|
* Split operations emit one entry per wrapper (keyed by wrapper name + variant).
|
|
10
|
+
*
|
|
11
|
+
* The accessor (`service` field) uses the raw mountOn — accessor names stay
|
|
12
|
+
* unsuffixed even when the underlying service class gets a `Service` suffix
|
|
13
|
+
* on collision.
|
|
10
14
|
*/
|
|
11
15
|
export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
|
|
12
16
|
void spec;
|
|
17
|
+
void ctx;
|
|
13
18
|
const manifest: OperationsMap = {};
|
|
14
19
|
|
|
15
20
|
for (const r of ctx.resolvedOperations ?? []) {
|
package/src/ruby/naming.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
4
|
import { stripUrnPrefix, applyAcronymFixes } 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
|
/**
|
|
7
11
|
* Ruby class names that collide with core classes. When a model name resolves
|
|
@@ -117,6 +121,32 @@ export function moduleName(name: string): string {
|
|
|
117
121
|
return toSnakeCase(name);
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Build the set of model + enum Ruby class names that the SDK exposes under
|
|
126
|
+
* `WorkOS::`. Used to detect collisions with operation-client class names —
|
|
127
|
+
* a colliding service gets a `Service` suffix (`OrganizationMembershipService`)
|
|
128
|
+
* so it doesn't shadow the model class under Zeitwerk's collapsed namespace.
|
|
129
|
+
*/
|
|
130
|
+
export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
|
|
131
|
+
return buildExportedClassNameSetShared(ctx, className);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a service's mount-target identifier, appending `Service` on
|
|
136
|
+
* collision with an exported model/enum class name. The returned PascalCase
|
|
137
|
+
* value feeds `className`/`fileName` to derive matching class + file names
|
|
138
|
+
* (e.g. `OrganizationMembershipService` / `organization_membership_service`).
|
|
139
|
+
*
|
|
140
|
+
* Accessor names (`servicePropertyName`) intentionally use the RAW target —
|
|
141
|
+
* `client.organization_membership` is more readable than the suffixed form.
|
|
142
|
+
*
|
|
143
|
+
* The directory used by `loader.collapse` (the model home) likewise uses the
|
|
144
|
+
* raw target.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
|
|
147
|
+
return resolveServiceTargetShared(target, exportedClasses, className);
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
/**
|
|
121
151
|
* PascalCase class name for a parameter-group variant. Mirrors the Python
|
|
122
152
|
* convention: group "password" + variant "plaintext" → `PasswordPlaintext`.
|
package/src/ruby/rbi.ts
CHANGED
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
safeParamName,
|
|
8
8
|
scopedGroupVariantClassName,
|
|
9
9
|
resolveMethodName,
|
|
10
|
+
servicePropertyName,
|
|
11
|
+
buildExportedClassNameSet,
|
|
12
|
+
resolveServiceTarget,
|
|
10
13
|
} from './naming.js';
|
|
11
14
|
import {
|
|
12
15
|
buildResolvedLookup,
|
|
@@ -129,9 +132,11 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
129
132
|
if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
|
|
130
133
|
}
|
|
131
134
|
const groupOwners = buildGroupOwnerMap(ctx);
|
|
135
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
132
136
|
|
|
133
137
|
for (const [mountTarget, group] of groups) {
|
|
134
|
-
const
|
|
138
|
+
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
139
|
+
const cls = className(resolvedTarget);
|
|
135
140
|
const lines: string[] = [];
|
|
136
141
|
lines.push('# typed: strong');
|
|
137
142
|
lines.push('');
|
|
@@ -143,6 +148,9 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
143
148
|
// layout in lib/workos/<service>.rb.
|
|
144
149
|
const variants = collectVariantsForMountTarget(ctx, spec.models as Model[], mountTarget);
|
|
145
150
|
for (const v of variants) {
|
|
151
|
+
// Rewrite mountTarget to the (possibly pluralized) service class so the
|
|
152
|
+
// RBI's `WorkOS::<Service>::<Variant>` reference matches the runtime.
|
|
153
|
+
v.mountTarget = resolvedTarget;
|
|
146
154
|
for (const line of emitInlineVariantRbi(v)) lines.push(line);
|
|
147
155
|
lines.push('');
|
|
148
156
|
}
|
|
@@ -183,7 +191,8 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
183
191
|
if (!owner) {
|
|
184
192
|
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
185
193
|
}
|
|
186
|
-
const
|
|
194
|
+
const resolvedOwner = resolveServiceTarget(owner, exportedClasses);
|
|
195
|
+
const variants = group.variants.map((v) => scopedGroupVariantClassName(resolvedOwner, group.name, v.name));
|
|
187
196
|
if (variants.length === 1) return variants[0];
|
|
188
197
|
return `T.any(${variants.join(', ')})`;
|
|
189
198
|
};
|
|
@@ -267,7 +276,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
267
276
|
lines.push('end');
|
|
268
277
|
|
|
269
278
|
files.push({
|
|
270
|
-
path: `rbi/workos/${fileName(
|
|
279
|
+
path: `rbi/workos/${fileName(resolvedTarget)}.rbi`,
|
|
271
280
|
content: lines.join('\n'),
|
|
272
281
|
integrateTarget: true,
|
|
273
282
|
overwriteExisting: true,
|
|
@@ -283,11 +292,9 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
283
292
|
lines.push(' class Client < BaseClient');
|
|
284
293
|
|
|
285
294
|
for (const [mountTarget] of groups) {
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`)
|
|
290
|
-
.replace(/^_/, '');
|
|
295
|
+
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
296
|
+
const cls = className(resolvedTarget);
|
|
297
|
+
const prop = servicePropertyName(mountTarget);
|
|
291
298
|
lines.push(` sig { returns(WorkOS::${cls}) }`);
|
|
292
299
|
lines.push(` def ${prop}; end`);
|
|
293
300
|
lines.push('');
|
package/src/ruby/resources.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
safeParamName,
|
|
9
9
|
resolveMethodName,
|
|
10
10
|
scopedGroupVariantClassName,
|
|
11
|
+
buildExportedClassNameSet,
|
|
12
|
+
resolveServiceTarget,
|
|
11
13
|
} from './naming.js';
|
|
12
14
|
import { mapTypeRefForYard } from './type-map.js';
|
|
13
15
|
import {
|
|
@@ -48,10 +50,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
48
50
|
// dispatcher and YARD `@param` reference resolves variant classes through
|
|
49
51
|
// this map so cross-resource references stay consistent.
|
|
50
52
|
const groupOwners = buildGroupOwnerMap(ctx);
|
|
53
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
51
54
|
|
|
52
55
|
for (const [mountTarget, group] of groups) {
|
|
53
|
-
const
|
|
54
|
-
const
|
|
56
|
+
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
57
|
+
const cls = className(resolvedTarget);
|
|
58
|
+
const file = fileName(resolvedTarget);
|
|
55
59
|
|
|
56
60
|
const operations = group.operations;
|
|
57
61
|
if (operations.length === 0) continue;
|
|
@@ -103,6 +107,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
103
107
|
listWrapperModels,
|
|
104
108
|
requires,
|
|
105
109
|
groupOwners,
|
|
110
|
+
exportedClasses,
|
|
106
111
|
});
|
|
107
112
|
methodBodies.push(body);
|
|
108
113
|
|
|
@@ -182,6 +187,7 @@ function emitMethod(args: {
|
|
|
182
187
|
listWrapperModels: Map<string, Model>;
|
|
183
188
|
requires: Set<string>;
|
|
184
189
|
groupOwners: Map<string, string>;
|
|
190
|
+
exportedClasses: Set<string>;
|
|
185
191
|
}): string {
|
|
186
192
|
const {
|
|
187
193
|
op,
|
|
@@ -195,6 +201,7 @@ function emitMethod(args: {
|
|
|
195
201
|
listWrapperModels,
|
|
196
202
|
requires,
|
|
197
203
|
groupOwners,
|
|
204
|
+
exportedClasses,
|
|
198
205
|
} = args;
|
|
199
206
|
void enumNames;
|
|
200
207
|
|
|
@@ -204,7 +211,7 @@ function emitMethod(args: {
|
|
|
204
211
|
if (!owner) {
|
|
205
212
|
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
206
213
|
}
|
|
207
|
-
return scopedGroupVariantClassName(owner, group.name, variantName);
|
|
214
|
+
return scopedGroupVariantClassName(resolveServiceTarget(owner, exportedClasses), group.name, variantName);
|
|
208
215
|
};
|
|
209
216
|
|
|
210
217
|
const plan = planOperation(op);
|
package/src/ruby/tests.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
scopedGroupVariantClassName,
|
|
8
8
|
servicePropertyName,
|
|
9
9
|
resolveMethodName,
|
|
10
|
+
buildExportedClassNameSet,
|
|
11
|
+
resolveServiceTarget,
|
|
10
12
|
} from './naming.js';
|
|
11
13
|
import {
|
|
12
14
|
buildResolvedLookup,
|
|
@@ -38,11 +40,13 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
38
40
|
|
|
39
41
|
const lookup = buildResolvedLookup(ctx);
|
|
40
42
|
const groupOwners = buildGroupOwnerMap(ctx);
|
|
43
|
+
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
41
44
|
|
|
42
45
|
for (const [mountTarget, group] of groups) {
|
|
43
|
-
const
|
|
46
|
+
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
47
|
+
const cls = className(resolvedTarget);
|
|
44
48
|
const prop = servicePropertyName(mountTarget);
|
|
45
|
-
const file = fileName(
|
|
49
|
+
const file = fileName(resolvedTarget);
|
|
46
50
|
|
|
47
51
|
const lines: string[] = [];
|
|
48
52
|
lines.push(`require 'test_helper'`);
|
|
@@ -85,7 +89,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
85
89
|
|
|
86
90
|
const resolved = lookupResolved(op, lookup);
|
|
87
91
|
const hiddenParams = buildHiddenParams(resolved);
|
|
88
|
-
const callArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models);
|
|
92
|
+
const callArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models, exportedClasses);
|
|
89
93
|
const bodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models);
|
|
90
94
|
|
|
91
95
|
// Collect method info for the parameterized 401 test (T20).
|
|
@@ -118,7 +122,15 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
118
122
|
for (let vi = 1; vi < group.variants.length; vi++) {
|
|
119
123
|
const variant = group.variants[vi];
|
|
120
124
|
const overrides = new Map<string, number>([[group.name, vi]]);
|
|
121
|
-
const variantCallArgs = buildCallArgsStub(
|
|
125
|
+
const variantCallArgs = buildCallArgsStub(
|
|
126
|
+
op,
|
|
127
|
+
modelByName,
|
|
128
|
+
hiddenParams,
|
|
129
|
+
groupOwners,
|
|
130
|
+
models,
|
|
131
|
+
exportedClasses,
|
|
132
|
+
overrides,
|
|
133
|
+
);
|
|
122
134
|
const variantBodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models, overrides);
|
|
123
135
|
const suffix = `with_${fieldName(group.name)}_${fieldName(variant.name)}`;
|
|
124
136
|
lines.push('');
|
|
@@ -338,6 +350,7 @@ function buildCallArgsStub(
|
|
|
338
350
|
hiddenParams: Set<string>,
|
|
339
351
|
groupOwners: Map<string, string>,
|
|
340
352
|
models: Model[],
|
|
353
|
+
exportedClasses: Set<string>,
|
|
341
354
|
variantOverrides: Map<string, number> = new Map(),
|
|
342
355
|
): string {
|
|
343
356
|
const parts: string[] = [];
|
|
@@ -406,7 +419,11 @@ function buildCallArgsStub(
|
|
|
406
419
|
if (!owner) {
|
|
407
420
|
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
408
421
|
}
|
|
409
|
-
const variantClass = scopedGroupVariantClassName(
|
|
422
|
+
const variantClass = scopedGroupVariantClassName(
|
|
423
|
+
resolveServiceTarget(owner, exportedClasses),
|
|
424
|
+
group.name,
|
|
425
|
+
variant.name,
|
|
426
|
+
);
|
|
410
427
|
const fieldStubs = variant.parameters
|
|
411
428
|
.map((p) => `${fieldName(p.name)}: ${stubValueFor(pickVariantParamType(p.type, bodyFieldTypes.get(p.name)))}`)
|
|
412
429
|
.join(', ');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EmitterContext, Model, Enum } from '@workos/oagen';
|
|
2
|
+
import { isListWrapperModel, isListMetadataModel } from './model-utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Suffix applied to an operation-client class name when it collides with an
|
|
6
|
+
* exported model/enum class name in the same SDK namespace. Standardized
|
|
7
|
+
* across emitters so colliding services look the same in every language —
|
|
8
|
+
* e.g. `OrganizationMembershipService` regardless of language.
|
|
9
|
+
*
|
|
10
|
+
* Languages whose operation clients already carry a unique suffix (Go's
|
|
11
|
+
* `…Service`, Rust's `…Api`, .NET's `…Service`) skip this helper entirely.
|
|
12
|
+
*/
|
|
13
|
+
export const SERVICE_COLLISION_SUFFIX = 'Service';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the set of model + enum class names that the SDK exports under its
|
|
17
|
+
* top-level namespace. Each emitter passes its own `classNameFn` so the
|
|
18
|
+
* comparison happens on the language-specific class-name form (e.g. Ruby's
|
|
19
|
+
* `RoleList`, Python's `RoleList`).
|
|
20
|
+
*
|
|
21
|
+
* List-wrapper and list-metadata models are excluded — they aren't exposed
|
|
22
|
+
* as user-facing types.
|
|
23
|
+
*/
|
|
24
|
+
export function buildExportedClassNameSet(ctx: EmitterContext, classNameFn: (name: string) => string): Set<string> {
|
|
25
|
+
const out = new Set<string>();
|
|
26
|
+
for (const model of ctx.spec.models as Model[]) {
|
|
27
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
28
|
+
out.add(classNameFn(model.name));
|
|
29
|
+
}
|
|
30
|
+
for (const enumDef of ctx.spec.enums as Enum[]) {
|
|
31
|
+
out.add(classNameFn(enumDef.name));
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the PascalCase mount-target identifier for an operation client,
|
|
38
|
+
* appending `Service` when the un-suffixed class name would shadow an
|
|
39
|
+
* exported model or enum.
|
|
40
|
+
*
|
|
41
|
+
* Operates on the PascalCase target (the mount-target string the IR carries),
|
|
42
|
+
* so the returned value feeds cleanly into each language's `className` and
|
|
43
|
+
* `fileName` helpers — e.g. `OrganizationMembership` → `OrganizationMembershipService`,
|
|
44
|
+
* then `fileName` → `organization_membership_service` / `organization-membership-service`.
|
|
45
|
+
*
|
|
46
|
+
* The accessor on the client (`client.organization_membership`) is intentionally
|
|
47
|
+
* NOT suffixed — callers should keep using the raw target for `servicePropertyName`
|
|
48
|
+
* so the accessor reads naturally.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveServiceTarget(
|
|
51
|
+
target: string,
|
|
52
|
+
exportedClasses: Set<string>,
|
|
53
|
+
classNameFn: (name: string) => string,
|
|
54
|
+
): string {
|
|
55
|
+
return exportedClasses.has(classNameFn(target)) ? `${target}${SERVICE_COLLISION_SUFFIX}` : target;
|
|
56
|
+
}
|
package/test/node/models.test.ts
CHANGED
|
@@ -590,4 +590,60 @@ describe('generateSerializers', () => {
|
|
|
590
590
|
expect(barrel!.overwriteExisting).toBe(true);
|
|
591
591
|
}
|
|
592
592
|
});
|
|
593
|
+
|
|
594
|
+
it('omits deserialize half for request-body-only models', () => {
|
|
595
|
+
// `CreateOrganization` is sent as a POST body but the operation responds
|
|
596
|
+
// with a separate `Organization` model. The deserializer for the request
|
|
597
|
+
// model would be dead code AND would silently misbehave if called (the
|
|
598
|
+
// response wire shape doesn't match), so it shouldn't be emitted.
|
|
599
|
+
const models: Model[] = [
|
|
600
|
+
{
|
|
601
|
+
name: 'Organization',
|
|
602
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
name: 'CreateOrganization',
|
|
606
|
+
fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
607
|
+
},
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
const spec: ApiSpec = {
|
|
611
|
+
...emptySpec,
|
|
612
|
+
models,
|
|
613
|
+
services: [
|
|
614
|
+
{
|
|
615
|
+
name: 'Organizations',
|
|
616
|
+
operations: [
|
|
617
|
+
{
|
|
618
|
+
name: 'createOrganization',
|
|
619
|
+
httpMethod: 'post',
|
|
620
|
+
path: '/organizations',
|
|
621
|
+
pathParams: [],
|
|
622
|
+
queryParams: [],
|
|
623
|
+
headerParams: [],
|
|
624
|
+
response: { kind: 'model', name: 'Organization' },
|
|
625
|
+
requestBody: { kind: 'model', name: 'CreateOrganization' },
|
|
626
|
+
errors: [],
|
|
627
|
+
injectIdempotencyKey: false,
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
635
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
636
|
+
|
|
637
|
+
const createSerializer = result.find((f) => f.path.endsWith('create-organization.serializer.ts'));
|
|
638
|
+
expect(createSerializer).toBeDefined();
|
|
639
|
+
expect(createSerializer!.content).toContain('export const serializeCreateOrganization');
|
|
640
|
+
expect(createSerializer!.content).not.toContain('export const deserializeCreateOrganization');
|
|
641
|
+
|
|
642
|
+
// Response-side model still gets both halves.
|
|
643
|
+
const orgSerializer = result.find(
|
|
644
|
+
(f) => f.path.endsWith('organization.serializer.ts') && !f.path.includes('create'),
|
|
645
|
+
);
|
|
646
|
+
expect(orgSerializer).toBeDefined();
|
|
647
|
+
expect(orgSerializer!.content).toContain('export const deserializeOrganization');
|
|
648
|
+
});
|
|
593
649
|
});
|