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
43/// The content hash of a compiled schema, as a hex string.
44///
45/// This is the **one** place the digest is defined. The CLI embeds it, `from_json`
46/// re-derives it to verify, [`CompiledSchema::content_hash`] uses it as the cache
47/// version, and the integrity test drives it — because three hand-copies of
48/// "canonicalize, pretty-print, SHA-256, take 16 bytes" is three chances to
49/// disagree, and they did: the integrity test hashed the *uncanonicalized* value
50/// and passed only because `serde_json::Map` happened to be a sorted `BTreeMap`
51/// in the build it ran in (#899).
52///
53/// `value` must not contain the `_content_hash` key: a schema cannot hash its own
54/// hash.
55#[must_use]
56pub fn content_hash_of(value: &serde_json::Value) -> String {
57 let canonical = canonicalize_json(value);
58 // `to_string_pretty` on a canonicalized value cannot fail — every node is a
59 // plain JSON value with no non-string map keys.
60 let rendered = serde_json::to_string_pretty(&canonical).unwrap_or_default();
61 let digest = Sha256::digest(rendered.as_bytes());
62 hex::encode(&digest[..16]) // 32 hex chars — sufficient collision resistance
63}
64
65impl CompiledSchema {
66 /// Deserialize from JSON string.
67 ///
68 /// This is the primary way to create a schema from any authoring language.
69 /// The authoring language emits `schema.json`; `fraiseql-cli compile` produces
70 /// `schema.compiled.json`; Rust deserializes and owns the result.
71 ///
72 /// # Integrity Checking
73 ///
74 /// `fraiseql-cli compile` embeds a `_content_hash` field (SHA-256 of the compiled JSON
75 /// body, first 16 bytes as lowercase hex) in the compiled output. This function
76 /// extracts that field, recomputes the hash over the remaining JSON, and compares.
77 ///
78 /// - `strict_integrity = true`: missing or mismatched hash returns `Err`.
79 /// - `strict_integrity = false`: missing hash logs a warning; mismatch logs a warning but
80 /// proceeds (backwards compatibility for schemas compiled without `_content_hash`).
81 ///
82 /// # Errors
83 ///
84 /// Returns error if JSON is malformed or doesn't match schema structure.
85 ///
86 /// # Example
87 ///
88 /// ```
89 /// use fraiseql_core::schema::CompiledSchema;
90 ///
91 /// let json = r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#;
92 /// let schema = CompiledSchema::from_json(json, false).unwrap();
93 /// ```
94 pub fn from_json(
95 json: &str,
96 strict_integrity: bool,
97 ) -> std::result::Result<Self, FraiseQLError> {
98 let serde_err = |e: serde_json::Error| FraiseQLError::Parse {
99 message: format!("Schema JSON parse error: {e}"),
100 location: String::new(),
101 };
102 // Typed deserialization goes through `serde_path_to_error` so a refused
103 // schema names the offending section ("security.rate_limiting.…"), not
104 // just a line/column into a machine-formatted file — the #778 property,
105 // kept now that malformed sections are refused at load (#977).
106 let path_err = |e: serde_path_to_error::Error<serde_json::Error>| FraiseQLError::Parse {
107 message: format!("Schema JSON parse error at `{}`: {}", e.path(), e.inner()),
108 location: e.path().to_string(),
109 };
110
111 let mut value: serde_json::Value = serde_json::from_str(json).map_err(serde_err)?;
112
113 let obj = value.as_object_mut().ok_or_else(|| FraiseQLError::Validation {
114 message: "Schema JSON must be an object".to_string(),
115 path: None,
116 })?;
117
118 // Extract and remove _content_hash
119 let expected_hash = if let Some(hash_val) = obj.remove("_content_hash") {
120 if let Some(hash_str) = hash_val.as_str() {
121 Some(hash_str.to_string())
122 } else {
123 return Err(FraiseQLError::Validation {
124 message: "_content_hash must be a string".to_string(),
125 path: None,
126 });
127 }
128 } else if strict_integrity {
129 return Err(FraiseQLError::Validation {
130 message: "Schema integrity check failed: missing _content_hash field. Enable strict_schema_integrity=false for backwards compatibility.".to_string(),
131 path: None,
132 });
133 } else {
134 warn!(
135 "Schema integrity check skipped: no _content_hash field present. Consider recompiling with a newer CLI for integrity verification."
136 );
137 // No hash, parse directly from original
138 let de = &mut serde_json::Deserializer::from_str(json);
139 let mut schema: Self = serde_path_to_error::deserialize(de).map_err(path_err)?;
140 schema.build_indexes();
141 schema.finish_load()?;
142 return Ok(schema);
143 };
144
145 // Canonicalize and serialize deterministically (sorted keys at all levels)
146 let computed_hash = content_hash_of(&value);
147
148 if let Some(expected) = expected_hash {
149 if expected != computed_hash {
150 if strict_integrity {
151 return Err(FraiseQLError::Validation {
152 message: format!(
153 "Schema integrity check failed: hash mismatch (expected {expected}, got {computed_hash})"
154 ),
155 path: None,
156 });
157 }
158 warn!(
159 "Schema integrity check: hash mismatch (expected {expected}, got {computed_hash}). Proceeding because strict_integrity is disabled."
160 );
161 } else {
162 info!("Schema integrity verified: hash matches");
163 }
164 }
165
166 // Now deserialize the schema from the remaining JSON (the parsed value
167 // with `_content_hash` already removed).
168 let mut schema: Self = serde_path_to_error::deserialize(value).map_err(path_err)?;
169 schema.build_indexes();
170 schema.finish_load()?;
171 Ok(schema)
172 }
173
174 /// Normalization every load must perform, and the checks that must hold after it.
175 ///
176 /// Lives here rather than in `build_indexes` because it can fail, and because
177 /// both `from_json` branches must run it — the hashed and the unhashed one. A
178 /// normalization step performed on one load path and not its sibling is the
179 /// shape that produced #748 and #812 in other subsystems.
180 ///
181 /// Today it refuses a schema that declares a name twice (#1265), lowers each
182 /// type's `requires_role` onto the operations that return it (#677) and refuses a
183 /// schema whose role declarations the runtime cannot honour, then refuses one whose
184 /// type-level and query-level scoping declarations contradict each other (#1142),
185 /// one whose subscription row-visibility policy the delivery path cannot honour
186 /// (#596/#1265), one whose subscription filter names an argument it does not
187 /// declare (#1262), one declaring `requires_scope` with no role able to grant it
188 /// (ruling Y 7), and one declaring a relationship no embed can follow (#1266).
189 ///
190 /// # Errors
191 ///
192 /// Returns [`FraiseQLError::Validation`] when a role-gated type is declared in a
193 /// shape no execution path can enforce — see
194 /// [`CompiledSchema::type_role_violations`] — when a type and its backing query
195 /// scope the same column from different sources, see
196 /// [`CompiledSchema::type_inject_violations`], or when a relationship names a
197 /// target, a join column or a list query the embed executor cannot resolve, see
198 /// [`CompiledSchema::relationship_violations`].
199 fn finish_load(&mut self) -> std::result::Result<(), FraiseQLError> {
200 // First: a name declared twice makes every later check ambiguous. `build_indexes`
201 // keys by name, so the second definition silently shadows the first and the
202 // schema behaves as something nobody wrote (#1265).
203 let violations = self.duplicate_name_violations();
204 if !violations.is_empty() {
205 return Err(FraiseQLError::Validation {
206 message: format!(
207 "schema declares a name twice:\n - {}",
208 violations.join("\n - ")
209 ),
210 path: Some("schema.names".to_string()),
211 });
212 }
213 self.propagate_type_roles();
214 let violations = self.type_role_violations();
215 if !violations.is_empty() {
216 return Err(FraiseQLError::Validation {
217 message: format!(
218 "type-level `requires_role` cannot be enforced as declared:\n - {}",
219 violations.join("\n - ")
220 ),
221 path: Some("security.requires_role".to_string()),
222 });
223 }
224 let violations = self.type_inject_violations();
225 if !violations.is_empty() {
226 return Err(FraiseQLError::Validation {
227 message: format!(
228 "type-level `inject_params` cannot be enforced as declared:\n - {}",
229 violations.join("\n - ")
230 ),
231 path: Some("security.inject_params".to_string()),
232 });
233 }
234 let violations = self.subscription_policy_violations();
235 if !violations.is_empty() {
236 return Err(FraiseQLError::Validation {
237 message: format!(
238 "subscription policies cannot be applied as declared:\n - {}",
239 violations.join("\n - ")
240 ),
241 path: Some("types.subscription_policy".to_string()),
242 });
243 }
244 let violations = self.subscription_filter_violations();
245 if !violations.is_empty() {
246 return Err(FraiseQLError::Validation {
247 message: format!(
248 "subscription filters cannot be applied as declared:\n - {}",
249 violations.join("\n - ")
250 ),
251 path: Some("subscriptions.filter".to_string()),
252 });
253 }
254 let violations = self.scope_violations();
255 if !violations.is_empty() {
256 return Err(FraiseQLError::Validation {
257 message: format!(
258 "field-level `requires_scope` cannot be enforced as declared:\n - {}",
259 violations.join("\n - ")
260 ),
261 path: Some("security.requires_scope".to_string()),
262 });
263 }
264 let violations = self.fact_table_link_violations();
265 if !violations.is_empty() {
266 return Err(FraiseQLError::Validation {
267 message: format!(
268 "fact tables cannot be read as their types:\n - {}",
269 violations.join("\n - ")
270 ),
271 path: Some("fact_tables.type_name".to_string()),
272 });
273 }
274 let violations = self.relationship_violations();
275 if !violations.is_empty() {
276 return Err(FraiseQLError::Validation {
277 message: format!(
278 "relationships cannot be followed as declared:\n - {}",
279 violations.join("\n - ")
280 ),
281 path: Some("types.relationships".to_string()),
282 });
283 }
284 Ok(())
285 }
286
287 /// Types whose declared subscription row-visibility policy the delivery path cannot
288 /// honour (#596).
289 ///
290 /// This check lived in `CompiledSchema::validate()`, which had **no caller outside
291 /// tests** — so #596's own comment calling it "a load-time error, not a silent
292 /// deliver-all at subscribe time" described something that never ran, and
293 /// `SubscriptionPolicy::validate` had exactly one caller: the unreachable one
294 /// (#1265).
295 ///
296 /// It matters precisely here, because `subscription_policy` has no authoring
297 /// producer at all — the CLI hardcodes `None` at every construction site, the
298 /// intermediate schema has no field for it and no SDK emits one. The only way a
299 /// policy reaches a deployment is a hand-written compiled schema, which is exactly
300 /// the input this path accepts.
301 fn subscription_policy_violations(&self) -> Vec<String> {
302 self.types
303 .iter()
304 .filter_map(|type_def| {
305 type_def
306 .subscription_policy
307 .as_ref()
308 .and_then(|policy| policy.validate().err())
309 .map(|e| format!("Type '{}': {e}", type_def.name))
310 })
311 .collect()
312 }
313
314 /// Types, queries or mutations declared under a name already taken (#1265).
315 ///
316 /// `build_indexes` keys each collection by name, so a duplicate does not conflict —
317 /// it *shadows*, and the schema serves whichever definition indexed last while the
318 /// document plainly contains both. `fraiseql compile` refuses to emit one
319 /// (`SchemaValidator::validate` checks the intermediate schema), which leaves the
320 /// hand-edited artifact.
321 ///
322 /// ⚠ `CompiledSchema::validate()` also checked that every query and mutation return
323 /// type resolved, and that check is **not** carried over, because it was wrong: it
324 /// resolved against `self.types` plus ten builtin scalar names, while the schema
325 /// holds `enums`, `interfaces` and `unions` in separate collections. Measured before
326 /// its removal, it reported `Query 'orderStatus' references undefined type
327 /// 'OrderStatus'` for a valid enum-returning query — so moving it onto this path
328 /// would have refused, at boot, every schema with one. Nobody noticed in the years
329 /// it existed, because nothing ran it. `SchemaValidator::validate` covers return
330 /// types on the compile path, correctly. See
331 /// `a_query_returning_an_enum_or_union_still_loads`.
332 fn duplicate_name_violations(&self) -> Vec<String> {
333 fn duplicates<'a>(kind: &str, names: impl Iterator<Item = &'a str>, out: &mut Vec<String>) {
334 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
335 for name in names {
336 if !seen.insert(name) {
337 out.push(format!("Duplicate {kind} name: {name}"));
338 }
339 }
340 }
341
342 let mut violations = Vec::new();
343 duplicates("type", self.types.iter().map(|t| t.name.as_str()), &mut violations);
344 duplicates("query", self.queries.iter().map(|q| q.name.as_str()), &mut violations);
345 duplicates("mutation", self.mutations.iter().map(|m| m.name.as_str()), &mut violations);
346 violations
347 }
348
349 /// Subscriptions whose filter names an argument they do not declare (#1262).
350 ///
351 /// `fraiseql compile` refuses to emit such a document, which leaves the hand-edited
352 /// artifact — the case a compile-time check cannot reach, and the reason this is
353 /// checked again on the load path every entry point shares.
354 fn subscription_filter_violations(&self) -> Vec<String> {
355 self.subscriptions
356 .iter()
357 .filter_map(crate::schema::SubscriptionDefinition::filter_violation)
358 .collect()
359 }
360
361 /// Serialize to JSON string.
362 ///
363 /// # Errors
364 ///
365 /// Returns error if serialization fails (should not happen for valid schema).
366 pub fn to_json(&self) -> Result<String, serde_json::Error> {
367 serde_json::to_string(self)
368 }
369
370 /// Serialize to pretty JSON string (for debugging/config files).
371 ///
372 /// # Errors
373 ///
374 /// Returns error if serialization fails.
375 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
376 serde_json::to_string_pretty(self)
377 }
378
379 /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
380 ///
381 /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
382 /// cache invalidation on any schema change, regardless of whether the package
383 /// version was bumped.
384 ///
385 /// Two schemas that differ by even one field will produce different hashes.
386 /// The same schema serialised twice always produces the same hash (stable).
387 ///
388 /// # Panics
389 ///
390 /// Does not panic — `CompiledSchema` always serialises to valid JSON.
391 ///
392 /// # Example
393 ///
394 /// ```
395 /// use fraiseql_core::schema::CompiledSchema;
396 ///
397 /// let schema = CompiledSchema::default();
398 /// let hash = schema.content_hash();
399 /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
400 /// ```
401 #[must_use]
402 pub fn content_hash(&self) -> String {
403 let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
404 let value: serde_json::Value =
405 serde_json::from_str(&json).expect("just serialised — always valid JSON");
406 content_hash_of(&value)
407 }
408}