//! Synor.json Configuration //! //! Parses and manages the synor.json configuration file that users //! include in their deployed projects. use crate::router::{Redirect, RouteConfig}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Synor.json configuration file #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SynorJson { /// Name of the deployment (optional, can be inferred) #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, /// Build configuration #[serde(skip_serializing_if = "Option::is_none")] pub build: Option, /// Routes configuration #[serde(skip_serializing_if = "Option::is_none")] pub routes: Option, /// Headers configuration #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option>, /// Redirects configuration #[serde(skip_serializing_if = "Option::is_none")] pub redirects: Option>, /// Custom error pages #[serde(skip_serializing_if = "Option::is_none")] pub error_pages: Option>, /// Environment variables (non-secret, build-time) #[serde(skip_serializing_if = "Option::is_none")] pub env: Option>, /// Functions/serverless configuration #[serde(skip_serializing_if = "Option::is_none")] pub functions: Option, } /// Build configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BuildConfig { /// Build command #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, /// Output directory (default: "dist" or "build") #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, /// Install command (default: "npm install" or "pnpm install") #[serde(skip_serializing_if = "Option::is_none")] pub install: Option, /// Node.js version #[serde(skip_serializing_if = "Option::is_none")] pub node_version: Option, } /// Routes configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoutesConfig { /// Enable SPA mode (fallback to index.html) #[serde(default)] pub spa: bool, /// Custom cleanUrls (remove .html extensions) #[serde(default)] pub clean_urls: bool, /// Trailing slash behavior #[serde(skip_serializing_if = "Option::is_none")] pub trailing_slash: Option, /// Custom route rewrites #[serde(skip_serializing_if = "Option::is_none")] pub rewrites: Option>, } /// Header rule #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeaderRule { /// Path pattern (glob) pub source: String, /// Headers to apply pub headers: Vec, } /// Single header key-value pair #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeaderKeyValue { pub key: String, pub value: String, } /// Redirect rule (in synor.json format) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RedirectRule { /// Source path pub source: String, /// Destination path or URL pub destination: String, /// HTTP status (default: 308) #[serde(default = "default_redirect_status")] pub status: u16, } fn default_redirect_status() -> u16 { 308 } /// Rewrite rule #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RewriteRule { /// Source path pattern pub source: String, /// Destination path pub destination: String, } /// Serverless functions configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunctionsConfig { /// Functions directory #[serde(default = "default_functions_dir")] pub directory: String, /// Runtime (e.g., "nodejs20", "python3.11") #[serde(skip_serializing_if = "Option::is_none")] pub runtime: Option, /// Memory limit in MB #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, /// Timeout in seconds #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, } fn default_functions_dir() -> String { "api".to_string() } impl SynorJson { /// Parse from JSON string pub fn from_json(json: &str) -> Result { serde_json::from_str(json) } /// Parse from JSON bytes pub fn from_bytes(bytes: &[u8]) -> Result { serde_json::from_slice(bytes) } /// Serialize to JSON string pub fn to_json(&self) -> Result { serde_json::to_string_pretty(self) } /// Convert to RouteConfig for the router pub fn to_route_config(&self) -> RouteConfig { let mut config = RouteConfig::default(); // Process routes if let Some(routes) = &self.routes { if routes.spa { // SPA mode: all non-file paths go to index.html config .routes .insert("/*".to_string(), "/index.html".to_string()); } // Process rewrites if let Some(rewrites) = &routes.rewrites { for rewrite in rewrites { config .routes .insert(rewrite.source.clone(), rewrite.destination.clone()); } } } // Process headers if let Some(headers) = &self.headers { for rule in headers { let mut header_map = HashMap::new(); for kv in &rule.headers { header_map.insert(kv.key.clone(), kv.value.clone()); } config.headers.insert(rule.source.clone(), header_map); } } // Process redirects if let Some(redirects) = &self.redirects { for rule in redirects { config.redirects.push(Redirect { from: rule.source.clone(), to: rule.destination.clone(), status: rule.status, }); } } // Process error pages if let Some(error_pages) = &self.error_pages { config.error_pages = error_pages.clone(); } config } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_minimal() { let json = r#"{}"#; let config = SynorJson::from_json(json).unwrap(); assert!(config.name.is_none()); assert!(config.build.is_none()); } #[test] fn test_parse_spa_config() { let json = r#"{ "name": "myapp", "routes": { "spa": true, "clean_urls": true } }"#; let config = SynorJson::from_json(json).unwrap(); assert_eq!(config.name, Some("myapp".to_string())); assert!(config.routes.as_ref().unwrap().spa); assert!(config.routes.as_ref().unwrap().clean_urls); } #[test] fn test_parse_full_config() { let json = r#"{ "name": "my-blog", "build": { "command": "npm run build", "output": "dist" }, "routes": { "spa": true }, "headers": [ { "source": "/**", "headers": [ { "key": "X-Frame-Options", "value": "DENY" } ] } ], "redirects": [ { "source": "/old-page", "destination": "/new-page", "status": 301 } ], "error_pages": { "404": "/404.html" } }"#; let config = SynorJson::from_json(json).unwrap(); assert_eq!(config.name, Some("my-blog".to_string())); assert_eq!( config.build.as_ref().unwrap().command, Some("npm run build".to_string()) ); assert!(config.routes.as_ref().unwrap().spa); assert_eq!(config.headers.as_ref().unwrap().len(), 1); assert_eq!(config.redirects.as_ref().unwrap().len(), 1); assert_eq!( config.error_pages.as_ref().unwrap().get(&404), Some(&"/404.html".to_string()) ); } #[test] fn test_to_route_config() { let json = r#"{ "routes": { "spa": true }, "redirects": [ { "source": "/blog", "destination": "/posts", "status": 301 } ] }"#; let synor_json = SynorJson::from_json(json).unwrap(); let route_config = synor_json.to_route_config(); // SPA mode should add /* -> /index.html assert_eq!( route_config.routes.get("/*"), Some(&"/index.html".to_string()) ); // Redirect should be converted assert_eq!(route_config.redirects.len(), 1); assert_eq!(route_config.redirects[0].from, "/blog"); assert_eq!(route_config.redirects[0].to, "/posts"); assert_eq!(route_config.redirects[0].status, 301); } #[test] fn test_serialize() { let config = SynorJson { name: Some("test".to_string()), routes: Some(RoutesConfig { spa: true, clean_urls: false, trailing_slash: None, rewrites: None, }), ..Default::default() }; let json = config.to_json().unwrap(); assert!(json.contains("\"name\": \"test\"")); assert!(json.contains("\"spa\": true")); } }