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    /// Which dead-letter store backs function dispatch (#598): `"memory"` (the
98    /// default — dead-letters vanish on restart) or `"postgres"` (durable, survives
99    /// a restart; requires a database pool). Overridable by the
100    /// `FRAISEQL_FUNCTIONS_DLQ_STORE` env var. Absent ⇒ memory.
101    #[serde(default)]
102    pub dlq_store: Option<String>,
103}
104
105/// A compiled schema with all optional platform extensions parsed out.
106///
107/// Use [`CompiledSchemaLoader::load_extended`] to obtain this type. It bundles the
108/// core [`CompiledSchema`] together with optional storage, functions, and realtime
109/// configurations that are embedded in the compiled schema JSON.
110#[derive(Debug)]
111pub struct ExtendedCompiledSchema {
112    /// Core compiled GraphQL schema (types, queries, mutations, subscriptions).
113    pub schema: CompiledSchema,
114
115    /// Storage bucket configuration, if the `"storage"` key is present and non-null.
116    pub storage: Option<SchemaStorageConfig>,
117
118    /// Serverless functions configuration, if the `"functions"` key is present.
119    pub functions: Option<FunctionsConfig>,
120
121    /// Realtime broadcast observer configuration, if the `"realtime"` key is present.
122    pub realtime: Option<RealtimeSchemaConfig>,
123}
124
125/// Loader for compiled GraphQL schemas from JSON files.
126///
127/// Loads and caches a compiled schema from a JSON file on disk.
128/// Used during server startup to prepare the schema for query execution.
129#[derive(Debug, Clone)]
130pub struct CompiledSchemaLoader {
131    /// Path to the compiled schema JSON file.
132    path: PathBuf,
133}
134
135impl CompiledSchemaLoader {
136    /// Create a new schema loader pointing to a schema file.
137    ///
138    /// # Arguments
139    ///
140    /// * `path` - Path to the compiled schema JSON file
141    ///
142    /// # Example
143    ///
144    /// ```no_run
145    /// // Requires: schema.compiled.json file on disk.
146    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
147    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
148    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
149    /// let schema = loader.load().await?;
150    /// # Ok(())
151    /// # }
152    /// ```
153    #[must_use]
154    pub fn new<P: AsRef<Path>>(path: P) -> Self {
155        Self {
156            path: path.as_ref().to_path_buf(),
157        }
158    }
159
160    /// Load schema from file.
161    ///
162    /// Reads the schema JSON file, parses it, and returns a `CompiledSchema`.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`SchemaLoadError::NotFound`] if the file does not exist.
167    /// Returns [`SchemaLoadError::IoError`] if the file cannot be read.
168    /// Returns [`SchemaLoadError::ParseError`] if the JSON is malformed.
169    /// Returns [`SchemaLoadError::ValidationError`] if schema validation fails.
170    ///
171    /// # Example
172    ///
173    /// ```no_run
174    /// // Requires: schema.compiled.json file on disk.
175    /// # use fraiseql_server::schema::loader::CompiledSchemaLoader;
176    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
177    /// let loader = CompiledSchemaLoader::new("schema.compiled.json");
178    /// let schema = loader.load().await?;
179    /// # Ok(())
180    /// # }
181    /// ```
182    pub async fn load(&self) -> Result<CompiledSchema, SchemaLoadError> {
183        info!(path = %self.path.display(), "Loading compiled schema");
184
185        // Check if file exists
186        if !self.path.exists() {
187            return Err(SchemaLoadError::NotFound(self.path.clone()));
188        }
189
190        // Read file asynchronously
191        let contents =
192            tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
193
194        debug!(
195            path = %self.path.display(),
196            size_bytes = contents.len(),
197            "Schema file read successfully"
198        );
199
200        // Parse JSON and validate it's valid JSON first
201        serde_json::from_str::<serde_json::Value>(&contents)?;
202
203        // Create CompiledSchema from JSON string
204        let schema = CompiledSchema::from_json(&contents, false)
205            .map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
206
207        info!(path = %self.path.display(), "Schema loaded successfully");
208
209        Ok(schema)
210    }
211
212    /// Load schema and all optional platform extension sections from file.
213    ///
214    /// In addition to the core schema (types, queries, mutations, subscriptions),
215    /// this method parses and validates the `"storage"`, `"functions"`, and
216    /// `"realtime"` top-level keys if they are present. Unknown top-level keys are
217    /// ignored for forward compatibility.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`SchemaLoadError::NotFound`] if the file does not exist.
222    /// Returns [`SchemaLoadError::IoError`] if the file cannot be read.
223    /// Returns [`SchemaLoadError::ParseError`] if the JSON is malformed.
224    /// Returns [`SchemaLoadError::ValidationError`] if any of the following fail:
225    ///   - A storage bucket name contains whitespace or is empty.
226    ///   - A function trigger string does not match a recognised pattern.
227    ///   - A realtime entity name does not appear in the schema's type definitions.
228    pub async fn load_extended(&self) -> Result<ExtendedCompiledSchema, SchemaLoadError> {
229        info!(path = %self.path.display(), "Loading extended compiled schema");
230
231        if !self.path.exists() {
232            return Err(SchemaLoadError::NotFound(self.path.clone()));
233        }
234
235        let contents =
236            tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
237
238        debug!(
239            path = %self.path.display(),
240            size_bytes = contents.len(),
241            "Schema file read for extended loading"
242        );
243
244        // Parse once as a raw JSON value so we can extract platform sections without
245        // touching the CompiledSchema deserialization path.
246        let raw: serde_json::Value = serde_json::from_str(&contents)?;
247
248        // Core schema (always required).
249        let schema = CompiledSchema::from_json(&contents, false)
250            .map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
251
252        // Collect type names for cross-validation.
253        let type_names: std::collections::HashSet<String> =
254            schema.types.iter().map(|t| t.name.as_str().to_owned()).collect();
255
256        // Parse and validate the optional sections.
257        let storage = raw
258            .get("storage")
259            .filter(|v| !v.is_null())
260            .map(|v| {
261                let cfg: SchemaStorageConfig = serde_json::from_value(v.clone())?;
262                validate_storage_config(&cfg)?;
263                Ok::<_, SchemaLoadError>(cfg)
264            })
265            .transpose()?;
266
267        let functions = raw
268            .get("functions")
269            .filter(|v| !v.is_null())
270            .map(|v| {
271                let cfg: FunctionsConfig = serde_json::from_value(v.clone())?;
272                validate_functions_config(&cfg)?;
273                Ok::<_, SchemaLoadError>(cfg)
274            })
275            .transpose()?;
276
277        let realtime = raw
278            .get("realtime")
279            .filter(|v| !v.is_null())
280            .map(|v| {
281                let cfg: RealtimeSchemaConfig = serde_json::from_value(v.clone())?;
282                validate_realtime_config(&cfg, &type_names)?;
283                Ok::<_, SchemaLoadError>(cfg)
284            })
285            .transpose()?;
286
287        info!(
288            path = %self.path.display(),
289            has_storage = storage.is_some(),
290            has_functions = functions.is_some(),
291            has_realtime = realtime.is_some(),
292            "Extended schema loaded successfully"
293        );
294
295        Ok(ExtendedCompiledSchema {
296            schema,
297            storage,
298            functions,
299            realtime,
300        })
301    }
302
303    /// Get the path to the schema file.
304    #[must_use]
305    pub fn path(&self) -> &Path {
306        &self.path
307    }
308}
309
310/// Validate storage bucket configurations.
311///
312/// # Errors
313///
314/// Returns `ValidationError` if any bucket name is empty or contains whitespace.
315fn validate_storage_config(config: &SchemaStorageConfig) -> Result<(), SchemaLoadError> {
316    for bucket in &config.buckets {
317        if bucket.name.is_empty() {
318            return Err(SchemaLoadError::ValidationError(
319                "storage bucket name must not be empty".to_string(),
320            ));
321        }
322        if bucket.name.chars().any(char::is_whitespace) {
323            return Err(SchemaLoadError::ValidationError(format!(
324                "storage bucket name {:?} must not contain whitespace",
325                bucket.name
326            )));
327        }
328    }
329    Ok(())
330}
331
332/// Valid trigger prefixes recognised by the trigger system.
333const VALID_TRIGGER_PREFIXES: &[&str] = &[
334    "after:mutation:",
335    "before:mutation:",
336    "after:storage:",
337    "cron:",
338    "http:",
339];
340
341/// Validate function definitions.
342///
343/// # Errors
344///
345/// Returns `ValidationError` if any function definition has an unrecognised trigger format.
346fn validate_functions_config(config: &FunctionsConfig) -> Result<(), SchemaLoadError> {
347    for def in &config.definitions {
348        let known = VALID_TRIGGER_PREFIXES.iter().any(|prefix| def.trigger.starts_with(prefix));
349        if !known {
350            return Err(SchemaLoadError::ValidationError(format!(
351                "function {:?} has unrecognised trigger format {:?}; \
352                 expected one of: after:mutation:<name>, before:mutation:<name>, \
353                 after:storage:<bucket>:<op>, cron:<expr>, http:<method>:<path>",
354                def.name, def.trigger
355            )));
356        }
357    }
358    Ok(())
359}
360
361/// Validate that realtime entities exist in the schema's type definitions.
362///
363/// # Errors
364///
365/// Returns `ValidationError` if any entity name is not present in `type_names`.
366fn validate_realtime_config(
367    config: &RealtimeSchemaConfig,
368    type_names: &std::collections::HashSet<String>,
369) -> Result<(), SchemaLoadError> {
370    for entity in &config.entities {
371        if !type_names.contains(entity) {
372            return Err(SchemaLoadError::ValidationError(format!(
373                "realtime entity {entity:?} is not defined in schema types; \
374                 add a @fraiseql.type decorated class named {entity:?} or remove it from the realtime entities list"
375            )));
376        }
377    }
378    Ok(())
379}