@workos/oagen-emitters 0.6.7 → 0.7.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/README.md +1 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-Bk0xWTQC.mjs → plugin-Bp46oZIh.mjs} +397 -52
- package/dist/plugin-Bp46oZIh.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/node/tests.ts +1 -1
- package/src/node/utils.ts +1 -1
- package/src/python/models.ts +1 -1
- package/src/python/resources.ts +1 -1
- package/src/python/tests.ts +2 -2
- package/src/ruby/client.ts +1 -1
- package/src/ruby/index.ts +4 -2
- package/src/ruby/models.ts +1 -1
- package/src/ruby/naming.ts +23 -0
- package/src/ruby/parameter-groups.ts +221 -0
- package/src/ruby/rbi.ts +52 -3
- package/src/ruby/resources.ts +118 -19
- package/src/ruby/tests.ts +161 -18
- package/dist/plugin-Bk0xWTQC.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-Bp46oZIh.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.7.0",
|
|
4
4
|
"description": "WorkOS' oagen emitters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "WorkOS",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"node": ">=24.10.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@workos/oagen": "^0.
|
|
57
|
+
"@workos/oagen": "^0.12.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/node/tests.ts
CHANGED
|
@@ -854,7 +854,7 @@ function modelNeedsRoundTripTest(model: Model): boolean {
|
|
|
854
854
|
*/
|
|
855
855
|
function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
856
856
|
const files: GeneratedFile[] = [];
|
|
857
|
-
const modelToService = assignModelsToServices(spec.models, spec.services);
|
|
857
|
+
const modelToService = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
|
|
858
858
|
const serviceNameMap = new Map<string, string>();
|
|
859
859
|
for (const service of spec.services) {
|
|
860
860
|
serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
|
package/src/node/utils.ts
CHANGED
|
@@ -223,7 +223,7 @@ export function createServiceDirResolver(
|
|
|
223
223
|
serviceNameMap: Map<string, string>;
|
|
224
224
|
resolveDir: (irService: string | undefined) => string;
|
|
225
225
|
} {
|
|
226
|
-
const modelToService = assignModelsToServices(models, services);
|
|
226
|
+
const modelToService = assignModelsToServices(models, services, ctx.modelHints);
|
|
227
227
|
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
228
228
|
const resolveDir = (irService: string | undefined) =>
|
|
229
229
|
irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
|
package/src/python/models.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { assignEnumsToServices, collectGeneratedEnumSymbolsByDir } from './enums
|
|
|
11
11
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
12
12
|
if (models.length === 0) return [];
|
|
13
13
|
|
|
14
|
-
const modelToService = assignModelsToServices(models, ctx.spec.services);
|
|
14
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
|
|
15
15
|
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
16
16
|
const mountDirMap = buildMountDirMap(ctx);
|
|
17
17
|
const resolveDir = (irService: string | undefined) =>
|
package/src/python/resources.ts
CHANGED
|
@@ -1116,7 +1116,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1116
1116
|
const actualModelImports = [...modelImports];
|
|
1117
1117
|
|
|
1118
1118
|
// Split imports into same-service and cross-service (using mount-based dirs)
|
|
1119
|
-
const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services);
|
|
1119
|
+
const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services, ctx.modelHints);
|
|
1120
1120
|
// Discriminator variant type aliases (e.g. EventSchemaVariant) live in the same
|
|
1121
1121
|
// service as their dispatcher model, so ensure they resolve to the same directory.
|
|
1122
1122
|
for (const model of ctx.spec.models) {
|
package/src/python/tests.ts
CHANGED
|
@@ -227,7 +227,7 @@ function generateServiceTest(
|
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
// Group imports by their actual service directory (models may live in different services)
|
|
230
|
-
const modelToServiceMap = assignModelsToServices(spec.models, spec.services);
|
|
230
|
+
const modelToServiceMap = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
|
|
231
231
|
const mountDirMap = buildMountDirMap(ctx);
|
|
232
232
|
const resolveModelDir = (modelName: string) => {
|
|
233
233
|
const svc = modelToServiceMap.get(modelName);
|
|
@@ -1402,7 +1402,7 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1402
1402
|
);
|
|
1403
1403
|
if (models.length === 0) return null;
|
|
1404
1404
|
|
|
1405
|
-
const modelToService = assignModelsToServices(spec.models, spec.services);
|
|
1405
|
+
const modelToService = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
|
|
1406
1406
|
const roundTripDirMap = buildMountDirMap(ctx);
|
|
1407
1407
|
const resolveDir = (irService: string | undefined) =>
|
|
1408
1408
|
irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
|
package/src/ruby/client.ts
CHANGED
|
@@ -182,7 +182,7 @@ function generateMainEntryFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
|
|
|
182
182
|
* keep the generated namespace flat while the filesystem is grouped.
|
|
183
183
|
*/
|
|
184
184
|
function collectModelSubdirs(spec: ApiSpec, ctx: EmitterContext): string[] {
|
|
185
|
-
const modelToService = assignModelsToServices(spec.models as Model[], spec.services);
|
|
185
|
+
const modelToService = assignModelsToServices(spec.models as Model[], spec.services, ctx.modelHints);
|
|
186
186
|
const mountDirMap = buildMountDirMap(ctx);
|
|
187
187
|
const subdirs = new Set<string>();
|
|
188
188
|
for (const model of spec.models as Model[]) {
|
package/src/ruby/index.ts
CHANGED
|
@@ -30,7 +30,8 @@ export const rubyEmitter: Emitter = {
|
|
|
30
30
|
language: 'ruby',
|
|
31
31
|
|
|
32
32
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
33
|
-
|
|
33
|
+
const modelFiles = generateModels(models, ctx);
|
|
34
|
+
return ensureTrailingNewlines(modelFiles);
|
|
34
35
|
},
|
|
35
36
|
|
|
36
37
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -54,7 +55,8 @@ export const rubyEmitter: Emitter = {
|
|
|
54
55
|
},
|
|
55
56
|
|
|
56
57
|
generateTypeSignatures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
57
|
-
|
|
58
|
+
const rbiFiles = generateRbiFiles(spec, ctx);
|
|
59
|
+
return ensureTrailingNewlines(rbiFiles);
|
|
58
60
|
},
|
|
59
61
|
|
|
60
62
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
package/src/ruby/models.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
67
67
|
// Model → mount target directory. Each model is assigned to the first service
|
|
68
68
|
// that references it (transitively). Orphans land in `shared/`. Zeitwerk is
|
|
69
69
|
// told to collapse each subfolder (in client.ts) so the namespace stays flat.
|
|
70
|
-
const modelToService = assignModelsToServices(models, ctx.spec.services);
|
|
70
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
|
|
71
71
|
const mountDirMap = buildMountDirMap(ctx);
|
|
72
72
|
const dirFor = (modelName: string): string => {
|
|
73
73
|
const service = modelToService.get(modelName);
|
package/src/ruby/naming.ts
CHANGED
|
@@ -117,6 +117,29 @@ export function moduleName(name: string): string {
|
|
|
117
117
|
return toSnakeCase(name);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* PascalCase class name for a parameter-group variant. Mirrors the Python
|
|
122
|
+
* convention: group "password" + variant "plaintext" → `PasswordPlaintext`.
|
|
123
|
+
* Used as the Ruby constant under the WorkOS module.
|
|
124
|
+
*/
|
|
125
|
+
export function groupVariantClassName(groupName: string, variantName: string): string {
|
|
126
|
+
return className(`${groupName}_${variantName}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** snake_case Zeitwerk-compatible filename for a parameter-group variant. */
|
|
130
|
+
export function groupVariantFileName(groupName: string, variantName: string): string {
|
|
131
|
+
return fileName(`${groupName}_${variantName}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fully-qualified Ruby constant for a parameter-group variant scoped under
|
|
136
|
+
* its owning resource module — e.g. "WorkOS::UserManagement::PasswordPlaintext".
|
|
137
|
+
* Mirrors Python's `workos.user_management.PasswordPlaintext` namespacing.
|
|
138
|
+
*/
|
|
139
|
+
export function scopedGroupVariantClassName(mountTarget: string, groupName: string, variantName: string): string {
|
|
140
|
+
return `WorkOS::${className(mountTarget)}::${groupVariantClassName(groupName, variantName)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
/** snake_case property name for service accessors on the client. */
|
|
121
144
|
export function servicePropertyName(name: string): string {
|
|
122
145
|
return toSnakeCase(name);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { EmitterContext, TypeRef, Model } from '@workos/oagen';
|
|
2
|
+
import { className, fieldName, groupVariantClassName } from './naming.js';
|
|
3
|
+
import { mapTypeRefForYard } from './type-map.js';
|
|
4
|
+
import { collectBodyFieldTypes, groupByMount } from '../shared/resolved-ops.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sorbet type string for a TypeRef. Mirrors `mapSorbetType` in rbi.ts but
|
|
8
|
+
* lives here so the parameter-groups module is self-contained.
|
|
9
|
+
*/
|
|
10
|
+
function mapSorbetType(ref: TypeRef): string {
|
|
11
|
+
switch (ref.kind) {
|
|
12
|
+
case 'primitive':
|
|
13
|
+
switch (ref.type) {
|
|
14
|
+
case 'string':
|
|
15
|
+
return 'String';
|
|
16
|
+
case 'integer':
|
|
17
|
+
return 'Integer';
|
|
18
|
+
case 'number':
|
|
19
|
+
return 'Float';
|
|
20
|
+
case 'boolean':
|
|
21
|
+
return 'T::Boolean';
|
|
22
|
+
case 'unknown':
|
|
23
|
+
return 'T.untyped';
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
case 'array':
|
|
27
|
+
return `T::Array[${mapSorbetType(ref.items)}]`;
|
|
28
|
+
case 'model':
|
|
29
|
+
return `WorkOS::${className(ref.name)}`;
|
|
30
|
+
case 'enum':
|
|
31
|
+
return 'String';
|
|
32
|
+
case 'nullable':
|
|
33
|
+
return `T.nilable(${mapSorbetType(ref.inner)})`;
|
|
34
|
+
case 'literal':
|
|
35
|
+
if (typeof ref.value === 'string') return 'String';
|
|
36
|
+
if (ref.value === null) return 'NilClass';
|
|
37
|
+
if (typeof ref.value === 'number') return Number.isInteger(ref.value) ? 'Integer' : 'Float';
|
|
38
|
+
return 'T::Boolean';
|
|
39
|
+
case 'union': {
|
|
40
|
+
const variants = ref.variants.map((v) => mapSorbetType(v));
|
|
41
|
+
const unique = [...new Set(variants)];
|
|
42
|
+
if (unique.length === 1) return unique[0];
|
|
43
|
+
return `T.any(${unique.join(', ')})`;
|
|
44
|
+
}
|
|
45
|
+
case 'map':
|
|
46
|
+
return `T::Hash[String, ${mapSorbetType(ref.valueType)}]`;
|
|
47
|
+
}
|
|
48
|
+
return 'T.untyped';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CollectedVariant {
|
|
52
|
+
className: string;
|
|
53
|
+
groupName: string;
|
|
54
|
+
variantName: string;
|
|
55
|
+
/** PascalCase mount target this variant is scoped under (e.g. "UserManagement"). */
|
|
56
|
+
mountTarget: string;
|
|
57
|
+
parameters: { name: string; type: TypeRef }[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a stable groupName -> mountTarget map. Each parameter group is owned
|
|
62
|
+
* by exactly one resource module — Ruby variant classes are inlined into
|
|
63
|
+
* `WorkOS::<MountTarget>::<Variant>` (matching Python's per-resource layout),
|
|
64
|
+
* so a dispatcher in another resource that references the same group still
|
|
65
|
+
* resolves to a single canonical class.
|
|
66
|
+
*
|
|
67
|
+
* Mount targets are visited in alphabetical order so first-wins is
|
|
68
|
+
* deterministic across runs. In the current spec no group is shared across
|
|
69
|
+
* mount targets; if one ever is, the alphabetically-first owner gets the
|
|
70
|
+
* class and other dispatchers reference it by full path.
|
|
71
|
+
*/
|
|
72
|
+
export function buildGroupOwnerMap(ctx: EmitterContext): Map<string, string> {
|
|
73
|
+
const owner = new Map<string, string>();
|
|
74
|
+
const groups = groupByMount(ctx);
|
|
75
|
+
const sortedTargets = [...groups.keys()].sort();
|
|
76
|
+
for (const target of sortedTargets) {
|
|
77
|
+
const g = groups.get(target);
|
|
78
|
+
if (!g) continue;
|
|
79
|
+
for (const op of g.operations) {
|
|
80
|
+
for (const grp of op.parameterGroups ?? []) {
|
|
81
|
+
if (!owner.has(grp.name)) owner.set(grp.name, target);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return owner;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect all variant classes a given mount target owns. Variants are
|
|
90
|
+
* inlined into the resource file (and its RBI counterpart) — Zeitwerk's
|
|
91
|
+
* collapse convention means subdirectories under `lib/workos/<service>/`
|
|
92
|
+
* don't add a namespace level, so files there can't define
|
|
93
|
+
* `WorkOS::<Service>::<Variant>`. Inline definitions sidestep that.
|
|
94
|
+
*
|
|
95
|
+
* Variant parameter types are taken from the IR's leaf type. When the IR's
|
|
96
|
+
* leaf is a bare primitive but the request body model has a richer type
|
|
97
|
+
* (array/enum/model/map), we fall back to the body type to recover fidelity
|
|
98
|
+
* the IR drops. Body nullability is stripped — when a parameter group is
|
|
99
|
+
* optional, the body field for the group becomes nullable, but within a
|
|
100
|
+
* variant the leaf is always required (selecting the variant means passing it).
|
|
101
|
+
*/
|
|
102
|
+
export function collectVariantsForMountTarget(
|
|
103
|
+
ctx: EmitterContext,
|
|
104
|
+
models: Model[],
|
|
105
|
+
mountTarget: string,
|
|
106
|
+
): CollectedVariant[] {
|
|
107
|
+
const owner = buildGroupOwnerMap(ctx);
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
const out: CollectedVariant[] = [];
|
|
110
|
+
const groups = groupByMount(ctx);
|
|
111
|
+
const g = groups.get(mountTarget);
|
|
112
|
+
if (!g) return out;
|
|
113
|
+
for (const op of g.operations) {
|
|
114
|
+
const bodyFieldTypes = collectBodyFieldTypes(op, models);
|
|
115
|
+
for (const group of op.parameterGroups ?? []) {
|
|
116
|
+
if (owner.get(group.name) !== mountTarget) continue;
|
|
117
|
+
for (const variant of group.variants) {
|
|
118
|
+
const cls = groupVariantClassName(group.name, variant.name);
|
|
119
|
+
if (seen.has(cls)) continue;
|
|
120
|
+
seen.add(cls);
|
|
121
|
+
out.push({
|
|
122
|
+
className: cls,
|
|
123
|
+
groupName: group.name,
|
|
124
|
+
variantName: variant.name,
|
|
125
|
+
mountTarget,
|
|
126
|
+
parameters: variant.parameters.map((p) => ({
|
|
127
|
+
name: p.name,
|
|
128
|
+
type: pickVariantParamType(p.type, bodyFieldTypes.get(p.name)),
|
|
129
|
+
})),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pick the type for a variant leaf parameter.
|
|
139
|
+
*
|
|
140
|
+
* Prefer the IR's leaf type. Use the body model's type only when the IR is a
|
|
141
|
+
* bare primitive but the body has a structured type — that's the original
|
|
142
|
+
* fidelity-recovery case the body fallback was added for. Strip any outer
|
|
143
|
+
* nullable from the body type, since body nullability reflects the parent
|
|
144
|
+
* group's optionality, not the leaf's required-ness within the variant.
|
|
145
|
+
*
|
|
146
|
+
* Exported so the test emitter can recover the same type the variant class
|
|
147
|
+
* declares — IR primitives for fields like `role_slugs` would otherwise stub
|
|
148
|
+
* as `"stub"` strings instead of the `["stub"]` arrays the class accepts.
|
|
149
|
+
*/
|
|
150
|
+
export function pickVariantParamType(irType: TypeRef, bodyType: TypeRef | undefined): TypeRef {
|
|
151
|
+
if (!bodyType) return irType;
|
|
152
|
+
const unwrappedBody = bodyType.kind === 'nullable' ? bodyType.inner : bodyType;
|
|
153
|
+
const bodyIsStructured =
|
|
154
|
+
unwrappedBody.kind === 'array' ||
|
|
155
|
+
unwrappedBody.kind === 'enum' ||
|
|
156
|
+
unwrappedBody.kind === 'model' ||
|
|
157
|
+
unwrappedBody.kind === 'map';
|
|
158
|
+
if (irType.kind === 'primitive' && bodyIsStructured) return unwrappedBody;
|
|
159
|
+
return irType;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readableName(name: string): string {
|
|
163
|
+
return name.replace(/_/g, ' ');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render the inline `Data.define` block for a single variant, indented for
|
|
168
|
+
* inclusion inside a `class <Service>` body. Returns an array of lines with
|
|
169
|
+
* 4-space indent (the resource file's class members are 4-space indented).
|
|
170
|
+
*/
|
|
171
|
+
export function emitInlineVariantClass(v: CollectedVariant): string[] {
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
lines.push(` # Identifies the ${readableName(v.groupName)} (${readableName(v.variantName)} variant).`);
|
|
174
|
+
lines.push(' #');
|
|
175
|
+
for (const p of v.parameters) {
|
|
176
|
+
const yardType = mapTypeRefForYard(p.type);
|
|
177
|
+
lines.push(` # @!attribute [r] ${fieldName(p.name)}`);
|
|
178
|
+
lines.push(` # @return [${yardType}]`);
|
|
179
|
+
}
|
|
180
|
+
if (v.parameters.length === 0) {
|
|
181
|
+
lines.push(` ${v.className} = Data.define`);
|
|
182
|
+
} else {
|
|
183
|
+
const fields = v.parameters.map((p) => `:${fieldName(p.name)}`).join(', ');
|
|
184
|
+
lines.push(` ${v.className} = Data.define(${fields})`);
|
|
185
|
+
}
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Render the inline RBI `class` block for a single variant, indented for
|
|
191
|
+
* inclusion inside a `class <Service>` body in a service .rbi file. Returns
|
|
192
|
+
* lines with 4-space indent.
|
|
193
|
+
*/
|
|
194
|
+
export function emitInlineVariantRbi(v: CollectedVariant): string[] {
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
const fqcn = `WorkOS::${v.mountTarget}::${v.className}`;
|
|
197
|
+
lines.push(` class ${v.className}`);
|
|
198
|
+
for (const p of v.parameters) {
|
|
199
|
+
lines.push(` sig { returns(${mapSorbetType(p.type)}) }`);
|
|
200
|
+
lines.push(` def ${fieldName(p.name)}; end`);
|
|
201
|
+
lines.push('');
|
|
202
|
+
}
|
|
203
|
+
if (v.parameters.length === 0) {
|
|
204
|
+
lines.push(` sig { returns(${fqcn}) }`);
|
|
205
|
+
lines.push(` def self.new; end`);
|
|
206
|
+
} else {
|
|
207
|
+
lines.push(' sig do');
|
|
208
|
+
lines.push(' params(');
|
|
209
|
+
for (let i = 0; i < v.parameters.length; i++) {
|
|
210
|
+
const p = v.parameters[i];
|
|
211
|
+
const sep = i === v.parameters.length - 1 ? '' : ',';
|
|
212
|
+
lines.push(` ${fieldName(p.name)}: ${mapSorbetType(p.type)}${sep}`);
|
|
213
|
+
}
|
|
214
|
+
lines.push(` ).returns(${fqcn})`);
|
|
215
|
+
lines.push(' end');
|
|
216
|
+
const kwargs = v.parameters.map((p) => `${fieldName(p.name)}:`).join(', ');
|
|
217
|
+
lines.push(` def self.new(${kwargs}); end`);
|
|
218
|
+
}
|
|
219
|
+
lines.push(' end');
|
|
220
|
+
return lines;
|
|
221
|
+
}
|
package/src/ruby/rbi.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
|
|
2
2
|
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
className,
|
|
5
|
+
fieldName,
|
|
6
|
+
fileName,
|
|
7
|
+
safeParamName,
|
|
8
|
+
scopedGroupVariantClassName,
|
|
9
|
+
resolveMethodName,
|
|
10
|
+
} from './naming.js';
|
|
4
11
|
import {
|
|
5
12
|
buildResolvedLookup,
|
|
6
13
|
groupByMount,
|
|
@@ -9,6 +16,7 @@ import {
|
|
|
9
16
|
collectGroupedParamNames,
|
|
10
17
|
} from '../shared/resolved-ops.js';
|
|
11
18
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
19
|
+
import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantRbi } from './parameter-groups.js';
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
22
|
* Map an IR TypeRef to a Sorbet type string for RBI files.
|
|
@@ -120,6 +128,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
120
128
|
for (const m of spec.models as Model[]) {
|
|
121
129
|
if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
|
|
122
130
|
}
|
|
131
|
+
const groupOwners = buildGroupOwnerMap(ctx);
|
|
123
132
|
|
|
124
133
|
for (const [mountTarget, group] of groups) {
|
|
125
134
|
const cls = className(mountTarget);
|
|
@@ -129,6 +138,15 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
129
138
|
lines.push('module WorkOS');
|
|
130
139
|
lines.push(` class ${cls}`);
|
|
131
140
|
|
|
141
|
+
// Inline parameter-group variant RBI blocks owned by this mount target.
|
|
142
|
+
// Mirrors the runtime `class ... PasswordPlaintext = Data.define(...) end`
|
|
143
|
+
// layout in lib/workos/<service>.rb.
|
|
144
|
+
const variants = collectVariantsForMountTarget(ctx, spec.models as Model[], mountTarget);
|
|
145
|
+
for (const v of variants) {
|
|
146
|
+
for (const line of emitInlineVariantRbi(v)) lines.push(line);
|
|
147
|
+
lines.push('');
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
lines.push(' sig { params(client: WorkOS::BaseClient).void }');
|
|
133
151
|
lines.push(' def initialize(client); end');
|
|
134
152
|
lines.push('');
|
|
@@ -153,9 +171,26 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
153
171
|
const hiddenParams = buildHiddenParams(resolved);
|
|
154
172
|
const groupedParamNames = collectGroupedParamNames(op);
|
|
155
173
|
const queryParams = (op.queryParams ?? []).filter((q) => !groupedParamNames.has(q.name));
|
|
156
|
-
|
|
174
|
+
// Drop body fields that collide with a parameter-group name; the group
|
|
175
|
+
// dispatcher kwarg handles them. See ruby/resources.ts for the matching
|
|
176
|
+
// filter on the runtime side.
|
|
177
|
+
const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName).filter(
|
|
178
|
+
(f) => !groupedParamNames.has(f.name),
|
|
179
|
+
);
|
|
180
|
+
const parameterGroups = op.parameterGroups ?? [];
|
|
181
|
+
const groupSorbetType = (group: (typeof parameterGroups)[number]): string => {
|
|
182
|
+
const owner = groupOwners.get(group.name);
|
|
183
|
+
if (!owner) {
|
|
184
|
+
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
185
|
+
}
|
|
186
|
+
const variants = group.variants.map((v) => scopedGroupVariantClassName(owner, group.name, v.name));
|
|
187
|
+
if (variants.length === 1) return variants[0];
|
|
188
|
+
return `T.any(${variants.join(', ')})`;
|
|
189
|
+
};
|
|
157
190
|
|
|
158
|
-
// Build parameter list for sig
|
|
191
|
+
// Build parameter list for sig. Order mirrors the runtime emitter:
|
|
192
|
+
// path → required body → required query → required groups → optional
|
|
193
|
+
// body → optional query → optional groups → request_options.
|
|
159
194
|
const sigParams: string[] = [];
|
|
160
195
|
const seen = new Set<string>();
|
|
161
196
|
|
|
@@ -181,6 +216,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
181
216
|
seen.add(n);
|
|
182
217
|
sigParams.push(`${n}: ${mapSorbetType(q.type)}`);
|
|
183
218
|
}
|
|
219
|
+
for (const group of parameterGroups) {
|
|
220
|
+
if (group.optional) continue;
|
|
221
|
+
const n = fieldName(group.name);
|
|
222
|
+
if (seen.has(n)) continue;
|
|
223
|
+
seen.add(n);
|
|
224
|
+
sigParams.push(`${n}: ${groupSorbetType(group)}`);
|
|
225
|
+
}
|
|
184
226
|
for (const f of bodyFields) {
|
|
185
227
|
if (hiddenParams.has(f.name)) continue;
|
|
186
228
|
if (f.required) continue;
|
|
@@ -197,6 +239,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
197
239
|
seen.add(n);
|
|
198
240
|
sigParams.push(`${n}: T.nilable(${unwrapNilable(mapSorbetType(q.type))})`);
|
|
199
241
|
}
|
|
242
|
+
for (const group of parameterGroups) {
|
|
243
|
+
if (!group.optional) continue;
|
|
244
|
+
const n = fieldName(group.name);
|
|
245
|
+
if (seen.has(n)) continue;
|
|
246
|
+
seen.add(n);
|
|
247
|
+
sigParams.push(`${n}: T.nilable(${groupSorbetType(group)})`);
|
|
248
|
+
}
|
|
200
249
|
sigParams.push('request_options: T::Hash[Symbol, T.untyped]');
|
|
201
250
|
|
|
202
251
|
// Return type
|