RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
244
src/main.rs
Normal file
244
src/main.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::RwLock;
|
||||
use axum::http::header::HeaderValue;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::{DefaultOnResponse, TraceLayer};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use rustycms::api::handlers::AppState;
|
||||
use rustycms::schema::SchemaRegistry;
|
||||
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "rustycms", about = "A file-based headless CMS written in Rust")]
|
||||
struct Cli {
|
||||
/// Path to the directory containing type definitions (*.json5)
|
||||
#[arg(long, default_value = "./types")]
|
||||
types_dir: PathBuf,
|
||||
|
||||
/// Path to the directory containing content files
|
||||
#[arg(long, default_value = "./content")]
|
||||
content_dir: PathBuf,
|
||||
|
||||
/// Port to listen on
|
||||
#[arg(short = 'p', long, default_value_t = 3000)]
|
||||
port: u16,
|
||||
|
||||
/// Host address to bind to
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
}
|
||||
|
||||
fn reload_schemas(
|
||||
types_dir: &PathBuf,
|
||||
server_url: &str,
|
||||
registry: &Arc<RwLock<SchemaRegistry>>,
|
||||
openapi_spec: &Arc<RwLock<serde_json::Value>>,
|
||||
) {
|
||||
let types_dir = types_dir.clone();
|
||||
let server_url = server_url.to_string();
|
||||
let registry = Arc::clone(registry);
|
||||
let openapi_spec = Arc::clone(openapi_spec);
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async move {
|
||||
match SchemaRegistry::load(&types_dir) {
|
||||
Ok(new_registry) => {
|
||||
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
|
||||
*registry.write().await = new_registry;
|
||||
*openapi_spec.write().await = spec;
|
||||
tracing::info!("Hot-reload: schemas and OpenAPI spec updated");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Hot-reload failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("rustycms=info,tower_http=info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
tracing::info!("Loading schemas from {}", cli.types_dir.display());
|
||||
let registry = SchemaRegistry::load(&cli.types_dir)?;
|
||||
tracing::info!(
|
||||
"Loaded {} schema(s): {:?}",
|
||||
registry.names().len(),
|
||||
registry.names()
|
||||
);
|
||||
|
||||
let store: std::sync::Arc<dyn ContentStore> = {
|
||||
let kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into());
|
||||
match kind.as_str() {
|
||||
"sqlite" => {
|
||||
let url = std::env::var("RUSTYCMS_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "sqlite:content.db".into());
|
||||
tracing::info!("Using SQLite store: {}", url);
|
||||
let s = SqliteStore::new(&url).await?;
|
||||
for name in registry.collection_names() {
|
||||
s.ensure_collection_dir(&name).await?;
|
||||
}
|
||||
std::sync::Arc::new(s)
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Using file store: {}", cli.content_dir.display());
|
||||
let s = FileStore::new(&cli.content_dir);
|
||||
for name in registry.collection_names() {
|
||||
s.ensure_collection_dir(&name).await?;
|
||||
}
|
||||
std::sync::Arc::new(s)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let server_url = format!("http://{}:{}", cli.host, cli.port);
|
||||
let openapi_spec = rustycms::api::openapi::generate_spec(®istry, &server_url);
|
||||
tracing::info!("OpenAPI spec generated");
|
||||
|
||||
let registry = Arc::new(RwLock::new(registry));
|
||||
let openapi_spec = Arc::new(RwLock::new(openapi_spec));
|
||||
|
||||
let api_key = std::env::var("RUSTYCMS_API_KEY").ok();
|
||||
if api_key.is_some() {
|
||||
tracing::info!("API key auth enabled (POST/PUT/DELETE require key)");
|
||||
}
|
||||
|
||||
let cache_ttl_secs = std::env::var("RUSTYCMS_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let cache = Arc::new(rustycms::api::cache::ContentCache::new(cache_ttl_secs));
|
||||
let transform_cache = Arc::new(rustycms::api::cache::TransformCache::new(cache_ttl_secs));
|
||||
if cache_ttl_secs > 0 {
|
||||
tracing::info!("Response cache enabled, TTL {}s", cache_ttl_secs);
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let locales: Option<Vec<String>> = std::env::var("RUSTYCMS_LOCALES")
|
||||
.ok()
|
||||
.map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
|
||||
.filter(|v: &Vec<String>| !v.is_empty());
|
||||
if let Some(ref locs) = locales {
|
||||
tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]);
|
||||
}
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
registry: Arc::clone(®istry),
|
||||
store,
|
||||
openapi_spec: Arc::clone(&openapi_spec),
|
||||
types_dir: cli.types_dir.clone(),
|
||||
api_key,
|
||||
cache,
|
||||
transform_cache,
|
||||
http_client,
|
||||
locales,
|
||||
});
|
||||
|
||||
// Hot-reload: watch types_dir and reload schemas on change
|
||||
let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(ev) = res {
|
||||
if ev.kind.is_modify() || ev.kind.is_create() || ev.kind.is_remove() {
|
||||
// Any change under types_dir triggers reload (robust for editors that use temp files)
|
||||
let under_types = ev.paths.iter().any(|p| {
|
||||
p.canonicalize()
|
||||
.map(|c| c.starts_with(&types_dir_for_callback))
|
||||
.unwrap_or_else(|_| {
|
||||
p.extension()
|
||||
.map(|e| e == "json5" || e == "json")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
});
|
||||
if under_types {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)?;
|
||||
watcher.watch(&cli.types_dir, RecursiveMode::Recursive)?;
|
||||
let types_dir_watch = cli.types_dir.clone();
|
||||
let server_url_watch = server_url.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _watcher = watcher;
|
||||
while rx.recv().is_ok() {
|
||||
// Debounce: wait for editor to finish writing, drain extra events, then reload once
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
while rx.try_recv().is_ok() {}
|
||||
reload_schemas(&types_dir_watch, &server_url_watch, ®istry, &openapi_spec);
|
||||
}
|
||||
});
|
||||
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());
|
||||
|
||||
let cors = match std::env::var("RUSTYCMS_CORS_ORIGIN") {
|
||||
Ok(s) if s.trim().is_empty() || s.trim() == "*" => CorsLayer::permissive(),
|
||||
Ok(s) => {
|
||||
let o = s.trim().to_string();
|
||||
match HeaderValue::try_from(o) {
|
||||
Ok(h) => CorsLayer::new().allow_origin(AllowOrigin::exact(h)),
|
||||
Err(_) => CorsLayer::permissive(),
|
||||
}
|
||||
}
|
||||
Err(_) => CorsLayer::permissive(),
|
||||
};
|
||||
|
||||
let trace = TraceLayer::new_for_http().on_response(
|
||||
DefaultOnResponse::new()
|
||||
.level(Level::INFO)
|
||||
.latency_unit(tower_http::LatencyUnit::Millis),
|
||||
);
|
||||
let app = rustycms::api::routes::create_router(state)
|
||||
.layer(cors)
|
||||
.layer(trace);
|
||||
|
||||
let addr = format!("{}:{}", cli.host, cli.port);
|
||||
tracing::info!("RustyCMS v0.1.0 listening on http://{}", addr);
|
||||
tracing::info!("Swagger UI: http://{}/swagger-ui", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
let shutdown = async {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(mut sig) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = sig.recv() => {}
|
||||
}
|
||||
} else {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
}
|
||||
tracing::info!("Shutdown signal received, draining requests...");
|
||||
};
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user