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

38
src/api/auth.rs Normal file
View File

@@ -0,0 +1,38 @@
//! Optional API key auth via env RUSTYCMS_API_KEY. Protects POST/PUT/DELETE.
use axum::http::HeaderMap;
use super::error::ApiError;
/// Read token from `Authorization: Bearer <token>` or `X-API-Key: <token>`.
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("Authorization") {
if let Ok(s) = v.to_str() {
let s = s.trim();
if s.starts_with("Bearer ") {
return Some(s["Bearer ".len()..].trim().to_string());
}
}
}
if let Some(v) = headers.get("X-API-Key") {
if let Ok(s) = v.to_str() {
return Some(s.trim().to_string());
}
}
None
}
/// If `required_key` is Some, the request must send a matching token (Bearer or X-API-Key).
pub fn require_api_key(required_key: Option<&String>, headers: &HeaderMap) -> Result<(), ApiError> {
let Some(required) = required_key else {
return Ok(());
};
let provided = token_from_headers(headers);
if provided.as_deref() != Some(required.as_str()) {
return Err(ApiError::Unauthorized(
"Missing or invalid API key. Use Authorization: Bearer <key> or X-API-Key: <key>."
.to_string(),
));
}
Ok(())
}

133
src/api/cache.rs Normal file
View File

@@ -0,0 +1,133 @@
//! In-memory response cache for GET /api/content (TTL, invalidation on write access).
use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde_json::Value;
use tokio::sync::RwLock;
/// A cache entry with expiration time.
struct CachedItem {
value: Value,
inserted_at: Instant,
}
/// Response cache for Content API. Keys: `e:{collection}:{slug}:{resolve}` (Entry)
/// and `l:{collection}:{query_hash}` (List). TTL configurable; on POST/PUT/DELETE
/// the affected collection is invalidated.
pub struct ContentCache {
data: RwLock<HashMap<String, CachedItem>>,
ttl: Duration,
}
impl ContentCache {
/// New cache with TTL in seconds (0 = caching disabled).
pub fn new(ttl_secs: u64) -> Self {
Self {
data: RwLock::new(HashMap::new()),
ttl: Duration::from_secs(ttl_secs),
}
}
/// Returns the cached value if present and not expired.
pub async fn get(&self, key: &str) -> Option<Value> {
if self.ttl.is_zero() {
return None;
}
let mut guard = self.data.write().await;
let item = guard.get(key)?;
if item.inserted_at.elapsed() > self.ttl {
guard.remove(key);
return None;
}
Some(item.value.clone())
}
/// Stores a value under the given key.
pub async fn set(&self, key: String, value: Value) {
if self.ttl.is_zero() {
return;
}
let mut guard = self.data.write().await;
guard.insert(
key,
CachedItem {
value,
inserted_at: Instant::now(),
},
);
}
/// Removes all entries for the given collection (after create/update/delete).
/// Invalidates all locales for this collection (e:collection:*, l:collection:*).
pub async fn invalidate_collection(&self, collection: &str) {
let prefix_e = format!("e:{}:", collection);
let prefix_l = format!("l:{}:", collection);
let mut guard = self.data.write().await;
guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l));
}
}
/// Cache key for a single entry (incl. _resolve and optional _locale).
pub fn entry_cache_key(collection: &str, slug: &str, resolve_key: &str, locale: Option<&str>) -> String {
let loc = locale.unwrap_or("");
format!("e:{}:{}:{}:{}", collection, slug, resolve_key, loc)
}
/// Cache key for a list (collection + hash of query params + optional locale).
pub fn list_cache_key(collection: &str, query_hash: u64, locale: Option<&str>) -> String {
let loc = locale.unwrap_or("");
format!("l:{}:{}:{}", collection, loc, query_hash)
}
// ---------------------------------------------------------------------------
// Transform Cache (image response bytes + content-type)
// ---------------------------------------------------------------------------
struct CachedImage {
bytes: Vec<u8>,
content_type: String,
inserted_at: Instant,
}
/// In-memory cache for GET /api/transform (same URL + params = cache hit).
pub struct TransformCache {
data: RwLock<HashMap<String, CachedImage>>,
ttl: Duration,
}
impl TransformCache {
pub fn new(ttl_secs: u64) -> Self {
Self {
data: RwLock::new(HashMap::new()),
ttl: Duration::from_secs(ttl_secs),
}
}
pub async fn get(&self, key: &str) -> Option<(Vec<u8>, String)> {
if self.ttl.is_zero() {
return None;
}
let mut guard = self.data.write().await;
let item = guard.get(key)?;
if item.inserted_at.elapsed() > self.ttl {
guard.remove(key);
return None;
}
Some((item.bytes.clone(), item.content_type.clone()))
}
pub async fn set(&self, key: String, bytes: Vec<u8>, content_type: String) {
if self.ttl.is_zero() {
return;
}
let mut guard = self.data.write().await;
guard.insert(
key,
CachedImage {
bytes,
content_type,
inserted_at: Instant::now(),
},
);
}
}

