Skip to content

Commit c14dab1

Browse files
tnineslingclaude
andcommitted
feat: support @OneOf composition with consistency validation
Adds composition support for the @OneOf directive. When merging input object types, validates that all subgraphs defining the type agree on whether @OneOf is applied. Reports INPUT_OBJECT_ONE_OF_MISMATCH if some subgraphs mark the input as @OneOf and others do not. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 412a157 commit c14dab1

4 files changed

Lines changed: 232 additions & 3 deletions

File tree

‎apollo-federation/src/error/mod.rs‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ pub enum CompositionError {
289289
message: String,
290290
locations: Locations,
291291
},
292+
#[error("{message}")]
293+
InputObjectOneOfMismatch {
294+
message: String,
295+
locations: Locations,
296+
},
292297
}
293298

294299
impl CompositionError {
@@ -357,6 +362,7 @@ impl CompositionError {
357362
Self::ArgumentDefaultMismatch { .. } => ErrorCode::FieldArgumentDefaultMismatch,
358363
Self::InputFieldDefaultMismatch { .. } => ErrorCode::InputFieldDefaultMismatch,
359364
Self::InterfaceFieldNoImplem { .. } => ErrorCode::InterfaceFieldNoImplem,
365+
Self::InputObjectOneOfMismatch { .. } => ErrorCode::InputObjectOneOfMismatch,
360366
}
361367
}
362368

@@ -486,6 +492,12 @@ impl CompositionError {
486492
message: format!("{message}{appendix}"),
487493
locations,
488494
},
495+
Self::InputObjectOneOfMismatch { message, locations } => {
496+
Self::InputObjectOneOfMismatch {
497+
message: format!("{message}{appendix}"),
498+
locations,
499+
}
500+
}
489501
// Remaining errors do not have an obvious way to appending a message, so we just return self.
490502
Self::SubgraphError { .. }
491503
| Self::MergeError { .. }
@@ -518,7 +530,8 @@ impl CompositionError {
518530
| Self::InvalidFieldSharing { locations, .. }
519531
| Self::MergeError { locations, .. }
520532
| Self::ArgumentDefaultMismatch { locations, .. }
521-
| Self::InputFieldDefaultMismatch { locations, .. } => locations.extend(new_locations),
533+
| Self::InputFieldDefaultMismatch { locations, .. }
534+
| Self::InputObjectOneOfMismatch { locations, .. } => locations.extend(new_locations),
522535
// Remaining errors do not have an obvious way to appending locations, so we do nothing
523536
_ => {}
524537
}
@@ -538,7 +551,8 @@ impl CompositionError {
538551
| Self::MergeError { locations, .. }
539552
| Self::ArgumentDefaultMismatch { locations, .. }
540553
| Self::InputFieldDefaultMismatch { locations, .. }
541-
| Self::InterfaceFieldNoImplem { locations, .. } => locations,
554+
| Self::InterfaceFieldNoImplem { locations, .. }
555+
| Self::InputObjectOneOfMismatch { locations, .. } => locations,
542556
_ => &[],
543557
}
544558
}
@@ -2576,6 +2590,14 @@ static MISSING_TRANSITIVE_AUTH_REQUIREMENTS: LazyLock<ErrorCodeDefinition> = Laz
25762590
)
25772591
});
25782592

2593+
static INPUT_OBJECT_ONE_OF_MISMATCH: LazyLock<ErrorCodeDefinition> = LazyLock::new(|| {
2594+
ErrorCodeDefinition::new(
2595+
"INPUT_OBJECT_ONE_OF_MISMATCH".to_owned(),
2596+
"An input object type has the @oneOf directive in some subgraphs but not all subgraphs that define the type.".to_owned(),
2597+
None,
2598+
)
2599+
});
2600+
25792601
#[derive(Debug, PartialEq, strum_macros::EnumIter)]
25802602
pub enum ErrorCode {
25812603
ErrorCodeMissing,
@@ -2685,6 +2707,7 @@ pub enum ErrorCode {
26852707
QueryRootMissing,
26862708
AuthRequirementsAppliedOnInterface,
26872709
MissingTransitiveAuthRequirements,
2710+
InputObjectOneOfMismatch,
26882711
}
26892712

26902713
impl ErrorCode {
@@ -2821,6 +2844,7 @@ impl ErrorCode {
28212844
&AUTH_REQUIREMENTS_APPLIED_ON_INTERFACE
28222845
}
28232846
ErrorCode::MissingTransitiveAuthRequirements => &MISSING_TRANSITIVE_AUTH_REQUIREMENTS,
2847+
ErrorCode::InputObjectOneOfMismatch => &INPUT_OBJECT_ONE_OF_MISMATCH,
28242848
}
28252849
}
28262850
}

