use std::fs;
use anyhow::Result;
use serde_json::json;
use crate::output::CommandResult;
pub fn run(schema_path: &str, supergraph_path: Option<&str>, json: bool) -> Result<CommandResult> {
let schema_content = fs::read_to_string(schema_path)
.map_err(|e| anyhow::anyhow!("Failed to read schema: {e}"))?;
let schema: serde_json::Value = serde_json::from_str(&schema_content)
.map_err(|e| anyhow::anyhow!("Failed to parse schema JSON: {e}"))?;
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let Some(federation) = schema.get("federation") else {
return Ok(CommandResult::error(
"federation check",
"No federation metadata found in schema",
"NO_FEDERATION_METADATA",
));
};
let enabled = federation.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
if !enabled {
warnings.push("Federation is present but not enabled".to_string());
}
let version = federation.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
if version != "v2" {
warnings.push(format!("Federation version '{version}' is not v2"));
}
let types = federation.get("types").and_then(|v| v.as_array());
let type_count = types.map_or(0, |t| t.len());
if type_count == 0 && enabled {
warnings.push("Federation enabled but no federated types defined".to_string());
}
if let Some(types) = types {
for fed_type in types {
let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
let keys = fed_type.get("keys").and_then(|v| v.as_array());
if keys.is_none_or(|k| k.is_empty()) {
errors.push(format!("Type '{name}' has no @key directive"));
}
if let Some(keys) = keys {
for key in keys {
let fields = key.get("fields").and_then(|v| v.as_array());
if fields.is_none_or(|f| f.is_empty()) {
errors.push(format!("Type '{name}' has @key with no fields"));
}
if let Some(fields) = fields {
let known_fields = collect_known_fields(fed_type);
if !known_fields.is_empty() {
for field in fields {
if let Some(field_name) = field.as_str() {
if !known_fields.contains(field_name) {
errors.push(format!(
"Type '{name}' has @key(fields: \"{field_name}\") \
but no field named '{field_name}' exists on the type"
));
}
}
}
}
}
}
}
}
}
if let Some(types) = types {
for fed_type in types {
let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
errors.extend(check_requires_fields(name, fed_type));
warnings.extend(check_provides_fields(name, fed_type));
if let Some(keys) = fed_type.get("keys").and_then(|v| v.as_array()) {
for key in keys {
let resolvable =
key.get("resolvable").and_then(|v| v.as_bool()).unwrap_or(true);
if !resolvable {
let fields_str = key
.get("fields")
.and_then(|v| v.as_array())
.map(|f| {
f.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
})
.unwrap_or_default();
warnings.push(format!(
"Type '{name}' @key(fields: \"{fields_str}\") has resolvable: false \
— this key cannot be used for entity resolution"
));
}
}
}
}
}
warnings.extend(check_root_field_inaccessibility(&schema));
if let Some(types) = types {
for fed_type in types {
let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
for (field_name, directive) in directives {
if let Some(override_from) =
directive.get("override_from").and_then(|v| v.as_str())
{
if override_from.is_empty() {
errors.push(format!(
"Type '{name}' field '{field_name}': \
@override(from: \"\") — empty string is invalid"
));
}
}
}
}
}
}
if let Some(supergraph_path) = supergraph_path {
match validate_against_supergraph(schema_path, supergraph_path) {
Ok(composition_warnings) => warnings.extend(composition_warnings),
Err(composition_errors) => errors.extend(composition_errors),
}
}
let result = if errors.is_empty() {
let data = json!({
"schema": schema_path,
"federation_version": version,
"type_count": type_count,
"composable": true,
});
if warnings.is_empty() {
CommandResult::success("federation check", data)
} else {
CommandResult::success_with_warnings("federation check", data, warnings)
}
} else {
let data = json!({
"schema": schema_path,
"composable": false,
"error_count": errors.len(),
});
CommandResult {
status: "validation-failed".to_string(),
command: "federation check".to_string(),
data: Some(data),
message: None,
code: Some("COMPOSITION_ERROR".to_string()),
errors,
warnings,
}
};
if json {
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|e| anyhow::anyhow!("Failed to serialize result: {e}"))?
);
}
Ok(result)
}
fn collect_known_fields(fed_type: &serde_json::Value) -> std::collections::HashSet<String> {
let mut known = std::collections::HashSet::new();
if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
for key in directives.keys() {
known.insert(key.clone());
}
}
if let Some(fields) = fed_type.get("external_fields").and_then(|v| v.as_array()) {
for f in fields {
if let Some(name) = f.as_str() {
known.insert(name.to_string());
}
}
}
if let Some(fields) = fed_type.get("shareable_fields").and_then(|v| v.as_array()) {
for f in fields {
if let Some(name) = f.as_str() {
known.insert(name.to_string());
}
}
}
if let Some(fields) = fed_type.get("inaccessible_fields").and_then(|v| v.as_array()) {
for f in fields {
if let Some(name) = f.as_str() {
known.insert(name.to_string());
}
}
}
known
}
fn supergraph_roster(schema: &serde_json::Value) -> Option<std::collections::HashSet<String>> {
let subgraphs = schema.pointer("/federation/subgraphs")?.as_array()?;
Some(
subgraphs
.iter()
.filter_map(|sg| sg.get("name").and_then(|n| n.as_str()))
.map(ToString::to_string)
.collect(),
)
}
fn field_is_shareable(fed_type: &serde_json::Value, field: &str) -> bool {
if fed_type.get("type_shareable").and_then(|v| v.as_bool()).unwrap_or(false) {
return true;
}
if let Some(fields) = fed_type.get("shareable_fields").and_then(|v| v.as_array()) {
if fields.iter().any(|f| f.as_str() == Some(field)) {
return true;
}
}
fed_type
.pointer(&format!("/field_directives/{field}/shareable"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn field_is_external(fed_type: &serde_json::Value, field: &str) -> bool {
fed_type
.get("external_fields")
.and_then(|v| v.as_array())
.is_some_and(|fields| fields.iter().any(|f| f.as_str() == Some(field)))
}
fn declared_keys(fed_type: &serde_json::Value) -> Vec<Vec<String>> {
fed_type
.get("keys")
.and_then(|v| v.as_array())
.map(|keys| {
keys.iter()
.filter_map(|k| k.get("fields").and_then(|f| f.as_array()))
.map(|fields| {
fields.iter().filter_map(|f| f.as_str()).map(ToString::to_string).collect()
})
.collect()
})
.unwrap_or_default()
}
fn check_requires_fields(type_name: &str, fed_type: &serde_json::Value) -> Vec<String> {
let mut errs = Vec::new();
let known = collect_known_fields(fed_type);
if known.is_empty() {
return errs;
}
if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
for (field_name, directive) in directives {
if let Some(requires) = directive.get("requires").and_then(|v| v.as_array()) {
for req in requires {
let top_field = req
.get("path")
.and_then(|p| p.as_array())
.and_then(|p| p.first())
.and_then(|v| v.as_str());
if let Some(top) = top_field {
if !known.contains(top) {
errs.push(format!(
"Type '{type_name}' field '{field_name}': \
@requires references field '{top}' which does not exist on the type"
));
}
}
}
}
}
}
errs
}
fn check_provides_fields(type_name: &str, fed_type: &serde_json::Value) -> Vec<String> {
let mut warns = Vec::new();
if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
for (field_name, directive) in directives {
if let Some(provides) = directive.get("provides").and_then(|v| v.as_array()) {
if !provides.is_empty() {
warns.push(format!(
"Type '{type_name}' field '{field_name}': \
@provides cannot be fully validated locally \
(return type fields may be in another subgraph)"
));
}
}
}
}
warns
}
fn check_root_field_inaccessibility(schema: &serde_json::Value) -> Vec<String> {
let mut warns = Vec::new();
let types = schema.pointer("/federation/types").and_then(|v| v.as_array());
if let Some(types) = types {
for fed_type in types {
let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name == "Query" || name == "Mutation" {
if let Some(fields) = fed_type.get("inaccessible_fields").and_then(|v| v.as_array())
{
for f in fields {
if let Some(field_name) = f.as_str() {
warns.push(format!(
"Type '{name}' field '{field_name}' is @inaccessible — \
this hides a root {name} field from the public API, \
which is unusual and likely unintentional"
));
}
}
}
}
}
}
warns
}
fn validate_against_supergraph(
local_path: &str,
supergraph_path: &str,
) -> std::result::Result<Vec<String>, Vec<String>> {
if !std::path::Path::new(supergraph_path).exists() {
return Err(vec![format!("Supergraph schema not found: {supergraph_path}")]);
}
let content = fs::read_to_string(supergraph_path)
.map_err(|e| vec![format!("Failed to read supergraph: {e}")])?;
let supergraph: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| vec![format!("Failed to parse supergraph JSON: {e}")])?;
let mut warnings = Vec::new();
let mut errs = Vec::new();
if supergraph.get("federation").is_none() {
return Err(vec!["Supergraph schema has no federation metadata".to_string()]);
}
let roster = supergraph_roster(&supergraph);
let empty = Vec::new();
let supergraph_types: std::collections::HashMap<&str, &serde_json::Value> = supergraph
.pointer("/federation/types")
.and_then(|v| v.as_array())
.unwrap_or(&empty)
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(|n| (n, t)))
.collect();
let local_content = fs::read_to_string(local_path)
.map_err(|e| vec![format!("Failed to re-read local schema: {e}")])?;
let local_schema: serde_json::Value = serde_json::from_str(&local_content)
.map_err(|e| vec![format!("Failed to re-parse local schema: {e}")])?;
if let Some(types) = local_schema.pointer("/federation/types").and_then(|v| v.as_array()) {
for fed_type in types {
let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
let super_type = supergraph_types.get(name);
if let Some(super_type) = super_type {
let super_keys = declared_keys(super_type);
if !super_keys.is_empty() {
for local_key in declared_keys(fed_type) {
if !super_keys.contains(&local_key) {
errs.push(format!(
"Type '{name}': local @key(fields: \"{}\") is not one of the \
supergraph's declared keys for this type ({:?})",
local_key.join(" "),
super_keys.iter().map(|k| k.join(" ")).collect::<Vec<_>>()
));
}
}
}
}
if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
for (field_name, directive) in directives {
if let Some(override_from) =
directive.get("override_from").and_then(|v| v.as_str())
{
if !override_from.is_empty() {
match &roster {
Some(names) if !names.contains(override_from) => {
errs.push(format!(
"Type '{name}' field '{field_name}': \
@override(from: \"{override_from}\") references \
subgraph '{override_from}', which is not in the \
supergraph's declared roster"
));
},
Some(_) => {},
None => {
warnings.push(format!(
"Type '{name}' field '{field_name}': \
@override(from: \"{override_from}\") could not be \
checked — the supergraph declares no subgraph roster \
(federation.subgraphs)"
));
},
}
}
continue;
}
if let Some(super_type) = super_type {
let super_has_field = super_type
.pointer(&format!("/field_directives/{field_name}"))
.is_some();
if super_has_field
&& !field_is_external(fed_type, field_name)
&& !(field_is_shareable(fed_type, field_name)
&& field_is_shareable(super_type, field_name))
{
errs.push(format!(
"Type '{name}' field '{field_name}': defined by both this \
subgraph and the supergraph but not @shareable on both \
sides (INVALID_FIELD_SHARING)"
));
}
}
}
}
}
}
if !errs.is_empty() {
return Err(errs);
}
warnings.push(format!(
"Compared against '{supergraph_path}': @key agreement, field sharing, and \
@override roster references. Authoritative composition (satisfiability, full \
directive semantics) is performed by the gateway composer"
));
Ok(warnings)
}