60
src/api/error.rs Normal file
View File

@@ -0,0 +1,60 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
/// API error type that maps to appropriate HTTP status codes.
#[derive(Debug)]
pub enum ApiError {
NotFound(String),
BadRequest(String),
Unauthorized(String),
Conflict(String),
Internal(String),
ValidationFailed(Vec<String>),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, body) = match self {
ApiError::NotFound(msg) => (
StatusCode::NOT_FOUND,
json!({ "error": msg }),
),
ApiError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
json!({ "error": msg }),
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
json!({ "error": msg }),
),
ApiError::Conflict(msg) => (
StatusCode::CONFLICT,
json!({ "error": msg }),
),
ApiError::Internal(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": msg }),
),
ApiError::ValidationFailed(errors) => (
StatusCode::BAD_REQUEST,
json!({ "errors": errors }),
),
};
(status, axum::Json(body)).into_response()
}
}
impl From<anyhow::Error> for ApiError {
fn from(err: anyhow::Error) -> Self {
let msg = err.to_string();
if msg.contains("not found") || msg.contains("does not exist") {
ApiError::NotFound(msg)
} else if msg.contains("already exists") {
ApiError::Conflict(msg)
} else {
ApiError::Internal(msg)
}
}
}

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)
}

8
src/api/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod auth;
pub mod cache;
pub mod error;
pub mod handlers;
pub mod openapi;
pub mod response;
pub mod routes;
pub mod transform;

604
src/api/openapi.rs Normal file
View File

