fraiseql_cli/commands/federation/
check.rs1use std::fs;
9
10use anyhow::Result;
11use serde_json::json;
12
13use crate::output::CommandResult;
14
15pub 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 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 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 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 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 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 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 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 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 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 warnings.extend(check_root_field_inaccessibility(&schema));
136
137 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 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 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
210fn collect_known_fields(fed_type: &serde_json::Value) -> std::collections::HashSet<String> {
215 let mut known = std::collections::HashSet::new();
216
217 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 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 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 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
254fn 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
275fn 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 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
308fn 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
327fn 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
355fn validate_against_supergraph(
359 local_path: &str,
360 supergraph_path: &str,
361) -> std::result::Result<Vec<String>, Vec<String>> {
362 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 if supergraph.get("federation").is_none() {
378 return Err(vec!["Supergraph schema has no federation metadata".to_string()]);
379 }
380
381 let supergraph_subgraph_names = known_subgraph_names_from_metadata(&supergraph);
383
384 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}