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:
@@ -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(¶ms, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user