use async_graphql_parser::types::{
ConstDirective, FieldDefinition, TypeKind, TypeSystemDefinition,
};
use async_graphql_value::ConstValue;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Entity {
pub key: Vec<String>,
pub subgraphs: Vec<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Supergraph {
pub field_owners: BTreeMap<(String, String), Vec<String>>,
pub entities: BTreeMap<String, Entity>,
pub root_query: BTreeMap<String, String>,
pub root_mutation: BTreeMap<String, String>,
pub field_types: BTreeMap<(String, String), String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CompositionError {
Parse { subgraph: String, message: String },
FieldConflict {
type_name: String,
field: String,
subgraphs: Vec<String>,
},
}
impl std::fmt::Display for CompositionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse { subgraph, message } => {
write!(f, "subgraph `{subgraph}` SDL did not parse: {message}")
}
Self::FieldConflict {
type_name,
field,
subgraphs,
} => write!(
f,
"field `{type_name}.{field}` is resolved by multiple subgraphs \
({}) without @shareable",
subgraphs.join(", ")
),
}
}
}
pub(crate) fn compose(subgraphs: &[(String, String)]) -> Result<Supergraph, CompositionError> {
let mut sg = Supergraph::default();
let mut shareable: BTreeSet<(String, String)> = BTreeSet::new();
for (name, sdl) in subgraphs {
let doc = async_graphql_parser::parse_schema(sdl).map_err(|e| CompositionError::Parse {
subgraph: name.clone(),
message: e.to_string(),
})?;
for def in &doc.definitions {
if let TypeSystemDefinition::Type(ty) = def {
if let TypeKind::Object(obj) = &ty.node.kind {
ingest_object(
&mut sg,
&mut shareable,
name,
ty.node.name.node.as_str(),
&ty.node.directives,
&obj.fields,
);
}
}
}
}
for ((type_name, field), owners) in &sg.field_owners {
if owners.len() > 1 && !shareable.contains(&(type_name.clone(), field.clone())) {
return Err(CompositionError::FieldConflict {
type_name: type_name.clone(),
field: field.clone(),
subgraphs: owners.clone(),
});
}
}
Ok(sg)
}
fn ingest_object(
sg: &mut Supergraph,
shareable: &mut BTreeSet<(String, String)>,
subgraph: &str,
type_name: &str,
directives: &[async_graphql_parser::Positioned<ConstDirective>],
fields: &[async_graphql_parser::Positioned<FieldDefinition>],
) {
if let Some(key) = key_fields(directives) {
let entity = sg.entities.entry(type_name.to_string()).or_insert(Entity {
key: key.clone(),
subgraphs: Vec::new(),
});
if !entity.subgraphs.iter().any(|s| s == subgraph) {
entity.subgraphs.push(subgraph.to_string());
}
}
for field in fields {
let field = &field.node;
let field_name = field.name.node.as_str();
sg.field_types
.entry((type_name.to_string(), field_name.to_string()))
.or_insert_with(|| base_type_name(&field.ty.node));
if has_directive(&field.directives, "external") {
continue;
}
if has_directive(&field.directives, "shareable") {
shareable.insert((type_name.to_string(), field_name.to_string()));
}
match type_name {
"Query" => {
sg.root_query
.insert(field_name.to_string(), subgraph.to_string());
}
"Mutation" => {
sg.root_mutation
.insert(field_name.to_string(), subgraph.to_string());
}
_ => {
let owners = sg
.field_owners
.entry((type_name.to_string(), field_name.to_string()))
.or_default();
if !owners.iter().any(|s| s == subgraph) {
owners.push(subgraph.to_string());
}
}
}
}
}
fn has_directive(dirs: &[async_graphql_parser::Positioned<ConstDirective>], name: &str) -> bool {
dirs.iter().any(|d| d.node.name.node == name)
}
fn base_type_name(ty: &async_graphql_parser::types::Type) -> String {
use async_graphql_parser::types::BaseType;
match &ty.base {
BaseType::Named(name) => name.to_string(),
BaseType::List(inner) => base_type_name(inner),
}
}
fn key_fields(dirs: &[async_graphql_parser::Positioned<ConstDirective>]) -> Option<Vec<String>> {
let key = dirs.iter().find(|d| d.node.name.node == "key")?;
let (_, value) = key
.node
.arguments
.iter()
.find(|(name, _)| name.node == "fields")?;
match &value.node {
ConstValue::String(s) => Some(s.split_whitespace().map(str::to_string).collect()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCOUNTS: &str = r#"
type Query { me: User }
type User @key(fields: "id") { id: ID! name: String }
"#;
const REVIEWS: &str = r#"
type Query { topReviews: [Review] }
type Review { id: ID! body: String author: User }
extend type User @key(fields: "id") { id: ID! @external reviews: [Review] }
"#;
fn sub(name: &str, sdl: &str) -> (String, String) {
(name.to_string(), sdl.to_string())
}
#[test]
fn composes_root_fields_by_owning_subgraph() {
let sg = compose(&[sub("accounts", ACCOUNTS), sub("reviews", REVIEWS)]).unwrap();
assert_eq!(
sg.root_query.get("me").map(String::as_str),
Some("accounts")
);
assert_eq!(
sg.root_query.get("topReviews").map(String::as_str),
Some("reviews")
);
}
#[test]
fn user_is_an_entity_resolvable_by_both_subgraphs() {
let sg = compose(&[sub("accounts", ACCOUNTS), sub("reviews", REVIEWS)]).unwrap();
let user = sg.entities.get("User").expect("User is an entity");
assert_eq!(user.key, vec!["id".to_string()]);
assert_eq!(user.subgraphs, vec!["accounts", "reviews"]);
}
#[test]
fn field_ownership_splits_across_subgraphs_and_external_is_not_owned() {
let sg = compose(&[sub("accounts", ACCOUNTS), sub("reviews", REVIEWS)]).unwrap();
assert_eq!(
sg.field_owners.get(&("User".into(), "name".into())),
Some(&vec!["accounts".to_string()])
);
assert_eq!(
sg.field_owners.get(&("User".into(), "reviews".into())),
Some(&vec!["reviews".to_string()])
);
assert_eq!(
sg.field_owners.get(&("User".into(), "id".into())),
Some(&vec!["accounts".to_string()])
);
}
#[test]
fn co_owned_field_without_shareable_is_a_conflict() {
let a = "type Query { x: Int } type T { f: Int }";
let b = "type T { f: Int }";
let err = compose(&[sub("a", a), sub("b", b)]).unwrap_err();
assert!(matches!(err, CompositionError::FieldConflict { field, .. } if field == "f"));
}
#[test]
fn shareable_allows_co_ownership() {
let a = "type Query { x: Int } type T { f: Int @shareable }";
let b = "type T { f: Int @shareable }";
assert!(compose(&[sub("a", a), sub("b", b)]).is_ok());
}
const ASYNC_GRAPHQL_V2: &str = r#"type Query {
users: [User!]!
}
type User @key(fields: "id") {
id: ID!
name: String!
}
"""
Directs the executor to include this field or fragment only when the `if` argument is true.
"""
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
"""
Directs the executor to skip this field or fragment when the `if` argument is true.
"""
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
extend schema @link(
url: "https://specs.apollo.dev/federation/v2.5",
import: ["@key", "@tag", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@composeDirective", "@interfaceObject", "@requiresScopes"]
)
"#;
#[test]
fn composes_a_real_federation_v2_subgraph_with_link_preamble() {
let sg = compose(&[sub("accounts", ASYNC_GRAPHQL_V2)]).expect("v2 SDL must compose");
assert_eq!(
sg.root_query.get("users").map(String::as_str),
Some("accounts")
);
let user = sg.entities.get("User").expect("User is an entity");
assert_eq!(user.key, vec!["id".to_string()]);
assert_eq!(user.subgraphs, vec!["accounts".to_string()]);
}
#[test]
fn extend_type_and_extend_schema_are_handled() {
let base = "type User @key(fields: \"id\") { id: ID! }";
let ext = concat!(
"extend schema @link(url: \"https://specs.apollo.dev/federation/v2.5\", import: [\"@key\"])\n",
"extend type User @key(fields: \"id\") { id: ID! @external tag: String }\n",
);
let sg = compose(&[sub("a", base), sub("b", ext)]).unwrap();
assert_eq!(
sg.field_owners.get(&("User".into(), "tag".into())),
Some(&vec!["b".to_string()])
);
}
#[test]
fn unparsable_sdl_is_a_composition_error() {
let err = compose(&[sub("bad", "type Query { ")]).unwrap_err();
assert!(matches!(err, CompositionError::Parse { subgraph, .. } if subgraph == "bad"));
}
}