synor/crates/synor-hosting/src/config.rs
2026-02-02 05:58:22 +05:30

343 lines
9.6 KiB
Rust

//! 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<String>,
/// Build configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<BuildConfig>,
/// Routes configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub routes: Option<RoutesConfig>,
/// Headers configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<Vec<HeaderRule>>,
/// Redirects configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub redirects: Option<Vec<RedirectRule>>,
/// Custom error pages
#[serde(skip_serializing_if = "Option::is_none")]
pub error_pages: Option<HashMap<u16, String>>,
/// Environment variables (non-secret, build-time)
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Functions/serverless configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub functions: Option<FunctionsConfig>,
}
/// Build configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildConfig {
/// Build command
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// Output directory (default: "dist" or "build")
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
/// Install command (default: "npm install" or "pnpm install")
#[serde(skip_serializing_if = "Option::is_none")]
pub install: Option<String>,
/// Node.js version
#[serde(skip_serializing_if = "Option::is_none")]
pub node_version: Option<String>,
}
/// 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<bool>,
/// Custom route rewrites
#[serde(skip_serializing_if = "Option::is_none")]
pub rewrites: Option<Vec<RewriteRule>>,
}
/// Header rule
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderRule {
/// Path pattern (glob)
pub source: String,
/// Headers to apply
pub headers: Vec<HeaderKeyValue>,
}
/// 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<String>,
/// Memory limit in MB
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<u32>,
/// Timeout in seconds
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
}
fn default_functions_dir() -> String {
"api".to_string()
}
impl SynorJson {
/// Parse from JSON string
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Parse from JSON bytes
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
/// Serialize to JSON string
pub fn to_json(&self) -> Result<String, serde_json::Error> {
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"));
}
}