@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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-Bk0xWTQC.mjs";
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.6.7",
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.9.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';
@@ -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) =>
@@ -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) {
@@ -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';
@@ -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
- return ensureTrailingNewlines(generateModels(models, ctx));
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
- return ensureTrailingNewlines(generateRbiFiles(spec, ctx));
58
+ const rbiFiles = generateRbiFiles(spec, ctx);
59
+ return ensureTrailingNewlines(rbiFiles);
58
60
  },
59
61
 
60
62
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
@@ -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);
@@ -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 { className, fieldName, fileName, safeParamName, resolveMethodName } from './naming.js';
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
- const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName);
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