@workos/oagen-emitters 0.4.0 → 0.5.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.
Files changed (105) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +8 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,459 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
2
+ import { className, fileName, fieldName, safeParamName, servicePropertyName, resolveMethodName } from './naming.js';
3
+ import { buildResolvedLookup, groupByMount, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
4
+ import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
5
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
6
+
7
+ /**
8
+ * Generate Ruby Minitest test files for each service and per-method.
9
+ * Tests use WebMock to stub HTTP and fixtures from test/fixtures/*.fixture.json.
10
+ *
11
+ * For MVP, we produce:
12
+ * - test/test_helper.rb (if not present, via hand-maintained runtime)
13
+ * - test/workos/{service}_test.rb — per-service test class with basic assertions
14
+ * - test/fixtures/{op}.fixture.json — sample fixture for each operation
15
+ */
16
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
17
+ const files: GeneratedFile[] = [];
18
+
19
+ const groups = groupByMount(ctx);
20
+ const modelByName = new Map<string, Model>();
21
+ for (const m of spec.models as Model[]) modelByName.set(m.name, m);
22
+
23
+ const lookup = buildResolvedLookup(ctx);
24
+
25
+ for (const [mountTarget, group] of groups) {
26
+ const cls = className(mountTarget);
27
+ const prop = servicePropertyName(mountTarget);
28
+ const file = fileName(mountTarget);
29
+
30
+ const lines: string[] = [];
31
+ lines.push(`require 'test_helper'`);
32
+ lines.push('');
33
+ lines.push(`class ${cls}Test < Minitest::Test`);
34
+ lines.push(' include FixtureHelper');
35
+ lines.push('');
36
+ lines.push(' def setup');
37
+ lines.push(' @client = WorkOS::Client.new(api_key: "sk_test_123")');
38
+ lines.push(' end');
39
+
40
+ const emittedTestMethods = new Set<string>();
41
+ const authMethodManifest: { method: string; httpMethodSym: string; stubUrl: string; callArgs: string }[] = [];
42
+
43
+ for (const op of group.operations) {
44
+ const ownerService = group.resolvedOps.find((r) => r.operation === op)?.service;
45
+ if (!ownerService) continue;
46
+ const method = resolveMethodName(op, ownerService, ctx);
47
+ // Skip url-builder ops: their generated wrappers are suppressed (handled
48
+ // in resources.ts) and their hand-maintained replacements live inside
49
+ // @oagen-ignore blocks with bespoke tests.
50
+ if (lookupResolved(op, lookup)?.urlBuilder) continue;
51
+ // Skip duplicate method names (two ops may resolve to the same name).
52
+ if (emittedTestMethods.has(method)) continue;
53
+ emittedTestMethods.add(method);
54
+ lines.push('');
55
+
56
+ // Build the exact stub URL with path params substituted.
57
+ const stubUrl = buildStubUrl(op);
58
+
59
+ const isList =
60
+ (op.response.kind === 'model' &&
61
+ modelByName.get(op.response.name) &&
62
+ (isListWrapperModel(modelByName.get(op.response.name)!) || false)) ||
63
+ // Also detect paginated endpoints whose IR response is typed as array
64
+ (op.response.kind === 'array' && !!op.pagination);
65
+
66
+ const _isDelete = op.httpMethod.toLowerCase() === 'delete';
67
+ const httpMethodSym = `:${op.httpMethod.toLowerCase()}`;
68
+
69
+ const resolved = lookupResolved(op, lookup);
70
+ const hiddenParams = buildHiddenParams(resolved);
71
+ const callArgs = buildCallArgsStub(op, modelByName, hiddenParams);
72
+
73
+ // Collect method info for the parameterized 401 test (T20).
74
+ authMethodManifest.push({ method, httpMethodSym, stubUrl, callArgs });
75
+
76
+ const stubRegex = stubUrlRegex(stubUrl);
77
+ lines.push(` def test_${method}_returns_expected_result`);
78
+ if (isList) {
79
+ lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
80
+ lines.push(` .to_return(body: '{"data": [], "list_metadata": {}}', status: 200)`);
81
+ lines.push(` result = @client.${prop}.${method}(${callArgs})`);
82
+ lines.push(' assert_kind_of WorkOS::Types::ListStruct, result');
83
+ } else if (op.response.kind === 'primitive') {
84
+ lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
85
+ lines.push(` .to_return(body: "{}", status: 200)`);
86
+ lines.push(` result = @client.${prop}.${method}(${callArgs})`);
87
+ lines.push(' assert_nil result');
88
+ } else {
89
+ lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
90
+ lines.push(` .to_return(body: "{}", status: 200)`);
91
+ lines.push(` result = @client.${prop}.${method}(${callArgs})`);
92
+ lines.push(' refute_nil result');
93
+ }
94
+ lines.push(' end');
95
+
96
+ // Wrapper tests (union split variants).
97
+ if (resolved?.wrappers && resolved.wrappers.length > 0) {
98
+ for (const wrapper of resolved.wrappers) {
99
+ emitWrapperTests({
100
+ lines,
101
+ wrapper,
102
+ op,
103
+ prop,
104
+ stubUrl,
105
+ httpMethodSym,
106
+ ctx,
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ // T20: parameterized 401 test — one define_method per endpoint.
113
+ if (authMethodManifest.length > 0) {
114
+ lines.push('');
115
+ lines.push(' # Parameterized authentication error tests (one per endpoint).');
116
+ lines.push(' [');
117
+ for (const entry of authMethodManifest) {
118
+ const argsLit = entry.callArgs ? `, args: { ${entry.callArgs} }` : '';
119
+ lines.push(
120
+ ` { name: :${entry.method}, verb: ${entry.httpMethodSym}, url: ${stubUrlRegex(entry.stubUrl)}${argsLit} },`,
121
+ );
122
+ }
123
+ lines.push(' ].each do |spec|');
124
+ lines.push(` define_method("test_#{spec[:name]}_raises_authentication_error_on_401") do`);
125
+ lines.push(` stub_request(spec[:verb], spec[:url])`);
126
+ lines.push(` .to_return(body: '{"message": "Unauthorized"}', status: 401)`);
127
+ lines.push(` assert_raises(WorkOS::AuthenticationError) do`);
128
+ lines.push(` @client.${prop}.send(spec[:name], **(spec[:args] || {}))`);
129
+ lines.push(' end');
130
+ lines.push(' end');
131
+ lines.push(' end');
132
+ }
133
+
134
+ lines.push('end');
135
+
136
+ files.push({
137
+ path: `test/workos/test_${file}.rb`,
138
+ content: lines.join('\n'),
139
+ integrateTarget: true,
140
+ overwriteExisting: true,
141
+ });
142
+ }
143
+
144
+ files.push(generateModelRoundTripTest(spec));
145
+
146
+ return files;
147
+ }
148
+
149
+ /**
150
+ * Emit test/workos/model_round_trip_test.rb that round-trips every non-wrapper
151
+ * model through `.new(json)` and `.to_json`, asserting the result is a Hash and
152
+ * that required fields appear with the seeded values.
153
+ */
154
+ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
155
+ const lines: string[] = [];
156
+ lines.push(`require 'test_helper'`);
157
+ lines.push('');
158
+ lines.push('class ModelRoundTripTest < Minitest::Test');
159
+
160
+ const models = (spec.models as Model[]).filter((m) => !isListWrapperModel(m) && !isListMetadataModel(m));
161
+ const enumNames = new Set(spec.enums.map((e) => e.name));
162
+ const emitted = new Set<string>();
163
+
164
+ for (const model of models) {
165
+ // Avoid duplicate test names when two IR model names collapse to the same
166
+ // snake_case file name (we use the file name as the test suffix).
167
+ const fileBase = fileName(model.name);
168
+ if (emitted.has(fileBase)) continue;
169
+ emitted.add(fileBase);
170
+
171
+ // Build a fixture hash with string keys and stub values for every field.
172
+ const fixtureEntries: string[] = [];
173
+ const assertions: string[] = [];
174
+ const dedupFields = new Set<string>();
175
+ for (const f of model.fields) {
176
+ const wireName = f.name;
177
+ const rubyFieldName = fieldName(f.name);
178
+ if (dedupFields.has(rubyFieldName)) continue;
179
+ dedupFields.add(rubyFieldName);
180
+ const stub = roundTripStub(f.type, enumNames);
181
+ fixtureEntries.push(` ${stringKeyLiteral(wireName)} => ${stub},`);
182
+ // For primitive required fields we can assert the value round-trips.
183
+ // The model's to_json uses `<wireName>:` shorthand (symbol keys) for
184
+ // valid Ruby identifiers and `"wire#name" =>` (string keys) otherwise.
185
+ // Note: the symbol key uses the original wire name, not the snake_cased
186
+ // Ruby field name.
187
+ void rubyFieldName;
188
+ if (f.required && isPrimitiveLike(f.type)) {
189
+ const accessorKey = /^[A-Za-z_][A-Za-z0-9_]*$/.test(wireName) ? `:${wireName}` : stringKeyLiteral(wireName);
190
+ const actualExpr = `json[${accessorKey}]`;
191
+ // Minitest 6 removed the `assert_equal nil, x` form — emit the
192
+ // correct assertion based on the stub the emitter chose above.
193
+ if (stub === 'nil') {
194
+ assertions.push(` assert_nil ${actualExpr}`);
195
+ } else {
196
+ assertions.push(` assert_equal fixture[${stringKeyLiteral(wireName)}], ${actualExpr}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ lines.push('');
202
+ lines.push(` def test_${fileBase}_round_trip`);
203
+ if (fixtureEntries.length === 0) {
204
+ lines.push(` model = WorkOS::${className(model.name)}.new('{}')`);
205
+ lines.push(' json = model.to_h');
206
+ lines.push(' assert_kind_of Hash, json');
207
+ } else {
208
+ lines.push(' fixture = {');
209
+ for (const line of fixtureEntries) lines.push(line);
210
+ lines.push(' }');
211
+ lines.push(` model = WorkOS::${className(model.name)}.new(fixture.to_json)`);
212
+ lines.push(' json = model.to_h');
213
+ lines.push(' assert_kind_of Hash, json');
214
+ for (const a of assertions) lines.push(a);
215
+ // T23: Assert every fixture key round-trips into to_h (handles both symbol and string keys).
216
+ lines.push(
217
+ ' fixture.each_key { |k| assert json.key?(k.to_sym) || json.key?(k), "Expected to_h to include key #{k}" }',
218
+ );
219
+ }
220
+ lines.push(' end');
221
+ }
222
+
223
+ lines.push('end');
224
+
225
+ return {
226
+ path: 'test/workos/test_model_round_trip.rb',
227
+ content: lines.join('\n'),
228
+ integrateTarget: true,
229
+ overwriteExisting: true,
230
+ };
231
+ }
232
+
233
+ /** Produce a Ruby string literal for a raw (possibly non-identifier) key. */
234
+ function stringKeyLiteral(name: string): string {
235
+ return `"${name.replace(/"/g, '\\"')}"`;
236
+ }
237
+
238
+ function isPrimitiveLike(ref: TypeRef): boolean {
239
+ if (ref.kind === 'primitive') return true;
240
+ if (ref.kind === 'nullable') return isPrimitiveLike(ref.inner);
241
+ return false;
242
+ }
243
+
244
+ /** Produce a Ruby literal value for seeding a model fixture. */
245
+ function roundTripStub(ref: TypeRef, enumNames: Set<string>): string {
246
+ switch (ref.kind) {
247
+ case 'primitive':
248
+ switch (ref.type) {
249
+ case 'string':
250
+ return `"stub"`;
251
+ case 'integer':
252
+ return `1`;
253
+ case 'number':
254
+ return `1.0`;
255
+ case 'boolean':
256
+ return `true`;
257
+ default:
258
+ return `nil`;
259
+ }
260
+ case 'array':
261
+ return `[]`;
262
+ case 'map':
263
+ return `{}`;
264
+ case 'enum':
265
+ return enumNames.has(ref.name) ? `"stub"` : `"stub"`;
266
+ case 'literal':
267
+ if (typeof ref.value === 'string') return `"${ref.value}"`;
268
+ if (ref.value === null) return `nil`;
269
+ return String(ref.value);
270
+ case 'nullable':
271
+ return `nil`;
272
+ case 'model':
273
+ return `{}`;
274
+ case 'union':
275
+ return ref.variants.length > 0 ? roundTripStub(ref.variants[0], enumNames) : `nil`;
276
+ default:
277
+ return `nil`;
278
+ }
279
+ }
280
+
281
+ /** Build minimal placeholder arguments for calling the SDK method from a test. */
282
+ function buildCallArgsStub(op: Operation, modelByName: Map<string, Model>, hiddenParams: Set<string>): string {
283
+ const parts: string[] = [];
284
+ const seen = new Set<string>();
285
+
286
+ // Path params (required).
287
+ const pathParamNames = new Set<string>();
288
+ for (const p of op.pathParams ?? []) {
289
+ const name = safeParamName(p.name);
290
+ pathParamNames.add(name);
291
+ if (seen.has(name)) continue;
292
+ seen.add(name);
293
+ parts.push(`${name}: ${stubValueFor(p.type)}`);
294
+ }
295
+
296
+ // Required body fields — expand from model if present.
297
+ // Apply path/body collision rename (body_ prefix) matching resources.ts.
298
+ const body = op.requestBody;
299
+ if (body) {
300
+ const bodyModel = resolveBodyModel(body, modelByName);
301
+ if (bodyModel) {
302
+ for (const f of bodyModel.fields) {
303
+ if (!f.required) continue;
304
+ if (hiddenParams.has(f.name)) continue;
305
+ let name = fieldName(f.name);
306
+ if (pathParamNames.has(name)) {
307
+ name = `body_${name}`;
308
+ }
309
+ if (seen.has(name)) continue;
310
+ seen.add(name);
311
+ parts.push(`${name}: ${stubValueFor(f.type)}`);
312
+ }
313
+ }
314
+ }
315
+
316
+ // Required query params.
317
+ for (const q of op.queryParams ?? []) {
318
+ if (!q.required) continue;
319
+ if (hiddenParams.has(q.name)) continue;
320
+ const name = safeParamName(q.name);
321
+ if (seen.has(name)) continue;
322
+ seen.add(name);
323
+ parts.push(`${name}: ${stubValueFor(q.type)}`);
324
+ }
325
+
326
+ // Required parameter group kwargs.
327
+ for (const group of op.parameterGroups ?? []) {
328
+ if (group.optional) continue;
329
+ const name = fieldName(group.name);
330
+ if (seen.has(name)) continue;
331
+ seen.add(name);
332
+ // Stub as a hash with the first variant's type discriminant.
333
+ const firstVariant = group.variants[0];
334
+ if (firstVariant) {
335
+ parts.push(`${name}: { type: "${firstVariant.name}" }`);
336
+ } else {
337
+ parts.push(`${name}: {}`);
338
+ }
339
+ }
340
+
341
+ return parts.join(', ');
342
+ }
343
+
344
+ function resolveBodyModel(ref: TypeRef, modelByName: Map<string, Model>): Model | null {
345
+ if (ref.kind === 'model') return modelByName.get(ref.name) ?? null;
346
+ if (ref.kind === 'nullable') return resolveBodyModel(ref.inner, modelByName);
347
+ if (ref.kind === 'union') {
348
+ for (const v of ref.variants) {
349
+ if (v.kind === 'model') return modelByName.get(v.name) ?? null;
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
355
+ function emitWrapperTests(args: {
356
+ lines: string[];
357
+ wrapper: ResolvedWrapper;
358
+ op: Operation;
359
+ prop: string;
360
+ stubUrl: string;
361
+ httpMethodSym: string;
362
+ ctx: EmitterContext;
363
+ }): void {
364
+ const { lines, wrapper, op, prop, stubUrl, httpMethodSym, ctx } = args;
365
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
366
+
367
+ // Build call args: path params + required exposed params only.
368
+ const parts: string[] = [];
369
+ const seen = new Set<string>();
370
+ for (const p of op.pathParams ?? []) {
371
+ const n = safeParamName(p.name);
372
+ if (seen.has(n)) continue;
373
+ seen.add(n);
374
+ parts.push(`${n}: ${stubValueFor(p.type)}`);
375
+ }
376
+ for (const wp of wrapperParams) {
377
+ if (wp.isOptional) continue;
378
+ const n = fieldName(wp.paramName);
379
+ if (seen.has(n)) continue;
380
+ seen.add(n);
381
+ parts.push(`${n}: ${wp.field ? stubValueFor(wp.field.type) : '"stub"'}`);
382
+ }
383
+ const callArgs = parts.join(', ');
384
+
385
+ const wrapperRegex = stubUrlRegex(stubUrl);
386
+ lines.push('');
387
+ lines.push(` def test_${wrapper.name}_returns_expected_result`);
388
+ lines.push(` stub_request(${httpMethodSym}, ${wrapperRegex})`);
389
+ lines.push(` .to_return(body: "{}", status: 200)`);
390
+ lines.push(` result = @client.${prop}.${wrapper.name}(${callArgs})`);
391
+ lines.push(' refute_nil result');
392
+ lines.push(' end');
393
+
394
+ lines.push('');
395
+ lines.push(` def test_${wrapper.name}_raises_authentication_error_on_401`);
396
+ lines.push(` stub_request(${httpMethodSym}, ${wrapperRegex})`);
397
+ lines.push(` .to_return(body: '{"message": "Unauthorized"}', status: 401)`);
398
+ lines.push(` assert_raises(WorkOS::AuthenticationError) do`);
399
+ lines.push(` @client.${prop}.${wrapper.name}(${callArgs})`);
400
+ lines.push(' end');
401
+ lines.push(' end');
402
+ }
403
+
404
+ function stubValueFor(ref: TypeRef): string {
405
+ switch (ref.kind) {
406
+ case 'primitive':
407
+ switch (ref.type) {
408
+ case 'string':
409
+ return `"stub"`;
410
+ case 'integer':
411
+ return `1`;
412
+ case 'number':
413
+ return `1.0`;
414
+ case 'boolean':
415
+ return `true`;
416
+ default:
417
+ return `nil`;
418
+ }
419
+ case 'array':
420
+ return `[]`;
421
+ case 'map':
422
+ return `{}`;
423
+ case 'enum':
424
+ return `"stub"`;
425
+ case 'literal':
426
+ if (typeof ref.value === 'string') return `"${ref.value}"`;
427
+ if (ref.value === null) return `nil`;
428
+ return String(ref.value);
429
+ case 'nullable':
430
+ return stubValueFor(ref.inner);
431
+ case 'model':
432
+ return `{}`;
433
+ case 'union':
434
+ return ref.variants.length > 0 ? stubValueFor(ref.variants[0]) : `nil`;
435
+ default:
436
+ return `nil`;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Build a WebMock-compatible regex string that matches the exact API path
442
+ * (with stub path params) plus an optional query string.
443
+ *
444
+ * Returns a Ruby Regexp literal like: %r{\Ahttps://api\.workos\.com/organizations(\?|\z)}
445
+ */
446
+ function buildStubUrl(op: Operation): string {
447
+ let path = op.path;
448
+ for (const p of op.pathParams ?? []) {
449
+ path = path.replace(`{${p.name}}`, 'stub');
450
+ }
451
+ // Escape regex special chars in the URL path (dots, slashes, etc.)
452
+ const escaped = `https://api.workos.com${path}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
453
+ return escaped;
454
+ }
455
+
456
+ /** Format the stub URL as a Ruby regex literal for WebMock. */
457
+ function stubUrlRegex(escaped: string): string {
458
+ return `%r{\\A${escaped}(\\?|\\z)}`;
459
+ }
@@ -0,0 +1,97 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+
5
+ /**
6
+ * Map an IR TypeRef to a Ruby YARD doc type string.
7
+ * Ruby is dynamically typed, so these are used only in YARD comments.
8
+ */
9
+ export function mapTypeRef(ref: TypeRef): string {
10
+ return irMapTypeRef<string>(ref, {
11
+ primitive: mapPrimitive,
12
+ array: (ref, items) => {
13
+ void ref;
14
+ return `Array<${items}>`;
15
+ },
16
+ model: (r) => `WorkOS::${className(r.name)}`,
17
+ enum: (r) => `WorkOS::Types::${className(r.name)}`,
18
+ union: (r, variants) => joinUnionVariants(r, variants),
19
+ nullable: (ref, inner) => {
20
+ void ref;
21
+ return inner;
22
+ },
23
+ literal: (r) => (typeof r.value === 'string' ? 'String' : r.value === null ? 'nil' : typeof r.value),
24
+ map: (ref, value) => {
25
+ void ref;
26
+ return `Hash{String => ${value}}`;
27
+ },
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Map an IR TypeRef to a more verbose Ruby-compatible type string for documentation.
33
+ * Includes `nil` for nullable types (YARD convention: `[Foo, nil]`).
34
+ */
35
+ export function mapTypeRefForYard(ref: TypeRef): string {
36
+ return irMapTypeRef<string>(ref, {
37
+ primitive: mapPrimitive,
38
+ array: (ref, items) => {
39
+ void ref;
40
+ return `Array<${items}>`;
41
+ },
42
+ model: (r) => `WorkOS::${className(r.name)}`,
43
+ enum: (r) => `WorkOS::Types::${className(r.name)}`,
44
+ union: (r, variants) => joinUnionVariantsYard(r, variants),
45
+ nullable: (ref, inner) => {
46
+ void ref;
47
+ // Avoid duplicate nil when inner already contains nil (e.g. from a union with a null literal).
48
+ const parts = inner.split(', ');
49
+ if (parts.includes('nil')) return inner;
50
+ return `${inner}, nil`;
51
+ },
52
+ literal: (r) => (typeof r.value === 'string' ? 'String' : r.value === null ? 'nil' : typeof r.value),
53
+ map: (ref, value) => {
54
+ void ref;
55
+ return `Hash{String => ${value}}`;
56
+ },
57
+ });
58
+ }
59
+
60
+ function mapPrimitive(ref: PrimitiveType): string {
61
+ if (ref.format) {
62
+ switch (ref.format) {
63
+ case 'binary':
64
+ return 'String';
65
+ }
66
+ }
67
+ switch (ref.type) {
68
+ case 'string':
69
+ return 'String';
70
+ case 'integer':
71
+ return 'Integer';
72
+ case 'number':
73
+ return 'Float';
74
+ case 'boolean':
75
+ return 'Boolean';
76
+ case 'unknown':
77
+ return 'Object';
78
+ }
79
+ }
80
+
81
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
82
+ if (ref.compositionKind === 'allOf') {
83
+ return variants[0] ?? 'Object';
84
+ }
85
+ const unique = [...new Set(variants)];
86
+ if (unique.length === 1) return unique[0];
87
+ return unique.join(', ');
88
+ }
89
+
90
+ function joinUnionVariantsYard(ref: UnionType, variants: string[]): string {
91
+ if (ref.compositionKind === 'allOf') {
92
+ return variants[0] ?? 'Object';
93
+ }
94
+ const unique = [...new Set(variants)];
95
+ if (unique.length === 1) return unique[0];
96
+ return unique.join(', ');
97
+ }