Skip to content

Commit c8cd99d

Browse files
fix(ruby): handle non-object request body types (arrays, undiscriminated unions) in dynamic snippets
Co-Authored-By: [email protected] <[email protected]>
1 parent f60c363 commit c8cd99d

File tree

3 files changed

+181
-27
lines changed

3 files changed

+181
-27
lines changed

generators/ruby-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts

Lines changed: 147 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ export class EndpointSnippetGenerator {
553553
);
554554

555555
// Add body fields as keyword arguments
556-
if (request.body != null) {
556+
if (request.body != null && snippet.requestBody != null) {
557557
switch (request.body.type) {
558558
case "bytes":
559559
// Not supported in Ruby snippets yet
@@ -563,22 +563,45 @@ export class EndpointSnippetGenerator {
563563
});
564564
break;
565565
case "typeReference": {
566-
// For typeReference bodies, we need to flatten the body fields into keyword arguments
567-
// The Ruby SDK expects keyword args that get wrapped into the type by the method
568-
const bodyRecord = this.context.getRecord(snippet.requestBody);
569-
if (bodyRecord != null) {
570-
// Get the type definition to understand the field names
571-
const typeRef = request.body.value;
572-
if (typeRef.type === "named") {
573-
const namedType = this.context.resolveNamedType({ typeId: typeRef.value });
574-
if (namedType != null) {
575-
// Convert the body record fields to keyword arguments
566+
const typeRef = request.body.value;
567+
568+
// Check if this is a named type that we can resolve
569+
if (typeRef.type === "named") {
570+
const namedType = this.context.resolveNamedType({ typeId: typeRef.value });
571+
if (namedType != null && namedType.type === "object") {
572+
// For objects, flatten the body fields into keyword arguments
573+
const bodyRecord = this.context.getRecord(snippet.requestBody);
574+
if (bodyRecord != null) {
576575
const bodyFields = this.getBodyFieldsAsKeywordArgs({
577576
namedType,
578577
bodyRecord
579578
});
580579
args.push(...bodyFields);
581580
}
581+
} else if (namedType != null) {
582+
// For non-object named types (undiscriminated unions, aliases, etc.),
583+
// convert the entire body value and pass as a single 'request' keyword argument
584+
const bodyArgs = this.getBodyArgsForNonObjectType({
585+
namedType,
586+
typeRef,
587+
bodyValue: snippet.requestBody
588+
});
589+
args.push(...bodyArgs);
590+
}
591+
} else {
592+
// For non-named type references (containers, primitives, etc.),
593+
// convert the body value directly
594+
const convertedValue = this.context.dynamicTypeLiteralMapper.convert({
595+
typeReference: typeRef,
596+
value: snippet.requestBody
597+
});
598+
if (!ruby.TypeLiteral.isNop(convertedValue)) {
599+
args.push(
600+
ruby.keywordArgument({
601+
name: "request",
602+
value: convertedValue
603+
})
604+
);
582605
}
583606
}
584607
break;
@@ -591,6 +614,119 @@ export class EndpointSnippetGenerator {
591614
return args;
592615
}
593616

617+
private getBodyArgsForNonObjectType({
618+
namedType,
619+
typeRef,
620+
bodyValue
621+
}: {
622+
namedType: FernIr.dynamic.NamedType;
623+
typeRef: FernIr.dynamic.TypeReference;
624+
bodyValue: unknown;
625+
}): ruby.KeywordArgument[] {
626+
const args: ruby.KeywordArgument[] = [];
627+
628+
switch (namedType.type) {
629+
case "undiscriminatedUnion": {
630+
// For undiscriminated unions, the body value should match one of the variants
631+
// Try to convert it and extract the fields as keyword arguments
632+
const bodyRecord = this.context.getRecord(bodyValue);
633+
if (bodyRecord != null) {
634+
// The body is an object - try to find a matching variant and extract its fields
635+
for (const variant of namedType.types) {
636+
if (variant.type === "named") {
637+
const variantType = this.context.resolveNamedType({ typeId: variant.value });
638+
if (variantType != null && variantType.type === "object") {
639+
// Check if the body matches this variant's properties
640+
const variantProps = new Set(variantType.properties.map((p) => p.name.wireValue));
641+
const bodyKeys = Object.keys(bodyRecord);
642+
const allKeysMatch = bodyKeys.every((key) => variantProps.has(key));
643+
if (allKeysMatch && bodyKeys.length > 0) {
644+
// This variant matches - flatten its fields
645+
const bodyFields = this.getBodyFieldsAsKeywordArgs({
646+
namedType: variantType,
647+
bodyRecord
648+
});
649+
args.push(...bodyFields);
650+
return args;
651+
}
652+
}
653+
}
654+
}
655+
}
656+
// If we couldn't match a variant or extract fields, convert the whole value
657+
const convertedValue = this.context.dynamicTypeLiteralMapper.convert({
658+
typeReference: typeRef,
659+
value: bodyValue
660+
});
661+
if (!ruby.TypeLiteral.isNop(convertedValue)) {
662+
args.push(
663+
ruby.keywordArgument({
664+
name: "request",
665+
value: convertedValue
666+
})
667+
);
668+
}
669+
break;
670+
}
671+
case "alias": {
672+
// For aliases, check if the underlying type is an object we can flatten
673+
const aliasedType = namedType.typeReference;
674+
if (aliasedType.type === "named") {
675+
const resolvedAliasType = this.context.resolveNamedType({ typeId: aliasedType.value });
676+
if (resolvedAliasType != null && resolvedAliasType.type === "object") {
677+
const bodyRecord = this.context.getRecord(bodyValue);
678+
if (bodyRecord != null) {
679+
const bodyFields = this.getBodyFieldsAsKeywordArgs({
680+
namedType: resolvedAliasType,
681+
bodyRecord
682+
});
683+
args.push(...bodyFields);
684+
return args;
685+
}
686+
}
687+
}
688+
// For non-object aliases (arrays, primitives, etc.), convert the whole value
689+
const convertedValue = this.context.dynamicTypeLiteralMapper.convert({
690+
typeReference: typeRef,
691+
value: bodyValue
692+
});
693+
if (!ruby.TypeLiteral.isNop(convertedValue)) {
694+
args.push(
695+
ruby.keywordArgument({
696+
name: "request",
697+
value: convertedValue
698+
})
699+
);
700+
}
701+
break;
702+
}
703+
case "discriminatedUnion":
704+
case "enum": {
705+
// For discriminated unions and enums, convert the whole value
706+
const convertedValue = this.context.dynamicTypeLiteralMapper.convert({
707+
typeReference: typeRef,
708+
value: bodyValue
709+
});
710+
if (!ruby.TypeLiteral.isNop(convertedValue)) {
711+
args.push(
712+
ruby.keywordArgument({
713+
name: "request",
714+
value: convertedValue
715+
})
716+
);
717+
}
718+
break;
719+
}
720+
case "object":
721+
// This shouldn't happen as objects are handled separately
722+
break;
723+
default:
724+
assertNever(namedType);
725+
}
726+
727+
return args;
728+
}
729+
594730
private getBodyFieldsAsKeywordArgs({
595731
namedType,
596732
bodyRecord

generators/ruby-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -114,30 +114,43 @@ exports[`snippets (default) > exhaustive > 'POST /container/list-of-objects (inv
114114
"[
115115
{
116116
"severity": "CRITICAL",
117-
"path": [],
118-
"message": "Expected object with key, value pairs but got: array"
117+
"path": [
118+
"string"
119+
],
120+
"message": "Expected string, got boolean"
121+
},
122+
{
123+
"severity": "CRITICAL",
124+
"path": [
125+
"string"
126+
],
127+
"message": "Expected string, got number"
119128
}
120129
]"
121130
`;
122131
123132
exports[`snippets (default) > exhaustive > 'POST /container/list-of-objects (simp…' 1`] = `
124-
"[
125-
{
126-
"severity": "CRITICAL",
127-
"path": [],
128-
"message": "Expected object with key, value pairs but got: array"
129-
}
130-
]"
133+
"require "acme"
134+
135+
client = Acme::Client.new(token: '<YOUR_API_KEY>');
136+
137+
client.endpoints.container.get_and_return_list_of_objects(request: [{
138+
string: 'one'
139+
}, {
140+
string: 'two'
141+
}, {
142+
string: 'three'
143+
}]);
144+
"
131145
`;
132146
133147
exports[`snippets (default) > exhaustive > 'POST /container/list-of-primitives (s…' 1`] = `
134-
"[
135-
{
136-
"severity": "CRITICAL",
137-
"path": [],
138-
"message": "Expected object with key, value pairs but got: array"
139-
}
140-
]"
148+
"require "acme"
149+
150+
client = Acme::Client.new(token: '<YOUR_API_KEY>');
151+
152+
client.endpoints.container.get_and_return_list_of_primitives(request: ['one', 'two', 'three']);
153+
"
141154
`;
142155
143156
exports[`snippets (default) > file-upload > 'POST /' 1`] = `

generators/ruby-v2/sdk/versions.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
Add support for discriminated unions, enums, and undiscriminated unions in dynamic snippets.
99
This fixes issues where snippet examples were showing empty arrays or missing values for
1010
complex types like union recipients in email/message send operations.
11+
- type: fix
12+
summary: |
13+
Fix dynamic snippet generation for non-object request body types (arrays, undiscriminated unions).
14+
Endpoints with array or undiscriminated union request bodies now correctly generate snippets
15+
with a `request:` keyword argument containing the converted body value.
1116
irVersion: 61
1217

1318
- version: 1.0.0-rc61

0 commit comments

Comments
 (0)