Skip to content

Commit 8eb7426

Browse files
feat(ruby): add support for discriminated unions, enums, and undiscriminated unions in dynamic snippets (#11099)
* feat(ruby-v2): add support for discriminated unions, enums, and undiscriminated unions in dynamic snippets Co-Authored-By: [email protected] <[email protected]> * test(ruby): update snapshot for big-entity test with discriminated union and enum values Co-Authored-By: [email protected] <[email protected]> * fix(ruby): handle non-object request body types (arrays, undiscriminated unions) in dynamic snippets Co-Authored-By: [email protected] <[email protected]> * chore(ruby):update seed * fix(ruby): prevent duplicate errors in undiscriminated union conversion Return the already-converted result from the cloned context instead of calling convert() again on the main context. This prevents duplicate errors when the same conversion is performed twice. Co-Authored-By: [email protected] <[email protected]> * bump version --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent d81401b commit 8eb7426

File tree

1,022 files changed

+9571
-2873
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

1,022 files changed

+9571
-2873
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: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ exports[`snippets (default) > examples > 'POST /big-entity (simple)' 1`] = `
3131
client = Acme::Client.new(token: '<YOUR_API_KEY>');
3232
3333
client.service.create_big_entity(
34+
cast_member: {
35+
id: 'john.doe',
36+
name: 'John Doe'
37+
},
3438
extended_movie: {
3539
cast: ['John Travolta', 'Samuel L. Jackson', 'Uma Thurman', 'Bruce Willis'],
3640
id: 'movie-sda231x',
@@ -43,7 +47,8 @@ client.service.create_big_entity(
4347
revenue: 1000000
4448
},
4549
migration: {
46-
name: 'Migration 31 Aug'
50+
name: 'Migration 31 Aug',
51+
status: 'RUNNING'
4752
}
4853
);
4954
"
@@ -109,30 +114,43 @@ exports[`snippets (default) > exhaustive > 'POST /container/list-of-objects (inv
109114
"[
110115
{
111116
"severity": "CRITICAL",
112-
"path": [],
113-
"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"
114128
}
115129
]"
116130
`;
117131
118132
exports[`snippets (default) > exhaustive > 'POST /container/list-of-objects (simp…' 1`] = `
119-
"[
120-
{
121-
"severity": "CRITICAL",
122-
"path": [],
123-
"message": "Expected object with key, value pairs but got: array"
124-
}
125-
]"
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+
"
126145
`;
127146
128147
exports[`snippets (default) > exhaustive > 'POST /container/list-of-primitives (s…' 1`] = `
129-
"[
130-
{
131-
"severity": "CRITICAL",
132-
"path": [],
133-
"message": "Expected object with key, value pairs but got: array"
134-
}
135-
]"
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+
"
136154
`;
137155
138156
exports[`snippets (default) > file-upload > 'POST /' 1`] = `

0 commit comments

Comments
 (0)