Skip to main content

fraiseql_server/schema/
loader.rs

1//! Schema loader for compiled GraphQL schemas.
2
3use std::path::{Path, PathBuf};
4
5use fraiseql_core::schema::CompiledSchema;
6use tracing::{debug, info};
7
8/// Error loading schema.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum SchemaLoadError {
12    /// Schema file not found.
13    #[error("Schema file not found: {0}")]
14    NotFound(PathBuf),
15
16    /// IO error reading file.
17    #[error("Failed to read schema file: {0}")]
18    IoError(#[from] std::io::Error),
19
20    /// JSON parsing error.
21    #[error("Failed to parse schema JSON: {0}")]
22    ParseError(#[from] serde_json::Error),
23
24    /// Schema validation error.
25    #[error("Invalid schema: {0}")]
26    ValidationError(String),
27}
28
29/// Loader for compiled GraphQL schemas from JSON files.
30///
31/// Loads and caches a compiled schema from a JSON file on disk.
32/// Used during server startup to prepare the schema for query execution.
33#[derive(Debug, Clone)]
34pub struct CompiledSchemaLoader {
35    /// Path to the compiled schema JSON file.
36    path: PathBuf,
37}
38
39impl CompiledSchemaLoader {
40    /// Create a new schema loader pointing to a schema file.
41    ///
42    /// # Arguments
43    ///
44    /// * `path` - Path to the compiled schema JSON file
45    ///
46    /// # Example
47    ///
48    /// ```no_run
49    /// // Requires: schema.compiled.json file on disk.
50    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
51    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
52    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
53    /// let schema = loader.load().await?;
54    /// # Ok(())
55    /// # }
56    /// ```
57    #[must_use]
58    pub fn new<P: AsRef<Path>>(path: P) -> Self {
59        Self {
60            path: path.as_ref().to_path_buf(),
61        }
62    }
63
64    /// Load schema from file.
65    ///
66    /// Reads the schema JSON file, parses it, and returns a `CompiledSchema`.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SchemaLoadError::NotFound`] if the file does not exist.
71    /// Returns [`SchemaLoadError::IoError`] if the file cannot be read.
72    /// Returns [`SchemaLoadError::ParseError`] if the JSON is malformed.
73    /// Returns [`SchemaLoadError::ValidationError`] if schema validation fails.
74    ///
75    /// # Example
76    ///
77    /// ```no_run
78    /// // Requires: schema.compiled.json file on disk.
79    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
80    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
81    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
82    /// let schema = loader.load().await?;
83    /// # Ok(())
84    /// # }
85    /// ```
86    pub async fn load(&self) -> Result<CompiledSchema, SchemaLoadError> {
87        info!(path = %self.path.display(), "Loading compiled schema");
88
89        // Check if file exists
90        if !self.path.exists() {
91            return Err(SchemaLoadError::NotFound(self.path.clone()));
92        }
93
94        // Read file asynchronously
95        let contents =
96            tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
97
98        debug!(
99            path = %self.path.display(),
100            size_bytes = contents.len(),
101            "Schema file read successfully"
102        );
103
104        // Parse JSON and validate it's valid JSON first
105        serde_json::from_str::<serde_json::Value>(&contents)?;
106
107        // Create CompiledSchema from JSON string
108        let schema = CompiledSchema::from_json(&contents)
109            .map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
110
111        info!(path = %self.path.display(), "Schema loaded successfully");
112
113        Ok(schema)
114    }
115
116    /// Get the path to the schema file.
117    #[must_use]
118    pub fn path(&self) -> &Path {
119        &self.path
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
126    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
127    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
128    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
129    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
130    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
131    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
132    #![allow(missing_docs)] // Reason: test code
133    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
134
135    use std::io::Write;
136
137    use tempfile::NamedTempFile;
138
139    use super::*;
140
141    #[tokio::test]
142    async fn test_loader_not_found() {
143        let loader = CompiledSchemaLoader::new("/nonexistent/path/schema.json");
144        let result = loader.load().await;
145        assert!(matches!(result, Err(SchemaLoadError::NotFound(_))));
146    }
147
148    #[tokio::test]
149    async fn test_loader_invalid_json() {
150        let mut file = NamedTempFile::new().unwrap();
151        writeln!(file, "{{invalid json").unwrap();
152        file.flush().unwrap();
153
154        let loader = CompiledSchemaLoader::new(file.path());
155        let result = loader.load().await;
156        assert!(matches!(result, Err(SchemaLoadError::ParseError(_))));
157    }
158}