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:
542
src/api/assets.rs
Normal file
542
src/api/assets.rs
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod error;
|
||||
|
||||
@@ -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 (1–100)" }
|
||||
{ "name": "quality", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 85 }, "description": "Quality 1–100 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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 1–100. Only for format=jpeg.
|
||||
/// Output quality 1–100. 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")
|
||||
}
|
||||
|
||||
45
src/main.rs
45
src/main.rs
@@ -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(®istry),
|
||||
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);
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user