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