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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user