use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FederationDirective {
Key { fields: String, resolvable: bool },
Requires { fields: String },
Provides { fields: String },
External,
Shareable,
Override { from: String },
Inaccessible,
Tag { name: String },
}
impl fmt::Display for FederationDirective {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FederationDirective::Key { fields, resolvable } => {
if *resolvable {
write!(f, "@key(fields: \"{fields}\")")
} else {
write!(f, "@key(fields: \"{fields}\", resolvable: false)")
}
}
FederationDirective::Requires { fields } => {
write!(f, "@requires(fields: \"{fields}\")")
}
FederationDirective::Provides { fields } => {
write!(f, "@provides(fields: \"{fields}\")")
}
FederationDirective::External => write!(f, "@external"),
FederationDirective::Shareable => write!(f, "@shareable"),
FederationDirective::Override { from } => write!(f, "@override(from: \"{from}\")"),
FederationDirective::Inaccessible => write!(f, "@inaccessible"),
FederationDirective::Tag { name } => write!(f, "@tag(name: \"{name}\")"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityKey {
pub type_name: String,
pub fields: Vec<String>,
pub resolvable: bool,
}
impl EntityKey {
pub fn new(type_name: impl Into<String>, fields: Vec<String>) -> Self {
Self {
type_name: type_name.into(),
fields,
resolvable: true,
}
}
pub fn stub(type_name: impl Into<String>, fields: Vec<String>) -> Self {
Self {
type_name: type_name.into(),
fields,
resolvable: false,
}
}
pub fn matches(&self, provided_fields: &HashMap<String, String>) -> bool {
self.fields.iter().all(|f| provided_fields.contains_key(f))
}
pub fn parse_fields(fields_str: &str) -> Vec<String> {
fields_str
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedField {
pub name: String,
pub field_type: String,
pub nullable: bool,
pub directives: Vec<FederationDirective>,
}
impl FederatedField {
pub fn new(name: impl Into<String>, field_type: impl Into<String>) -> Self {
Self {
name: name.into(),
field_type: field_type.into(),
nullable: true,
directives: Vec::new(),
}
}
pub fn non_null(mut self) -> Self {
self.nullable = false;
self
}
pub fn with_directive(mut self, directive: FederationDirective) -> Self {
self.directives.push(directive);
self
}
pub fn is_external(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, FederationDirective::External))
}
pub fn is_shareable(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, FederationDirective::Shareable))
}
pub fn is_inaccessible(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, FederationDirective::Inaccessible))
}
pub fn requires_fields(&self) -> Option<Vec<String>> {
self.directives.iter().find_map(|d| {
if let FederationDirective::Requires { fields } = d {
Some(EntityKey::parse_fields(fields))
} else {
None
}
})
}
pub fn provides_fields(&self) -> Option<Vec<String>> {
self.directives.iter().find_map(|d| {
if let FederationDirective::Provides { fields } = d {
Some(EntityKey::parse_fields(fields))
} else {
None
}
})
}
pub fn to_sdl(&self) -> String {
let type_str = if self.nullable {
self.field_type.clone()
} else {
format!("{}!", self.field_type)
};
let directives: Vec<String> = self.directives.iter().map(|d| d.to_string()).collect();
if directives.is_empty() {
format!(" {}: {}", self.name, type_str)
} else {
format!(" {}: {} {}", self.name, type_str, directives.join(" "))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedType {
pub name: String,
pub keys: Vec<EntityKey>,
pub fields: Vec<FederatedField>,
pub directives: Vec<FederationDirective>,
}
impl FederatedType {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
keys: Vec::new(),
fields: Vec::new(),
directives: Vec::new(),
}
}
pub fn with_key(mut self, fields: impl Into<String>, resolvable: bool) -> Self {
let fields_str: String = fields.into();
let key = if resolvable {
EntityKey::new(&self.name, EntityKey::parse_fields(&fields_str))
} else {
EntityKey::stub(&self.name, EntityKey::parse_fields(&fields_str))
};
self.keys.push(key);
self.directives.push(FederationDirective::Key {
fields: fields_str,
resolvable,
});
self
}
pub fn with_field(mut self, field: FederatedField) -> Self {
self.fields.push(field);
self
}
pub fn is_entity(&self) -> bool {
!self.keys.is_empty()
}
pub fn field_names(&self) -> Vec<&str> {
self.fields.iter().map(|f| f.name.as_str()).collect()
}
pub fn external_fields(&self) -> Vec<&str> {
self.fields
.iter()
.filter(|f| f.is_external())
.map(|f| f.name.as_str())
.collect()
}
pub fn owned_fields(&self) -> Vec<&str> {
self.fields
.iter()
.filter(|f| !f.is_external())
.map(|f| f.name.as_str())
.collect()
}
pub fn to_sdl(&self) -> String {
let directives: Vec<String> = self.directives.iter().map(|d| d.to_string()).collect();
let dir_str = if directives.is_empty() {
String::new()
} else {
format!(" {}", directives.join(" "))
};
let fields: Vec<String> = self.fields.iter().map(|f| f.to_sdl()).collect();
format!(
"type {}{} {{\n{}\n}}",
self.name,
dir_str,
fields.join("\n")
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityRepresentation {
#[serde(rename = "__typename")]
pub typename: String,
#[serde(flatten)]
pub fields: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct SubgraphSchema {
pub name: String,
pub types: HashMap<String, FederatedType>,
pub url: Option<String>,
}
impl SubgraphSchema {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
types: HashMap::new(),
url: None,
}
}
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn add_type(&mut self, federated_type: FederatedType) {
self.types
.insert(federated_type.name.clone(), federated_type);
}
pub fn entity_types(&self) -> Vec<&FederatedType> {
self.types.values().filter(|t| t.is_entity()).collect()
}
pub fn type_names(&self) -> Vec<&str> {
self.types.keys().map(|k| k.as_str()).collect()
}
pub fn get_type(&self, name: &str) -> Option<&FederatedType> {
self.types.get(name)
}
pub fn resolve_entities(
&self,
representations: &[EntityRepresentation],
) -> Vec<Result<HashMap<String, serde_json::Value>, String>> {
representations
.iter()
.map(|rep| self.resolve_entity(rep))
.collect()
}
fn resolve_entity(
&self,
rep: &EntityRepresentation,
) -> Result<HashMap<String, serde_json::Value>, String> {
let federated_type = self
.types
.get(&rep.typename)
.ok_or_else(|| format!("Unknown entity type: {}", rep.typename))?;
if !federated_type.is_entity() {
return Err(format!("{} is not an entity type", rep.typename));
}
let key_satisfied = federated_type
.keys
.iter()
.any(|key| key.fields.iter().all(|f| rep.fields.contains_key(f)));
if !key_satisfied {
return Err(format!(
"No matching key for entity {} with provided fields",
rep.typename
));
}
let mut result = rep.fields.clone();
result.insert(
"__typename".to_string(),
serde_json::Value::String(rep.typename.clone()),
);
Ok(result)
}
pub fn service_sdl(&self) -> String {
let mut parts = Vec::new();
parts.push(
"extend schema @link(url: \"https://specs.apollo.dev/federation/v2.0\", \
import: [\"@key\", \"@requires\", \"@provides\", \"@external\", \"@shareable\", \"@override\", \"@inaccessible\", \"@tag\"])"
.to_string(),
);
parts.push(String::new());
for federated_type in self.types.values() {
parts.push(federated_type.to_sdl());
parts.push(String::new());
}
parts.join("\n")
}
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
for (type_name, federated_type) in &self.types {
if federated_type.is_entity() && federated_type.fields.is_empty() {
errors.push(ValidationError {
type_name: type_name.clone(),
field_name: None,
message: "Entity type has no fields".to_string(),
});
}
for key in &federated_type.keys {
for field_name in &key.fields {
if !federated_type.fields.iter().any(|f| &f.name == field_name) {
errors.push(ValidationError {
type_name: type_name.clone(),
field_name: Some(field_name.clone()),
message: format!("Key field '{field_name}' not found in type"),
});
}
}
}
for field in &federated_type.fields {
if let Some(required_fields) = field.requires_fields() {
for req_field in required_fields {
let target_field =
federated_type.fields.iter().find(|f| f.name == req_field);
if let Some(target) = target_field {
if !target.is_external() {
errors.push(ValidationError {
type_name: type_name.clone(),
field_name: Some(req_field),
message: "Field referenced in @requires must be @external"
.to_string(),
});
}
}
}
}
}
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub type_name: String,
pub field_name: Option<String>,
pub message: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(field) = &self.field_name {
write!(f, "{}.{}: {}", self.type_name, field, self.message)
} else {
write!(f, "{}: {}", self.type_name, self.message)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_directive_display() {
let d = FederationDirective::Key {
fields: "id".to_string(),
resolvable: true,
};
assert_eq!(d.to_string(), "@key(fields: \"id\")");
}
#[test]
fn test_key_directive_non_resolvable_display() {
let d = FederationDirective::Key {
fields: "id".to_string(),
resolvable: false,
};
assert!(d.to_string().contains("resolvable: false"));
}
#[test]
fn test_requires_directive_display() {
let d = FederationDirective::Requires {
fields: "price weight".to_string(),
};
assert_eq!(d.to_string(), "@requires(fields: \"price weight\")");
}
#[test]
fn test_provides_directive_display() {
let d = FederationDirective::Provides {
fields: "name".to_string(),
};
assert_eq!(d.to_string(), "@provides(fields: \"name\")");
}
#[test]
fn test_external_directive_display() {
assert_eq!(FederationDirective::External.to_string(), "@external");
}
#[test]
fn test_shareable_directive_display() {
assert_eq!(FederationDirective::Shareable.to_string(), "@shareable");
}
#[test]
fn test_override_directive_display() {
let d = FederationDirective::Override {
from: "products".to_string(),
};
assert_eq!(d.to_string(), "@override(from: \"products\")");
}
#[test]
fn test_tag_directive_display() {
let d = FederationDirective::Tag {
name: "internal".to_string(),
};
assert_eq!(d.to_string(), "@tag(name: \"internal\")");
}
#[test]
fn test_entity_key_new() {
let key = EntityKey::new("Product", vec!["id".to_string()]);
assert_eq!(key.type_name, "Product");
assert_eq!(key.fields, vec!["id"]);
assert!(key.resolvable);
}
#[test]
fn test_entity_key_stub() {
let key = EntityKey::stub("Product", vec!["id".to_string()]);
assert!(!key.resolvable);
}
#[test]
fn test_entity_key_matches() {
let key = EntityKey::new("Product", vec!["id".to_string(), "sku".to_string()]);
let mut fields = HashMap::new();
fields.insert("id".to_string(), "123".to_string());
fields.insert("sku".to_string(), "ABC".to_string());
assert!(key.matches(&fields));
let mut partial = HashMap::new();
partial.insert("id".to_string(), "123".to_string());
assert!(!key.matches(&partial));
}
#[test]
fn test_entity_key_parse_fields() {
let fields = EntityKey::parse_fields("id name email");
assert_eq!(fields, vec!["id", "name", "email"]);
}
#[test]
fn test_entity_key_parse_single_field() {
let fields = EntityKey::parse_fields("id");
assert_eq!(fields, vec!["id"]);
}
#[test]
fn test_field_new() {
let field = FederatedField::new("name", "String");
assert_eq!(field.name, "name");
assert_eq!(field.field_type, "String");
assert!(field.nullable);
}
#[test]
fn test_field_non_null() {
let field = FederatedField::new("id", "ID").non_null();
assert!(!field.nullable);
}
#[test]
fn test_field_with_directive() {
let field =
FederatedField::new("price", "Float").with_directive(FederationDirective::External);
assert!(field.is_external());
}
#[test]
fn test_field_is_shareable() {
let field =
FederatedField::new("name", "String").with_directive(FederationDirective::Shareable);
assert!(field.is_shareable());
}
#[test]
fn test_field_is_inaccessible() {
let field = FederatedField::new("internal", "String")
.with_directive(FederationDirective::Inaccessible);
assert!(field.is_inaccessible());
}
#[test]
fn test_field_requires_fields() {
let field = FederatedField::new("displayPrice", "String").with_directive(
FederationDirective::Requires {
fields: "price currency".to_string(),
},
);
let req = field.requires_fields();
assert!(req.is_some());
assert_eq!(
req.expect("should have requires"),
vec!["price", "currency"]
);
}
#[test]
fn test_field_provides_fields() {
let field = FederatedField::new("reviews", "[Review]").with_directive(
FederationDirective::Provides {
fields: "body author".to_string(),
},
);
let prov = field.provides_fields();
assert!(prov.is_some());
assert_eq!(prov.expect("should have provides"), vec!["body", "author"]);
}
#[test]
fn test_field_to_sdl_simple() {
let field = FederatedField::new("name", "String");
assert_eq!(field.to_sdl(), " name: String");
}
#[test]
fn test_field_to_sdl_non_null() {
let field = FederatedField::new("id", "ID").non_null();
assert_eq!(field.to_sdl(), " id: ID!");
}
#[test]
fn test_field_to_sdl_with_directive() {
let field =
FederatedField::new("price", "Float").with_directive(FederationDirective::External);
let sdl = field.to_sdl();
assert!(sdl.contains("@external"));
}
#[test]
fn test_type_new() {
let t = FederatedType::new("Product");
assert_eq!(t.name, "Product");
assert!(!t.is_entity());
assert!(t.keys.is_empty());
}
#[test]
fn test_type_with_key() {
let t = FederatedType::new("Product").with_key("id", true);
assert!(t.is_entity());
assert_eq!(t.keys.len(), 1);
assert_eq!(t.keys[0].fields, vec!["id"]);
}
#[test]
fn test_type_with_multiple_keys() {
let t = FederatedType::new("Product")
.with_key("id", true)
.with_key("sku", true);
assert_eq!(t.keys.len(), 2);
}
#[test]
fn test_type_with_fields() {
let t = FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String").non_null())
.with_field(
FederatedField::new("price", "Float").with_directive(FederationDirective::External),
);
assert_eq!(t.field_names().len(), 3);
assert_eq!(t.external_fields(), vec!["price"]);
assert_eq!(t.owned_fields().len(), 2);
}
#[test]
fn test_type_to_sdl() {
let t = FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String"));
let sdl = t.to_sdl();
assert!(sdl.contains("type Product"));
assert!(sdl.contains("@key"));
assert!(sdl.contains("id: ID!"));
assert!(sdl.contains("name: String"));
}
#[test]
fn test_subgraph_schema_new() {
let schema = SubgraphSchema::new("products");
assert_eq!(schema.name, "products");
assert!(schema.types.is_empty());
}
#[test]
fn test_subgraph_schema_with_url() {
let schema = SubgraphSchema::new("products").with_url("http://localhost:4001/graphql");
assert_eq!(
schema.url,
Some("http://localhost:4001/graphql".to_string())
);
}
#[test]
fn test_subgraph_add_type() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null()),
);
assert_eq!(schema.types.len(), 1);
assert!(schema.get_type("Product").is_some());
}
#[test]
fn test_subgraph_entity_types() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null()),
);
schema.add_type(
FederatedType::new("Category").with_field(FederatedField::new("name", "String")),
);
let entities = schema.entity_types();
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].name, "Product");
}
#[test]
fn test_subgraph_type_names() {
let mut schema = SubgraphSchema::new("test");
schema.add_type(FederatedType::new("A"));
schema.add_type(FederatedType::new("B"));
let names = schema.type_names();
assert_eq!(names.len(), 2);
}
#[test]
fn test_subgraph_resolve_entities_success() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String")),
);
let rep = EntityRepresentation {
typename: "Product".to_string(),
fields: {
let mut m = HashMap::new();
m.insert(
"id".to_string(),
serde_json::Value::String("123".to_string()),
);
m
},
};
let results = schema.resolve_entities(&[rep]);
assert_eq!(results.len(), 1);
assert!(results[0].is_ok());
}
#[test]
fn test_subgraph_resolve_entities_unknown_type() {
let schema = SubgraphSchema::new("products");
let rep = EntityRepresentation {
typename: "Unknown".to_string(),
fields: HashMap::new(),
};
let results = schema.resolve_entities(&[rep]);
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
}
#[test]
fn test_subgraph_resolve_entities_missing_key() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null()),
);
let rep = EntityRepresentation {
typename: "Product".to_string(),
fields: HashMap::new(), };
let results = schema.resolve_entities(&[rep]);
assert!(results[0].is_err());
}
#[test]
fn test_service_sdl() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String")),
);
let sdl = schema.service_sdl();
assert!(sdl.contains("@link"));
assert!(sdl.contains("federation/v2.0"));
assert!(sdl.contains("type Product"));
}
#[test]
fn test_validate_valid_schema() {
let mut schema = SubgraphSchema::new("products");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String")),
);
let errors = schema.validate();
assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
}
#[test]
fn test_validate_entity_no_fields() {
let mut schema = SubgraphSchema::new("test");
schema.add_type(FederatedType::new("Empty").with_key("id", true));
let errors = schema.validate();
assert!(!errors.is_empty());
assert!(errors[0].message.contains("no fields"));
}
#[test]
fn test_validate_key_field_missing() {
let mut schema = SubgraphSchema::new("test");
schema.add_type(
FederatedType::new("Product")
.with_key("missing_field", true)
.with_field(FederatedField::new("name", "String")),
);
let errors = schema.validate();
assert!(!errors.is_empty());
assert!(errors.iter().any(|e| e.message.contains("not found")));
}
#[test]
fn test_validate_requires_external() {
let mut schema = SubgraphSchema::new("test");
schema.add_type(
FederatedType::new("Product")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("price", "Float")) .with_field(
FederatedField::new("displayPrice", "String").with_directive(
FederationDirective::Requires {
fields: "price".to_string(),
},
),
),
);
let errors = schema.validate();
assert!(!errors.is_empty());
assert!(errors
.iter()
.any(|e| e.message.contains("must be @external")));
}
#[test]
fn test_validation_error_display_with_field() {
let err = ValidationError {
type_name: "Product".to_string(),
field_name: Some("price".to_string()),
message: "must be @external".to_string(),
};
assert_eq!(err.to_string(), "Product.price: must be @external");
}
#[test]
fn test_validation_error_display_without_field() {
let err = ValidationError {
type_name: "Product".to_string(),
field_name: None,
message: "has no fields".to_string(),
};
assert_eq!(err.to_string(), "Product: has no fields");
}
#[test]
fn test_federation_v2_complete_scenario() {
let mut schema = SubgraphSchema::new("inventory").with_url("http://localhost:4002/graphql");
let product = FederatedType::new("Product")
.with_key("id", true)
.with_key("sku warehouse", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("sku", "String").non_null())
.with_field(FederatedField::new("warehouse", "String").non_null())
.with_field(
FederatedField::new("price", "Float").with_directive(FederationDirective::External),
)
.with_field(
FederatedField::new("inStock", "Boolean")
.with_directive(FederationDirective::Shareable),
)
.with_field(
FederatedField::new("shippingEstimate", "Float").with_directive(
FederationDirective::Requires {
fields: "price".to_string(),
},
),
);
schema.add_type(product);
let errors = schema.validate();
assert!(errors.is_empty(), "Errors: {:?}", errors);
assert_eq!(schema.entity_types().len(), 1);
let sdl = schema.service_sdl();
assert!(sdl.contains("@key"));
assert!(sdl.contains("@external"));
assert!(sdl.contains("@shareable"));
assert!(sdl.contains("@requires"));
}
#[test]
fn test_entity_representation_deserialization() {
let json = r#"{"__typename": "Product", "id": "p1", "sku": "SKU-001"}"#;
let rep: EntityRepresentation = serde_json::from_str(json).expect("should deserialize");
assert_eq!(rep.typename, "Product");
assert!(rep.fields.contains_key("id"));
assert!(rep.fields.contains_key("sku"));
}
#[test]
fn test_multiple_resolve() {
let mut schema = SubgraphSchema::new("test");
schema.add_type(
FederatedType::new("User")
.with_key("id", true)
.with_field(FederatedField::new("id", "ID").non_null())
.with_field(FederatedField::new("name", "String")),
);
let reps = vec![
EntityRepresentation {
typename: "User".to_string(),
fields: {
let mut m = HashMap::new();
m.insert(
"id".to_string(),
serde_json::Value::String("u1".to_string()),
);
m
},
},
EntityRepresentation {
typename: "User".to_string(),
fields: {
let mut m = HashMap::new();
m.insert(
"id".to_string(),
serde_json::Value::String("u2".to_string()),
);
m
},
},
];
let results = schema.resolve_entities(&reps);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.is_ok()));
}
}