343 lines
9.6 KiB
Rust
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"));
|
|
}
|
|
}
|