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}