RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control

Made-with: Cursor
This commit is contained in:
Peter Meier
2026-03-12 14:21:49 +01:00
parent aad93d145f
commit 7795a238e1
278 changed files with 15551 additions and 4072 deletions

View File

@@ -21,7 +21,7 @@ use crate::store::slug;
use super::auth;
use super::cache::{self, ContentCache, TransformCache};
use super::error::ApiError;
use super::response::{format_references, parse_resolve};
use super::response::{collapse_asset_urls, expand_asset_urls, 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.
@@ -39,6 +39,10 @@ pub struct AppState {
pub http_client: reqwest::Client,
/// If set, first element is default locale. Enables content/{locale}/{collection}/.
pub locales: Option<Vec<String>>,
/// Path to the assets directory (e.g. ./content/assets) for image storage.
pub assets_dir: PathBuf,
/// Public base URL (e.g. https://api.example.com). Used to expand relative /api/assets/ paths.
pub base_url: String,
}
/// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off.
@@ -158,6 +162,101 @@ pub async fn create_schema(
))
}
// ---------------------------------------------------------------------------
// PUT /api/schemas/:name update existing schema
// ---------------------------------------------------------------------------
pub async fn update_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
Json(schema): Json<SchemaDefinition>,
) -> Result<Json<Value>, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if name != schema.name {
return Err(ApiError::BadRequest(
"Path name must match schema.name".to_string(),
));
}
if !is_valid_schema_name(&schema.name) {
return Err(ApiError::BadRequest(
"name: lowercase letters, digits and underscore only, max 64 chars".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 json5_path = state.types_dir.join(format!("{}.json5", name));
let json_path = state.types_dir.join(format!("{}.json", name));
let path = if json5_path.exists() {
json5_path
} else if json_path.exists() {
json_path
} else {
return Err(ApiError::NotFound(format!("Schema '{}' not found", 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)))?;
tracing::info!("Schema updated: {} ({})", name, path.display());
Ok(Json(serde_json::to_value(&schema).unwrap()))
}
// ---------------------------------------------------------------------------
// DELETE /api/schemas/:name delete schema file
// ---------------------------------------------------------------------------
pub async fn delete_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if !is_valid_schema_name(&name) {
return Err(ApiError::BadRequest("Invalid schema name".to_string()));
}
let json5_path = state.types_dir.join(format!("{}.json5", name));
let json_path = state.types_dir.join(format!("{}.json", name));
let mut deleted = false;
if json5_path.exists() {
tokio::fs::remove_file(&json5_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete schema file: {}", e)))?;
deleted = true;
tracing::info!("Schema deleted: {} ({})", name, json5_path.display());
}
if json_path.exists() {
tokio::fs::remove_file(&json_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete schema file: {}", e)))?;
deleted = true;
tracing::info!("Schema deleted: {} ({})", name, json_path.display());
}
if !deleted {
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
}
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// GET /api/collections/:collection
// ---------------------------------------------------------------------------
@@ -270,13 +369,10 @@ pub async fn list_entries(
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()
};
let registry = state.registry.read().await;
let schema = registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?;
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
@@ -287,12 +383,24 @@ pub async fn list_entries(
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let mut keys: Vec<_> = params.keys().collect();
// Apply schema default sort when client does not send _sort
let mut list_params = params.clone();
if !list_params.contains_key("_sort") {
if let Some(ref sort) = schema.default_sort {
list_params.insert("_sort".to_string(), sort.clone());
list_params.insert(
"_order".to_string(),
schema.default_order.clone().unwrap_or_else(|| "desc".to_string()),
);
}
}
let mut keys: Vec<_> = list_params.keys().collect();
keys.sort();
let mut hasher = DefaultHasher::new();
for k in &keys {
k.hash(&mut hasher);
params[*k].hash(&mut hasher);
list_params.get(k.as_str()).unwrap().hash(&mut hasher);
}
let query_hash = hasher.finish();
let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref);
@@ -302,19 +410,23 @@ pub async fn list_entries(
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 resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(list_params);
let mut result = query.apply(entries);
for item in result.items.iter_mut() {
*item = format_references(
std::mem::take(item),
&schema,
schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
)
.await;
if resolve.is_some() {
expand_asset_urls(item, &state.base_url);
}
}
let response_value = serde_json::to_value(&result).unwrap();
@@ -332,13 +444,10 @@ pub async fn get_entry(
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()
};
let registry = state.registry.read().await;
let schema = registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?;
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
@@ -387,14 +496,18 @@ pub async fn get_entry(
})?;
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let formatted = format_references(
let mut formatted = format_references(
entry,
&schema,
schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
)
.await;
if resolve.is_some() {
expand_asset_urls(&mut formatted, &state.base_url);
}
state
.cache
@@ -476,6 +589,9 @@ pub async fn create_entry(
// Apply defaults and auto-generated values
validator::apply_defaults(&schema, &mut body);
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
// Validate against schema (type checks, constraints, strict mode, …)
let errors = validator::validate_content(&schema, &body);
if !errors.is_empty() {
@@ -505,6 +621,9 @@ pub async fn create_entry(
return Err(ApiError::ValidationFailed(messages));
}
// Normalize absolute asset URLs → relative before persisting
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
@@ -520,7 +639,15 @@ pub async fn create_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
let mut formatted = format_references(
entry,
&schema,
state.store.as_ref(),
None,
locale_ref,
None,
)
.await;
Ok((StatusCode::CREATED, Json(formatted)))
}
@@ -560,6 +687,9 @@ pub async fn update_entry(
obj.remove("_slug");
}
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
// Load existing content for readonly check
let existing = state
.store
@@ -606,6 +736,9 @@ pub async fn update_entry(
return Err(ApiError::ValidationFailed(messages));
}
// Normalize absolute asset URLs → relative before persisting
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
@@ -621,11 +754,35 @@ pub async fn update_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
let mut formatted = format_references(
entry,
&schema,
state.store.as_ref(),
None,
locale_ref,
None,
)
.await;
Ok(Json(formatted))
}
// ---------------------------------------------------------------------------
// GET /api/locales
// ---------------------------------------------------------------------------
pub async fn list_locales(State(state): State<Arc<AppState>>) -> Json<Value> {
match &state.locales {
Some(locales) if !locales.is_empty() => Json(json!({
"locales": locales,
"default": locales[0],
})),
_ => Json(json!({
"locales": [],
"default": null,
})),
}
}
// ---------------------------------------------------------------------------
// DELETE /api/content/:collection/:slug
// ---------------------------------------------------------------------------