@workos/oagen-emitters 0.6.7 → 0.6.8
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 +7 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-Bk0xWTQC.mjs → plugin-Cmg_LFtm.mjs} +389 -44
- package/dist/plugin-Cmg_LFtm.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/ruby/index.ts +4 -2
- 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-Cmg_LFtm.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
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/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
|
package/src/ruby/resources.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { Service, EmitterContext, GeneratedFile, Operation, TypeRef, Parameter, Model } from '@workos/oagen';
|
|
2
2
|
import { planOperation } from '@workos/oagen';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
className,
|
|
5
|
+
fieldName,
|
|
6
|
+
fileName,
|
|
7
|
+
methodName,
|
|
8
|
+
safeParamName,
|
|
9
|
+
resolveMethodName,
|
|
10
|
+
scopedGroupVariantClassName,
|
|
11
|
+
} from './naming.js';
|
|
4
12
|
import { mapTypeRefForYard } from './type-map.js';
|
|
5
13
|
import {
|
|
6
14
|
buildResolvedLookup,
|
|
@@ -13,6 +21,7 @@ import {
|
|
|
13
21
|
} from '../shared/resolved-ops.js';
|
|
14
22
|
import { isListWrapperModel } from '../shared/model-utils.js';
|
|
15
23
|
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
|
|
24
|
+
import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantClass } from './parameter-groups.js';
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Generate Ruby resource (service) classes from IR services.
|
|
@@ -35,6 +44,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
35
44
|
if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
// Resolve groupName -> owner mountTarget once per generation pass; every
|
|
48
|
+
// dispatcher and YARD `@param` reference resolves variant classes through
|
|
49
|
+
// this map so cross-resource references stay consistent.
|
|
50
|
+
const groupOwners = buildGroupOwnerMap(ctx);
|
|
51
|
+
|
|
38
52
|
for (const [mountTarget, group] of groups) {
|
|
39
53
|
const cls = className(mountTarget);
|
|
40
54
|
const file = fileName(mountTarget);
|
|
@@ -88,6 +102,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
88
102
|
modelByName,
|
|
89
103
|
listWrapperModels,
|
|
90
104
|
requires,
|
|
105
|
+
groupOwners,
|
|
91
106
|
});
|
|
92
107
|
methodBodies.push(body);
|
|
93
108
|
|
|
@@ -121,6 +136,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
121
136
|
}
|
|
122
137
|
lines.push('module WorkOS');
|
|
123
138
|
lines.push(` class ${cls}`);
|
|
139
|
+
|
|
140
|
+
// Inline parameter-group variant classes owned by this mount target.
|
|
141
|
+
// Zeitwerk's `loader.collapse` flattens `lib/workos/<service>/` so files
|
|
142
|
+
// there can't define `WorkOS::<Service>::*` constants — variants must
|
|
143
|
+
// live inside the service's own file. Matches Python's per-resource
|
|
144
|
+
// dataclass layout.
|
|
145
|
+
const variants = collectVariantsForMountTarget(ctx, ctx.spec.models as Model[], mountTarget);
|
|
146
|
+
for (const v of variants) {
|
|
147
|
+
for (const line of emitInlineVariantClass(v)) lines.push(line);
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
lines.push(' def initialize(client)');
|
|
125
152
|
lines.push(' @client = client');
|
|
126
153
|
lines.push(' end');
|
|
@@ -154,6 +181,7 @@ function emitMethod(args: {
|
|
|
154
181
|
modelByName: Map<string, Model>;
|
|
155
182
|
listWrapperModels: Map<string, Model>;
|
|
156
183
|
requires: Set<string>;
|
|
184
|
+
groupOwners: Map<string, string>;
|
|
157
185
|
}): string {
|
|
158
186
|
const {
|
|
159
187
|
op,
|
|
@@ -166,9 +194,19 @@ function emitMethod(args: {
|
|
|
166
194
|
modelByName,
|
|
167
195
|
listWrapperModels,
|
|
168
196
|
requires,
|
|
197
|
+
groupOwners,
|
|
169
198
|
} = args;
|
|
170
199
|
void enumNames;
|
|
171
200
|
|
|
201
|
+
/** Fully-qualified Ruby constant for a variant (e.g. WorkOS::UserManagement::PasswordPlaintext). */
|
|
202
|
+
const variantClassRef = (group: { name: string }, variantName: string): string => {
|
|
203
|
+
const owner = groupOwners.get(group.name);
|
|
204
|
+
if (!owner) {
|
|
205
|
+
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
206
|
+
}
|
|
207
|
+
return scopedGroupVariantClassName(owner, group.name, variantName);
|
|
208
|
+
};
|
|
209
|
+
|
|
172
210
|
const plan = planOperation(op);
|
|
173
211
|
const lines: string[] = [];
|
|
174
212
|
|
|
@@ -177,8 +215,11 @@ function emitMethod(args: {
|
|
|
177
215
|
const groupedParamNames = collectGroupedParamNames(op);
|
|
178
216
|
const queryParams = (op.queryParams ?? []).filter((q) => !groupedParamNames.has(q.name));
|
|
179
217
|
|
|
180
|
-
// Request body params: if body is a model, expand its fields.
|
|
181
|
-
|
|
218
|
+
// Request body params: if body is a model, expand its fields. Drop any field
|
|
219
|
+
// whose name is also a parameter-group name — those are dispatched by the
|
|
220
|
+
// group kwarg below, so emitting them as flat kwargs would shadow the group
|
|
221
|
+
// and cause `String#[Symbol]` TypeErrors when the dispatcher reads `:type`.
|
|
222
|
+
const bodyFields = getRequestBodyFields(op, hiddenParams, modelByName).filter((f) => !groupedParamNames.has(f.name));
|
|
182
223
|
|
|
183
224
|
// Detect path/body name collisions and build a rename map for body fields.
|
|
184
225
|
// When a body field's snake_case name matches a path param, prefix with "body_"
|
|
@@ -269,7 +310,16 @@ function emitMethod(args: {
|
|
|
269
310
|
sigParts.push('request_options: {}');
|
|
270
311
|
|
|
271
312
|
// YARD docs.
|
|
272
|
-
const doc = buildYardDoc(
|
|
313
|
+
const doc = buildYardDoc(
|
|
314
|
+
op,
|
|
315
|
+
pathParams,
|
|
316
|
+
queryParams,
|
|
317
|
+
bodyFields,
|
|
318
|
+
hiddenParams,
|
|
319
|
+
bodyFieldRenames,
|
|
320
|
+
listWrapperModels,
|
|
321
|
+
variantClassRef,
|
|
322
|
+
);
|
|
273
323
|
for (const line of doc) lines.push(` ${line}`);
|
|
274
324
|
|
|
275
325
|
// Signature.
|
|
@@ -309,30 +359,41 @@ function emitMethod(args: {
|
|
|
309
359
|
const groupsGoToQuery = hasGroups && !hasBodyMethod;
|
|
310
360
|
const hasQuery = qEntries.length > 0 || groupsGoToQuery;
|
|
311
361
|
if (hasQuery) {
|
|
362
|
+
// Skip `.compact` when no entry can be nil — required kwargs are always
|
|
363
|
+
// passed (Ruby raises ArgumentError otherwise), so the literal has no nil
|
|
364
|
+
// values to drop. Group dispatch happens after the literal is built and
|
|
365
|
+
// doesn't contribute potentially-nil entries either.
|
|
366
|
+
const queryHasNilable = qEntries.some((q) => !q.required);
|
|
367
|
+
const queryCompact = queryHasNilable ? '.compact' : '';
|
|
312
368
|
lines.push(' params = {');
|
|
313
369
|
for (let i = 0; i < qEntries.length; i++) {
|
|
314
370
|
const q = qEntries[i];
|
|
315
371
|
const sep = i === qEntries.length - 1 && !groupsGoToQuery ? '' : ',';
|
|
316
372
|
lines.push(` ${rubyStringLit(q.name)} => ${safeParamName(q.name)}${sep}`);
|
|
317
373
|
}
|
|
318
|
-
lines.push(
|
|
374
|
+
lines.push(` }${queryCompact}`);
|
|
319
375
|
|
|
320
376
|
if (groupsGoToQuery) {
|
|
321
|
-
// Parameter group dispatch:
|
|
377
|
+
// Parameter group dispatch: callers pass a typed variant class instance
|
|
378
|
+
// (e.g. `WorkOS::ParentResourceById`); we pattern-match on its class
|
|
379
|
+
// and forward its readers into the query hash.
|
|
322
380
|
for (const group of op.parameterGroups ?? []) {
|
|
323
381
|
const prop = fieldName(group.name);
|
|
324
382
|
if (group.optional) {
|
|
325
383
|
lines.push(` if ${prop}`);
|
|
326
|
-
lines.push(` case ${prop}
|
|
384
|
+
lines.push(` case ${prop}`);
|
|
327
385
|
} else {
|
|
328
|
-
lines.push(` case ${prop}
|
|
386
|
+
lines.push(` case ${prop}`);
|
|
329
387
|
}
|
|
330
388
|
for (const variant of group.variants) {
|
|
331
|
-
|
|
389
|
+
const variantClass = variantClassRef(group, variant.name);
|
|
390
|
+
lines.push(` when ${variantClass}`);
|
|
332
391
|
for (const p of variant.parameters) {
|
|
333
|
-
lines.push(` params[${rubyStringLit(p.name)}] = ${prop}
|
|
392
|
+
lines.push(` params[${rubyStringLit(p.name)}] = ${prop}.${fieldName(p.name)}`);
|
|
334
393
|
}
|
|
335
394
|
}
|
|
395
|
+
lines.push(` else`);
|
|
396
|
+
lines.push(` raise ArgumentError, ${dispatchErrorLiteral(group, prop, variantClassRef)}`);
|
|
336
397
|
lines.push(' end');
|
|
337
398
|
if (group.optional) {
|
|
338
399
|
lines.push(' end');
|
|
@@ -341,8 +402,12 @@ function emitMethod(args: {
|
|
|
341
402
|
}
|
|
342
403
|
}
|
|
343
404
|
|
|
344
|
-
// Request body
|
|
345
|
-
|
|
405
|
+
// Request body. Emit when there are non-group body fields OR a parameter
|
|
406
|
+
// group dispatches into the body — the latter case matters when an
|
|
407
|
+
// operation's body is exclusively managed by a group (e.g.
|
|
408
|
+
// update_organization_membership's `role`), where filtering the group's
|
|
409
|
+
// leaves leaves bodyFields empty but the request still needs a payload.
|
|
410
|
+
const hasBody = (bodyFields.length > 0 && !['get', 'head'].includes(method_http)) || (hasGroups && hasBodyMethod);
|
|
346
411
|
|
|
347
412
|
if (hasBody) {
|
|
348
413
|
const bodyEntries: string[] = [];
|
|
@@ -355,35 +420,44 @@ function emitMethod(args: {
|
|
|
355
420
|
const optKey = fc === 'client_secret' ? 'api_key' : fc;
|
|
356
421
|
bodyEntries.push(`${rubyStringLit(fc)} => (request_options[:${optKey}] || @client.${clientProp})`);
|
|
357
422
|
}
|
|
423
|
+
// Track whether any literal entry can be nil — defaults/inferFromClient
|
|
424
|
+
// resolve to non-nil values, so only optional body kwargs are nilable.
|
|
425
|
+
let bodyHasNilable = false;
|
|
358
426
|
for (const f of bodyFields) {
|
|
359
427
|
if (hiddenParams.has(f.name)) continue;
|
|
360
428
|
bodyEntries.push(`${rubyStringLit(f.name)} => ${bodyKwargName(f.name)}`);
|
|
429
|
+
if (!f.required) bodyHasNilable = true;
|
|
361
430
|
}
|
|
431
|
+
const bodyCompact = bodyHasNilable ? '.compact' : '';
|
|
362
432
|
lines.push(' body = {');
|
|
363
433
|
for (let i = 0; i < bodyEntries.length; i++) {
|
|
364
434
|
const sep = i === bodyEntries.length - 1 ? '' : ',';
|
|
365
435
|
lines.push(` ${bodyEntries[i]}${sep}`);
|
|
366
436
|
}
|
|
367
|
-
lines.push(
|
|
437
|
+
lines.push(` }${bodyCompact}`);
|
|
368
438
|
|
|
369
439
|
// Parameter group dispatch into body for POST/PUT/PATCH so sensitive
|
|
370
440
|
// fields (passwords, role slugs) never leak into the URL query string.
|
|
371
441
|
// DELETE groups are already handled via query above (groupsGoToQuery).
|
|
442
|
+
// Callers pass a typed variant class instance and we pattern-match on it.
|
|
372
443
|
if (hasGroups && hasBodyMethod) {
|
|
373
444
|
for (const group of op.parameterGroups ?? []) {
|
|
374
445
|
const prop = fieldName(group.name);
|
|
375
446
|
if (group.optional) {
|
|
376
447
|
lines.push(` if ${prop}`);
|
|
377
|
-
lines.push(` case ${prop}
|
|
448
|
+
lines.push(` case ${prop}`);
|
|
378
449
|
} else {
|
|
379
|
-
lines.push(` case ${prop}
|
|
450
|
+
lines.push(` case ${prop}`);
|
|
380
451
|
}
|
|
381
452
|
for (const variant of group.variants) {
|
|
382
|
-
|
|
453
|
+
const variantClass = variantClassRef(group, variant.name);
|
|
454
|
+
lines.push(` when ${variantClass}`);
|
|
383
455
|
for (const p of variant.parameters) {
|
|
384
|
-
lines.push(` body[${rubyStringLit(p.name)}] = ${prop}
|
|
456
|
+
lines.push(` body[${rubyStringLit(p.name)}] = ${prop}.${fieldName(p.name)}`);
|
|
385
457
|
}
|
|
386
458
|
}
|
|
459
|
+
lines.push(` else`);
|
|
460
|
+
lines.push(` raise ArgumentError, ${dispatchErrorLiteral(group, prop, variantClassRef)}`);
|
|
387
461
|
lines.push(' end');
|
|
388
462
|
if (group.optional) {
|
|
389
463
|
lines.push(' end');
|
|
@@ -719,8 +793,9 @@ function buildYardDoc(
|
|
|
719
793
|
queryParams: Parameter[],
|
|
720
794
|
bodyFields: { name: string; required: boolean; type: TypeRef; description?: string; deprecated?: boolean }[],
|
|
721
795
|
hiddenParams: Set<string>,
|
|
722
|
-
bodyFieldRenames
|
|
723
|
-
listWrapperModels
|
|
796
|
+
bodyFieldRenames: Map<string, string> | undefined,
|
|
797
|
+
listWrapperModels: Map<string, Model> | undefined,
|
|
798
|
+
variantClassRef: (group: { name: string }, variantName: string) => string,
|
|
724
799
|
): string[] {
|
|
725
800
|
const lines: string[] = [];
|
|
726
801
|
const summary = op.description ?? `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
@@ -764,6 +839,16 @@ function buildYardDoc(
|
|
|
764
839
|
const deprecatedPrefix = q.deprecated ? '(deprecated) ' : '';
|
|
765
840
|
lines.push(`# @param ${n} [${type}${suffix}] ${deprecatedPrefix}${oneLine(q.description)}`.trim());
|
|
766
841
|
}
|
|
842
|
+
// Parameter group kwargs: the type bracket lists the variant classes the
|
|
843
|
+
// caller may pass; no extra prose needed since YARD already renders them.
|
|
844
|
+
for (const group of op.parameterGroups ?? []) {
|
|
845
|
+
const n = fieldName(group.name);
|
|
846
|
+
if (emittedParamNames.has(n)) continue;
|
|
847
|
+
emittedParamNames.add(n);
|
|
848
|
+
const variantTypes = group.variants.map((v) => variantClassRef(group, v.name)).join(', ');
|
|
849
|
+
const suffix = group.optional ? ', nil' : '';
|
|
850
|
+
lines.push(`# @param ${n} [${variantTypes}${suffix}] Identifies the ${group.name.replace(/_/g, ' ')}.`);
|
|
851
|
+
}
|
|
767
852
|
lines.push(`# @param request_options [Hash] (see WorkOS::Types::RequestOptions)`);
|
|
768
853
|
|
|
769
854
|
// Return type: void for unknown-primitive, ListStruct for list wrappers and
|
|
@@ -797,3 +882,17 @@ void methodName;
|
|
|
797
882
|
function rubyStringLit(s: string): string {
|
|
798
883
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
799
884
|
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Build a Ruby double-quoted string expression for the `else raise ArgumentError`
|
|
888
|
+
* arm of a parameter-group dispatcher. Lists the expected variant classes and
|
|
889
|
+
* interpolates the actual class of the value the caller passed.
|
|
890
|
+
*/
|
|
891
|
+
function dispatchErrorLiteral(
|
|
892
|
+
group: { name: string; variants: { name: string }[] },
|
|
893
|
+
prop: string,
|
|
894
|
+
variantClassRef: (group: { name: string }, variantName: string) => string,
|
|
895
|
+
): string {
|
|
896
|
+
const expected = group.variants.map((v) => variantClassRef(group, v.name)).join(', ');
|
|
897
|
+
return `"expected ${prop} to be one of: ${expected}, got #{${prop}.class}"`;
|
|
898
|
+
}
|