Skip to main content

fraiseql_core/schema/compiled/
schema_serde.rs

1//! Serialization, deserialization, and integrity checking for [`CompiledSchema`].
2
3use sha2::{Digest, Sha256};
4use tracing::{info, warn};
5
6use super::schema::CompiledSchema;
7use crate::error::FraiseQLError;
8
9/// Recursively sort all JSON object keys to produce a canonical representation.
10///
11/// This guarantees deterministic serialization regardless of `HashMap` iteration
12/// order or `serde_json` feature flags (`preserve_order`). Used by both the CLI
13/// (hash embed) and `from_json` (hash verify) to ensure round-trip consistency.
14///
15/// # Example
16///
17/// ```
18/// use fraiseql_core::schema::canonicalize_json;
19///
20/// let v: serde_json::Value = serde_json::from_str(r#"{"b":1,"a":2}"#).unwrap();
21/// let c = canonicalize_json(&v);
22/// assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"a":2,"b":1}"#);
23/// ```
24#[must_use]
25pub fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
26    match value {
27        serde_json::Value::Object(map) => {
28            let mut sorted = serde_json::Map::new();
29            let mut keys: Vec<&String> = map.keys().collect();
30            keys.sort();
31            for key in keys {
32                sorted.insert(key.clone(), canonicalize_json(&map[key]));
33            }
34            serde_json::Value::Object(sorted)
35        },
36        serde_json::Value::Array(arr) => {
37            serde_json::Value::Array(arr.iter().map(canonicalize_json).collect())
38        },
39        other => other.clone(),
40    }
41}
42
43impl CompiledSchema {
44    /// Deserialize from JSON string.
45    ///
46    /// This is the primary way to create a schema from any authoring language.
47    /// The authoring language emits `schema.json`; `fraiseql-cli compile` produces
48    /// `schema.compiled.json`; Rust deserializes and owns the result.
49    ///
50    /// # Integrity Checking
51    ///
52    /// `fraiseql-cli compile` embeds a `_content_hash` field (SHA-256 of the compiled JSON
53    /// body, first 16 bytes as lowercase hex) in the compiled output. This function
54    /// extracts that field, recomputes the hash over the remaining JSON, and compares.
55    ///
56    /// - `strict_integrity = true`: missing or mismatched hash returns `Err`.
57    /// - `strict_integrity = false`: missing hash logs a warning; mismatch logs a warning but
58    ///   proceeds (backwards compatibility for schemas compiled without `_content_hash`).
59    ///
60    /// # Errors
61    ///
62    /// Returns error if JSON is malformed or doesn't match schema structure.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// use fraiseql_core::schema::CompiledSchema;
68    ///
69    /// let json = r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#;
70    /// let schema = CompiledSchema::from_json(json, false).unwrap();
71    /// ```
72    pub fn from_json(
73        json: &str,
74        strict_integrity: bool,
75    ) -> std::result::Result<Self, FraiseQLError> {
76        let serde_err = |e: serde_json::Error| FraiseQLError::Parse {
77            message:  format!("Schema JSON parse error: {e}"),
78            location: String::new(),
79        };
80
81        let mut value: serde_json::Value = serde_json::from_str(json).map_err(serde_err)?;
82
83        let obj = value.as_object_mut().ok_or_else(|| FraiseQLError::Validation {
84            message: "Schema JSON must be an object".to_string(),
85            path:    None,
86        })?;
87
88        // Extract and remove _content_hash
89        let expected_hash = if let Some(hash_val) = obj.remove("_content_hash") {
90            if let Some(hash_str) = hash_val.as_str() {
91                Some(hash_str.to_string())
92            } else {
93                return Err(FraiseQLError::Validation {
94                    message: "_content_hash must be a string".to_string(),
95                    path:    None,
96                });
97            }
98        } else if strict_integrity {
99            return Err(FraiseQLError::Validation {
100                message: "Schema integrity check failed: missing _content_hash field. Enable strict_schema_integrity=false for backwards compatibility.".to_string(),
101                path: None,
102            });
103        } else {
104            warn!(
105                "Schema integrity check skipped: no _content_hash field present. Consider recompiling with a newer CLI for integrity verification."
106            );
107            // No hash, parse directly from original
108            let mut schema: Self = serde_json::from_str(json).map_err(serde_err)?;
109            schema.build_indexes();
110            return Ok(schema);
111        };
112
113        // Canonicalize and serialize deterministically (sorted keys at all levels)
114        let canonical = canonicalize_json(&value);
115        let remaining_json = serde_json::to_string_pretty(&canonical).map_err(serde_err)?;
116        let computed_digest = Sha256::digest(remaining_json.as_bytes());
117        let computed_hash = hex::encode(&computed_digest[..16]);
118
119        if let Some(expected) = expected_hash {
120            if expected != computed_hash {
121                if strict_integrity {
122                    return Err(FraiseQLError::Validation {
123                        message: format!(
124                            "Schema integrity check failed: hash mismatch (expected {expected}, got {computed_hash})"
125                        ),
126                        path:    None,
127                    });
128                }
129                warn!(
130                    "Schema integrity check: hash mismatch (expected {expected}, got {computed_hash}). Proceeding because strict_integrity is disabled."
131                );
132            } else {
133                info!("Schema integrity verified: hash matches");
134            }
135        }
136
137        // Now deserialize the schema from the remaining JSON
138        let mut schema: Self = serde_json::from_str(&remaining_json).map_err(serde_err)?;
139        schema.build_indexes();
140        Ok(schema)
141    }
142
143    /// Serialize to JSON string.
144    ///
145    /// # Errors
146    ///
147    /// Returns error if serialization fails (should not happen for valid schema).
148    pub fn to_json(&self) -> Result<String, serde_json::Error> {
149        serde_json::to_string(self)
150    }
151
152    /// Serialize to pretty JSON string (for debugging/config files).
153    ///
154    /// # Errors
155    ///
156    /// Returns error if serialization fails.
157    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
158        serde_json::to_string_pretty(self)
159    }
160
161    /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
162    ///
163    /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
164    /// cache invalidation on any schema change, regardless of whether the package
165    /// version was bumped.
166    ///
167    /// Two schemas that differ by even one field will produce different hashes.
168    /// The same schema serialised twice always produces the same hash (stable).
169    ///
170    /// # Panics
171    ///
172    /// Does not panic — `CompiledSchema` always serialises to valid JSON.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use fraiseql_core::schema::CompiledSchema;
178    ///
179    /// let schema = CompiledSchema::default();
180    /// let hash = schema.content_hash();
181    /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
182    /// ```
183    #[must_use]
184    pub fn content_hash(&self) -> String {
185        use sha2::{Digest, Sha256};
186        let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
187        let digest = Sha256::digest(json.as_bytes());
188        hex::encode(&digest[..16]) // 32 hex chars — sufficient collision resistance
189    }
190}