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}