Skip to main content

fraiseql_cli/commands/federation/
check.rs

1//! Federation check command — validate subgraph composition
2//!
3//! Usage: fraiseql federation check <schema.compiled.json> [--against <supergraph.json>]
4//!
5//! Validates that the local subgraph SDL is composable with the running supergraph.
6//! Without `--against`, performs local-only validation of federation directives.
7
8use std::fs;
9
10use anyhow::Result;
11use serde_json::json;
12
13use crate::output::CommandResult;
14
15/// Run federation check command.
16///
17/// Validates the federation metadata in a compiled schema for correctness.
18/// If `supergraph_path` is provided, also validates composition against it.
19/// When `json` is `true`, the result is serialized and written to stdout before returning.
20///
21/// # Errors
22///
23/// Returns an error if the schema file cannot be read or parsed.
24pub fn run(schema_path: &str, supergraph_path: Option<&str>, json: bool) -> Result<CommandResult> {
25    let schema_content = fs::read_to_string(schema_path)
26        .map_err(|e| anyhow::anyhow!("Failed to read schema: {e}"))?;
27
28    let schema: serde_json::Value = serde_json::from_str(&schema_content)
29        .map_err(|e| anyhow::anyhow!("Failed to parse schema JSON: {e}"))?;
30
31    let mut errors: Vec<String> = Vec::new();
32    let mut warnings: Vec<String> = Vec::new();
33
34    // Check federation metadata exists
35    let Some(federation) = schema.get("federation") else {
36        return Ok(CommandResult::error(
37            "federation check",
38            "No federation metadata found in schema",
39            "NO_FEDERATION_METADATA",
40        ));
41    };
42
43    // Validate federation is enabled
44    let enabled = federation.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
45    if !enabled {
46        warnings.push("Federation is present but not enabled".to_string());
47    }
48
49    // Validate federation version
50    let version = federation.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
51    if version != "v2" {
52        warnings.push(format!("Federation version '{version}' is not v2"));
53    }
54
55    // Validate types have @key directives
56    let types = federation.get("types").and_then(|v| v.as_array());
57    let type_count = types.map_or(0, |t| t.len());
58
59    if type_count == 0 && enabled {
60        warnings.push("Federation enabled but no federated types defined".to_string());
61    }
62
63    if let Some(types) = types {
64        for fed_type in types {
65            let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
66
67            // Check @key presence
68            let keys = fed_type.get("keys").and_then(|v| v.as_array());
69            if keys.is_none() || keys.is_some_and(|k| k.is_empty()) {
70                errors.push(format!("Type '{name}' has no @key directive"));
71            }
72
73            // Check for empty key fields and key field existence
74            if let Some(keys) = keys {
75                for key in keys {
76                    let fields = key.get("fields").and_then(|v| v.as_array());
77                    if fields.is_none() || fields.is_some_and(|f| f.is_empty()) {
78                        errors.push(format!("Type '{name}' has @key with no fields"));
79                    }
80
81                    // Validate @key field names exist on the type (when
82                    // enough metadata is available to check)
83                    if let Some(fields) = fields {
84                        let known_fields = collect_known_fields(fed_type);
85                        if !known_fields.is_empty() {
86                            for field in fields {
87                                if let Some(field_name) = field.as_str() {
88                                    if !known_fields.contains(field_name) {
89                                        errors.push(format!(
90                                            "Type '{name}' has @key(fields: \"{field_name}\") \
91                                             but no field named '{field_name}' exists on the type"
92                                        ));
93                                    }
94                                }
95                            }
96                        }
97                    }
98                }
99            }
100        }
101    }
102
103    // Validate @requires fields exist on type
104    if let Some(types) = types {
105        for fed_type in types {
106            let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
107
108            errors.extend(check_requires_fields(name, fed_type));
109            warnings.extend(check_provides_fields(name, fed_type));
110
111            // Check resolvable: false keys
112            if let Some(keys) = fed_type.get("keys").and_then(|v| v.as_array()) {
113                for key in keys {
114                    let resolvable =
115                        key.get("resolvable").and_then(|v| v.as_bool()).unwrap_or(true);
116                    if !resolvable {
117                        let fields_str = key
118                            .get("fields")
119                            .and_then(|v| v.as_array())
120                            .map(|f| {
121                                f.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
122                            })
123                            .unwrap_or_default();
124                        warnings.push(format!(
125                            "Type '{name}' @key(fields: \"{fields_str}\") has resolvable: false \
126                             — this key cannot be used for entity resolution"
127                        ));
128                    }
129                }
130            }
131        }
132    }
133
134    // Check @inaccessible on root Query/Mutation fields
135    warnings.extend(check_root_field_inaccessibility(&schema));
136
137    // Validate @override directives
138    if let Some(types) = types {
139        for fed_type in types {
140            let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
141
142            if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
143                for (field_name, directive) in directives {
144                    if let Some(override_from) =
145                        directive.get("override_from").and_then(|v| v.as_str())
146                    {
147                        // Empty string is always an error
148                        if override_from.is_empty() {
149                            errors.push(format!(
150                                "Type '{name}' field '{field_name}': \
151                                 @override(from: \"\") — empty string is invalid"
152                            ));
153                        }
154                    }
155                }
156            }
157        }
158    }
159
160    // If supergraph is provided, validate composition
161    if let Some(supergraph_path) = supergraph_path {
162        match validate_against_supergraph(schema_path, supergraph_path) {
163            Ok(composition_warnings) => warnings.extend(composition_warnings),
164            Err(composition_errors) => errors.extend(composition_errors),
165        }
166    }
167
168    let result = if errors.is_empty() {
169        let data = json!({
170            "schema": schema_path,
171            "federation_version": version,
172            "type_count": type_count,
173            "composable": true,
174        });
175
176        if warnings.is_empty() {
177            CommandResult::success("federation check", data)
178        } else {
179            CommandResult::success_with_warnings("federation check", data, warnings)
180        }
181    } else {
182        let data = json!({
183            "schema": schema_path,
184            "composable": false,
185            "error_count": errors.len(),
186        });
187
188        CommandResult {
189            status: "validation-failed".to_string(),
190            command: "federation check".to_string(),
191            data: Some(data),
192            message: None,
193            code: Some("COMPOSITION_ERROR".to_string()),
194            errors,
195            warnings,
196        }
197    };
198
199    if json {
200        println!(
201            "{}",
202            serde_json::to_string_pretty(&result)
203                .map_err(|e| anyhow::anyhow!("Failed to serialize result: {e}"))?
204        );
205    }
206
207    Ok(result)
208}
209
210/// Collect known field names for a federated type from its JSON metadata.
211///
212/// Checks `field_directives` keys, `external_fields`, and `shareable_fields` —
213/// any field that appears in these lists is definitely declared on the type.
214fn collect_known_fields(fed_type: &serde_json::Value) -> std::collections::HashSet<String> {
215    let mut known = std::collections::HashSet::new();
216
217    // Fields from field_directives keys
218    if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
219        for key in directives.keys() {
220            known.insert(key.clone());
221        }
222    }
223
224    // Fields from external_fields
225    if let Some(fields) = fed_type.get("external_fields").and_then(|v| v.as_array()) {
226        for f in fields {
227            if let Some(name) = f.as_str() {
228                known.insert(name.to_string());
229            }
230        }
231    }
232
233    // Fields from shareable_fields
234    if let Some(fields) = fed_type.get("shareable_fields").and_then(|v| v.as_array()) {
235        for f in fields {
236            if let Some(name) = f.as_str() {
237                known.insert(name.to_string());
238            }
239        }
240    }
241
242    // Fields from inaccessible_fields
243    if let Some(fields) = fed_type.get("inaccessible_fields").and_then(|v| v.as_array()) {
244        for f in fields {
245            if let Some(name) = f.as_str() {
246                known.insert(name.to_string());
247            }
248        }
249    }
250
251    known
252}
253
254/// Collect known subgraph names from `@override(from:)` annotations in the schema.
255fn known_subgraph_names_from_metadata(
256    schema: &serde_json::Value,
257) -> std::collections::HashSet<String> {
258    let mut names = std::collections::HashSet::new();
259    if let Some(types) = schema.pointer("/federation/types").and_then(|v| v.as_array()) {
260        for fed_type in types {
261            if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
262                for directive in directives.values() {
263                    if let Some(from) = directive.get("override_from").and_then(|v| v.as_str()) {
264                        if !from.is_empty() {
265                            names.insert(from.to_string());
266                        }
267                    }
268                }
269            }
270        }
271    }
272    names
273}
274
275/// Validate that `@requires` field references exist on the type.
276fn check_requires_fields(type_name: &str, fed_type: &serde_json::Value) -> Vec<String> {
277    let mut errs = Vec::new();
278    let known = collect_known_fields(fed_type);
279    if known.is_empty() {
280        return errs;
281    }
282
283    if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
284        for (field_name, directive) in directives {
285            if let Some(requires) = directive.get("requires").and_then(|v| v.as_array()) {
286                for req in requires {
287                    // Extract the top-level field name from the path
288                    let top_field = req
289                        .get("path")
290                        .and_then(|p| p.as_array())
291                        .and_then(|p| p.first())
292                        .and_then(|v| v.as_str());
293                    if let Some(top) = top_field {
294                        if !known.contains(top) {
295                            errs.push(format!(
296                                "Type '{type_name}' field '{field_name}': \
297                                 @requires references field '{top}' which does not exist on the type"
298                            ));
299                        }
300                    }
301                }
302            }
303        }
304    }
305    errs
306}
307
308/// Emit warnings for `@provides` fields that cannot be verified locally.
309fn check_provides_fields(type_name: &str, fed_type: &serde_json::Value) -> Vec<String> {
310    let mut warns = Vec::new();
311    if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
312        for (field_name, directive) in directives {
313            if let Some(provides) = directive.get("provides").and_then(|v| v.as_array()) {
314                if !provides.is_empty() {
315                    warns.push(format!(
316                        "Type '{type_name}' field '{field_name}': \
317                         @provides cannot be fully validated locally \
318                         (return type fields may be in another subgraph)"
319                    ));
320                }
321            }
322        }
323    }
324    warns
325}
326
327/// Warn if any `@inaccessible` field appears on a root Query or Mutation type.
328fn check_root_field_inaccessibility(schema: &serde_json::Value) -> Vec<String> {
329    let mut warns = Vec::new();
330
331    let types = schema.pointer("/federation/types").and_then(|v| v.as_array());
332
333    if let Some(types) = types {
334        for fed_type in types {
335            let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("");
336            if name == "Query" || name == "Mutation" {
337                if let Some(fields) = fed_type.get("inaccessible_fields").and_then(|v| v.as_array())
338                {
339                    for f in fields {
340                        if let Some(field_name) = f.as_str() {
341                            warns.push(format!(
342                                "Type '{name}' field '{field_name}' is @inaccessible — \
343                                 this hides a root {name} field from the public API, \
344                                 which is unusual and likely unintentional"
345                            ));
346                        }
347                    }
348                }
349            }
350        }
351    }
352    warns
353}
354
355/// Validate local subgraph against a supergraph schema.
356///
357/// Returns `Ok(warnings)` on success, `Err(errors)` on composition failure.
358fn validate_against_supergraph(
359    local_path: &str,
360    supergraph_path: &str,
361) -> std::result::Result<Vec<String>, Vec<String>> {
362    // Validate supergraph file exists and is readable
363    if !std::path::Path::new(supergraph_path).exists() {
364        return Err(vec![format!("Supergraph schema not found: {supergraph_path}")]);
365    }
366
367    let content = fs::read_to_string(supergraph_path)
368        .map_err(|e| vec![format!("Failed to read supergraph: {e}")])?;
369
370    let supergraph: serde_json::Value = serde_json::from_str(&content)
371        .map_err(|e| vec![format!("Failed to parse supergraph JSON: {e}")])?;
372
373    let mut warnings = Vec::new();
374    let mut errs = Vec::new();
375
376    // Basic supergraph structure validation
377    if supergraph.get("federation").is_none() {
378        return Err(vec!["Supergraph schema has no federation metadata".to_string()]);
379    }
380
381    // Collect known subgraph names from the supergraph
382    let supergraph_subgraph_names = known_subgraph_names_from_metadata(&supergraph);
383
384    // Validate @override(from:) references in local schema
385    let local_content = fs::read_to_string(local_path)
386        .map_err(|e| vec![format!("Failed to re-read local schema: {e}")])?;
387    let local_schema: serde_json::Value = serde_json::from_str(&local_content)
388        .map_err(|e| vec![format!("Failed to re-parse local schema: {e}")])?;
389
390    if let Some(types) = local_schema.pointer("/federation/types").and_then(|v| v.as_array()) {
391        for fed_type in types {
392            let name = fed_type.get("name").and_then(|v| v.as_str()).unwrap_or("<unknown>");
393            if let Some(directives) = fed_type.get("field_directives").and_then(|v| v.as_object()) {
394                for (field_name, directive) in directives {
395                    if let Some(override_from) =
396                        directive.get("override_from").and_then(|v| v.as_str())
397                    {
398                        if !override_from.is_empty()
399                            && !supergraph_subgraph_names.contains(override_from)
400                        {
401                            errs.push(format!(
402                                "Type '{name}' field '{field_name}': \
403                                 @override(from: \"{override_from}\") references unknown \
404                                 subgraph '{override_from}'"
405                            ));
406                        }
407                    }
408                }
409            }
410        }
411    }
412
413    if !errs.is_empty() {
414        return Err(errs);
415    }
416
417    warnings.push(format!("Composition check against '{supergraph_path}' passed"));
418
419    Ok(warnings)
420}