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}