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

542
src/api/assets.rs Normal file
View File

@@ -0,0 +1,542 @@
//! Asset management with single-level folder support.
//!
//! Assets are stored in `content/assets/` with optional subdirectories.
//!
//! Endpoints:
//! GET /api/assets list all assets (no ?folder = all, ?folder=name, ?folder= = root only)
//! POST /api/assets[?folder=name] upload image (multipart/form-data, auth required)
//! GET /api/assets/folders list all folders with asset counts
//! POST /api/assets/folders create folder { "name": "..." } (auth required)
//! DELETE /api/assets/folders/:name delete empty folder (auth required)
//! GET /api/assets/*path serve image (e.g. /api/assets/blog/hero.jpg)
//! PATCH /api/assets/*path rename image (body: { "filename": "newname.jpg" }, auth required)
//! DELETE /api/assets/*path delete image (auth required)
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::fs;
use super::auth;
use super::error::ApiError;
use super::handlers::AppState;
const ALLOWED_EXTENSIONS: &[(&str, &str)] = &[
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("png", "image/png"),
("webp", "image/webp"),
("avif", "image/avif"),
("gif", "image/gif"),
("svg", "image/svg+xml"),
];
fn mime_for_ext(ext: &str) -> Option<&'static str> {
ALLOWED_EXTENSIONS
.iter()
.find(|(e, _)| *e == ext)
.map(|(_, m)| *m)
}
/// Sanitize a single path segment: lowercase, alphanumeric + dash + underscore + dot.
fn sanitize_segment(name: &str) -> Result<String, ApiError> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::BadRequest("Name is empty".into()));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(ApiError::BadRequest("Invalid name".into()));
}
let lower = name.to_lowercase();
if !lower
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(ApiError::BadRequest(
"Name may only contain letters, digits, hyphens, underscores, and dots".into(),
));
}
Ok(lower)
}
fn validate_filename(name: &str) -> Result<String, ApiError> {
let lower = sanitize_segment(name)?;
let ext = lower.rsplit('.').next().unwrap_or("");
if mime_for_ext(ext).is_none() {
return Err(ApiError::BadRequest(format!(
"File type '.{}' is not allowed. Allowed: jpg, jpeg, png, webp, avif, gif, svg",
ext
)));
}
Ok(lower)
}
fn validate_folder_name(name: &str) -> Result<String, ApiError> {
let lower = sanitize_segment(name)?;
if lower.contains('.') {
return Err(ApiError::BadRequest(
"Folder names may not contain dots".into(),
));
}
Ok(lower)
}
/// Parse a wildcard path into (folder, filename).
/// "hero.jpg" → (None, "hero.jpg")
/// "blog/hero.jpg" → (Some("blog"), "hero.jpg")
fn parse_path(path: &str) -> Result<(Option<String>, String), ApiError> {
let path = path.trim_start_matches('/');
let mut parts = path.splitn(2, '/');
match (parts.next(), parts.next()) {
(Some(a), None) => Ok((None, validate_filename(a)?)),
(Some(folder), Some(filename)) => {
Ok((Some(validate_folder_name(folder)?), validate_filename(filename)?))
}
_ => Err(ApiError::BadRequest("Invalid asset path".into())),
}
}
/// Read all image files from a directory, tagging them with the folder name.
async fn read_images(
dir: &std::path::Path,
folder: Option<&str>,
) -> Result<Vec<Value>, ApiError> {
if !dir.exists() {
return Ok(vec![]);
}
let mut entries = Vec::new();
let mut rd = fs::read_dir(dir)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if ft.is_dir() {
continue;
}
let fname = e.file_name().to_string_lossy().to_string();
let ext = fname.rsplit('.').next().unwrap_or("").to_lowercase();
if mime_for_ext(&ext).is_none() {
continue;
}
let size = e.metadata().await.map(|m| m.len()).unwrap_or(0);
let mime = mime_for_ext(&ext).unwrap_or("application/octet-stream");
let url = match folder {
Some(f) => format!("/api/assets/{}/{}", f, fname),
None => format!("/api/assets/{}", fname),
};
entries.push(json!({
"filename": fname,
"folder": folder,
"url": url,
"mime_type": mime,
"size": size,
}));
}
Ok(entries)
}
// ---------------------------------------------------------------------------
// GET /api/assets
// ?folder not present → all assets (root + all subdirectories)
// ?folder=name → only that folder
// ?folder= → only root (no folder)
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ListAssetsParams {
folder: Option<String>,
}
pub async fn list_assets(
State(state): State<Arc<AppState>>,
Query(params): Query<ListAssetsParams>,
) -> Result<Json<Value>, ApiError> {
let base = &state.assets_dir;
if !base.exists() {
fs::create_dir_all(base)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
}
let mut all: Vec<Value> = Vec::new();
match params.folder.as_deref() {
// Specific named folder
Some(f) if !f.is_empty() => {
let folder_name = validate_folder_name(f)?;
let dir = base.join(&folder_name);
all.extend(read_images(&dir, Some(&folder_name)).await?);
}
// Root only (folder= with empty value)
Some(_empty) => {
all.extend(read_images(base, None).await?);
}
// All: root + every subdirectory
None => {
all.extend(read_images(base, None).await?);
let mut rd = fs::read_dir(base)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ft.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
let dir = base.join(&name);
all.extend(read_images(&dir, Some(&name)).await?);
}
}
}
// Sort: by folder, then filename
all.sort_by(|a, b| {
let fa = a["folder"].as_str().unwrap_or("");
let fb = b["folder"].as_str().unwrap_or("");
let na = a["filename"].as_str().unwrap_or("");
let nb = b["filename"].as_str().unwrap_or("");
fa.cmp(fb).then(na.cmp(nb))
});
let total = all.len();
Ok(Json(json!({ "assets": all, "total": total })))
}
// ---------------------------------------------------------------------------
// GET /api/assets/folders
// ---------------------------------------------------------------------------
pub async fn list_folders(
State(state): State<Arc<AppState>>,
) -> Result<Json<Value>, ApiError> {
let base = &state.assets_dir;
if !base.exists() {
fs::create_dir_all(base)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
}
let mut folders: Vec<Value> = Vec::new();
let mut rd = fs::read_dir(base)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ft.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
let mut count = 0u64;
if let Ok(mut sub) = fs::read_dir(base.join(&name)).await {
while let Ok(Some(se)) = sub.next_entry().await {
let ext = se
.file_name()
.to_string_lossy()
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
if mime_for_ext(&ext).is_some() {
count += 1;
}
}
}
folders.push(json!({ "name": name, "count": count }));
}
folders.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
Ok(Json(json!({ "folders": folders })))
}
// ---------------------------------------------------------------------------
// POST /api/assets/folders { "name": "blog" }
// ---------------------------------------------------------------------------
pub async fn create_folder(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let name = body["name"]
.as_str()
.ok_or_else(|| ApiError::BadRequest("Missing 'name' field".into()))?;
let name = validate_folder_name(name)?;
let path = state.assets_dir.join(&name);
if path.exists() {
return Err(ApiError::Conflict(format!(
"Folder '{}' already exists",
name
)));
}
fs::create_dir_all(&path)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create folder: {}", e)))?;
Ok((StatusCode::CREATED, Json(json!({ "name": name }))).into_response())
}
// ---------------------------------------------------------------------------
// DELETE /api/assets/folders/:name
// ---------------------------------------------------------------------------
pub async fn delete_folder(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(name): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let name = validate_folder_name(&name)?;
let path = state.assets_dir.join(&name);
if !path.exists() {
return Err(ApiError::NotFound(format!("Folder '{}' not found", name)));
}
// remove_dir only succeeds when directory is empty
fs::remove_dir(&path).await.map_err(|_| {
ApiError::BadRequest(format!(
"Folder '{}' is not empty delete all images first",
name
))
})?;
Ok(StatusCode::NO_CONTENT.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/assets[?folder=name]
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct UploadParams {
folder: Option<String>,
}
pub async fn upload_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<UploadParams>,
mut multipart: Multipart,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let folder = match params.folder.as_deref() {
Some(f) if !f.is_empty() => Some(validate_folder_name(f)?),
_ => None,
};
let dir = match &folder {
Some(f) => state.assets_dir.join(f),
None => state.assets_dir.clone(),
};
if !dir.exists() {
fs::create_dir_all(&dir)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create dir: {}", e)))?;
}
let field = {
let mut found = None;
while let Some(f) = multipart
.next_field()
.await
.map_err(|e| ApiError::BadRequest(e.to_string()))?
{
found = Some(f);
break;
}
found.ok_or_else(|| ApiError::BadRequest("No file field found".into()))?
};
let original_name = field
.file_name()
.map(|s| s.to_string())
.or_else(|| field.name().map(|s| s.to_string()))
.unwrap_or_else(|| "upload".to_string());
let filename = validate_filename(&original_name)?;
let dest = dir.join(&filename);
if dest.exists() {
return Err(ApiError::Conflict(format!(
"Asset '{}' already exists",
filename
)));
}
let data = field
.bytes()
.await
.map_err(|e| ApiError::BadRequest(format!("Failed to read file: {}", e)))?;
if data.is_empty() {
return Err(ApiError::BadRequest("File is empty".into()));
}
fs::write(&dest, &data)
.await
.map_err(|e| ApiError::Internal(format!("Failed to save file: {}", e)))?;
let ext = filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let url = match &folder {
Some(f) => format!("/api/assets/{}/{}", f, filename),
None => format!("/api/assets/{}", filename),
};
Ok((
StatusCode::CREATED,
Json(json!({
"filename": filename,
"folder": folder,
"url": url,
"mime_type": mime,
"size": data.len(),
})),
)
.into_response())
}
// ---------------------------------------------------------------------------
// GET /api/assets/*path
// ---------------------------------------------------------------------------
pub async fn get_asset(
State(state): State<Arc<AppState>>,
AxumPath(path): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
let (folder, filename) = parse_path(&path)?;
let file_path = match &folder {
Some(f) => state.assets_dir.join(f).join(&filename),
None => state.assets_dir.join(&filename),
};
if !file_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
let data = fs::read(&file_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to read asset: {}", e)))?;
let ext = filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(
header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
)
.body(Body::from(data))
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(response)
}
// ---------------------------------------------------------------------------
// PATCH /api/assets/*path { "filename": "newname.jpg" }
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct RenameBody {
pub filename: String,
}
pub async fn rename_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(path): AxumPath<String>,
Json(body): Json<RenameBody>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let (folder, old_filename) = parse_path(&path)?;
let new_filename = validate_filename(&body.filename)?;
if new_filename == old_filename {
return Err(ApiError::BadRequest("New filename is the same as current".into()));
}
let base = match &folder {
Some(f) => state.assets_dir.join(f),
None => state.assets_dir.clone(),
};
let old_path = base.join(&old_filename);
let new_path = base.join(&new_filename);
if !old_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
if new_path.exists() {
return Err(ApiError::Conflict(format!(
"Asset '{}' already exists",
new_filename
)));
}
fs::rename(&old_path, &new_path)
.await
.map_err(|e| ApiError::Internal(format!("Rename failed: {}", e)))?;
let url = match &folder {
Some(f) => format!("/api/assets/{}/{}", f, new_filename),
None => format!("/api/assets/{}", new_filename),
};
let ext = new_filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let meta = fs::metadata(&new_path)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok((
StatusCode::OK,
Json(json!({
"filename": new_filename,
"folder": folder,
"url": url,
"mime_type": mime,
"size": meta.len(),
})),
)
.into_response())
}
// ---------------------------------------------------------------------------
// DELETE /api/assets/*path
// ---------------------------------------------------------------------------
pub async fn delete_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(path): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let (folder, filename) = parse_path(&path)?;
let file_path = match &folder {
Some(f) => state.assets_dir.join(f).join(&filename),
None => state.assets_dir.join(&filename),
};
if !file_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
fs::remove_file(&file_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete asset: {}", e)))?;
Ok(StatusCode::NO_CONTENT.into_response())
}

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
// ---------------------------------------------------------------------------

View File

@@ -1,3 +1,4 @@
pub mod assets;
pub mod auth;
pub mod cache;
pub mod error;

View File

@@ -237,6 +237,122 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
);
}
// ── Asset management ─────────────────────────────────────────────────
paths.insert(
"/api/assets".to_string(),
json!({
"get": {
"summary": "List all assets",
"description": "Returns all image files stored in `content/assets/`.",
"operationId": "listAssets",
"tags": ["Assets"],
"responses": {
"200": {
"description": "List of assets",
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"assets": {
"type": "array",
"items": { "$ref": "#/components/schemas/Asset" }
},
"total": { "type": "integer" }
}
}}}
}
}
},
"post": {
"summary": "Upload an image asset",
"description": "Upload an image file (jpg, jpeg, png, webp, avif, gif, svg) to `content/assets/`. Requires API key when `RUSTYCMS_API_KEY` is set.",
"operationId": "uploadAsset",
"tags": ["Assets"],
"security": [{ "ApiKeyAuth": [] }],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": { "type": "string", "format": "binary", "description": "Image file to upload" }
},
"required": ["file"]
}
}
}
},
"responses": {
"201": {
"description": "Asset uploaded",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Asset" } } }
},
"400": { "description": "Invalid file or disallowed extension" },
"401": { "description": "Missing or invalid API key" },
"409": { "description": "Asset with this filename already exists" }
}
}
}),
);
paths.insert(
"/api/assets/{filename}".to_string(),
json!({
"get": {
"summary": "Serve an asset",
"description": "Returns the raw image file with the appropriate `Content-Type` header. Cached by browsers for 1 year (`Cache-Control: immutable`).",
"operationId": "getAsset",
"tags": ["Assets"],
"parameters": [
{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Asset filename (e.g. hero.jpg)" }
],
"responses": {
"200": {
"description": "Image file",
"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" } },
"image/gif": { "schema": { "type": "string", "format": "binary" } },
"image/svg+xml": { "schema": { "type": "string", "format": "binary" } }
}
},
"400": { "description": "Invalid filename" },
"404": { "description": "Asset not found" }
}
},
"delete": {
"summary": "Delete an asset",
"description": "Deletes an image file from `content/assets/`. Requires API key when `RUSTYCMS_API_KEY` is set.",
"operationId": "deleteAsset",
"tags": ["Assets"],
"security": [{ "ApiKeyAuth": [] }],
"parameters": [
{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Asset filename (e.g. hero.jpg)" }
],
"responses": {
"204": { "description": "Asset deleted" },
"401": { "description": "Missing or invalid API key" },
"404": { "description": "Asset not found" }
}
}
}),
);
schemas.insert(
"Asset".to_string(),
json!({
"type": "object",
"properties": {
"filename": { "type": "string", "description": "Filename of the asset" },
"url": { "type": "string", "description": "URL path to serve the asset (e.g. /api/assets/hero.jpg)" },
"mime_type":{ "type": "string", "description": "MIME type (e.g. image/jpeg)" },
"size": { "type": "integer", "description": "File size in bytes" }
}
}),
);
// ── GET /api/transform (Image transformation) ─────────────────────────
paths.insert(
"/api/transform".to_string(),
@@ -253,7 +369,7 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
{ "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)" }
{ "name": "quality", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 85 }, "description": "Quality 1100 for JPEG and WebP (lossy)" }
],
"responses": {
"200": {
@@ -353,7 +469,7 @@ fn build_input_schema(fields: &IndexMap<String, FieldDefinition>) -> Value {
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" }),
"richtext" | "html" | "markdown" | "textOrRef" => json!({ "type": "string" }),
"number" => json!({ "type": "number" }),
"integer" => json!({ "type": "integer" }),
"boolean" => json!({ "type": "boolean" }),
@@ -386,6 +502,26 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
};
json!({ "type": "string", "description": desc })
},
"referenceOrInline" => {
let ref_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()
};
let ref_schema = json!({ "type": "string", "description": ref_desc });
let inline_schema = if let Some(ref nested) = fd.fields {
fields_to_json_schema(nested, false)
} else {
json!({ "type": "object" })
};
json!({ "oneOf": [ ref_schema, inline_schema ] })
},
_ => json!({ "type": "string" }),
};
@@ -490,6 +626,7 @@ fn query_parameters(fields: &IndexMap<String, FieldDefinition>) -> Value {
fn build_tags(registry: &SchemaRegistry) -> Value {
let mut tags = vec![
json!({ "name": "Collections", "description": "Schema / type management" }),
json!({ "name": "Assets", "description": "Image asset management upload, serve, and delete files stored in content/assets/" }),
json!({ "name": "Transform", "description": "Image transformation from external URL (resize, crop, format)" }),
];
for (name, schema) in registry.list().iter().filter(|(_, s)| !s.reusable) {

View File

@@ -1,10 +1,40 @@
//! Response formatting: reference fields as { _type, _slug } and optional _resolve.
//! When a registry is provided, resolved nested entries are recursively formatted
//! so their reference fields (e.g. filterByTag in post_overview) are also resolved.
use serde_json::{json, Value};
use crate::schema::types::SchemaDefinition;
use crate::schema::SchemaRegistry;
use crate::store::ContentStore;
/// Recursively expand relative `/api/assets/` paths to absolute URLs.
/// Idempotent: strings already starting with a scheme (http/https) are skipped.
pub fn expand_asset_urls(value: &mut Value, base_url: &str) {
match value {
Value::String(s) if s.starts_with("/api/assets/") => {
*s = format!("{}{}", base_url.trim_end_matches('/'), s);
}
Value::Array(arr) => arr.iter_mut().for_each(|v| expand_asset_urls(v, base_url)),
Value::Object(map) => map.values_mut().for_each(|v| expand_asset_urls(v, base_url)),
_ => {}
}
}
/// Reverse of expand_asset_urls: strip the base_url prefix from absolute asset URLs
/// before persisting to disk, so stored paths are always relative.
pub fn collapse_asset_urls(value: &mut Value, base_url: &str) {
let prefix = format!("{}/api/assets/", base_url.trim_end_matches('/'));
match value {
Value::String(s) if s.starts_with(&prefix) => {
*s = format!("/api/assets/{}", &s[prefix.len()..]);
}
Value::Array(arr) => arr.iter_mut().for_each(|v| collapse_asset_urls(v, base_url)),
Value::Object(map) => map.values_mut().for_each(|v| collapse_asset_urls(v, base_url)),
_ => {}
}
}
/// Parse _resolve query param: "all" or "field1,field2".
pub fn parse_resolve(resolve_param: Option<&str>) -> Option<ResolveSet> {
let s = resolve_param?.trim();
@@ -43,12 +73,15 @@ impl ResolveSet {
/// 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.
/// When registry is set, embedded entries are recursively formatted so their
/// reference fields (e.g. filterByTag) are also resolved or expanded to { _type, _slug }.
pub async fn format_references(
mut entry: Value,
schema: &SchemaDefinition,
store: &dyn ContentStore,
resolve: Option<&ResolveSet>,
locale: Option<&str>,
registry: Option<&SchemaRegistry>,
) -> Value {
let obj = match entry.as_object_mut() {
Some(o) => o,
@@ -56,12 +89,12 @@ pub async fn format_references(
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "reference" {
let is_ref_field = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref_field {
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 {
@@ -72,34 +105,66 @@ pub async fn format_references(
res_obj.insert("_type".to_string(), Value::String(coll.to_string()));
res_obj.insert("_slug".to_string(), Value::String(slug.to_string()));
}
if let Some(reg) = registry {
if let Some(ref_schema) = reg.get(coll) {
resolved = Box::pin(format_references(
resolved,
ref_schema,
store,
resolve,
locale,
registry,
))
.await;
}
}
resolved_opt = Some((coll.to_string(), resolved));
break;
}
}
let ref_obj = json!({ "_type": colls[0], "_slug": slug });
obj.insert(
field_name.clone(),
resolved_opt
.map(|(_, v)| v)
.unwrap_or_else(|| ref_obj.clone()),
.unwrap_or_else(|| ref_obj),
);
} else {
let mut found_coll = None;
for coll in &colls {
if store.get(coll, slug, locale).await.is_ok_and(|r| r.is_some()) {
found_coll = Some(coll.to_string());
break;
}
}
let ref_obj = found_coll
.map(|c| json!({ "_type": c, "_slug": slug }))
.unwrap_or_else(|| json!({ "_type": colls[0], "_slug": slug }));
obj.insert(field_name.clone(), ref_obj);
}
}
continue;
}
if fd.field_type == "referenceOrInline" {
if let Some(o) = obj.get_mut(field_name).and_then(|v| v.as_object_mut()) {
let colls = fd.reference_collections();
if let Some(coll) = colls.first() {
o.insert("_type".to_string(), Value::String((*coll).to_string()));
}
}
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() {
let is_ref_item = items.field_type == "reference" || items.field_type == "referenceOrInline";
if is_ref_item && !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(..) {
for mut 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 {
@@ -115,14 +180,45 @@ pub async fn format_references(
Value::String(slug.to_string()),
);
}
if let Some(reg) = registry {
if let Some(ref_schema) = reg.get(coll) {
resolved = Box::pin(format_references(
resolved,
ref_schema,
store,
resolve,
locale,
registry,
))
.await;
}
}
resolved_opt = Some(resolved);
break;
}
}
let ref_obj = json!({ "_type": colls[0], "_slug": slug });
new_arr.push(resolved_opt.unwrap_or(ref_obj));
} else {
let mut found_coll = None;
for coll in &colls {
if store.get(coll, slug, locale).await.is_ok_and(|r| r.is_some()) {
found_coll = Some(coll.to_string());
break;
}
}
let ref_obj = found_coll
.map(|c| json!({ "_type": c, "_slug": slug }))
.unwrap_or_else(|| json!({ "_type": colls[0], "_slug": slug }));
new_arr.push(ref_obj);
}
} else if let Some(o) = item.as_object_mut() {
if items.field_type == "referenceOrInline" {
if let Some(coll) = colls.first() {
o.insert("_type".to_string(), Value::String((*coll).to_string()));
}
}
new_arr.push(item);
} else {
new_arr.push(item);
}

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use axum::routing::{get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
use super::assets;
use super::handlers;
use super::handlers::AppState;
use super::openapi;
@@ -12,6 +13,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// Health (for Load Balancer / K8s)
.route("/health", get(handlers::health))
// Locales
.route("/api/locales", get(handlers::list_locales))
// API index (Living Documentation entry point)
.route("/api", get(openapi::api_index))
.route("/api/", get(openapi::api_index))
@@ -21,6 +24,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
// Collection schema endpoints
.route("/api/collections", get(handlers::list_collections))
.route("/api/schemas", post(handlers::create_schema))
.route(
"/api/schemas/:name",
put(handlers::update_schema).delete(handlers::delete_schema),
)
.route(
"/api/collections/:collection",
get(handlers::get_collection_schema),
@@ -42,5 +49,18 @@ pub fn create_router(state: Arc<AppState>) -> Router {
)
// Image transformation (external URL → transformed image)
.route("/api/transform", get(transform::transform_image))
// Asset management (images in content/assets/ with folder support)
.route("/api/assets", get(assets::list_assets).post(assets::upload_asset))
.route(
"/api/assets/folders",
get(assets::list_folders).post(assets::create_folder),
)
.route("/api/assets/folders/:name", delete(assets::delete_folder))
.route(
"/api/assets/*path",
get(assets::get_asset)
.patch(assets::rename_asset)
.delete(assets::delete_asset),
)
.with_state(state)
}

View File

@@ -13,6 +13,7 @@ use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat};
use serde::Deserialize;
use std::collections::hash_map::DefaultHasher;
use webpx::{Encoder as WebpEncoder, Unstoppable};
use super::error::ApiError;
use super::handlers::AppState;
@@ -43,7 +44,7 @@ pub struct TransformParams {
#[serde(default = "default_format")]
pub format: String,
/// JPEG quality 1100. Only for format=jpeg.
/// Output quality 1100. Used for JPEG and WebP (lossy). Default 85.
#[serde(default = "default_quality")]
pub quality: u8,
}
@@ -182,8 +183,11 @@ pub async fn transform_image(
(buf, "image/png")
}
"webp" => {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let buf = WebpEncoder::new_rgba(rgba.as_raw(), w, h)
.quality(quality as f32)
.encode(Unstoppable)
.map_err(|e| ApiError::Internal(format!("WebP encoding failed: {}", e)))?;
(buf, "image/webp")
}

View File

@@ -8,7 +8,7 @@ use tokio::sync::RwLock;
use axum::http::header::HeaderValue;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::{DefaultOnResponse, TraceLayer};
use tracing::Level;
use tracing::{info_span, Level};
use tracing_subscriber::EnvFilter;
use rustycms::api::handlers::AppState;
@@ -19,11 +19,11 @@ use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
#[command(name = "rustycms", about = "A file-based headless CMS written in Rust")]
struct Cli {
/// Path to the directory containing type definitions (*.json5)
#[arg(long, default_value = "./types")]
#[arg(long, default_value = "./types", env = "RUSTYCMS_TYPES_DIR")]
types_dir: PathBuf,
/// Path to the directory containing content files
#[arg(long, default_value = "./content")]
#[arg(long, default_value = "./content", env = "RUSTYCMS_CONTENT_DIR")]
content_dir: PathBuf,
/// Port to listen on
@@ -93,17 +93,11 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|_| "sqlite:content.db".into());
tracing::info!("Using SQLite store: {}", url);
let s = SqliteStore::new(&url).await?;
for name in registry.collection_names() {
s.ensure_collection_dir(&name).await?;
}
std::sync::Arc::new(s)
}
_ => {
tracing::info!("Using file store: {}", cli.content_dir.display());
let s = FileStore::new(&cli.content_dir);
for name in registry.collection_names() {
s.ensure_collection_dir(&name).await?;
}
std::sync::Arc::new(s)
}
}
@@ -140,6 +134,13 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]);
}
let assets_dir = cli.content_dir.join("assets");
// RUSTYCMS_BASE_URL is the public URL of the API (e.g. https://api.example.com).
// Used to expand relative /api/assets/ paths to absolute URLs in responses.
// Falls back to the local server_url (http://host:port).
let base_url = std::env::var("RUSTYCMS_BASE_URL").unwrap_or_else(|_| server_url.clone());
let state = Arc::new(AppState {
registry: Arc::clone(&registry),
store,
@@ -150,6 +151,8 @@ async fn main() -> anyhow::Result<()> {
transform_cache,
http_client,
locales,
assets_dir,
base_url,
});
// Hot-reload: watch types_dir and reload schemas on change
@@ -203,11 +206,25 @@ async fn main() -> anyhow::Result<()> {
Err(_) => CorsLayer::permissive(),
};
let trace = TraceLayer::new_for_http().on_response(
DefaultOnResponse::new()
.level(Level::INFO)
.latency_unit(tower_http::LatencyUnit::Millis),
);
let trace = TraceLayer::new_for_http()
.make_span_with(|request: &axum::http::Request<axum::body::Body>| {
let method = request.method().as_str();
let uri = request
.uri()
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| request.uri().path().to_string());
info_span!(
"request",
method = %method,
uri = %uri,
)
})
.on_response(
DefaultOnResponse::new()
.level(Level::INFO)
.latency_unit(tower_http::LatencyUnit::Millis),
);
let app = rustycms::api::routes::create_router(state)
.layer(cors)
.layer(trace);

