@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/src/ruby/tests.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
className,
|
|
4
|
+
fileName,
|
|
5
|
+
fieldName,
|
|
6
|
+
safeParamName,
|
|
7
|
+
scopedGroupVariantClassName,
|
|
8
|
+
servicePropertyName,
|
|
9
|
+
resolveMethodName,
|
|
10
|
+
} from './naming.js';
|
|
11
|
+
import {
|
|
12
|
+
buildResolvedLookup,
|
|
13
|
+
groupByMount,
|
|
14
|
+
lookupResolved,
|
|
15
|
+
buildHiddenParams,
|
|
16
|
+
collectBodyFieldTypes,
|
|
17
|
+
} from '../shared/resolved-ops.js';
|
|
4
18
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
5
19
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
20
|
+
import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js';
|
|
6
21
|
|
|
7
22
|
/**
|
|
8
23
|
* Generate Ruby Minitest test files for each service and per-method.
|
|
@@ -17,10 +32,12 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
17
32
|
const files: GeneratedFile[] = [];
|
|
18
33
|
|
|
19
34
|
const groups = groupByMount(ctx);
|
|
35
|
+
const models = spec.models as Model[];
|
|
20
36
|
const modelByName = new Map<string, Model>();
|
|
21
|
-
for (const m of
|
|
37
|
+
for (const m of models) modelByName.set(m.name, m);
|
|
22
38
|
|
|
23
39
|
const lookup = buildResolvedLookup(ctx);
|
|
40
|
+
const groupOwners = buildGroupOwnerMap(ctx);
|
|
24
41
|
|
|
25
42
|
for (const [mountTarget, group] of groups) {
|
|
26
43
|
const cls = className(mountTarget);
|
|
@@ -68,31 +85,63 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
68
85
|
|
|
69
86
|
const resolved = lookupResolved(op, lookup);
|
|
70
87
|
const hiddenParams = buildHiddenParams(resolved);
|
|
71
|
-
const callArgs = buildCallArgsStub(op, modelByName, hiddenParams);
|
|
88
|
+
const callArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models);
|
|
89
|
+
const bodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models);
|
|
72
90
|
|
|
73
91
|
// Collect method info for the parameterized 401 test (T20).
|
|
74
92
|
authMethodManifest.push({ method, httpMethodSym, stubUrl, callArgs });
|
|
75
93
|
|
|
76
94
|
const stubRegex = stubUrlRegex(stubUrl);
|
|
77
95
|
lines.push(` def test_${method}_returns_expected_result`);
|
|
96
|
+
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
|
|
97
|
+
if (bodyMatcher) lines.push(` .with(body: ${bodyMatcher})`);
|
|
78
98
|
if (isList) {
|
|
79
|
-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
|
|
80
99
|
lines.push(` .to_return(body: '{"data": [], "list_metadata": {}}', status: 200)`);
|
|
81
100
|
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
|
|
82
101
|
lines.push(' assert_kind_of WorkOS::Types::ListStruct, result');
|
|
83
102
|
} else if (op.response.kind === 'primitive') {
|
|
84
|
-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
|
|
85
103
|
lines.push(` .to_return(body: "{}", status: 200)`);
|
|
86
104
|
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
|
|
87
105
|
lines.push(' assert_nil result');
|
|
88
106
|
} else {
|
|
89
|
-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
|
|
90
107
|
lines.push(` .to_return(body: "{}", status: 200)`);
|
|
91
108
|
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
|
|
92
109
|
lines.push(' refute_nil result');
|
|
93
110
|
}
|
|
94
111
|
lines.push(' end');
|
|
95
112
|
|
|
113
|
+
// Per-variant tests: for every parameter group with more than one
|
|
114
|
+
// variant, emit one extra test per non-first variant so the second/third
|
|
115
|
+
// arm of the dispatcher gets exercised. Without this, a wrong wire-name
|
|
116
|
+
// mapping in (e.g.) ResourceTargetByExternalId would slip through.
|
|
117
|
+
for (const group of op.parameterGroups ?? []) {
|
|
118
|
+
for (let vi = 1; vi < group.variants.length; vi++) {
|
|
119
|
+
const variant = group.variants[vi];
|
|
120
|
+
const overrides = new Map<string, number>([[group.name, vi]]);
|
|
121
|
+
const variantCallArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models, overrides);
|
|
122
|
+
const variantBodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models, overrides);
|
|
123
|
+
const suffix = `with_${fieldName(group.name)}_${fieldName(variant.name)}`;
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(` def test_${method}_${suffix}_returns_expected_result`);
|
|
126
|
+
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
|
|
127
|
+
if (variantBodyMatcher) lines.push(` .with(body: ${variantBodyMatcher})`);
|
|
128
|
+
if (isList) {
|
|
129
|
+
lines.push(` .to_return(body: '{"data": [], "list_metadata": {}}', status: 200)`);
|
|
130
|
+
lines.push(` result = @client.${prop}.${method}(${variantCallArgs})`);
|
|
131
|
+
lines.push(' assert_kind_of WorkOS::Types::ListStruct, result');
|
|
132
|
+
} else if (op.response.kind === 'primitive') {
|
|
133
|
+
lines.push(` .to_return(body: "{}", status: 200)`);
|
|
134
|
+
lines.push(` result = @client.${prop}.${method}(${variantCallArgs})`);
|
|
135
|
+
lines.push(' assert_nil result');
|
|
136
|
+
} else {
|
|
137
|
+
lines.push(` .to_return(body: "{}", status: 200)`);
|
|
138
|
+
lines.push(` result = @client.${prop}.${method}(${variantCallArgs})`);
|
|
139
|
+
lines.push(' refute_nil result');
|
|
140
|
+
}
|
|
141
|
+
lines.push(' end');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
96
145
|
// Wrapper tests (union split variants).
|
|
97
146
|
if (resolved?.wrappers && resolved.wrappers.length > 0) {
|
|
98
147
|
for (const wrapper of resolved.wrappers) {
|
|
@@ -278,8 +327,19 @@ function roundTripStub(ref: TypeRef, enumNames: Set<string>): string {
|
|
|
278
327
|
}
|
|
279
328
|
}
|
|
280
329
|
|
|
281
|
-
/** Build minimal placeholder arguments for calling the SDK method from a test.
|
|
282
|
-
|
|
330
|
+
/** Build minimal placeholder arguments for calling the SDK method from a test.
|
|
331
|
+
* `variantOverrides` selects a non-zero variant index per group; absent groups
|
|
332
|
+
* default to variant 0. Used to emit per-variant test cases that exercise the
|
|
333
|
+
* second/third arm of each parameter-group dispatcher.
|
|
334
|
+
*/
|
|
335
|
+
function buildCallArgsStub(
|
|
336
|
+
op: Operation,
|
|
337
|
+
modelByName: Map<string, Model>,
|
|
338
|
+
hiddenParams: Set<string>,
|
|
339
|
+
groupOwners: Map<string, string>,
|
|
340
|
+
models: Model[],
|
|
341
|
+
variantOverrides: Map<string, number> = new Map(),
|
|
342
|
+
): string {
|
|
283
343
|
const parts: string[] = [];
|
|
284
344
|
const seen = new Set<string>();
|
|
285
345
|
|
|
@@ -323,24 +383,104 @@ function buildCallArgsStub(op: Operation, modelByName: Map<string, Model>, hidde
|
|
|
323
383
|
parts.push(`${name}: ${stubValueFor(q.type)}`);
|
|
324
384
|
}
|
|
325
385
|
|
|
326
|
-
//
|
|
386
|
+
// Parameter group kwargs (required and optional): instantiate the first
|
|
387
|
+
// variant's class. Optional groups are exercised too so the dispatcher
|
|
388
|
+
// code path is covered — passing nothing would skip the body block and
|
|
389
|
+
// hide silent-drop bugs (see workos/oagen-emitters#66).
|
|
390
|
+
//
|
|
391
|
+
// Variant param types are recovered from the body model: the IR's leaf type
|
|
392
|
+
// is often a bare primitive (`role_slugs: string`) even when the body model
|
|
393
|
+
// declares a richer shape (`Array<String>`). Stubbing without recovery would
|
|
394
|
+
// pass `"stub"` to `RoleMultiple.new(role_slugs:)` while the class signature
|
|
395
|
+
// declares `T::Array[String]` — the test passes locally but ships an invalid
|
|
396
|
+
// wire body the API rejects.
|
|
397
|
+
const bodyFieldTypes = collectBodyFieldTypes(op, models);
|
|
327
398
|
for (const group of op.parameterGroups ?? []) {
|
|
328
|
-
if (group.optional) continue;
|
|
329
399
|
const name = fieldName(group.name);
|
|
330
400
|
if (seen.has(name)) continue;
|
|
331
401
|
seen.add(name);
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
402
|
+
const idx = variantOverrides.get(group.name) ?? 0;
|
|
403
|
+
const variant = group.variants[idx];
|
|
404
|
+
if (variant) {
|
|
405
|
+
const owner = groupOwners.get(group.name);
|
|
406
|
+
if (!owner) {
|
|
407
|
+
throw new Error(`No owner mount target found for parameter group '${group.name}'`);
|
|
408
|
+
}
|
|
409
|
+
const variantClass = scopedGroupVariantClassName(owner, group.name, variant.name);
|
|
410
|
+
const fieldStubs = variant.parameters
|
|
411
|
+
.map((p) => `${fieldName(p.name)}: ${stubValueFor(pickVariantParamType(p.type, bodyFieldTypes.get(p.name)))}`)
|
|
412
|
+
.join(', ');
|
|
413
|
+
parts.push(`${name}: ${variantClass}.new(${fieldStubs})`);
|
|
338
414
|
}
|
|
339
415
|
}
|
|
340
416
|
|
|
341
417
|
return parts.join(', ');
|
|
342
418
|
}
|
|
343
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Build a Ruby `hash_including(...)` matcher describing the wire body the
|
|
422
|
+
* SDK should send for an operation whose body is constructed (in part) by a
|
|
423
|
+
* parameter-group dispatcher. Returns `null` for operations without body
|
|
424
|
+
* groups — those are still stubbed without a body matcher.
|
|
425
|
+
*
|
|
426
|
+
* The matcher includes every required non-group body field plus the first
|
|
427
|
+
* variant's wire-name leaves for each group dispatched into the body. This
|
|
428
|
+
* catches regressions where the dispatcher silently drops a passed group
|
|
429
|
+
* (the original `update_organization_membership` regression).
|
|
430
|
+
*/
|
|
431
|
+
function buildBodyMatcher(
|
|
432
|
+
op: Operation,
|
|
433
|
+
modelByName: Map<string, Model>,
|
|
434
|
+
hiddenParams: Set<string>,
|
|
435
|
+
models: Model[],
|
|
436
|
+
variantOverrides: Map<string, number> = new Map(),
|
|
437
|
+
): string | null {
|
|
438
|
+
const httpMethod = op.httpMethod.toLowerCase();
|
|
439
|
+
const hasBodyMethod = !['get', 'head', 'delete'].includes(httpMethod);
|
|
440
|
+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
441
|
+
if (!hasBodyMethod || !hasGroups) return null;
|
|
442
|
+
|
|
443
|
+
const groupedParamNames = new Set<string>();
|
|
444
|
+
for (const group of op.parameterGroups ?? []) {
|
|
445
|
+
for (const variant of group.variants) {
|
|
446
|
+
for (const p of variant.parameters) groupedParamNames.add(p.name);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const entries: string[] = [];
|
|
451
|
+
|
|
452
|
+
// Required non-group body fields, keyed by wire name.
|
|
453
|
+
if (op.requestBody) {
|
|
454
|
+
const bodyModel = resolveBodyModel(op.requestBody, modelByName);
|
|
455
|
+
if (bodyModel) {
|
|
456
|
+
for (const f of bodyModel.fields) {
|
|
457
|
+
if (!f.required) continue;
|
|
458
|
+
if (hiddenParams.has(f.name)) continue;
|
|
459
|
+
if (groupedParamNames.has(f.name)) continue;
|
|
460
|
+
entries.push(`"${f.name}" => ${stubValueFor(f.type)}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Selected variant of each group: its leaves get pumped into the body. The
|
|
466
|
+
// matcher value must use the recovered (body-model) type, not the IR leaf —
|
|
467
|
+
// see buildCallArgsStub for the same reasoning. Without this, the matcher
|
|
468
|
+
// shape diverges from what the SDK actually sends.
|
|
469
|
+
const bodyFieldTypes = collectBodyFieldTypes(op, models);
|
|
470
|
+
for (const group of op.parameterGroups ?? []) {
|
|
471
|
+
const idx = variantOverrides.get(group.name) ?? 0;
|
|
472
|
+
const variant = group.variants[idx];
|
|
473
|
+
if (!variant) continue;
|
|
474
|
+
for (const p of variant.parameters) {
|
|
475
|
+
const recovered = pickVariantParamType(p.type, bodyFieldTypes.get(p.name));
|
|
476
|
+
entries.push(`"${p.name}" => ${stubValueFor(recovered)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (entries.length === 0) return null;
|
|
481
|
+
return `hash_including(${entries.join(', ')})`;
|
|
482
|
+
}
|
|
483
|
+
|
|
344
484
|
function resolveBodyModel(ref: TypeRef, modelByName: Map<string, Model>): Model | null {
|
|
345
485
|
if (ref.kind === 'model') return modelByName.get(ref.name) ?? null;
|
|
346
486
|
if (ref.kind === 'nullable') return resolveBodyModel(ref.inner, modelByName);
|
|
@@ -417,7 +557,10 @@ function stubValueFor(ref: TypeRef): string {
|
|
|
417
557
|
return `nil`;
|
|
418
558
|
}
|
|
419
559
|
case 'array':
|
|
420
|
-
|
|
560
|
+
// Single-element array — exercises the wire shape under hash_including
|
|
561
|
+
// matchers. An empty `[]` would match `"role_slugs": []` on the wire,
|
|
562
|
+
// hiding regressions where the SDK serializes the wrong type.
|
|
563
|
+
return `[${stubValueFor(ref.items)}]`;
|
|
421
564
|
case 'map':
|
|
422
565
|
return `{}`;
|
|
423
566
|
case 'enum':
|