fraiseql_cli/commands/federation/
graph.rs1use std::{fmt::Display, str::FromStr};
15
16use anyhow::Result;
17use serde::Serialize;
18
19use super::check::load;
20use crate::output::CommandResult;
21
22const COMMAND: &str = "federation graph";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum GraphFormat {
28 Json,
30 Dot,
32 Mermaid,
34}
35
36impl FromStr for GraphFormat {
37 type Err = String;
38
39 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
40 match s.to_lowercase().as_str() {
41 "json" => Ok(GraphFormat::Json),
42 "dot" => Ok(GraphFormat::Dot),
43 "mermaid" => Ok(GraphFormat::Mermaid),
44 other => Err(format!("Unknown format: {other}. Use json, dot, or mermaid")),
45 }
46 }
47}
48
49impl Display for GraphFormat {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 GraphFormat::Json => write!(f, "json"),
53 GraphFormat::Dot => write!(f, "dot"),
54 GraphFormat::Mermaid => write!(f, "mermaid"),
55 }
56 }
57}
58
59#[derive(Debug, Serialize)]
61pub struct FederationGraph {
62 pub subgraphs: Vec<Subgraph>,
64 pub edges: Vec<Edge>,
66 pub unresolved: Vec<Unresolved>,
68}
69
70#[derive(Debug, Serialize)]
72pub struct Subgraph {
73 pub name: String,
75 pub owns: Vec<String>,
77 pub extends: Vec<String>,
79}
80
81#[derive(Debug, Serialize)]
83pub struct Edge {
84 pub from: String,
86 pub to: String,
88 pub entity: String,
90}
91
92#[derive(Debug, Serialize)]
94pub struct Unresolved {
95 pub subgraph: String,
97 pub entity: String,
99}
100
101pub fn run(schema_paths: &[String], format: GraphFormat) -> Result<CommandResult> {
109 let mut subgraphs = Vec::with_capacity(schema_paths.len());
110 let mut problems = Vec::new();
111 for path in schema_paths {
112 let schema = match load(path)? {
113 Ok(schema) => schema,
114 Err(problem) => {
115 problems.push(problem);
116 continue;
117 },
118 };
119 let Some(federation) = schema.federation.filter(|f| f.enabled) else {
120 problems
121 .push(format!("{path} declares no federation; compile it with federation enabled"));
122 continue;
123 };
124 let Some(name) = federation.service_name else {
125 problems.push(format!("{path} has no federation service_name"));
126 continue;
127 };
128 let (extended, owned): (Vec<_>, Vec<_>) =
129 federation.entities.into_iter().partition(|e| e.extends);
130 subgraphs.push(Subgraph {
131 name,
132 owns: owned.into_iter().map(|e| e.name).collect(),
133 extends: extended.into_iter().map(|e| e.name).collect(),
134 });
135 }
136 if !problems.is_empty() {
137 return Ok(CommandResult::validation_failed(COMMAND, problems, "INVALID_SCHEMA"));
138 }
139
140 let graph = build(subgraphs);
141 let output = match format {
142 GraphFormat::Json => serde_json::to_value(&graph)?,
143 GraphFormat::Dot => serde_json::Value::String(to_dot(&graph)),
144 GraphFormat::Mermaid => serde_json::Value::String(to_mermaid(&graph)),
145 };
146 Ok(CommandResult::success("federation/graph", output))
147}
148
149fn build(subgraphs: Vec<Subgraph>) -> FederationGraph {
151 let mut edges = Vec::new();
152 let mut unresolved = Vec::new();
153 for from in &subgraphs {
154 for entity in &from.extends {
155 let owners: Vec<&Subgraph> =
156 subgraphs.iter().filter(|s| s.owns.contains(entity)).collect();
157 if owners.is_empty() {
158 unresolved.push(Unresolved {
159 subgraph: from.name.clone(),
160 entity: entity.clone(),
161 });
162 }
163 for to in owners {
164 edges.push(Edge {
165 from: from.name.clone(),
166 to: to.name.clone(),
167 entity: entity.clone(),
168 });
169 }
170 }
171 }
172 FederationGraph {
173 subgraphs,
174 edges,
175 unresolved,
176 }
177}
178
179fn dot_quoted(s: &str) -> String {
184 serde_json::Value::String(s.to_owned()).to_string()
185}
186
187fn mermaid_text(s: &str) -> String {
189 s.replace('"', "#quot;")
190 .replace('<', "#lt;")
191 .replace('>', "#gt;")
192 .replace('\n', " ")
193}
194
195pub(crate) fn to_dot(graph: &FederationGraph) -> String {
200 let mut dot = String::from("digraph federation {\n");
201 let id = |name: &str| graph.subgraphs.iter().position(|s| s.name == name).unwrap_or(0);
202 for (i, subgraph) in graph.subgraphs.iter().enumerate() {
203 let label = format!("{}\n[{}]", subgraph.name, subgraph.owns.join(", "));
204 dot.push_str(&format!(" n{i} [label={}];\n", dot_quoted(&label)));
205 }
206 for edge in &graph.edges {
207 dot.push_str(&format!(
208 " n{} -> n{} [label={}];\n",
209 id(&edge.from),
210 id(&edge.to),
211 dot_quoted(&edge.entity)
212 ));
213 }
214 dot.push_str("}\n");
215 dot
216}
217
218pub(crate) fn to_mermaid(graph: &FederationGraph) -> String {
220 let mut mermaid = String::from("graph LR\n");
221 let id = |name: &str| graph.subgraphs.iter().position(|s| s.name == name).unwrap_or(0);
222 for (i, subgraph) in graph.subgraphs.iter().enumerate() {
223 let owns: Vec<String> = subgraph.owns.iter().map(|e| mermaid_text(e)).collect();
224 mermaid.push_str(&format!(
225 " n{i}[\"{}<br/>[{}]\"]\n",
226 mermaid_text(&subgraph.name),
227 owns.join("<br/>")
228 ));
229 }
230 for edge in &graph.edges {
231 mermaid.push_str(&format!(
232 " n{} -->|\"{}\"| n{}\n",
233 id(&edge.from),
234 mermaid_text(&edge.entity),
235 id(&edge.to)
236 ));
237 }
238 mermaid
239}