View File

@@ -39,6 +39,10 @@ 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" }),
"textOrRef" => json!({
"type": "string",
"description": "Inline text or file reference: use prefix 'file:' followed by path (e.g. file:my-entry.content.md) to load content from that file; otherwise the string is the content itself."
}),
"number" => json!({ "type": "number" }),
"integer" => json!({ "type": "integer" }),
"boolean" => json!({ "type": "boolean" }),
@@ -67,6 +71,11 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
o.insert("required".to_string(), json!(req));
}
Value::Object(o)
} else if let Some(ref ap) = fd.additional_properties {
let mut o = serde_json::Map::new();
o.insert("type".to_string(), json!("object"));
o.insert("additionalProperties".to_string(), field_to_json_schema(ap));
Value::Object(o)
} else {
json!({ "type": "object" })
}
@@ -85,6 +94,40 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
};
json!({ "type": "string", "description": desc })
},
"referenceOrInline" => {
let ref_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()
};
let ref_schema = json!({ "type": "string", "description": ref_desc });
let inline_schema = if let Some(ref nested) = fd.fields {
let mut props = serde_json::Map::new();
let mut req = Vec::new();
for (n, f) in nested {
props.insert(n.clone(), field_to_json_schema(f));
if f.required && !f.auto {
req.push(json!(n));
}
}
let mut o = serde_json::Map::new();
o.insert("type".to_string(), json!("object"));
o.insert("properties".to_string(), Value::Object(props));
if !req.is_empty() {
o.insert("required".to_string(), json!(req));
}
Value::Object(o)
} else {
json!({ "type": "object" })
};
json!({ "oneOf": [ ref_schema, inline_schema ] })
},
_ => json!({ "type": "string" }),
};

