use std::path::{Path, PathBuf};
use fraiseql_core::schema::CompiledSchema;
use fraiseql_functions::FunctionDefinition;
use serde::Deserialize;
use tracing::{debug, info, warn};
#[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>,
#[serde(default)]
pub dlq_store: Option<String>,
}
#[derive(Debug)]
pub struct ExtendedCompiledSchema {
pub schema: CompiledSchema,
pub storage: Option<SchemaStorageConfig>,
pub functions: Option<FunctionsConfig>,
}
#[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 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()?;
if raw.get("realtime").is_some_and(|v| !v.is_null()) {
warn!(
path = %self.path.display(),
"compiled-schema `realtime` section is no longer supported and is ignored; \
recompile with the current fraiseql-cli"
);
}
info!(
path = %self.path.display(),
has_storage = storage.is_some(),
has_functions = functions.is_some(),
"Extended schema loaded successfully"
);
Ok(ExtendedCompiledSchema {
schema,
storage,
functions,
})
}
#[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(())
}