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::referrers::{Referrer, ReferrerIndex}; use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES}; use crate::schema::validator; use crate::schema::SchemaRegistry; use crate::store::query::{QueryParams, StatusFilter, entry_is_draft}; use crate::store::ContentStore; use crate::store::slug; use super::auth; use super::cache::{self, ContentCache, TransformCache}; use super::error::ApiError; use super::response::{collapse_asset_urls, expand_asset_urls, format_references, parse_resolve}; use super::webhooks; /// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload. /// Store is selected via RUSTYCMS_STORE=file|sqlite. /// If api_keys is set (RUSTYCMS_API_KEY or RUSTYCMS_API_KEYS), write operations require a matching key with the right role. /// When locales is set (RUSTYCMS_LOCALES e.g. "de,en"), API accepts _locale query param. pub struct AppState { pub registry: Arc>, pub store: Arc, pub openapi_spec: Arc>, /// Path to types directory (e.g. ./types) for schema file writes. pub types_dir: PathBuf, /// API keys with roles (from RUSTYCMS_API_KEYS or RUSTYCMS_API_KEY). pub api_keys: Option, pub cache: Arc, pub transform_cache: Arc, pub http_client: reqwest::Client, /// If set, first element is default locale. Enables content/{locale}/{collection}/. pub locales: Option>, /// 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, /// Webhook URLs to POST on content/asset/schema changes (from RUSTYCMS_WEBHOOKS). pub webhook_urls: Vec, /// When set (RUSTYCMS_ENVIRONMENTS), content/assets are per-environment (e.g. production, staging). pub environments: Option>, /// Store per environment when environments is set. Key = env name. pub stores: Option>>, /// Assets dir per environment when environments is set. Key = env name. pub assets_dirs: Option>, /// Reverse index for "who references this entry?". Updated on create/update/delete. None when environments are used. pub referrer_index: Option>>, /// Path to persist referrer index (e.g. content/_referrers.json). pub referrer_index_path: Option, } impl AppState { /// Resolve environment from query _environment. Default = first in list. pub fn effective_environment(&self, params: &HashMap) -> String { let requested = params.get("_environment").map(|s| s.trim()).filter(|s| !s.is_empty()); self.effective_environment_from_param(requested) } /// Resolve environment from optional _environment value (e.g. from asset query params). pub fn effective_environment_from_param(&self, env_param: Option<&str>) -> String { let Some(ref list) = self.environments else { return "default".to_string(); }; if list.is_empty() { return "default".to_string(); } match env_param { Some(env) if list.iter().any(|e| e == env) => env.to_string(), _ => list[0].clone(), } } /// Store for the given environment. When environments is off, returns the single store. pub fn store_for(&self, env: &str) -> Arc { self.stores .as_ref() .and_then(|m| m.get(env).cloned()) .unwrap_or_else(|| Arc::clone(&self.store)) } /// Assets directory for the given environment. pub fn assets_dir_for(&self, env: &str) -> PathBuf { self.assets_dirs .as_ref() .and_then(|m| m.get(env).cloned()) .unwrap_or_else(|| self.assets_dir.clone()) } } /// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off. pub fn effective_locale(params: &HashMap, locales: Option<&[String]>) -> Option { 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) { (StatusCode::OK, Json(json!({ "status": "ok" }))) } // --------------------------------------------------------------------------- // GET /api/collections // --------------------------------------------------------------------------- pub async fn list_collections( State(state): State>, ) -> Json { let collections: Vec = { 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>, headers: HeaderMap, Json(schema): Json, ) -> Result<(StatusCode, Json), ApiError> { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::SchemasWrite)?; } 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()); webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_SCHEMA_CREATED, "name": schema.name, }), ); Ok(( StatusCode::CREATED, Json(serde_json::to_value(&schema).unwrap()), )) } // --------------------------------------------------------------------------- // PUT /api/schemas/:name – update existing schema // --------------------------------------------------------------------------- pub async fn update_schema( State(state): State>, Path(name): Path, headers: HeaderMap, Json(schema): Json, ) -> Result, ApiError> { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::SchemasWrite)?; } 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()); webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_SCHEMA_UPDATED, "name": name, }), ); Ok(Json(serde_json::to_value(&schema).unwrap())) } // --------------------------------------------------------------------------- // DELETE /api/schemas/:name – delete schema file // --------------------------------------------------------------------------- pub async fn delete_schema( State(state): State>, Path(name): Path, headers: HeaderMap, ) -> Result { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::SchemasWrite)?; } 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))); } webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_SCHEMA_DELETED, "name": name, }), ); Ok(StatusCode::NO_CONTENT) } // --------------------------------------------------------------------------- // GET /api/collections/:collection // --------------------------------------------------------------------------- pub async fn get_collection_schema( State(state): State>, Path(collection): Path, ) -> Result, 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/schemas – list all type names (including reusable, for extends dropdown) // --------------------------------------------------------------------------- pub async fn list_schema_names( State(state): State>, ) -> Json { let names: Vec = { let registry = state.registry.read().await; registry.names() }; Json(json!({ "names": names })) } // --------------------------------------------------------------------------- // GET /api/schemas/:name/raw – schema as stored on disk (own fields + extends, unresolved) // --------------------------------------------------------------------------- pub async fn get_schema_raw( State(state): State>, Path(name): Path, ) -> Result, ApiError> { let path_json5 = state.types_dir.join(format!("{}.json5", name)); let path_json = state.types_dir.join(format!("{}.json", name)); let path = if path_json5.exists() { path_json5 } else if path_json.exists() { path_json } else { return Err(ApiError::NotFound(format!("Schema '{}' not found", name))); }; let content = tokio::fs::read_to_string(&path) .await .map_err(|e| ApiError::Internal(format!("Failed to read schema file: {}", e)))?; let schema: SchemaDefinition = json5::from_str(&content) .map_err(|e| ApiError::BadRequest(format!("Invalid schema file: {}", e)))?; if schema.name != name { return Err(ApiError::BadRequest(format!( "Schema file name mismatch: expected '{}', got '{}'", name, schema.name ))); } 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>, Path(collection): Path, Query(params): Query>, ) -> Result, 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(¶ms, 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 env = state.effective_environment(¶ms); let store = state.store_for(&env); let exists = 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>, Path(collection): Path, Query(params): Query>, headers: HeaderMap, ) -> Result, ApiError> { 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", collection ))); } let env = state.effective_environment(¶ms); let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); // 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); list_params.get(k.as_str()).unwrap().hash(&mut hasher); } let query_hash = hasher.finish(); let cache_key = cache::list_cache_key(&env, &collection, query_hash, locale_ref); if let Some(cached) = state.cache.get(&cache_key).await { return Ok(Json(cached)); } let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?; let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str())); // When API keys are enabled but request has no valid key, show only published entries. let status_override = if state .api_keys .as_ref() .map(|k| k.is_enabled() && !k.is_authenticated(&headers)) .unwrap_or(false) { Some(StatusFilter::Published) } else { None }; let query = QueryParams::from_map_with_status(list_params, status_override); let mut result = query.apply(entries); for item in result.items.iter_mut() { *item = format_references( std::mem::take(item), schema, 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(); 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>, Path((collection, slug)): Path<(String, String)>, Query(params): Query>, headers: HeaderMap, ) -> Result { 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", collection ))); } let env = state.effective_environment(¶ms); let store = state.store_for(&env); let locale = effective_locale(¶ms, 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(&env, &collection, &slug, resolve_key, locale_ref); if let Some(ref cached) = state.cache.get(&cache_key).await { // Don't serve cached draft to unauthenticated requests. let is_authenticated = state .api_keys .as_ref() .map(|k| k.is_authenticated(&headers)) .unwrap_or(true); if !is_authenticated && entry_is_draft(cached) { // Fall through to load from store (will 404 if draft). } else { 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 = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? .ok_or_else(|| { ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection)) })?; // When API keys are enabled and request has no valid key, hide draft entries (return 404). if state .api_keys .as_ref() .map(|k| k.is_enabled() && !k.is_authenticated(&headers)) .unwrap_or(false) && entry_is_draft(&entry) { return Err(ApiError::NotFound(format!( "Entry '{}' not found in '{}'", slug, collection ))); } let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str())); let mut formatted = format_references( entry, schema, store.as_ref(), resolve.as_ref(), locale_ref, Some(&*registry), ) .await; if resolve.is_some() { expand_asset_urls(&mut formatted, &state.base_url); } // Only cache published entries so unauthenticated requests never see cached drafts. if !entry_is_draft(&formatted) { state.cache.set(cache_key.clone(), 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>, Path(collection): Path, Query(params): Query>, headers: HeaderMap, Json(mut body): Json, ) -> Result<(StatusCode, Json), ApiError> { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::ContentWrite)?; } let env = state.effective_environment(¶ms); let store = state.store_for(&env); let locale = effective_locale(¶ms, 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; validate and default _status (publish/draft) if let Some(obj) = body.as_object_mut() { obj.remove("_slug"); if let Some(v) = obj.get("_status") { let s = v.as_str().unwrap_or(""); if s != "draft" && s != "published" { return Err(ApiError::BadRequest( "Field '_status' must be \"draft\" or \"published\"".to_string(), )); } } else { obj.insert("_status".to_string(), Value::String("published".to_string())); } } // 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); validator::normalize_multi_select(&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 = errors.iter().map(|e| e.to_string()).collect(); return Err(ApiError::ValidationFailed(messages)); } // Unique constraint check (within same locale) let entries = 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 = 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 ref_errors = validator::validate_references(&schema, &body, &|coll, s| { let store_ref = Arc::clone(&store); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some() }) }) }); if !ref_errors.is_empty() { let messages: Vec = ref_errors.iter().map(|e| e.to_string()).collect(); return Err(ApiError::ValidationFailed(messages)); } // Normalize absolute asset URLs → relative before persisting collapse_asset_urls(&mut body, &state.base_url); // Persist to filesystem store .create(&collection, &slug, &body, locale_ref) .await .map_err(ApiError::from)?; state.cache.invalidate_collection(&env, &collection).await; // Update referrer index: this entry references (ref_coll, ref_slug) if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) { let refs = validator::extract_references(&schema, &body); let referrer = Referrer { collection: collection.clone(), slug: slug.clone(), field: String::new(), // we add per (ref_coll, ref_slug, field) below locale: locale_ref.map(str::to_string), }; let mut index = idx.write().await; for (ref_coll, ref_slug, field) in refs { let mut r = referrer.clone(); r.field = field; index.add_referrer(&ref_coll, &ref_slug, r); } drop(index); if let Err(e) = idx.read().await.save(path) { tracing::warn!("Failed to save referrer index: {}", e); } } // Return created entry (with reference format) let entry = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? .unwrap(); let formatted = format_references( entry, &schema, store.as_ref(), None, locale_ref, None, ) .await; webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_CONTENT_CREATED, "collection": collection, "slug": slug, "locale": locale_ref, "environment": env, }), ); Ok((StatusCode::CREATED, Json(formatted))) } // --------------------------------------------------------------------------- // PUT /api/content/:collection/:slug // --------------------------------------------------------------------------- pub async fn update_entry( State(state): State>, Path((collection, slug)): Path<(String, String)>, Query(params): Query>, headers: HeaderMap, Json(mut body): Json, ) -> Result, ApiError> { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::ContentWrite)?; } let env = state.effective_environment(¶ms); let store = state.store_for(&env); let locale = effective_locale(¶ms, 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; validate _status when present if let Some(obj) = body.as_object_mut() { obj.remove("_slug"); if let Some(v) = obj.get("_status") { let s = v.as_str().unwrap_or(""); if s != "draft" && s != "published" { return Err(ApiError::BadRequest( "Field '_status' must be \"draft\" or \"published\"".to_string(), )); } } } // Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs) validator::normalize_reference_arrays(&schema, &mut body); validator::normalize_multi_select(&schema, &mut body); // Load existing content for readonly check let existing = 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 = 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 = errors.iter().map(|e| e.to_string()).collect(); return Err(ApiError::ValidationFailed(messages)); } // Unique constraint check (exclude self, within same locale) let entries = 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 = unique_errors.iter().map(|e| e.to_string()).collect(); return Err(ApiError::ValidationFailed(messages)); } // Reference validation let ref_errors = validator::validate_references(&schema, &body, &|coll, s| { let store_ref = Arc::clone(&store); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some() }) }) }); if !ref_errors.is_empty() { let messages: Vec = ref_errors.iter().map(|e| e.to_string()).collect(); return Err(ApiError::ValidationFailed(messages)); } // Normalize absolute asset URLs → relative before persisting collapse_asset_urls(&mut body, &state.base_url); // Persist to filesystem store .update(&collection, &slug, &body, locale_ref) .await .map_err(ApiError::from)?; state.cache.invalidate_collection(&env, &collection).await; // Update referrer index: remove old refs from this entry, add new refs if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) { let mut index = idx.write().await; index.remove_all_referrers_from(&collection, &slug); let refs = validator::extract_references(&schema, &body); let referrer = Referrer { collection: collection.clone(), slug: slug.clone(), field: String::new(), locale: locale_ref.map(str::to_string), }; for (ref_coll, ref_slug, field) in refs { let mut r = referrer.clone(); r.field = field; index.add_referrer(&ref_coll, &ref_slug, r); } drop(index); if let Err(e) = idx.read().await.save(path) { tracing::warn!("Failed to save referrer index: {}", e); } } // Return updated entry (with reference format) let entry = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? .unwrap(); let formatted = format_references( entry, &schema, store.as_ref(), None, locale_ref, None, ) .await; webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_CONTENT_UPDATED, "collection": collection, "slug": slug, "locale": locale_ref, "environment": env, }), ); Ok(Json(formatted)) } // --------------------------------------------------------------------------- // GET /api/content/:collection/:slug/referrers // --------------------------------------------------------------------------- pub async fn get_referrers( State(state): State>, Path((collection, slug)): Path<(String, String)>, ) -> Result, ApiError> { let referrers = match &state.referrer_index { Some(idx) => idx.read().await.get_referrers(&collection, &slug), None => vec![], }; Ok(Json( serde_json::to_value(&referrers).unwrap_or_else(|_| json!([])), )) } // --------------------------------------------------------------------------- // GET /api/locales // --------------------------------------------------------------------------- pub async fn list_locales(State(state): State>) -> Json { 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 // --------------------------------------------------------------------------- pub async fn delete_entry( State(state): State>, Path((collection, slug)): Path<(String, String)>, Query(params): Query>, headers: HeaderMap, ) -> Result { if let Some(ref keys) = state.api_keys { keys.require(&headers, auth::Permission::ContentWrite)?; } let env = state.effective_environment(¶ms); let store = state.store_for(&env); let locale = effective_locale(¶ms, 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 ))); } // Update referrer index: remove this entry from every (ref_coll, ref_slug) it referenced; and remove key (collection, slug) if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) { let mut index = idx.write().await; index.remove_all_referrers_from(&collection, &slug); index.remove_referenced_entry(&collection, &slug); drop(index); if let Err(e) = idx.read().await.save(path) { tracing::warn!("Failed to save referrer index: {}", e); } } store .delete(&collection, &slug, locale_ref) .await .map_err(ApiError::from)?; state.cache.invalidate_collection(&env, &collection).await; webhooks::fire( state.http_client.clone(), &state.webhook_urls, json!({ "event": webhooks::EVENT_CONTENT_DELETED, "collection": collection, "slug": slug, "locale": locale_ref, "environment": env, }), ); Ok(StatusCode::NO_CONTENT) }