View File

@@ -188,32 +188,53 @@ pub fn resolve_extends(schemas: &mut IndexMap<String, SchemaDefinition>) -> Resu
Ok(())
}
/// Resolve `useFields` on object fields: copy fields from the referenced schema (partial).
/// Referenced schema should have `reusable: true` and is not exposed as a collection.
/// Resolve `useFields` on object fields and referenceOrInline (incl. in array items): copy fields from the referenced schema.
pub fn resolve_use_fields(schemas: &mut IndexMap<String, SchemaDefinition>) -> Result<()> {
let mut to_resolve: Vec<(String, String, String)> = Vec::new();
let mut to_resolve: Vec<(String, String, String, bool)> = Vec::new(); // (schema_name, field_name, ref_name, is_nested_item)
for (schema_name, schema) in schemas.iter() {
for (field_name, fd) in &schema.fields {
if fd.field_type != "object" {
continue;
let ref_name = if fd.field_type == "object" && fd.use_fields.is_some() {
fd.use_fields.clone()
} else if fd.field_type == "referenceOrInline" {
fd.use_fields.clone().or_else(|| fd.collection.clone())
} else {
None
};
if let Some(ref_name) = ref_name {
to_resolve.push((schema_name.clone(), field_name.clone(), ref_name, false));
}
if let Some(ref ref_name) = fd.use_fields {
to_resolve.push((schema_name.clone(), field_name.clone(), ref_name.clone()));
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let item_ref = if items.field_type == "referenceOrInline" {
items.use_fields.clone().or_else(|| items.collection.clone())
} else {
None
};
if let Some(item_ref_name) = item_ref {
to_resolve.push((schema_name.clone(), field_name.clone(), item_ref_name, true));
}
}
}
}
}
for (schema_name, field_name, ref_name) in to_resolve {
for (schema_name, field_name, ref_name, is_nested_item) in to_resolve {
let partial_fields = schemas.get(&ref_name).with_context(|| {
format!(
"Schema '{}': useFields '{}' not found (define a reusable schema with that name)",
schema_name, ref_name
"Schema '{}': field '{}' references '{}' not found",
schema_name, field_name, ref_name
)
})?.fields.clone();
let schema = schemas.get_mut(&schema_name).unwrap();
if let Some(fd) = schema.fields.get_mut(&field_name) {
fd.fields = Some(partial_fields);
if is_nested_item {
if let Some(ref mut items) = fd.items {
items.fields = Some(partial_fields);
}
} else {
fd.fields = Some(partial_fields);
}
}
tracing::debug!("Resolved useFields: {} -> {}", schema_name, ref_name);
tracing::debug!("Resolved useFields: {} -> {} ({})", schema_name, ref_name, field_name);
}
Ok(())
}

View File

@@ -76,6 +76,14 @@ pub struct SchemaDefinition {
#[serde(default, skip_serializing_if = "is_false")]
pub reusable: bool,
/// Default sort field for list endpoint when client does not send _sort (e.g. "created").
#[serde(rename = "defaultSort", skip_serializing_if = "Option::is_none", default)]
pub default_sort: Option<String>,
/// Default sort order when using default_sort: "asc" or "desc".
#[serde(rename = "defaultOrder", skip_serializing_if = "Option::is_none", default)]
pub default_order: Option<String>,
pub fields: IndexMap<String, FieldDefinition>,
}
@@ -87,7 +95,8 @@ pub struct SchemaDefinition {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldDefinition {
/// Field type: "string", "number", "boolean", "datetime",
/// "richtext", "html", "markdown", "integer", "array", "object", "reference"
/// "richtext", "html", "markdown", "integer", "array", "object", "reference",
/// "referenceOrInline" (string = slug ref, or object = inline with useFields schema)
#[serde(rename = "type")]
pub field_type: String,
@@ -125,6 +134,10 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub fields: Option<IndexMap<String, FieldDefinition>>,
/// For type "object" without fields: schema for arbitrary keys (e.g. additionalProperties: { type: "string" }).
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none", default)]
pub additional_properties: Option<Box<FieldDefinition>>,
/// Reuse fields from another schema (partial). Only for type "object".
#[serde(rename = "useFields", skip_serializing_if = "Option::is_none", default)]
pub use_fields: Option<String>,
@@ -141,6 +154,10 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub description: Option<String>,
/// Optional section key for grouping fields in the admin UI (collapsible blocks).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub section: Option<String>,
// ── String constraints ───────────────────────────────────────────────
#[serde(skip_serializing_if = "Option::is_none", default, rename = "minLength")]
@@ -177,7 +194,8 @@ pub struct FieldDefinition {
pub const VALID_FIELD_TYPES: &[&str] = &[
"string", "number", "integer", "boolean", "datetime",
"richtext", "html", "markdown",
"array", "object", "reference",
"textOrRef", // string = inline text, or "file:path" = content loaded from file
"array", "object", "reference", "referenceOrInline",
];
impl FieldDefinition {

View File

@@ -108,13 +108,14 @@ fn validate_field(
// ── Type check ──────────────────────────────────────────────────────
let type_ok = match fd.field_type.as_str() {
"string" | "richtext" | "html" | "markdown" | "datetime" => value.is_string(),
"string" | "richtext" | "html" | "markdown" | "datetime" | "textOrRef" => value.is_string(),
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
"referenceOrInline" => value.is_string() || value.is_object(),
_ => true,
};
@@ -222,17 +223,24 @@ fn validate_field(
}
// ── Nested object validation ────────────────────────────────────────
if fd.field_type == "object" {
if let (Some(ref nested_fields), Some(obj)) = (&fd.fields, value.as_object()) {
for (nested_name, nested_def) in nested_fields {
let full_name = format!("{}.{}", field_name, nested_name);
if let Some(nested_value) = obj.get(nested_name) {
validate_field(&full_name, nested_def, nested_value, errors);
} else if nested_def.required {
errors.push(ValidationError {
field: full_name,
message: "Field is required".to_string(),
});
if fd.field_type == "object" || fd.field_type == "referenceOrInline" {
if value.is_object() {
if let (Some(ref nested_fields), Some(obj)) = (&fd.fields, value.as_object()) {
for (nested_name, nested_def) in nested_fields {
let full_name = format!("{}.{}", field_name, nested_name);
if let Some(nested_value) = obj.get(nested_name) {
validate_field(&full_name, nested_def, nested_value, errors);
} else if nested_def.required {
errors.push(ValidationError {
field: full_name,
message: "Field is required".to_string(),
});
}
}
} else if let (Some(ref ap), Some(obj)) = (&fd.additional_properties, value.as_object()) {
for (key, val) in obj {
let full_name = format!("{}.{}", field_name, key);
validate_field(&full_name, ap, val, errors);
}
}
}
@@ -277,6 +285,61 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
}
}
// ---------------------------------------------------------------------------
// Normalize reference arrays (used before validation on create/update)
// ---------------------------------------------------------------------------
/// Convert reference-array items from objects `{ _slug, ... }` to slug strings.
/// So clients can send either slugs or resolved refs; we always store slugs.
pub fn normalize_reference_arrays(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
// Single reference / referenceOrInline: object with _slug → slug string
if fd.field_type == "reference" || fd.field_type == "referenceOrInline" {
let slug_opt = obj
.get(field_name)
.and_then(|v| v.as_object())
.and_then(|o| o.get("_slug"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(s) = slug_opt {
obj.insert(field_name.clone(), Value::String(s));
}
continue;
}
if fd.field_type != "array" {
continue;
}
let items = match &fd.items {
Some(it) => it,
None => continue,
};
let is_ref_array =
items.field_type == "reference" || items.field_type == "referenceOrInline";
if !is_ref_array {
continue;
}
let arr = match obj.get_mut(field_name).and_then(|v| v.as_array_mut()) {
Some(a) => a,
None => continue,
};
for item in arr.iter_mut() {
let slug_opt = item
.as_object()
.and_then(|o| o.get("_slug"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(s) = slug_opt {
*item = Value::String(s);
}
}
}
}
// ---------------------------------------------------------------------------
// Readonly check (used by update handler)
// ---------------------------------------------------------------------------
@@ -387,7 +450,9 @@ pub fn validate_references(
if let Some(obj) = content.as_object() {
for (field_name, fd) in &schema.fields {
if fd.field_type == "reference" {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
@@ -408,7 +473,9 @@ pub fn validate_references(
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
if items.field_type != "reference" {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();

View File

@@ -34,6 +34,104 @@ impl FileStore {
.join(format!("{}.json5", slug))
}
/// Path for optional external content file: same dir as entry, stem.content.md.
/// Used so markdown (and other long text) can live in a .md file instead of JSON.
fn content_file_path(&self, entry_path: &Path) -> PathBuf {
let stem = entry_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
entry_path
.parent()
.unwrap_or_else(|| entry_path.as_ref())
.join(format!("{}.content.md", stem))
}
/// Resolve content: if value["content"] is "file:path", load from that path (relative to entry dir);
/// else if sibling .content.md exists and content is missing, load it.
/// When content was a file reference, inserts _contentSource: { "content": "file:path" } for the admin UI.
async fn resolve_content_file(&self, entry_path: &Path, value: &mut Value) -> Result<()> {
let dir = entry_path
.parent()
.unwrap_or_else(|| entry_path.as_ref());
if let Some(obj) = value.as_object_mut() {
if let Some(Value::String(s)) = obj.get("content") {
const FILE_PREFIX: &str = "file:";
if s.starts_with(FILE_PREFIX) {
let file_ref = s.clone();
let rel = s.trim_start_matches(FILE_PREFIX).trim();
let content_path = dir.join(rel);
if content_path.exists() {
let content = fs::read_to_string(&content_path)
.await
.with_context(|| format!("Failed to read {}", content_path.display()))?;
let mut src = serde_json::Map::new();
src.insert("content".to_string(), Value::String(file_ref));
obj.insert("_contentSource".to_string(), Value::Object(src));
obj.insert("content".to_string(), Value::String(content));
}
}
return Ok(());
}
}
let content_path = self.content_file_path(entry_path);
if content_path.exists() {
let content = fs::read_to_string(&content_path)
.await
.with_context(|| format!("Failed to read {}", content_path.display()))?;
if let Some(obj) = value.as_object_mut() {
let stem = entry_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let file_ref = format!("file:{}.content.md", stem);
let mut src = serde_json::Map::new();
src.insert("content".to_string(), Value::String(file_ref));
obj.insert("_contentSource".to_string(), Value::Object(src));
obj.insert("content".to_string(), Value::String(content));
}
}
Ok(())
}
/// If value["content"] is inline text (no "file:" prefix), write to .content.md and store "file:stem.content.md" in value.
/// When _contentStorage is "inline", skip file write and keep content in JSON. _contentStorage is always removed from payload.
async fn write_content_file(&self, entry_path: &Path, value: &mut Value) -> Result<()> {
if let Some(obj) = value.as_object_mut() {
let store_inline = obj
.get("_contentStorage")
.and_then(|v| v.as_str())
.map(|s| s == "inline")
.unwrap_or(false);
obj.remove("_contentStorage");
if store_inline {
return Ok(());
}
}
let stem = entry_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let content_path = self.content_file_path(entry_path);
if let Some(obj) = value.as_object_mut() {
if let Some(Value::String(content)) = obj.get("content") {
const FILE_PREFIX: &str = "file:";
if !content.starts_with(FILE_PREFIX) {
let mut file = fs::File::create(&content_path)
.await
.with_context(|| format!("Failed to create {}", content_path.display()))?;
file.write_all(content.as_bytes())
.await
.with_context(|| format!("Failed to write {}", content_path.display()))?;
let ref_value = format!("file:{}.content.md", stem);
obj.insert("content".to_string(), Value::String(ref_value));
}
}
}
Ok(())
}
/// Ensure the collection directory exists (no locale: used at startup for all collections).
async fn ensure_collection_dir_impl(&self, collection: &str) -> Result<()> {
let dir = self.collection_dir(collection, None);
@@ -79,6 +177,7 @@ impl FileStore {
match self.read_file(&path).await {
Ok(mut value) => {
let _ = self.resolve_content_file(&path, &mut value).await;
if let Some(obj) = value.as_object_mut() {
obj.insert("_slug".to_string(), Value::String(slug.clone()));
}
@@ -101,6 +200,7 @@ impl FileStore {
}
let mut value = self.read_file(&path).await?;
self.resolve_content_file(&path, &mut value).await?;
if let Some(obj) = value.as_object_mut() {
obj.insert("_slug".to_string(), Value::String(slug.to_string()));
}
@@ -126,7 +226,12 @@ impl FileStore {
);
}
self.write_file(&path, data).await
let mut payload = data.clone();
if let Some(obj) = payload.as_object_mut() {
obj.remove("_contentSource");
}
self.write_content_file(&path, &mut payload).await?;
self.write_file(&path, &payload).await
}
/// Update an existing entry (optionally under a locale path).
@@ -141,7 +246,12 @@ impl FileStore {
);
}
self.write_file(&path, data).await
let mut payload = data.clone();
if let Some(obj) = payload.as_object_mut() {
obj.remove("_contentSource");
}
self.write_content_file(&path, &mut payload).await?;
self.write_file(&path, &payload).await
}
/// Delete an entry (optionally under a locale path).

View File

@@ -31,6 +31,8 @@ pub struct QueryParams {
pub per_page: Option<usize>,
/// (field_name, filter_op)
pub filters: Vec<(String, FilterOp)>,
/// Full-text search: case-insensitive contains across all string values (_q param).
pub full_text_query: Option<String>,
}
impl QueryParams {
@@ -40,6 +42,7 @@ impl QueryParams {
let order = map.remove("_order");
let page = map.remove("_page").and_then(|v| v.parse().ok());
let per_page = map.remove("_per_page").and_then(|v| v.parse().ok());
let full_text_query = map.remove("_q").filter(|s| !s.trim().is_empty());
map.retain(|k, _| !k.starts_with('_'));
let mut filters = Vec::new();
@@ -68,6 +71,7 @@ impl QueryParams {
page,
per_page,
filters,
full_text_query,
}
}
@@ -121,6 +125,11 @@ impl QueryParams {
}
fn matches_filters(&self, value: &Value) -> bool {
if let Some(ref q) = self.full_text_query {
if !value_contains_text(value, q) {
return false;
}
}
for (field_name, op) in &self.filters {
let actual = value.get(field_name);
if !filter_matches(actual, op) {
@@ -131,6 +140,19 @@ impl QueryParams {
}
}
/// Recursively check whether any string value in `value` contains `q` (case-insensitive).
fn value_contains_text(value: &Value, q: &str) -> bool {
let q_lower = q.to_lowercase();
match value {
Value::String(s) => s.to_lowercase().contains(&q_lower),
Value::Number(n) => n.to_string().contains(q),
Value::Bool(b) => b.to_string().contains(q),
Value::Array(arr) => arr.iter().any(|v| value_contains_text(v, &q_lower)),
Value::Object(map) => map.values().any(|v| value_contains_text(v, &q_lower)),
Value::Null => false,
}
}
fn filter_matches(actual: Option<&Value>, op: &FilterOp) -> bool {
match op {
FilterOp::Exact(expected) => value_matches_exact(actual, expected),