@workos/oagen-emitters 0.6.6 → 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 +15 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BgVrq-hM.mjs → plugin-Cmg_LFtm.mjs} +433 -53
- package/dist/plugin-Cmg_LFtm.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/index.ts +10 -3
- package/src/dotnet/models.ts +24 -0
- package/src/go/models.ts +18 -1
- package/src/go/tests.ts +8 -2
- 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/test/dotnet/models.test.ts +77 -0
- package/dist/plugin-BgVrq-hM.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/dotnet/index.ts
CHANGED
|
@@ -103,7 +103,14 @@ export const dotnetEmitter: Emitter = {
|
|
|
103
103
|
return m;
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
// Build a map of base model name → discriminator wire-property name so the
|
|
107
|
+
// model emitter can mark the discriminator field as internal-set.
|
|
108
|
+
const discriminatorProperties = new Map<string, string>();
|
|
109
|
+
for (const [baseName, disc] of modelDiscriminators) {
|
|
110
|
+
discriminatorProperties.set(baseName, disc.property);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const discCtx: DiscriminatorContext = { discriminatorBases, variantToBase, discriminatorProperties };
|
|
107
114
|
const files = generateModels(dotnetModels, c, discCtx);
|
|
108
115
|
|
|
109
116
|
// Generate discriminator converters for oneOf unions with discriminator
|
|
@@ -181,6 +188,8 @@ export const dotnetEmitter: Emitter = {
|
|
|
181
188
|
lines.push(` /// </summary>`);
|
|
182
189
|
lines.push(` public class ${converterName} : Newtonsoft.Json.JsonConverter`);
|
|
183
190
|
lines.push(' {');
|
|
191
|
+
lines.push(' public override bool CanWrite => false;');
|
|
192
|
+
lines.push('');
|
|
184
193
|
lines.push(
|
|
185
194
|
` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
|
|
186
195
|
);
|
|
@@ -206,8 +215,6 @@ export const dotnetEmitter: Emitter = {
|
|
|
206
215
|
lines.push(' return target;');
|
|
207
216
|
lines.push(' }');
|
|
208
217
|
lines.push('');
|
|
209
|
-
lines.push(' public override bool CanWrite => false;');
|
|
210
|
-
lines.push('');
|
|
211
218
|
lines.push(
|
|
212
219
|
' public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer)',
|
|
213
220
|
);
|
package/src/dotnet/models.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface DiscriminatorContext {
|
|
|
31
31
|
discriminatorBases: Set<string>;
|
|
32
32
|
/** Maps variant model name → base model name. */
|
|
33
33
|
variantToBase: Map<string, string>;
|
|
34
|
+
/** Maps base model name → wire name of the discriminator property. */
|
|
35
|
+
discriminatorProperties?: Map<string, string>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -161,6 +163,12 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
161
163
|
let initializer = '';
|
|
162
164
|
let setterModifier = '';
|
|
163
165
|
|
|
166
|
+
// On a discriminated union base, the discriminator property (e.g. "event")
|
|
167
|
+
// should be non-public-settable even though it lacks a single const value
|
|
168
|
+
// (each variant has a different value). Consumers must never mutate it.
|
|
169
|
+
const discProp = isDiscBase ? discCtx?.discriminatorProperties?.get(model.name) : undefined;
|
|
170
|
+
const isDiscriminatorField = discProp !== undefined && field.name === discProp;
|
|
171
|
+
|
|
164
172
|
if (constInit !== null && !isOptional) {
|
|
165
173
|
// Discriminator-style single-value enum/literal: emit with a const
|
|
166
174
|
// initializer and a non-public setter so callers can't drift the
|
|
@@ -170,6 +178,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
170
178
|
csType = baseType;
|
|
171
179
|
initializer = ` = ${constInit};`;
|
|
172
180
|
setterModifier = 'internal ';
|
|
181
|
+
} else if (isDiscriminatorField) {
|
|
182
|
+
// Discriminator property on the base class: varies per variant but
|
|
183
|
+
// should still be non-public-settable so consumers can't change it.
|
|
184
|
+
csType = baseType;
|
|
185
|
+
if (!isAlreadyNullable && !isValueTypeRef(field.type)) {
|
|
186
|
+
initializer = ' = default!;';
|
|
187
|
+
}
|
|
188
|
+
setterModifier = 'internal ';
|
|
173
189
|
} else if (isOptional) {
|
|
174
190
|
if (isAlreadyNullable) {
|
|
175
191
|
csType = baseType;
|
|
@@ -219,6 +235,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
219
235
|
lines.push(` /// <paramref name="key"/> coerced to <typeparamref name="T"/>, or the default`);
|
|
220
236
|
lines.push(` /// value when the key is missing or the value is not convertible.`);
|
|
221
237
|
lines.push(` /// </summary>`);
|
|
238
|
+
if (isDiscBase) {
|
|
239
|
+
lines.push(` /// <remarks>`);
|
|
240
|
+
lines.push(` /// Variant subclasses provide strongly-typed <c>${dict.csName}</c> properties that`);
|
|
241
|
+
lines.push(` /// shadow this dictionary. This accessor is intended for forward-compatible handling`);
|
|
242
|
+
lines.push(` /// of types not yet known to this SDK version. For recognized types, cast to the`);
|
|
243
|
+
lines.push(` /// specific subclass and access its typed <c>${dict.csName}</c> property directly.`);
|
|
244
|
+
lines.push(` /// </remarks>`);
|
|
245
|
+
}
|
|
222
246
|
lines.push(` /// <typeparam name="T">Expected value type.</typeparam>`);
|
|
223
247
|
lines.push(` /// <param name="key">The key to look up.</param>`);
|
|
224
248
|
lines.push(` public T? Get${dict.csName}Attribute<T>(string key)`);
|
package/src/go/models.ts
CHANGED
|
@@ -166,10 +166,27 @@ function makeOptional(goType: string): string {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function structuralHash(model: Model): string {
|
|
169
|
-
|
|
169
|
+
const fieldHash = model.fields
|
|
170
170
|
.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
|
|
171
171
|
.sort()
|
|
172
172
|
.join('|');
|
|
173
|
+
// Include entity domain for CRUD-prefixed models to prevent cross-domain
|
|
174
|
+
// aliasing (e.g. UpdateGroup vs UpdateAuthorizationPermission have identical
|
|
175
|
+
// fields but belong to different API domains and should stay separate types).
|
|
176
|
+
const domain = crudEntityDomain(model.name);
|
|
177
|
+
return domain ? `${domain}::${fieldHash}` : fieldHash;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const CRUD_PREFIXES = ['Create', 'Update', 'Delete', 'Get', 'List'];
|
|
181
|
+
|
|
182
|
+
/** Strip CRUD verb prefix to get the entity name, or null if no prefix matches. */
|
|
183
|
+
function crudEntityDomain(name: string): string | null {
|
|
184
|
+
for (const prefix of CRUD_PREFIXES) {
|
|
185
|
+
if (name.startsWith(prefix) && name.length > prefix.length) {
|
|
186
|
+
return name.slice(prefix.length);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
/** Known acronyms to preserve as single tokens during humanization. */
|
package/src/go/tests.ts
CHANGED
|
@@ -307,7 +307,10 @@ function generateServiceTest(
|
|
|
307
307
|
// Success test
|
|
308
308
|
const respModel = plan.responseModelName;
|
|
309
309
|
const isArrayResponse = !isPaginated && op.response?.kind === 'array';
|
|
310
|
-
|
|
310
|
+
let fixturePath = `testdata/${fileName(respModel)}.json`;
|
|
311
|
+
if (fixtureRewrites.has(fixturePath)) {
|
|
312
|
+
fixturePath = fixtureRewrites.get(fixturePath)!;
|
|
313
|
+
}
|
|
311
314
|
const expectedPath = buildExpectedPath(op);
|
|
312
315
|
|
|
313
316
|
const httpMethodUpper = op.httpMethod.toUpperCase();
|
|
@@ -411,7 +414,10 @@ function generateServiceTest(
|
|
|
411
414
|
const wrapperParamsStruct = paramsStructName(resolvedName, wrapperMethod);
|
|
412
415
|
const responseType = wrapper.responseModelName;
|
|
413
416
|
const testName = `Test${accessorName}_${wrapperMethod}`;
|
|
414
|
-
|
|
417
|
+
let fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
|
|
418
|
+
if (fixturePath && fixtureRewrites.has(fixturePath)) {
|
|
419
|
+
fixturePath = fixtureRewrites.get(fixturePath)!;
|
|
420
|
+
}
|
|
415
421
|
|
|
416
422
|
const wrapperCallArgs: string[] = ['context.Background()'];
|
|
417
423
|
for (const p of sortPathParamsByTemplateOrder(op)) {
|
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
|