Skip to main content

fraiseql_cli/commands/schema/
metadata.rs

1//! `fraiseql schema metadata` — display field-level security metadata from a running server.
2//!
3//! Fetches `GET /api/v1/schema/metadata` and renders the result as an aligned table:
4//!
5//! ```text
6//! Field       Encrypted  Scope     On Deny
7//! ----------  ---------  --------  -------
8//! User.email  true       -         -
9//! User.ssn    -          read:pii  mask
10//! ```
11
12use anyhow::Result;
13
14/// Fetch schema metadata from `server_url` and print as a formatted table.
15///
16/// # Errors
17///
18/// Returns an error if the HTTP request fails, the server responds with a non-2xx status,
19/// or the response body cannot be parsed as the expected JSON shape.
20pub async fn run(server_url: &str, token: Option<&str>) -> Result<()> {
21    let url = format!("{}/api/v1/schema/metadata", server_url.trim_end_matches('/'));
22
23    let client = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)).build()?;
24
25    let mut req = client.get(&url);
26    if let Some(tok) = token {
27        req = req.header("Authorization", format!("Bearer {tok}"));
28    }
29
30    let resp = req
31        .send()
32        .await
33        .map_err(|e| anyhow::anyhow!("Failed to connect to server at {url}: {e}"))?;
34
35    if !resp.status().is_success() {
36        return Err(anyhow::anyhow!("Server returned HTTP {}", resp.status()));
37    }
38
39    let body: serde_json::Value = resp
40        .json()
41        .await
42        .map_err(|e| anyhow::anyhow!("Failed to parse server response: {e}"))?;
43
44    let metadata = body
45        .pointer("/data/metadata")
46        .ok_or_else(|| anyhow::anyhow!("Unexpected response shape — missing /data/metadata"))?;
47
48    print!("{}", format_table(metadata));
49    Ok(())
50}
51
52/// Render field security metadata as an aligned plain-text table.
53///
54/// Each entry in `metadata` corresponds to one row. Optional columns (`Encrypted`, `Scope`,
55/// `On Deny`) show `"-"` when not set.
56pub fn format_table(metadata: &serde_json::Value) -> String {
57    let Some(obj) = metadata.as_object() else {
58        return "No metadata entries found.\n".to_string();
59    };
60
61    if obj.is_empty() {
62        return "No metadata entries found.\n".to_string();
63    }
64
65    // Build rows: (field, encrypted, scope, on_deny)
66    let mut rows: Vec<(String, String, String, String)> = obj
67        .iter()
68        .map(|(field, meta)| {
69            let encrypted = if meta.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false) {
70                "true".to_string()
71            } else {
72                "-".to_string()
73            };
74            let scope =
75                meta.get("requires_scope").and_then(|v| v.as_str()).unwrap_or("-").to_string();
76            let on_deny = meta.get("on_deny").and_then(|v| v.as_str()).unwrap_or("-").to_string();
77            (field.clone(), encrypted, scope, on_deny)
78        })
79        .collect();
80
81    rows.sort_by(|a, b| a.0.cmp(&b.0));
82
83    // Column widths: max of header and all row values
84    let w0 = rows.iter().map(|r| r.0.len()).max().unwrap_or(0).max("Field".len());
85    let w1 = rows.iter().map(|r| r.1.len()).max().unwrap_or(0).max("Encrypted".len());
86    let w2 = rows.iter().map(|r| r.2.len()).max().unwrap_or(0).max("Scope".len());
87    let w3 = rows.iter().map(|r| r.3.len()).max().unwrap_or(0).max("On Deny".len());
88
89    let mut out = String::new();
90    out.push_str(&format!(
91        "{:<w0$}  {:<w1$}  {:<w2$}  {:<w3$}\n",
92        "Field", "Encrypted", "Scope", "On Deny"
93    ));
94    out.push_str(&format!("{:-<w0$}  {:-<w1$}  {:-<w2$}  {:-<w3$}\n", "", "", "", ""));
95    for (field, enc, scope, on_deny) in &rows {
96        out.push_str(&format!("{:<w0$}  {:<w1$}  {:<w2$}  {:<w3$}\n", field, enc, scope, on_deny));
97    }
98
99    out
100}