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}