@@ -0,0 +1,604 @@
//! Dynamic OpenAPI 3.0 spec generation from the SchemaRegistry.
//!
//! Instead of static annotations, the spec is built at startup based on
//! the loaded content type definitions. Each collection gets its own
//! paths and component schemas in the resulting OpenAPI document.
use std::sync::Arc;
use axum::extract::State;
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use axum::http::header;
use indexmap::IndexMap;
use serde_json::{json, Value};
use crate::schema::types::FieldDefinition;
use crate::schema::SchemaRegistry;
use super::handlers::AppState;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// GET /api and GET /api/ Living-Doc index with links to Swagger UI and overview.
pub async fn api_index() -> Html<&'static str> {
Html(API_INDEX_HTML)
}
/// GET /swagger-ui Serves the Swagger UI (loaded from CDN).
pub async fn swagger_ui() -> Html<&'static str> {
Html(SWAGGER_HTML)
}
/// GET /api-docs/openapi.json Returns the generated OpenAPI spec.
/// Cache-Control: no-cache so Swagger UI always gets the latest after hot-reload.
pub async fn openapi_json(State(state): State<Arc<AppState>>) -> Response {
let spec = state.openapi_spec.read().await.clone();
let mut res = Json(spec).into_response();
res.headers_mut().insert(
header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse().unwrap(),
);
res
}
// ---------------------------------------------------------------------------
// Spec Generation
// ---------------------------------------------------------------------------
/// Generate a complete OpenAPI 3.0.3 spec from the schema registry.
pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
let mut paths = serde_json::Map::new();
let mut schemas = serde_json::Map::new();
// ── Static: GET /api/collections ─────────────────────────────────────
paths.insert(
"/api/collections".to_string(),
json!({
"get": {
"summary": "List all collections",
"operationId": "listCollections",
"tags": ["Collections"],
"responses": {
"200": {
"description": "List of registered content type collections",
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"collections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"field_count": { "type": "integer" },
"extends": { "type": "string", "nullable": true }
}
}
}
}
}}}
}
}
}
}),
);
// ── Per-Collection paths & schemas (skip reusable partials) ─────────────
for (name, schema) in registry.list().iter().filter(|(_, s)| !s.reusable) {
let tag = name.clone();
let pascal = to_pascal_case(name);
// Component schema for the content type (response)
let response_schema = fields_to_json_schema(&schema.fields, true);
schemas.insert(name.clone(), response_schema);
// Component schema for creation input (includes _slug, no readOnly _slug)
let input_schema = build_input_schema(&schema.fields);
schemas.insert(format!("{}_input", name), input_schema);
// GET /api/collections/:collection
paths.insert(
format!("/api/collections/{}", name),
json!({
"get": {
"summary": format!("Get schema definition for '{}'", name),
"operationId": format!("get{}Schema", pascal),
"tags": ["Collections"],
"responses": {
"200": { "description": "Schema definition" },
"404": { "description": "Collection not found" }
}
}
}),
);
// GET + POST /api/content/:collection
paths.insert(
format!("/api/content/{}", name),
json!({
"get": {
"summary": format!("List '{}' entries", name),
"operationId": format!("list{}", pascal),
"tags": [tag],
"parameters": query_parameters(&schema.fields),
"responses": {
"200": {
"description": "Paginated list of entries",
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": format!("#/components/schemas/{}", name) } },
"total": { "type": "integer" },
"page": { "type": "integer" },
"per_page": { "type": "integer" },
"total_pages": { "type": "integer" }
}
}}}
}
}
},
"post": {
"summary": format!("Create a new '{}' entry", name),
"operationId": format!("create{}", pascal),
"tags": [tag],
"parameters": content_get_query_params(),
"requestBody": {
"required": true,
"content": { "application/json": {
"schema": { "$ref": format!("#/components/schemas/{}_input", name) }
}}
},
"responses": {
"201": {
"description": "Entry created",
"content": { "application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", name) }
}}
},
"400": { "description": "Validation error" },
"409": { "description": "Entry already exists" }
}
}
}),
);
// GET + PUT + DELETE /api/content/:collection/:slug
let slug_param = json!({
"name": "slug", "in": "path", "required": true,
"schema": { "type": "string" },
"description": "Entry identifier (filename without extension)"
});
let mut get_params = vec![slug_param.clone()];
get_params.extend(content_get_query_params().as_array().cloned().unwrap_or_default());
let get_params = json!(get_params);
let mut put_params = vec![slug_param.clone()];
put_params.extend(content_get_query_params().as_array().cloned().unwrap_or_default());
let put_params = json!(put_params);
let mut delete_params = vec![slug_param.clone()];
delete_params.extend(content_get_query_params().as_array().cloned().unwrap_or_default());
let delete_params = json!(delete_params);
paths.insert(
format!("/api/content/{}/{{slug}}", name),
json!({
"get": {
"summary": format!("Get '{}' entry by slug", name),
"operationId": format!("get{}", pascal),
"tags": [tag],
"parameters": get_params,
"responses": {
"200": {
"description": "The entry",
"content": { "application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", name) }
}}
},
"404": { "description": "Entry not found" }
}
},
"put": {
"summary": format!("Update '{}' entry", name),
"operationId": format!("update{}", pascal),
"tags": [tag],
"parameters": put_params,
"requestBody": {
"required": true,
"content": { "application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", name) }
}}
},
"responses": {
"200": {
"description": "Entry updated",
"content": { "application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", name) }
}}
},
"400": { "description": "Validation error" },
"404": { "description": "Entry not found" }
}
},
"delete": {
"summary": format!("Delete '{}' entry", name),
"operationId": format!("delete{}", pascal),
"tags": [tag],
"parameters": delete_params,
"responses": {
"204": { "description": "Entry deleted" },
"404": { "description": "Entry not found" }
}
}
}),
);
}
// ── GET /api/transform (Image transformation) ─────────────────────────
paths.insert(
"/api/transform".to_string(),
json!({
"get": {
"summary": "Transform image from external URL",
"description": "Fetches an image from the given URL and returns it resized/cropped. Supports JPEG, PNG, WebP, AVIF output.",
"operationId": "transformImage",
"tags": ["Transform"],
"parameters": [
{ "name": "url", "in": "query", "required": true, "schema": { "type": "string", "format": "uri" }, "description": "External image URL" },
{ "name": "w", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Target width (alias: width)" },
{ "name": "h", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Target height (alias: height)" },
{ "name": "ar", "in": "query", "required": false, "schema": { "type": "string", "example": "1:1" }, "description": "Aspect ratio before resize, e.g. 1:1 or 16:9 (center crop)" },
{ "name": "fit", "in": "query", "required": false, "schema": { "type": "string", "enum": ["fill", "contain", "cover"], "default": "contain" }, "description": "fill = exact w×h, contain = fit inside, cover = fill with crop" },
{ "name": "format", "in": "query", "required": false, "schema": { "type": "string", "enum": ["jpeg", "png", "webp", "avif"], "default": "jpeg" }, "description": "Output format" },
{ "name": "quality", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 85 }, "description": "JPEG quality (1100)" }
],
"responses": {
"200": {
"description": "Transformed image",
"content": {
"image/jpeg": { "schema": { "type": "string", "format": "binary" } },
"image/png": { "schema": { "type": "string", "format": "binary" } },
"image/webp": { "schema": { "type": "string", "format": "binary" } },
"image/avif": { "schema": { "type": "string", "format": "binary" } }
}
},
"400": { "description": "Invalid URL or not a valid image" },
"500": { "description": "Failed to fetch or process image" }
}
}
}),
);
// ── Assemble the full spec ───────────────────────────────────────────
json!({
"openapi": "3.0.3",
"info": {
"title": "RustyCMS API",
"description": "File-based Headless CMS REST API.\n\nContent types and their fields are defined in `types/*.json5`.\nContent is stored as flat files under `content/<collection>/<slug>.json5`.",
"version": "0.1.0"
},
"servers": [
{ "url": server_url, "description": "Local development" }
],
"tags": build_tags(registry),
"paths": paths,
"components": { "schemas": schemas }
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Build the JSON Schema for a set of field definitions.
/// If `include_slug` is true, a read-only `_slug` property is added.
fn fields_to_json_schema(
fields: &IndexMap<String, FieldDefinition>,
include_slug: bool,
) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::<Value>::new();
if include_slug {
properties.insert(
"_slug".to_string(),
json!({ "type": "string", "description": "Entry identifier (filename)", "readOnly": true }),
);
}
for (name, fd) in fields {
properties.insert(name.clone(), field_to_json_schema(fd));
if fd.required && !fd.auto {
required.push(json!(name));
}
}
let mut schema = json!({ "type": "object", "properties": properties });
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
/// Build the input (creation) schema includes `_slug` as required field.
/// Skips auto-generated and readonly fields (they cannot be set by the user).
fn build_input_schema(fields: &IndexMap<String, FieldDefinition>) -> Value {
let mut properties = serde_json::Map::new();
let mut required = vec![json!("_slug")];
properties.insert(
"_slug".to_string(),
json!({ "type": "string", "description": "URL slug (used as filename)" }),
);
for (name, fd) in fields {
// Skip auto-generated and readonly fields from input schema
if fd.auto || fd.readonly {
continue;
}
properties.insert(name.clone(), field_to_json_schema(fd));
if fd.required {
required.push(json!(name));
}
}
json!({ "type": "object", "properties": properties, "required": required })
}
/// Convert a single FieldDefinition to a JSON Schema property,
/// including all constraints (minLength, max, pattern, nullable, …).
fn field_to_json_schema(fd: &FieldDefinition) -> Value {
let mut schema = match fd.field_type.as_str() {
"string" => json!({ "type": "string" }),
"richtext" | "html" | "markdown" => json!({ "type": "string" }),
"number" => json!({ "type": "number" }),
"integer" => json!({ "type": "integer" }),
"boolean" => json!({ "type": "boolean" }),
"datetime" => json!({ "type": "string", "format": "date-time" }),
"array" => {
if let Some(ref items) = fd.items {
json!({ "type": "array", "items": field_to_json_schema(items) })
} else {
json!({ "type": "array" })
}
}
"object" => {
if let Some(ref nested) = fd.fields {
fields_to_json_schema(nested, false)
} else {
json!({ "type": "object" })
}
}
"reference" => {
let desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
format!("Reference (slug) to one of: {}", list.join(", "))
}
} else if let Some(ref c) = fd.collection {
format!("Reference (slug) to collection '{}'", c)
} else {
"Reference (slug)".to_string()
};
json!({ "type": "string", "description": desc })
},
_ => json!({ "type": "string" }),
};
// ── Description ─────────────────────────────────────────────────────
if let Some(ref desc) = fd.description {
schema["description"] = json!(desc);
}
// ── Enum / default ──────────────────────────────────────────────────
if let Some(ref ev) = fd.enum_values {
schema["enum"] = json!(ev);
}
if let Some(ref dv) = fd.default {
schema["default"] = dv.clone();
}
// ── Nullable ────────────────────────────────────────────────────────
if fd.nullable {
schema["nullable"] = json!(true);
}
// ── Readonly ────────────────────────────────────────────────────────
if fd.readonly {
schema["readOnly"] = json!(true);
}
// ── String constraints ──────────────────────────────────────────────
if let Some(v) = fd.min_length {
schema["minLength"] = json!(v);
}
if let Some(v) = fd.max_length {
schema["maxLength"] = json!(v);
}
if let Some(ref p) = fd.pattern {
schema["pattern"] = json!(p);
}
// ── Number constraints ──────────────────────────────────────────────
if let Some(v) = fd.min {
schema["minimum"] = json!(v);
}
if let Some(v) = fd.max {
schema["maximum"] = json!(v);
}
// ── Array constraints ───────────────────────────────────────────────
if let Some(v) = fd.min_items {
schema["minItems"] = json!(v);
}
if let Some(v) = fd.max_items {
schema["maxItems"] = json!(v);
}
schema
}
/// Common query parameters for content GET (list + get by slug): _resolve, _locale.
fn content_get_query_params() -> Value {
json!([
{ "name": "_resolve", "in": "query", "required": false,
"schema": { "type": "string" },
"description": "Resolve references: 'all' or comma-separated field names (e.g. row1Content,topFullwidthBanner). Resolved entries are embedded instead of { _type, _slug }." },
{ "name": "_locale", "in": "query", "required": false,
"schema": { "type": "string", "example": "de" },
"description": "Locale for content (e.g. de, en). Only used when RUSTYCMS_LOCALES is set. Default = first locale. References are resolved in the same locale." }
])
}
/// Generate query parameters for the list endpoint, including filter params
/// for every field in the schema.
fn query_parameters(fields: &IndexMap<String, FieldDefinition>) -> Value {
let mut params = vec![
json!({ "name": "_sort", "in": "query", "schema": { "type": "string" }, "description": "Field name to sort by" }),
json!({ "name": "_order", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"] }, "description": "Sort order (default: asc)" }),
json!({ "name": "_page", "in": "query", "schema": { "type": "integer", "default": 1 }, "description": "Page number (1-indexed)" }),
json!({ "name": "_per_page", "in": "query", "schema": { "type": "integer", "default": 50 }, "description": "Items per page" }),
];
// _resolve and _locale (content GET)
params.extend(content_get_query_params().as_array().cloned().unwrap_or_default());
// Add one query parameter per schema field (for filtering)
for (name, fd) in fields {
let schema_type = match fd.field_type.as_str() {
"number" => "number",
"integer" => "integer",
"boolean" => "boolean",
_ => "string",
};
params.push(json!({
"name": name,
"in": "query",
"required": false,
"schema": { "type": schema_type },
"description": format!("Filter by {}", name)
}));
}
json!(params)
}
/// Build the tags array (Collections, Transform, then one per content type).
fn build_tags(registry: &SchemaRegistry) -> Value {
let mut tags = vec![
json!({ "name": "Collections", "description": "Schema / type management" }),
json!({ "name": "Transform", "description": "Image transformation from external URL (resize, crop, format)" }),
];
for (name, schema) in registry.list().iter().filter(|(_, s)| !s.reusable) {
let desc = if let Some(ref ext) = schema.extends {
let parents = ext.names().join(", ");
format!("Content type '{}' (extends {})", name, parents)
} else {
format!("Content type '{}'", name)
};
tags.push(json!({ "name": name, "description": desc }));
}
json!(tags)
}
/// Convert a snake_case string to PascalCase.
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(c) => {
let upper: String = c.to_uppercase().collect();
upper + chars.as_str()
}
}
})
.collect()
}
// ---------------------------------------------------------------------------
// API Index (GET /api, GET /api/) Living Documentation entry point
// ---------------------------------------------------------------------------
const API_INDEX_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RustyCMS API</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; line-height: 1.5; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #444; margin-bottom: 1.5rem; }
ul { list-style: none; padding: 0; margin: 0 0 1.5rem 0; }
li { padding: 0.35rem 0; border-bottom: 1px solid #eee; }
li:last-child { border-bottom: none; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
.card { background: #f6f8fa; border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.card h2 { font-size: 1rem; margin: 0 0 0.5rem 0; font-weight: 600; }
.card p { margin: 0; color: #555; font-size: 0.9rem; }
code { background: #eee; padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
</style>
</head>
<body>
<h1>RustyCMS API</h1>
<p>Headless CMS REST API. Schema-first, content as JSON5, optional SQLite.</p>
<div class="card">
<h2>Living Documentation</h2>
<p><a href="/swagger-ui">Swagger UI</a> Interactive API documentation (all endpoints, try-it-out).</p>
<p><a href="/api-docs/openapi.json">OpenAPI 3.0 Spec</a> Machine-readable specification (JSON).</p>
</div>
<h2>Endpoint Overview</h2>
<ul>
<li><code>GET</code> <a href="/api/collections">/api/collections</a> All content types</li>
<li><code>GET</code> /api/collections/:type Schema for a type</li>
<li><code>GET</code> /api/content/:type List entries</li>
<li><code>GET</code> /api/content/:type/:slug Get one entry</li>
<li><code>POST</code> /api/content/:type Create entry</li>
<li><code>PUT</code> /api/content/:type/:slug Update entry</li>
<li><code>DELETE</code> /api/content/:type/:slug Delete entry</li>
<li><code>GET</code> <a href="/api/transform?url=https://httpbin.org/image/png&w=80&h=80">/api/transform</a> Transform image from URL (w, h, ar, fit, format)</li>
<li><code>GET</code> <a href="/health">/health</a> Health check</li>
</ul>
</body>
</html>"#;
// ---------------------------------------------------------------------------
// Embedded Swagger UI HTML (loads JS/CSS from CDN)
// ---------------------------------------------------------------------------
const SWAGGER_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RustyCMS API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #fafafa; }
.topbar { display: none !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api-docs/openapi.json',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis],
layout: 'BaseLayout',
deepLinking: true,
defaultModelsExpandDepth: 2,
defaultModelExpandDepth: 2,
});
</script>
</body>
</html>"#;

138
src/api/response.rs Normal file
View File

@@ -0,0 +1,138 @@
//! Response formatting: reference fields as { _type, _slug } and optional _resolve.
use serde_json::{json, Value};
use crate::schema::types::SchemaDefinition;
use crate::store::ContentStore;
/// Parse _resolve query param: "all" or "field1,field2".
pub fn parse_resolve(resolve_param: Option<&str>) -> Option<ResolveSet> {
let s = resolve_param?.trim();
if s.is_empty() {
return None;
}
if s.eq_ignore_ascii_case("all") {
return Some(ResolveSet::All);
}
let fields: Vec<String> = s
.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect();
if fields.is_empty() {
None
} else {
Some(ResolveSet::Fields(fields))
}
}
pub enum ResolveSet {
All,
Fields(Vec<String>),
}
impl ResolveSet {
fn should_resolve(&self, field_name: &str) -> bool {
match self {
ResolveSet::All => true,
ResolveSet::Fields(list) => list.iter().any(|f| f == field_name),
}
}
}
/// Format an entry for API response: reference fields become { _type, _slug };
/// if resolve set includes the field (or all), embed the referenced entry.
/// When locale is set, resolved references are loaded from that locale.
pub async fn format_references(
mut entry: Value,
schema: &SchemaDefinition,
store: &dyn ContentStore,
resolve: Option<&ResolveSet>,
locale: Option<&str>,
) -> Value {
let obj = match entry.as_object_mut() {
Some(o) => o,
None => return entry,
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "reference" {
let colls = fd.reference_collections();
if !colls.is_empty() && obj.get(field_name).and_then(|v| v.as_str()).is_some() {
let slug = obj.get(field_name).and_then(|v| v.as_str()).unwrap();
let first_coll = colls[0];
let ref_obj = json!({ "_type": first_coll, "_slug": slug });
let should_resolve =
resolve.map(|r| r.should_resolve(field_name)).unwrap_or(false);
if should_resolve {
let mut resolved_opt = None;
for coll in &colls {
if let Ok(Some(mut resolved)) = store.get(coll, slug, locale).await {
if let Some(res_obj) = resolved.as_object_mut() {
res_obj.insert("_type".to_string(), Value::String(coll.to_string()));
res_obj.insert("_slug".to_string(), Value::String(slug.to_string()));
}
resolved_opt = Some((coll.to_string(), resolved));
break;
}
}
obj.insert(
field_name.clone(),
resolved_opt
.map(|(_, v)| v)
.unwrap_or_else(|| ref_obj.clone()),
);
} else {
obj.insert(field_name.clone(), ref_obj);
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let colls = items.reference_collections();
if items.field_type == "reference" && !colls.is_empty() {
if let Some(arr) = obj.get_mut(field_name).and_then(|v| v.as_array_mut()) {
let should_resolve =
resolve.map(|r| r.should_resolve(field_name)).unwrap_or(false);
let first_coll = colls[0];
let mut new_arr = Vec::new();
for item in arr.drain(..) {
if let Some(slug) = item.as_str() {
let ref_obj = json!({ "_type": first_coll, "_slug": slug });
if should_resolve {
let mut resolved_opt = None;
for coll in &colls {
if let Ok(Some(mut resolved)) = store.get(coll, slug, locale).await
{
if let Some(ro) = resolved.as_object_mut() {
ro.insert(
"_type".to_string(),
Value::String(coll.to_string()),
);
ro.insert(
"_slug".to_string(),
Value::String(slug.to_string()),
);
}
resolved_opt = Some(resolved);
break;
}
}
new_arr.push(resolved_opt.unwrap_or(ref_obj));
} else {
new_arr.push(ref_obj);
}
} else {
new_arr.push(item);
}
}
*arr = new_arr;
}
}
}
}
}
entry
}

46
src/api/routes.rs Normal file
View File

@@ -0,0 +1,46 @@
use std::sync::Arc;
use axum::routing::{get, post};
use axum::Router;
use super::handlers;
use super::handlers::AppState;
use super::openapi;
use super::transform;
pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// Health (for Load Balancer / K8s)
.route("/health", get(handlers::health))
// API index (Living Documentation entry point)
.route("/api", get(openapi::api_index))
.route("/api/", get(openapi::api_index))
// Swagger UI & OpenAPI spec
.route("/swagger-ui", get(openapi::swagger_ui))
.route("/api-docs/openapi.json", get(openapi::openapi_json))
// Collection schema endpoints
.route("/api/collections", get(handlers::list_collections))
.route("/api/schemas", post(handlers::create_schema))
.route(
"/api/collections/:collection",
get(handlers::get_collection_schema),
)
.route(
"/api/collections/:collection/slug-check",
get(handlers::slug_check),
)
// Content CRUD endpoints
.route(
"/api/content/:collection",
get(handlers::list_entries).post(handlers::create_entry),
)
.route(
"/api/content/:collection/:slug",
get(handlers::get_entry)
.put(handlers::update_entry)
.delete(handlers::delete_entry),
)
// Image transformation (external URL → transformed image)
.route("/api/transform", get(transform::transform_image))
.with_state(state)
}

221
src/api/transform.rs Normal file
View File

@@ -0,0 +1,221 @@
//! Image transform endpoint: external URL + params (w, h, ar, fit, format) → transformed image.
use std::hash::{Hash, Hasher};
use std::io::Cursor;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Query, State};
use axum::http::header::{HeaderValue, CONTENT_TYPE};
use axum::response::Response;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat};
use serde::Deserialize;
use std::collections::hash_map::DefaultHasher;
use super::error::ApiError;
use super::handlers::AppState;
/// Query parameters for GET /api/transform.
#[derive(Debug, Deserialize)]
pub struct TransformParams {
/// External image URL (required).
pub url: String,
/// Target width in pixels.
#[serde(alias = "w")]
pub width: Option<u32>,
/// Target height in pixels.
#[serde(alias = "h")]
pub height: Option<u32>,
/// Aspect ratio before scaling, e.g. "1:1" or "16:9". Centered crop.
#[serde(alias = "ar")]
pub aspect_ratio: Option<String>,
/// Fit mode: "fill" (exact w×h), "contain" (fit in rect), "cover" (fill, crop).
#[serde(default = "default_fit")]
pub fit: String,
/// Output format: "jpeg" | "png" | "webp" | "avif". Default: jpeg.
#[serde(default = "default_format")]
pub format: String,
/// JPEG quality 1100. Only for format=jpeg.
#[serde(default = "default_quality")]
pub quality: u8,
}
fn default_fit() -> String {
"contain".to_string()
}
fn default_format() -> String {
"jpeg".to_string()
}
fn default_quality() -> u8 {
85
}
/// Cache key from all transform params (same URL + params = same key).
fn transform_cache_key(params: &TransformParams) -> String {
let mut hasher = DefaultHasher::new();
params.url.hash(&mut hasher);
params.width.hash(&mut hasher);
params.height.hash(&mut hasher);
params.aspect_ratio.hash(&mut hasher);
params.fit.hash(&mut hasher);
params.format.hash(&mut hasher);
params.quality.hash(&mut hasher);
format!("t:{}", hasher.finish())
}
/// Parses "1:1" or "16:9" to (num, den).
fn parse_aspect_ratio(s: &str) -> Option<(u32, u32)> {
let s = s.trim();
let mut it = s.splitn(2, ':');
let a: u32 = it.next()?.trim().parse().ok()?;
let b: u32 = it.next()?.trim().parse().ok()?;
if a == 0 || b == 0 {
return None;
}
Some((a, b))
}
/// Centered crop to target aspect ratio (num:den).
fn crop_to_aspect_ratio(img: &DynamicImage, num: u32, den: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
let target_ratio = num as f64 / den as f64;
let current_ratio = w as f64 / h as f64;
let (crop_w, crop_h) = if current_ratio > target_ratio {
let crop_w = (h as f64 * target_ratio) as u32;
(crop_w.min(w), h)
} else {
let crop_h = (w as f64 / target_ratio) as u32;
(w, crop_h.min(h))
};
let x = (w - crop_w) / 2;
let y = (h - crop_h) / 2;
img.crop_imm(x, y, crop_w, crop_h)
}
pub async fn transform_image(
State(state): State<Arc<AppState>>,
Query(params): Query<TransformParams>,
) -> Result<Response, ApiError> {
if params.url.is_empty() {
return Err(ApiError::BadRequest("Parameter 'url' is required".to_string()));
}
let cache_key = transform_cache_key(&params);
if let Some((bytes, content_type)) = state.transform_cache.get(&cache_key).await {
let mut response = Response::new(Body::from(bytes));
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_str(&content_type).unwrap_or(HeaderValue::from_static("image/jpeg")),
);
return Ok(response);
}
let url = params
.url
.parse::<reqwest::Url>()
.map_err(|_| ApiError::BadRequest("Invalid URL".to_string()))?;
let resp = state
.http_client
.get(url)
.send()
.await
.map_err(|e| ApiError::Internal(format!("Image could not be loaded: {}", e)))?;
if !resp.status().is_success() {
return Err(ApiError::Internal(format!(
"Image URL returned status {}",
resp.status()
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ApiError::Internal(format!("Image data invalid: {}", e)))?;
let mut img =
image::load_from_memory(&bytes).map_err(|e| ApiError::BadRequest(format!("Invalid image: {}", e)))?;
// Optional: crop to aspect ratio first (e.g. 1:1)
if let Some(ref ar) = params.aspect_ratio {
if let Some((num, den)) = parse_aspect_ratio(ar) {
img = crop_to_aspect_ratio(&img, num, den);
}
}
let w = params.width.unwrap_or(img.width());
let h = params.height.unwrap_or(img.height());
// When both dimensions are set: default „fill“ (exact w×h), else „contain“ (fit in rect).
let fit = params.fit.to_lowercase();
let effective_fit = if params.width.is_some() && params.height.is_some() && fit.as_str() == "contain" {
"fill"
} else {
fit.as_str()
};
img = match effective_fit {
"fill" => img.resize_exact(w, h, FilterType::Lanczos3),
"cover" => img.resize_to_fill(w, h, FilterType::Lanczos3),
_ => img.thumbnail(w, h), // explicit "contain" or unknown
};
let quality = params.quality.clamp(1, 100);
let format_str = params.format.to_lowercase();
let (body_bytes, content_type) = match format_str.as_str() {
"png" => {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
.map_err(|e| ApiError::Internal(format!("PNG encoding failed: {}", e)))?;
(buf, "image/png")
}
"webp" => {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
.map_err(|e| ApiError::Internal(format!("WebP encoding failed: {}", e)))?;
(buf, "image/webp")
}
"avif" => {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Avif)
.map_err(|e| ApiError::Internal(format!("AVIF encoding failed: {}", e)))?;
(buf, "image/avif")
}
_ => {
let mut buf = Vec::new();
img.write_with_encoder(JpegEncoder::new_with_quality(&mut buf, quality))
.map_err(|e| ApiError::Internal(format!("JPEG encoding failed: {}", e)))?;
(buf, "image/jpeg")
}
};
state
.transform_cache
.set(cache_key, body_bytes.clone(), content_type.to_string())
.await;
let ct_header = if content_type == "image/png" {
HeaderValue::from_static("image/png")
} else if content_type == "image/webp" {
HeaderValue::from_static("image/webp")
} else if content_type == "image/avif" {
HeaderValue::from_static("image/avif")
} else {
HeaderValue::from_static("image/jpeg")
};
let mut response = Response::new(Body::from(body_bytes));
response.headers_mut().insert(CONTENT_TYPE, ct_header);
Ok(response)
}