‎apollo-federation/src/merger/merge_input.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ impl Merger {
2626
sources: &Sources<Node<InputObjectType>>,
2727
dest: &InputObjectTypeDefinitionPosition,
2828
) -> Result<(), FederationError> {
29+
self.validate_one_of_consistency(sources, dest);
30+
2931
// Like for other inputs, we add all the fields found in any subgraphs initially as a simple mean to have a complete list of
3032
// field to iterate over, but we will remove those that are not in all subgraphs.
3133
let added = self.add_input_fields_shallow(sources, dest)?;
@@ -276,4 +278,39 @@ impl Merger {
276278
self.merge_default_value(sources, dest_field)?;
277279
Ok(())
278280
}
281+
282+
fn validate_one_of_consistency(
283+
&mut self,
284+
sources: &Sources<Node<InputObjectType>>,
285+
dest: &InputObjectTypeDefinitionPosition,
286+
) {
287+
let mut with_one_of: Vec<String> = Vec::new();
288+
let mut without_one_of: Vec<String> = Vec::new();
289+
290+
for (idx, source) in sources {
291+
let Some(source) = source else {
292+
continue;
293+
};
294+
let has_one_of = source.directives.has("oneOf");
295+
let subgraph_name = self.names[*idx].to_string();
296+
if has_one_of {
297+
with_one_of.push(subgraph_name);
298+
} else {
299+
without_one_of.push(subgraph_name);
300+
}
301+
}
302+
303+
if !with_one_of.is_empty() && !without_one_of.is_empty() {
304+
let with_str = human_readable_subgraph_names(with_one_of.iter());
305+
let without_str = human_readable_subgraph_names(without_one_of.iter());
306+
self.error_reporter
307+
.add_error(CompositionError::InputObjectOneOfMismatch {
308+
message: format!(
309+
"Input object type \"{}\" is marked with @oneOf in {} but not in {}",
310+
dest.type_name, with_str, without_str,
311+
),
312+
locations: self.source_locations(sources),
313+
});
314+
}
315+
}
279316
}

