RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Peter Meier
2026-02-16 09:30:30 +01:00
commit aad93d145f
224 changed files with 19225 additions and 0 deletions

666
src/api/handlers.rs Normal file
View File

@@ -0,0 +1,666 @@
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::header::HeaderValue;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
use tokio::sync::RwLock;
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
use crate::schema::validator;
use crate::schema::SchemaRegistry;
use crate::store::query::QueryParams;
use crate::store::ContentStore;
use crate::store::slug;
use super::auth;
use super::cache::{self, ContentCache, TransformCache};
use super::error::ApiError;
use super::response::{format_references, parse_resolve};
/// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload.
/// Store is selected via RUSTYCMS_STORE=file|sqlite.
/// If api_key is set (RUSTYCMS_API_KEY), POST/PUT/DELETE require it (Bearer or X-API-Key).
/// When locales is set (RUSTYCMS_LOCALES e.g. "de,en"), API accepts _locale query param.
pub struct AppState {
pub registry: Arc<RwLock<SchemaRegistry>>,
pub store: Arc<dyn ContentStore>,
pub openapi_spec: Arc<RwLock<serde_json::Value>>,
/// Path to types directory (e.g. ./types) for schema file writes.
pub types_dir: PathBuf,
pub api_key: Option<String>,
pub cache: Arc<ContentCache>,
pub transform_cache: Arc<TransformCache>,
pub http_client: reqwest::Client,
/// If set, first element is default locale. Enables content/{locale}/{collection}/.
pub locales: Option<Vec<String>>,
}
/// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off.
pub fn effective_locale(params: &HashMap<String, String>, locales: Option<&[String]>) -> Option<String> {
let locales = locales?;
if locales.is_empty() {
return None;
}
let requested = params.get("_locale").map(|s| s.trim()).filter(|s| !s.is_empty());
match requested {
Some(loc) if locales.iter().any(|l| l == loc) => Some(loc.to_string()),
_ => Some(locales[0].clone()),
}
}
// ---------------------------------------------------------------------------
// GET /health
// ---------------------------------------------------------------------------
pub async fn health() -> (StatusCode, Json<Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
// ---------------------------------------------------------------------------
// GET /api/collections
// ---------------------------------------------------------------------------
pub async fn list_collections(
State(state): State<Arc<AppState>>,
) -> Json<Value> {
let collections: Vec<Value> = {
let registry = state.registry.read().await;
registry
.list()
.iter()
.filter(|(_, schema)| !schema.reusable)
.map(|(name, schema)| {
json!({
"name": name,
"field_count": schema.fields.len(),
"extends": schema.extends,
"strict": schema.strict,
"description": schema.description,
"tags": schema.tags,
"category": schema.category,
})
})
.collect()
};
Json(json!({ "collections": collections }))
}
// ---------------------------------------------------------------------------
// POST /api/schemas Create new content type (schema)
// ---------------------------------------------------------------------------
/// Content type name: lowercase letters, digits, underscore only.
fn is_valid_schema_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub async fn create_schema(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(schema): Json<SchemaDefinition>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if !is_valid_schema_name(&schema.name) {
return Err(ApiError::BadRequest(
"name: lowercase letters, digits and underscore only, max 64 chars".to_string(),
));
}
if schema.reusable {
return Err(ApiError::BadRequest(
"reusable must not be true for new content types".to_string(),
));
}
let mut errors = Vec::new();
for (field_name, fd) in &schema.fields {
if !VALID_FIELD_TYPES.contains(&fd.field_type.as_str()) {
errors.push(format!(
"Field '{}': unknown type '{}'",
field_name, fd.field_type
));
}
}
if !errors.is_empty() {
return Err(ApiError::ValidationFailed(errors));
}
let path = state.types_dir.join(format!("{}.json", schema.name));
let contents = serde_json::to_string_pretty(&schema).map_err(|e| ApiError::Internal(e.to_string()))?;
tokio::fs::write(&path, contents)
.await
.map_err(|e| ApiError::Internal(format!("Failed to write schema file: {}", e)))?;
state
.store
.ensure_collection_dir(&schema.name)
.await
.map_err(ApiError::from)?;
tracing::info!("Schema created: {} ({})", schema.name, path.display());
Ok((
StatusCode::CREATED,
Json(serde_json::to_value(&schema).unwrap()),
))
}
// ---------------------------------------------------------------------------
// GET /api/collections/:collection
// ---------------------------------------------------------------------------
pub async fn get_collection_schema(
State(state): State<Arc<AppState>>,
Path(collection): Path<String>,
) -> Result<Json<Value>, ApiError> {
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
Ok(Json(serde_json::to_value(schema).unwrap()))
}
// ---------------------------------------------------------------------------
// GET /api/collections/:collection/slug-check?slug=xxx&exclude=yyy&_locale=zzz
// ---------------------------------------------------------------------------
/// Response: { valid, normalized, available, error? }
/// - valid: slug format is ok (after normalize)
/// - normalized: normalized slug string
/// - available: not used by another entry (or equals exclude when editing)
pub async fn slug_check(
State(state): State<Arc<AppState>>,
Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
let slug_raw = params
.get("slug")
.filter(|s| !s.trim().is_empty())
.cloned()
.unwrap_or_default();
let valid = slug::validate_slug(&slug_raw).is_ok();
let normalized = slug::normalize_slug(&slug_raw);
let error = if !valid {
if slug_raw.is_empty() {
Some("Slug must not be empty.".to_string())
} else if normalized.is_empty() {
Some("Slug contains invalid characters. Allowed: lowercase, digits, hyphens (a-z, 0-9, -).".to_string())
} else if slug_raw.contains('/') || slug_raw.contains('\\') || slug_raw.contains("..") {
Some("Slug must not contain path characters (/, \\, ..).".to_string())
} else {
Some("Invalid slug.".to_string())
}
} else {
None
};
let available = if !valid || normalized.is_empty() {
false
} else {
let exclude = params.get("exclude").map(|s| s.trim()).filter(|s| !s.is_empty());
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let registry = state.registry.read().await;
let _schema = match registry.get(&collection) {
Some(s) if !s.reusable => s,
_ => {
return Ok(Json(json!({
"valid": valid,
"normalized": normalized,
"available": false,
"error": "Collection not found."
})));
}
};
drop(registry);
if exclude.as_deref() == Some(normalized.as_str()) {
true
} else {
let exists = state
.store
.get(&collection, &normalized, locale_ref)
.await
.map_err(ApiError::from)?
.is_some();
!exists
}
};
Ok(Json(json!({
"valid": valid,
"normalized": normalized,
"available": available,
"error": error,
})))
}
// ---------------------------------------------------------------------------
// GET /api/content/:collection
// ---------------------------------------------------------------------------
pub async fn list_entries(
State(state): State<Arc<AppState>>,
Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let mut keys: Vec<_> = params.keys().collect();
keys.sort();
let mut hasher = DefaultHasher::new();
for k in &keys {
k.hash(&mut hasher);
params[*k].hash(&mut hasher);
}
let query_hash = hasher.finish();
let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref);
if let Some(cached) = state.cache.get(&cache_key).await {
return Ok(Json(cached));
}
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(params);
let mut result = query.apply(entries);
for item in result.items.iter_mut() {
*item = format_references(
std::mem::take(item),
&schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
)
.await;
}
let response_value = serde_json::to_value(&result).unwrap();
state.cache.set(cache_key, response_value.clone()).await;
Ok(Json(response_value))
}
// ---------------------------------------------------------------------------
// GET /api/content/:collection/:slug
// ---------------------------------------------------------------------------
pub async fn get_entry(
State(state): State<Arc<AppState>>,
Path((collection, slug)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
if let Some(ref cached) = state.cache.get(&cache_key).await {
let json_str = serde_json::to_string(cached).unwrap_or_default();
let mut hasher = DefaultHasher::new();
json_str.hash(&mut hasher);
let etag_plain = format!("\"{:016x}\"", hasher.finish());
let etag_header =
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
let if_none_match = headers
.get("if-none-match")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().trim_matches('"').to_string());
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
return Ok((
StatusCode::NOT_MODIFIED,
[("ETag", etag_header.clone())],
)
.into_response());
}
return Ok((
StatusCode::OK,
[("ETag", etag_header)],
Json(cached.clone()),
)
.into_response());
}
let entry = state
.store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
.ok_or_else(|| {
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
})?;
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let formatted = format_references(
entry,
&schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
)
.await;
state
.cache
.set(cache_key, formatted.clone())
.await;
let json_str = serde_json::to_string(&formatted).unwrap_or_default();
let mut hasher = DefaultHasher::new();
json_str.hash(&mut hasher);
let etag_plain = format!("\"{:016x}\"", hasher.finish());
let etag_header =
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
let if_none_match = headers
.get("if-none-match")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().trim_matches('"').to_string());
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
return Ok((
StatusCode::NOT_MODIFIED,
[("ETag", etag_header.clone())],
)
.into_response());
}
Ok((
StatusCode::OK,
[("ETag", etag_header)],
Json(formatted),
)
.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/content/:collection
// ---------------------------------------------------------------------------
pub async fn create_entry(
State(state): State<Arc<AppState>>,
Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
// Extract and normalize _slug from body
let slug_raw = body
.as_object()
.and_then(|o| o.get("_slug"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| ApiError::BadRequest("Field '_slug' is required".to_string()))?;
slug::validate_slug(&slug_raw).map_err(ApiError::BadRequest)?;
let slug = slug::normalize_slug(&slug_raw);
// Remove _slug from content data
if let Some(obj) = body.as_object_mut() {
obj.remove("_slug");
}
// Apply defaults and auto-generated values
validator::apply_defaults(&schema, &mut body);
// Validate against schema (type checks, constraints, strict mode, …)
let errors = validator::validate_content(&schema, &body);
if !errors.is_empty() {
let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Unique constraint check (within same locale)
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let unique_errors = validator::validate_unique(&schema, &body, None, &entries);
if !unique_errors.is_empty() {
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Reference validation (blocking: we need sync closure; use tokio::task::block_in_place or spawn)
let store = &state.store;
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
})
})
});
if !ref_errors.is_empty() {
let messages: Vec<String> = ref_errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Persist to filesystem
state
.store
.create(&collection, &slug, &body, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
// Return created entry (with reference format)
let entry = state
.store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
Ok((StatusCode::CREATED, Json(formatted)))
}
// ---------------------------------------------------------------------------
// PUT /api/content/:collection/:slug
// ---------------------------------------------------------------------------
pub async fn update_entry(
State(state): State<Arc<AppState>>,
Path((collection, slug)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> Result<Json<Value>, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
// Remove _slug if present in body
if let Some(obj) = body.as_object_mut() {
obj.remove("_slug");
}
// Load existing content for readonly check
let existing = state
.store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
.ok_or_else(|| {
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
})?;
// Readonly violation check
let readonly_errors = validator::check_readonly_violations(&schema, &existing, &body);
if !readonly_errors.is_empty() {
let messages: Vec<String> = readonly_errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Validate against schema
let errors = validator::validate_content(&schema, &body);
if !errors.is_empty() {
let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Unique constraint check (exclude self, within same locale)
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let unique_errors = validator::validate_unique(&schema, &body, Some(&slug), &entries);
if !unique_errors.is_empty() {
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Reference validation
let store = &state.store;
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
})
})
});
if !ref_errors.is_empty() {
let messages: Vec<String> = ref_errors.iter().map(|e| e.to_string()).collect();
return Err(ApiError::ValidationFailed(messages));
}
// Persist to filesystem
state
.store
.update(&collection, &slug, &body, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
// Return updated entry (with reference format)
let entry = state
.store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
Ok(Json(formatted))
}
// ---------------------------------------------------------------------------
// DELETE /api/content/:collection/:slug
// ---------------------------------------------------------------------------
pub async fn delete_entry(
State(state): State<Arc<AppState>>,
Path((collection, slug)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
collection
)));
}
state
.store
.delete(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
Ok(StatusCode::NO_CONTENT)
}