use std::path::{Path, PathBuf};
use fraiseql_core::schema::CompiledSchema;
use fraiseql_functions::FunctionDefinition;
use serde::Deserialize;
use tracing::{debug, info};
use crate::realtime::routes::RealtimeSchemaConfig;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SchemaLoadError {
#[error("Schema file not found: {0}")]
NotFound(PathBuf),
#[error("Failed to read schema file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse schema JSON: {0}")]
ParseError(#[from] serde_json::Error),
#[error("Invalid schema: {0}")]
ValidationError(String),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SchemaStorageConfig {
pub buckets: Vec<SchemaBucketDef>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SchemaBucketDef {
pub name: String,
#[serde(default = "default_access")]
pub access: String,
#[serde(default)]
pub max_object_bytes: Option<u64>,
#[serde(default)]
pub allowed_mime_types: Option<Vec<String>>,
}
fn default_access() -> String {
"private".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct FunctionsConfig {
pub module_dir: PathBuf,
pub definitions: Vec<FunctionDefinition>,
}
#[derive(Debug)]
pub struct ExtendedCompiledSchema {
pub schema: CompiledSchema,
pub storage: Option<SchemaStorageConfig>,
pub functions: Option<FunctionsConfig>,
pub realtime: Option<RealtimeSchemaConfig>,
}
#[derive(Debug, Clone)]
pub struct CompiledSchemaLoader {
path: PathBuf,
}
impl CompiledSchemaLoader {
#[must_use]
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
path: path.as_ref().to_path_buf(),
}
}
pub async fn load(&self) -> Result<CompiledSchema, SchemaLoadError> {
info!(path = %self.path.display(), "Loading compiled schema");
if !self.path.exists() {
return Err(SchemaLoadError::NotFound(self.path.clone()));
}
let contents =
tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
debug!(
path = %self.path.display(),
size_bytes = contents.len(),
"Schema file read successfully"
);
serde_json::from_str::<serde_json::Value>(&contents)?;
let schema = CompiledSchema::from_json(&contents, false)
.map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
info!(path = %self.path.display(), "Schema loaded successfully");
Ok(schema)
}
pub async fn load_extended(&self) -> Result<ExtendedCompiledSchema, SchemaLoadError> {
info!(path = %self.path.display(), "Loading extended compiled schema");
if !self.path.exists() {
return Err(SchemaLoadError::NotFound(self.path.clone()));
}
let contents =
tokio::fs::read_to_string(&self.path).await.map_err(SchemaLoadError::IoError)?;
debug!(
path = %self.path.display(),
size_bytes = contents.len(),
"Schema file read for extended loading"
);
let raw: serde_json::Value = serde_json::from_str(&contents)?;
let schema = CompiledSchema::from_json(&contents, false)
.map_err(|e| SchemaLoadError::ValidationError(e.to_string()))?;
let type_names: std::collections::HashSet<String> =
schema.types.iter().map(|t| t.name.as_str().to_owned()).collect();
let storage = raw
.get("storage")
.filter(|v| !v.is_null())
.map(|v| {
let cfg: SchemaStorageConfig = serde_json::from_value(v.clone())?;
validate_storage_config(&cfg)?;
Ok::<_, SchemaLoadError>(cfg)
})
.transpose()?;
let functions = raw
.get("functions")
.filter(|v| !v.is_null())
.map(|v| {
let cfg: FunctionsConfig = serde_json::from_value(v.clone())?;
validate_functions_config(&cfg)?;
Ok::<_, SchemaLoadError>(cfg)
})
.transpose()?;
let realtime = raw
.get("realtime")
.filter(|v| !v.is_null())
.map(|v| {
let cfg: RealtimeSchemaConfig = serde_json::from_value(v.clone())?;
validate_realtime_config(&cfg, &type_names)?;
Ok::<_, SchemaLoadError>(cfg)
})
.transpose()?;
info!(
path = %self.path.display(),
has_storage = storage.is_some(),
has_functions = functions.is_some(),
has_realtime = realtime.is_some(),
"Extended schema loaded successfully"
);
Ok(ExtendedCompiledSchema {
schema,
storage,
functions,
realtime,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
fn validate_storage_config(config: &SchemaStorageConfig) -> Result<(), SchemaLoadError> {
for bucket in &config.buckets {
if bucket.name.is_empty() {
return Err(SchemaLoadError::ValidationError(
"storage bucket name must not be empty".to_string(),
));
}
if bucket.name.chars().any(char::is_whitespace) {
return Err(SchemaLoadError::ValidationError(format!(
"storage bucket name {:?} must not contain whitespace",
bucket.name
)));
}
}
Ok(())
}
const VALID_TRIGGER_PREFIXES: &[&str] = &[
"after:mutation:",
"before:mutation:",
"after:storage:",
"cron:",
"http:",
];
fn validate_functions_config(config: &FunctionsConfig) -> Result<(), SchemaLoadError> {
for def in &config.definitions {
let known = VALID_TRIGGER_PREFIXES.iter().any(|prefix| def.trigger.starts_with(prefix));
if !known {
return Err(SchemaLoadError::ValidationError(format!(
"function {:?} has unrecognised trigger format {:?}; \
expected one of: after:mutation:<name>, before:mutation:<name>, \
after:storage:<bucket>:<op>, cron:<expr>, http:<method>:<path>",
def.name, def.trigger
)));
}
}
Ok(())
}
fn validate_realtime_config(
config: &RealtimeSchemaConfig,
type_names: &std::collections::HashSet<String>,
) -> Result<(), SchemaLoadError> {
for entity in &config.entities {
if !type_names.contains(entity) {
return Err(SchemaLoadError::ValidationError(format!(
"realtime entity {entity:?} is not defined in schema types; \
add a @fraiseql.type decorated class named {entity:?} or remove it from the realtime entities list"
)));
}
}
Ok(())
}