‎apollo-federation/src/merger/merger.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,12 @@ static NON_MERGED_CORE_FEATURES: LazyLock<[Identity; 4]> = LazyLock::new(|| {
120120

121121
/// In JS, this is encoded indirectly in `isGraphQLBuiltInDirective`. Regardless of whether
122122
/// the end user redefined these directives, we consider them built-in for merging.
123-
static BUILT_IN_DIRECTIVES: [&str; 6] = [
123+
static BUILT_IN_DIRECTIVES: [&str; 7] = [
124124
"skip",
125125
"include",
126126
"deprecated",
127127
"specifiedBy",
128+
"oneOf",
128129
"defer",
129130
"stream",
130131
];

‎apollo-federation/tests/composition/compose_types.rs‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,170 @@ fn union_types_merges_inconsistent_unions() {
506506
.expect("Union U should exist");
507507
assert_snapshot!(union_u, @"union U = A | B | C");
508508
}
509+
510+
// =============================================================================
511+
// @oneOf INPUT OBJECTS - Tests for @oneOf directive merging behavior
512+
// =============================================================================
513+
514+
#[test]
515+
fn one_of_input_object_merges_when_consistent() {
516+
let subgraph_a = ServiceDefinition {
517+
name: "subgraphA",
518+
type_defs: r#"
519+
type Query {
520+
find(input: FindInput!): String @shareable
521+
}
522+
523+
input FindInput @oneOf {
524+
id: ID
525+
name: String
526+
}
527+
"#,
528+
};
529+
530+
let subgraph_b = ServiceDefinition {
531+
name: "subgraphB",
532+
type_defs: r#"
533+
type Query {
534+
find(input: FindInput!): String @shareable
535+
}
536+
537+
input FindInput @oneOf {
538+
id: ID
539+
name: String
540+
}
541+
"#,
542+
};
543+
544+
let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]);
545+
let supergraph = result.expect("Expected composition to succeed");
546+
let api_schema = supergraph
547+
.to_api_schema(Default::default())
548+
.expect("Expected API schema generation to succeed");
549+
let find_input = coord!(FindInput)
550+
.lookup(api_schema.schema())
551+
.expect("FindInput should exist");
552+
assert_snapshot!(find_input, @r"
553+
input FindInput @oneOf {
554+
id: ID
555+
name: String
556+
}
557+
");
558+
}
559+
560+
#[test]
561+
fn one_of_input_object_errors_when_inconsistent() {
562+
let subgraph_a = ServiceDefinition {
563+
name: "subgraphA",
564+
type_defs: r#"
565+
type Query {
566+
find(input: FindInput!): String @shareable
567+
}
568+
569+
input FindInput @oneOf {
570+
id: ID
571+
name: String
572+
}
573+
"#,
574+
};
575+
576+
let subgraph_b = ServiceDefinition {
577+
name: "subgraphB",
578+
type_defs: r#"
579+
type Query {
580+
find(input: FindInput!): String @shareable
581+
}
582+
583+
input FindInput {
584+
id: ID
585+
name: String
586+
}
587+
"#,
588+
};
589+
590+
let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]);
591+
assert_composition_errors(
592+
&result,
593+
&[(
594+
"INPUT_OBJECT_ONE_OF_MISMATCH",
595+
r#"Input object type "FindInput" is marked with @oneOf in subgraph "subgraphA" but not in subgraph "subgraphB""#,
596+
)],
597+
);
598+
}
599+
600+
#[test]
601+
fn one_of_input_object_merges_single_subgraph() {
602+
let subgraph_a = ServiceDefinition {
603+
name: "subgraphA",
604+
type_defs: r#"
605+
type Query {
606+
find(input: FindInput!): String
607+
}
608+
609+
input FindInput @oneOf {
610+
id: ID
611+
name: String
612+
}
613+
"#,
614+
};
615+
616+
let result = compose_as_fed2_subgraphs(&[subgraph_a]);
617+
let supergraph = result.expect("Expected composition to succeed");
618+
let api_schema = supergraph
619+
.to_api_schema(Default::default())
620+
.expect("Expected API schema generation to succeed");
621+
let find_input = coord!(FindInput)
622+
.lookup(api_schema.schema())
623+
.expect("FindInput should exist");
624+
assert_snapshot!(find_input, @r"
625+
input FindInput @oneOf {
626+
id: ID
627+
name: String
628+
}
629+
");
630+
}
631+
632+
#[test]
633+
fn one_of_input_object_with_different_fields_across_subgraphs() {
634+
let subgraph_a = ServiceDefinition {
635+
name: "subgraphA",
636+
type_defs: r#"
637+
type Query {
638+
find(input: FindInput!): String @shareable
639+
}
640+
641+
input FindInput @oneOf {
642+
id: ID
643+
name: String
644+
}
645+
"#,
646+
};
647+
648+
let subgraph_b = ServiceDefinition {
649+
name: "subgraphB",
650+
type_defs: r#"
651+
type Query {
652+
find(input: FindInput!): String @shareable
653+
}
654+
655+
input FindInput @oneOf {
656+
id: ID
657+
email: String
658+
}
659+
"#,
660+
};
661+
662+
let result = compose_as_fed2_subgraphs(&[subgraph_a, subgraph_b]);
663+
let supergraph = result.expect("Expected composition to succeed");
664+
let api_schema = supergraph
665+
.to_api_schema(Default::default())
666+
.expect("Expected API schema generation to succeed");
667+
let find_input = coord!(FindInput)
668+
.lookup(api_schema.schema())
669+
.expect("FindInput should exist");
670+
assert_snapshot!(find_input, @r"
671+
input FindInput @oneOf {
672+
id: ID
673+
}
674+
");
675+
}

0 commit comments

Comments
 (0)