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 fraiseql_functions::FunctionDefinition;
7use serde::Deserialize;
8use tracing::{debug, info, warn};
9
10/// Error loading schema.
11#[derive(Debug, thiserror::Error)]
12#[non_exhaustive]
13pub enum SchemaLoadError {
14    /// Schema file not found.
15    #[error("Schema file not found: {0}")]
16    NotFound(PathBuf),
17
18    /// IO error reading file.
19    #[error("Failed to read schema file: {0}")]
20    IoError(#[from] std::io::Error),
21
22    /// JSON parsing error.
23    #[error("Failed to parse schema JSON: {0}")]
24    ParseError(#[from] serde_json::Error),
25
26    /// Schema validation error.
27    #[error("Invalid schema: {0}")]
28    ValidationError(String),
29}
30
31/// Storage configuration extracted from the `"storage"` section of a compiled schema.
32///
33/// This describes the *bucket policies* declared by the developer, not the storage
34/// backend settings (which come from server TOML / environment variables).
35///
36/// ```json
37/// {
38///   "storage": {
39///     "buckets": [
40///       { "name": "avatars", "access": "private" },
41///       { "name": "media", "access": "public_read", "max_object_bytes": 5242880 }
42///     ]
43///   }
44/// }
45/// ```
46#[derive(Debug, Clone, Deserialize)]
47pub struct SchemaStorageConfig {
48    /// Bucket definitions declared in the schema.
49    pub buckets: Vec<SchemaBucketDef>,
50}
51
52/// A single bucket definition from the compiled schema.
53#[derive(Debug, Clone, Deserialize)]
54pub struct SchemaBucketDef {
55    /// Bucket name — must be a valid identifier (alphanumeric, hyphens, underscores; no spaces).
56    pub name: String,
57
58    /// Access policy: `"private"` (default) or `"public_read"`.
59    #[serde(default = "default_access")]
60    pub access: String,
61
62    /// Maximum object size in bytes. `None` means unlimited.
63    #[serde(default)]
64    pub max_object_bytes: Option<u64>,
65
66    /// Allowed MIME types. `None` means any MIME type is accepted.
67    #[serde(default)]
68    pub allowed_mime_types: Option<Vec<String>>,
69}
70
71fn default_access() -> String {
72    "private".to_string()
73}
74
75/// Functions configuration extracted from the `"functions"` section of a compiled schema.
76///
77/// ```json
78/// {
79///   "functions": {
80///     "module_dir": "/opt/fraiseql/functions",
81///     "definitions": [
82///       { "name": "on_create_user", "trigger": "after:mutation:createUser", "runtime": "Wasm" }
83///     ]
84///   }
85/// }
86/// ```
87#[derive(Debug, Clone, Deserialize)]
88pub struct FunctionsConfig {
89    /// Directory containing compiled function modules (`.wasm`, `.js`, etc.).
90    pub module_dir: PathBuf,
91
92    /// Function definitions loaded from the compiled schema.
93    pub definitions: Vec<FunctionDefinition>,
94
95    /// Which dead-letter store backs function dispatch (#598): `"memory"` (the
96    /// default — dead-letters vanish on restart) or `"postgres"` (durable, survives
97    /// a restart; requires a database pool). Overridable by the
98    /// `FRAISEQL_FUNCTIONS_DLQ_STORE` env var. Absent ⇒ memory.
99    #[serde(default)]
100    pub dlq_store: Option<String>,
101}
102
103/// A compiled schema with all optional platform extensions parsed out.
104///
105/// Use [`CompiledSchemaLoader::load_extended`] to obtain this type. It bundles the
106/// core [`CompiledSchema`] together with optional storage and functions
107/// configurations that are embedded in the compiled schema JSON.
108#[derive(Debug)]
109pub struct ExtendedCompiledSchema {
110    /// Core compiled GraphQL schema (types, queries, mutations, subscriptions).
111    pub schema: CompiledSchema,
112
113    /// Storage bucket configuration, if the `"storage"` key is present and non-null.
114    pub storage: Option<SchemaStorageConfig>,
115
116    /// Serverless functions configuration, if the `"functions"` key is present.
117    pub functions: Option<FunctionsConfig>,
118}
119
120/// Loader for compiled GraphQL schemas from JSON files.
121///
122/// Loads and caches a compiled schema from a JSON file on disk.
123/// Used during server startup to prepare the schema for query execution.
124#[derive(Debug, Clone)]
125pub struct CompiledSchemaLoader {
126    /// Path to the compiled schema JSON file.
127    path: PathBuf,
128}
129
130impl CompiledSchemaLoader {
131    /// Create a new schema loader pointing to a schema file.
132    ///
133    /// # Arguments
134    ///
135    /// * `path` - Path to the compiled schema JSON file
136    ///
137    /// # Example
138    ///
139    /// ```no_run
140    /// // Requires: schema.compiled.json file on disk.
141    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
142    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
143    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
144    /// let schema = loader.load().await?;
145    /// # Ok(())
146    /// # }
147    /// ```
148    #[must_use]
149    pub fn new<P: AsRef<Path>>(path: P) -> Self {
150        Self {
151            path: path.as_ref().to_path_buf(),
152        }
153    }
154
155    /// Load schema from file.
156    ///
157    /// Reads the schema JSON file, parses it, and returns a `CompiledSchema`.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`SchemaLoadError::NotFound`] if the file does not exist.
162    /// Returns [`SchemaLoadError::IoError`] if the file cannot be read.
163    /// Returns [`SchemaLoadError::ParseError`] if the JSON is malformed.
164    /// Returns [`SchemaLoadError::ValidationError`] if schema validation fails.
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// // Requires: schema.compiled.json file on disk.
170    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
171    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
172    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
173    /// let schema = loader.load().await?;
174    /// # Ok(())
175    /// # }
176    /// ```
177    pub async fn load(&self) -> Result<CompiledSchema, SchemaLoadError> {
178        info!(path = %self.path.display(), "Loading compiled schema");
179
180        // Check if file exists
181        if !self.path.exists() {
182            return Err(SchemaLoadError::NotFound(self.path.clone()));
183        }
184
185        // Read file asynchronously
186        let contents =
187            tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
188
189        debug!(
190            path = %self.path.display(),
191            size_bytes = contents.len(),
192            "Schema file read successfully"
193        );
194
195        // Parse JSON and validate it's valid JSON first
196        serde_json::from_str::<serde_json::Value>(&contents)?;
197
198        // Create CompiledSchema from JSON string
199        let schema = CompiledSchema::from_json(&contents, false)
200            .map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
201
202        info!(path = %self.path.display(), "Schema loaded successfully");
203
204        Ok(schema)
205    }
206
207    /// Load schema and all optional platform extension sections from file.
208    ///
209    /// In addition to the core schema (types, queries, mutations, subscriptions),
210    /// this method parses and validates the `"storage"` and `"functions"` top-level
211    /// keys if they are present. A legacy `"realtime"` key is ignored with a warning
212    /// (the subsystem was removed in #605). Unknown top-level keys are ignored for
213    /// forward compatibility.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`SchemaLoadError::NotFound`] if the file does not exist.
218    /// Returns [`SchemaLoadError::IoError`] if the file cannot be read.
219    /// Returns [`SchemaLoadError::ParseError`] if the JSON is malformed.
220    /// Returns [`SchemaLoadError::ValidationError`] if any of the following fail:
221    ///   - A storage bucket name contains whitespace or is empty.
222    ///   - A function trigger string does not match a recognised pattern.
223    pub async fn load_extended(&self) -> Result<ExtendedCompiledSchema, SchemaLoadError> {
224        info!(path = %self.path.display(), "Loading extended compiled schema");
225
226        if !self.path.exists() {
227            return Err(SchemaLoadError::NotFound(self.path.clone()));
228        }
229
230        let contents =
231            tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
232
233        debug!(
234            path = %self.path.display(),
235            size_bytes = contents.len(),
236            "Schema file read for extended loading"
237        );
238
239        // Parse once as a raw JSON value so we can extract platform sections without
240        // touching the CompiledSchema deserialization path.
241        let raw: serde_json::Value = serde_json::from_str(&contents)?;
242
243        // Core schema (always required).
244        let schema = CompiledSchema::from_json(&contents, false)
245            .map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
246
247        // Parse and validate the optional sections.
248        let storage = raw
249            .get("storage")
250            .filter(|v| !v.is_null())
251            .map(|v| {
252                let cfg: SchemaStorageConfig = serde_json::from_value(v.clone())?;
253                validate_storage_config(&cfg)?;
254                Ok::<_, SchemaLoadError>(cfg)
255            })
256            .transpose()?;
257
258        let functions = raw
259            .get("functions")
260            .filter(|v| !v.is_null())
261            .map(|v| {
262                let cfg: FunctionsConfig = serde_json::from_value(v.clone())?;
263                validate_functions_config(&cfg)?;
264                Ok::<_, SchemaLoadError>(cfg)
265            })
266            .transpose()?;
267
268        // The compiled-schema `"realtime"` section is no longer supported (#605): the
269        // dormant `/realtime/v1` subsystem was removed. fraiseql-cli never emitted this
270        // section (cli and server are version-locked on the format), so only a
271        // hand-authored or stale schema could contain one — warn and ignore rather than
272        // fail, keeping boot resilient while still surfacing the staleness.
273        if raw.get("realtime").is_some_and(|v| !v.is_null()) {
274            warn!(
275                path = %self.path.display(),
276                "compiled-schema `realtime` section is no longer supported and is ignored; \
277                 recompile with the current fraiseql-cli"
278            );
279        }
280
281        info!(
282            path = %self.path.display(),
283            has_storage = storage.is_some(),
284            has_functions = functions.is_some(),
285            "Extended schema loaded successfully"
286        );
287
288        Ok(ExtendedCompiledSchema {
289            schema,
290            storage,
291            functions,
292        })
293    }
294
295    /// Get the path to the schema file.
296    #[must_use]
297    pub fn path(&self) -> &Path {
298        &self.path
299    }
300}
301
302/// Validate storage bucket configurations.
303///
304/// # Errors
305///
306/// Returns `ValidationError` if any bucket name is empty or contains whitespace.
307fn validate_storage_config(config: &SchemaStorageConfig) -> Result<(), SchemaLoadError> {
308    for bucket in &config.buckets {
309        if bucket.name.is_empty() {
310            return Err(SchemaLoadError::ValidationError(
311                "storage bucket name must not be empty".to_string(),
312            ));
313        }
314        if bucket.name.chars().any(char::is_whitespace) {
315            return Err(SchemaLoadError::ValidationError(format!(
316                "storage bucket name {:?} must not contain whitespace",
317                bucket.name
318            )));
319        }
320    }
321    Ok(())
322}
323
324/// Valid trigger prefixes recognised by the trigger system.
325const VALID_TRIGGER_PREFIXES: &[&str] = &[
326    "after:mutation:",
327    "before:mutation:",
328    "after:storage:",
329    "cron:",
330    "http:",
331];
332
333/// Validate function definitions.
334///
335/// # Errors
336///
337/// Returns `ValidationError` if any function definition has an unrecognised trigger format.
338fn validate_functions_config(config: &FunctionsConfig) -> Result<(), SchemaLoadError> {
339    for def in &config.definitions {
340        let known = VALID_TRIGGER_PREFIXES.iter().any(|prefix| def.trigger.starts_with(prefix));
341        if !known {
342            return Err(SchemaLoadError::ValidationError(format!(
343                "function {:?} has unrecognised trigger format {:?}; \
344                 expected one of: after:mutation:<name>, before:mutation:<name>, \
345                 after:storage:<bucket>:<op>, cron:<expr>, http:<method>:<path>",
346                def.name, def.trigger
347            )));
348        }
349    }
350    Ok(())
351}