Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.
This commit is contained in:
@@ -11,6 +11,7 @@ use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::referrers::{Referrer, ReferrerIndex};
|
||||
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
|
||||
use crate::schema::validator;
|
||||
use crate::schema::SchemaRegistry;
|
||||
@@ -53,6 +54,10 @@ pub struct AppState {
|
||||
pub stores: Option<HashMap<String, Arc<dyn ContentStore>>>,
|
||||
/// Assets dir per environment when environments is set. Key = env name.
|
||||
pub assets_dirs: Option<HashMap<String, PathBuf>>,
|
||||
/// Reverse index for "who references this entry?". Updated on create/update/delete. None when environments are used.
|
||||
pub referrer_index: Option<Arc<RwLock<ReferrerIndex>>>,
|
||||
/// Path to persist referrer index (e.g. content/_referrers.json).
|
||||
pub referrer_index_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -726,6 +731,7 @@ pub async fn create_entry(
|
||||
|
||||
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
|
||||
validator::normalize_reference_arrays(&schema, &mut body);
|
||||
validator::normalize_multi_select(&schema, &mut body);
|
||||
|
||||
// Validate against schema (type checks, constraints, strict mode, …)
|
||||
let errors = validator::validate_content(&schema, &body);
|
||||
@@ -766,6 +772,27 @@ pub async fn create_entry(
|
||||
.map_err(ApiError::from)?;
|
||||
state.cache.invalidate_collection(&env, &collection).await;
|
||||
|
||||
// Update referrer index: this entry references (ref_coll, ref_slug)
|
||||
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
|
||||
let refs = validator::extract_references(&schema, &body);
|
||||
let referrer = Referrer {
|
||||
collection: collection.clone(),
|
||||
slug: slug.clone(),
|
||||
field: String::new(), // we add per (ref_coll, ref_slug, field) below
|
||||
locale: locale_ref.map(str::to_string),
|
||||
};
|
||||
let mut index = idx.write().await;
|
||||
for (ref_coll, ref_slug, field) in refs {
|
||||
let mut r = referrer.clone();
|
||||
r.field = field;
|
||||
index.add_referrer(&ref_coll, &ref_slug, r);
|
||||
}
|
||||
drop(index);
|
||||
if let Err(e) = idx.read().await.save(path) {
|
||||
tracing::warn!("Failed to save referrer index: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Return created entry (with reference format)
|
||||
let entry = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
@@ -846,6 +873,7 @@ pub async fn update_entry(
|
||||
|
||||
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
|
||||
validator::normalize_reference_arrays(&schema, &mut body);
|
||||
validator::normalize_multi_select(&schema, &mut body);
|
||||
|
||||
// Load existing content for readonly check
|
||||
let existing = store
|
||||
@@ -902,6 +930,28 @@ pub async fn update_entry(
|
||||
.map_err(ApiError::from)?;
|
||||
state.cache.invalidate_collection(&env, &collection).await;
|
||||
|
||||
// Update referrer index: remove old refs from this entry, add new refs
|
||||
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
|
||||
let mut index = idx.write().await;
|
||||
index.remove_all_referrers_from(&collection, &slug);
|
||||
let refs = validator::extract_references(&schema, &body);
|
||||
let referrer = Referrer {
|
||||
collection: collection.clone(),
|
||||
slug: slug.clone(),
|
||||
field: String::new(),
|
||||
locale: locale_ref.map(str::to_string),
|
||||
};
|
||||
for (ref_coll, ref_slug, field) in refs {
|
||||
let mut r = referrer.clone();
|
||||
r.field = field;
|
||||
index.add_referrer(&ref_coll, &ref_slug, r);
|
||||
}
|
||||
drop(index);
|
||||
if let Err(e) = idx.read().await.save(path) {
|
||||
tracing::warn!("Failed to save referrer index: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated entry (with reference format)
|
||||
let entry = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
@@ -933,6 +983,22 @@ pub async fn update_entry(
|
||||
Ok(Json(formatted))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/content/:collection/:slug/referrers
|
||||
// ---------------------------------------------------------------------------
|
||||
pub async fn get_referrers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((collection, slug)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let referrers = match &state.referrer_index {
|
||||
Some(idx) => idx.read().await.get_referrers(&collection, &slug),
|
||||
None => vec![],
|
||||
};
|
||||
Ok(Json(
|
||||
serde_json::to_value(&referrers).unwrap_or_else(|_| json!([])),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/locales
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -982,6 +1048,17 @@ pub async fn delete_entry(
|
||||
)));
|
||||
}
|
||||
|
||||
// Update referrer index: remove this entry from every (ref_coll, ref_slug) it referenced; and remove key (collection, slug)
|
||||
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
|
||||
let mut index = idx.write().await;
|
||||
index.remove_all_referrers_from(&collection, &slug);
|
||||
index.remove_referenced_entry(&collection, &slug);
|
||||
drop(index);
|
||||
if let Err(e) = idx.read().await.save(path) {
|
||||
tracing::warn!("Failed to save referrer index: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
.delete(&collection, &slug, locale_ref)
|
||||
.await
|
||||
|
||||
@@ -235,6 +235,41 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/content/:collection/:slug/referrers – who references this entry
|
||||
paths.insert(
|
||||
format!("/api/content/{}/{{slug}}/referrers", name),
|
||||
json!({
|
||||
"get": {
|
||||
"summary": format!("List referrers of '{}' entry", name),
|
||||
"description": "Returns all entries that reference this entry (reverse index). Empty when not using referrer index (e.g. with RUSTYCMS_ENVIRONMENTS).",
|
||||
"operationId": format!("get{}Referrers", pascal),
|
||||
"tags": [tag],
|
||||
"parameters": [
|
||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Entry slug" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of referrers (collection, slug, field, locale)",
|
||||
"content": { "application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": { "type": "string", "description": "Collection of the referring entry" },
|
||||
"slug": { "type": "string", "description": "Slug of the referring entry" },
|
||||
"field": { "type": "string", "description": "Field that holds the reference" },
|
||||
"locale": { "type": "string", "nullable": true, "description": "Locale of the referring entry if applicable" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Asset management ─────────────────────────────────────────────────
|
||||
@@ -701,6 +736,7 @@ const API_INDEX_HTML: &str = r#"<!DOCTYPE html>
|
||||
<li><code>POST</code> /api/content/:type – Create entry</li>
|
||||
<li><code>PUT</code> /api/content/:type/:slug – Update entry</li>
|
||||
<li><code>DELETE</code> /api/content/:type/:slug – Delete entry</li>
|
||||
<li><code>GET</code> /api/content/:type/:slug/referrers – List entries that reference this entry (reverse index)</li>
|
||||
<li><code>GET</code> <a href="/api/transform?url=https://httpbin.org/image/png&w=80&h=80">/api/transform</a> – Transform image from URL (w, h, ar, fit, format)</li>
|
||||
<li><code>GET</code> <a href="/health">/health</a> – Health check</li>
|
||||
</ul>
|
||||
|
||||
@@ -41,6 +41,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
"/api/content/:collection",
|
||||
get(handlers::list_entries).post(handlers::create_entry),
|
||||
)
|
||||
.route(
|
||||
"/api/content/:collection/:slug/referrers",
|
||||
get(handlers::get_referrers),
|
||||
)
|
||||
.route(
|
||||
"/api/content/:collection/:slug",
|
||||
get(handlers::get_entry)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Library for RustyCMS (used by the main server binary and by tools like export-json-schema).
|
||||
|
||||
pub mod api;
|
||||
pub mod referrers;
|
||||
pub mod schema;
|
||||
pub mod store;
|
||||
|
||||
120
src/main.rs
120
src/main.rs
@@ -13,6 +13,8 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
use rustycms::api::cache::ContentCache;
|
||||
use rustycms::api::handlers::AppState;
|
||||
use rustycms::referrers::{Referrer, ReferrerIndex};
|
||||
use rustycms::schema::validator;
|
||||
use rustycms::schema::SchemaRegistry;
|
||||
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
|
||||
|
||||
@@ -69,33 +71,26 @@ fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
|
||||
}
|
||||
|
||||
fn reload_schemas(
|
||||
types_dir: &PathBuf,
|
||||
server_url: &str,
|
||||
registry: &Arc<RwLock<SchemaRegistry>>,
|
||||
openapi_spec: &Arc<RwLock<serde_json::Value>>,
|
||||
cache: &Arc<ContentCache>,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
types_dir: PathBuf,
|
||||
server_url: String,
|
||||
registry: Arc<RwLock<SchemaRegistry>>,
|
||||
openapi_spec: Arc<RwLock<serde_json::Value>>,
|
||||
cache: Arc<ContentCache>,
|
||||
) {
|
||||
let types_dir = types_dir.clone();
|
||||
let server_url = server_url.to_string();
|
||||
let registry = Arc::clone(registry);
|
||||
let openapi_spec = Arc::clone(openapi_spec);
|
||||
let cache = Arc::clone(cache);
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async move {
|
||||
match SchemaRegistry::load(&types_dir) {
|
||||
Ok(new_registry) => {
|
||||
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
|
||||
*registry.write().await = new_registry;
|
||||
*openapi_spec.write().await = spec;
|
||||
cache.invalidate_all().await;
|
||||
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Hot-reload failed: {}", e);
|
||||
}
|
||||
rt_handle.spawn(async move {
|
||||
match SchemaRegistry::load(&types_dir) {
|
||||
Ok(new_registry) => {
|
||||
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
|
||||
*registry.write().await = new_registry;
|
||||
*openapi_spec.write().await = spec;
|
||||
cache.invalidate_all().await;
|
||||
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
|
||||
}
|
||||
});
|
||||
Err(e) => {
|
||||
tracing::error!("Hot-reload failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +218,67 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len());
|
||||
}
|
||||
|
||||
// Reverse referrer index (file-based in content dir). Only when not using environments (single content root).
|
||||
// When the index file is missing, run a full reindex over all collections and save.
|
||||
let (referrer_index, referrer_index_path) = if environments.is_none() {
|
||||
let path = assets_dir.parent().unwrap().join("_referrers.json");
|
||||
let index = if path.exists() {
|
||||
ReferrerIndex::load(&path)
|
||||
} else {
|
||||
tracing::info!("Referrer index not found, building full index from content…");
|
||||
let mut index = ReferrerIndex::new();
|
||||
let collections_with_schema: Vec<(String, rustycms::schema::types::SchemaDefinition)> = {
|
||||
let guard = registry.read().await;
|
||||
guard
|
||||
.collection_names()
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
guard.get(&c).filter(|s| !s.reusable).map(|s| (c, s.clone()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (collection, schema) in collections_with_schema {
|
||||
let locale_opts: Vec<Option<&str>> = locales
|
||||
.as_ref()
|
||||
.map(|l| l.iter().map(|s| s.as_str()).map(Some).collect())
|
||||
.unwrap_or_else(|| vec![None]);
|
||||
for locale_ref in locale_opts {
|
||||
match store.list(&collection, locale_ref).await {
|
||||
Ok(entries) => {
|
||||
for (slug, value) in entries {
|
||||
let refs = validator::extract_references(&schema, &value);
|
||||
let referrer = Referrer {
|
||||
collection: collection.clone(),
|
||||
slug: slug.clone(),
|
||||
field: String::new(),
|
||||
locale: locale_ref.map(str::to_string),
|
||||
};
|
||||
for (ref_coll, ref_slug, field) in refs {
|
||||
let mut r = referrer.clone();
|
||||
r.field = field;
|
||||
index.add_referrer(&ref_coll, &ref_slug, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("List {} (locale {:?}) failed: {}", collection, locale_ref, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = index.save(&path) {
|
||||
tracing::warn!("Failed to save referrer index: {}", e);
|
||||
} else {
|
||||
tracing::info!("Referrer index saved to {}", path.display());
|
||||
}
|
||||
index
|
||||
};
|
||||
if path.exists() {
|
||||
tracing::info!("Referrer index loaded from {}", path.display());
|
||||
}
|
||||
(Some(Arc::new(RwLock::new(index))), Some(path))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
registry: Arc::clone(®istry),
|
||||
store,
|
||||
@@ -239,9 +295,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
environments,
|
||||
stores: stores_map,
|
||||
assets_dirs: assets_dirs_map,
|
||||
referrer_index,
|
||||
referrer_index_path,
|
||||
});
|
||||
|
||||
// Hot-reload: watch types_dir and reload schemas on change
|
||||
// Hot-reload: watch types_dir and reload schemas on change (run reload on main Tokio runtime from watcher thread)
|
||||
let rt_handle = tokio::runtime::Handle::current();
|
||||
let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
@@ -275,7 +334,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Debounce: wait for editor to finish writing, drain extra events, then reload once
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
while rx.try_recv().is_ok() {}
|
||||
reload_schemas(&types_dir_watch, &server_url_watch, ®istry, &openapi_spec, &cache);
|
||||
reload_schemas(
|
||||
rt_handle.clone(),
|
||||
types_dir_watch.clone(),
|
||||
server_url_watch.clone(),
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&openapi_spec),
|
||||
Arc::clone(&cache),
|
||||
);
|
||||
}
|
||||
});
|
||||
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());
|
||||
|
||||
123
src/referrers.rs
Normal file
123
src/referrers.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Reverse index: for each (collection, slug) store which entries reference it.
|
||||
//! Updated on create/update/delete so we can answer "where is this entry referenced?".
|
||||
//! Persisted as a single JSON file in the content directory (e.g. `content/_referrers.json`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One referrer: an entry that points to another via a reference field.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct Referrer {
|
||||
pub collection: String,
|
||||
pub slug: String,
|
||||
pub field: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
fn key(collection: &str, slug: &str) -> String {
|
||||
format!("{}:{}", collection, slug)
|
||||
}
|
||||
|
||||
/// In-memory index: (ref_collection, ref_slug) -> list of referrers.
|
||||
/// Persisted as JSON file so it survives restarts.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ReferrerIndex {
|
||||
/// "collection:slug" -> list of referrers
|
||||
index: HashMap<String, Vec<Referrer>>,
|
||||
}
|
||||
|
||||
impl ReferrerIndex {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load index from JSON file. Missing or invalid file => empty index.
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
_ => return Self::new(),
|
||||
};
|
||||
let index: HashMap<String, Vec<Referrer>> = match serde_json::from_str(&data) {
|
||||
Ok(m) => m,
|
||||
_ => return Self::new(),
|
||||
};
|
||||
Self { index }
|
||||
}
|
||||
|
||||
/// Persist index to JSON file.
|
||||
pub fn save(&self, path: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let data = serde_json::to_string_pretty(&self.index)?;
|
||||
std::fs::write(path, data)
|
||||
}
|
||||
|
||||
/// Remove this (referrer_collection, referrer_slug) from the referrer list of (ref_collection, ref_slug).
|
||||
pub fn remove_referrer(
|
||||
&mut self,
|
||||
ref_collection: &str,
|
||||
ref_slug: &str,
|
||||
referrer: &Referrer,
|
||||
) {
|
||||
let k = key(ref_collection, ref_slug);
|
||||
if let Some(list) = self.index.get_mut(&k) {
|
||||
list.retain(|r| {
|
||||
r.collection != referrer.collection
|
||||
|| r.slug != referrer.slug
|
||||
|| r.field != referrer.field
|
||||
|| r.locale != referrer.locale
|
||||
});
|
||||
if list.is_empty() {
|
||||
self.index.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add this referrer to the list for (ref_collection, ref_slug).
|
||||
pub fn add_referrer(
|
||||
&mut self,
|
||||
ref_collection: &str,
|
||||
ref_slug: &str,
|
||||
referrer: Referrer,
|
||||
) {
|
||||
let k = key(ref_collection, ref_slug);
|
||||
self.index
|
||||
.entry(k)
|
||||
.or_default()
|
||||
.push(referrer);
|
||||
}
|
||||
|
||||
/// Remove all referrers that are (referrer_collection, referrer_slug) from every key.
|
||||
/// Used when an entry is updated: clear its old refs, then add new refs.
|
||||
pub fn remove_all_referrers_from(&mut self, referrer_collection: &str, referrer_slug: &str) {
|
||||
let keys_to_check: Vec<String> = self.index.keys().cloned().collect();
|
||||
for k in keys_to_check {
|
||||
let empty = if let Some(list) = self.index.get_mut(&k) {
|
||||
list.retain(|r| r.collection != referrer_collection || r.slug != referrer_slug);
|
||||
list.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if empty {
|
||||
self.index.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all referrers of (collection, slug).
|
||||
pub fn get_referrers(&self, collection: &str, slug: &str) -> Vec<Referrer> {
|
||||
self.index
|
||||
.get(&key(collection, slug))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remove the list for (ref_collection, ref_slug). Used when that entry is deleted.
|
||||
pub fn remove_referenced_entry(&mut self, ref_collection: &str, ref_slug: &str) {
|
||||
self.index.remove(&key(ref_collection, ref_slug));
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
|
||||
}
|
||||
}
|
||||
"reference" => {
|
||||
let desc = if let Some(ref list) = fd.collections {
|
||||
let mut desc = if let Some(ref list) = fd.collections {
|
||||
if list.is_empty() {
|
||||
"Reference (slug)".to_string()
|
||||
} else {
|
||||
@@ -92,10 +92,20 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
|
||||
} else {
|
||||
"Reference (slug)".to_string()
|
||||
};
|
||||
if let Some(ref allowed) = fd.allowed_slugs {
|
||||
if !allowed.is_empty() {
|
||||
desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(ref allowed) = fd.allowed_collections {
|
||||
if !allowed.is_empty() {
|
||||
desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
json!({ "type": "string", "description": desc })
|
||||
},
|
||||
"referenceOrInline" => {
|
||||
let ref_desc = if let Some(ref list) = fd.collections {
|
||||
let mut ref_desc = if let Some(ref list) = fd.collections {
|
||||
if list.is_empty() {
|
||||
"Reference (slug)".to_string()
|
||||
} else {
|
||||
@@ -106,6 +116,16 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
|
||||
} else {
|
||||
"Reference (slug)".to_string()
|
||||
};
|
||||
if let Some(ref allowed) = fd.allowed_slugs {
|
||||
if !allowed.is_empty() {
|
||||
ref_desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(ref allowed) = fd.allowed_collections {
|
||||
if !allowed.is_empty() {
|
||||
ref_desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -128,6 +148,14 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
|
||||
};
|
||||
json!({ "oneOf": [ ref_schema, inline_schema ] })
|
||||
},
|
||||
"multiSelect" => {
|
||||
let items = if let Some(ref ev) = fd.enum_values {
|
||||
json!({ "type": "string", "enum": ev })
|
||||
} else {
|
||||
json!({ "type": "string" })
|
||||
};
|
||||
json!({ "type": "array", "items": items })
|
||||
},
|
||||
_ => json!({ "type": "string" }),
|
||||
};
|
||||
|
||||
@@ -136,8 +164,10 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
|
||||
if let Some(ref desc) = fd.description {
|
||||
obj.insert("description".to_string(), json!(desc));
|
||||
}
|
||||
if let Some(ref ev) = fd.enum_values {
|
||||
obj.insert("enum".to_string(), json!(ev));
|
||||
if fd.field_type != "multiSelect" {
|
||||
if let Some(ref ev) = fd.enum_values {
|
||||
obj.insert("enum".to_string(), json!(ev));
|
||||
}
|
||||
}
|
||||
if let Some(ref dv) = fd.default {
|
||||
obj.insert("default".to_string(), dv.clone());
|
||||
|
||||
@@ -150,6 +150,14 @@ pub struct FieldDefinition {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub collections: Option<Vec<String>>,
|
||||
|
||||
/// Optional whitelist of allowed slugs for reference fields. If set and non-empty, only these slugs are valid.
|
||||
#[serde(rename = "allowedSlugs", skip_serializing_if = "Option::is_none", default)]
|
||||
pub allowed_slugs: Option<Vec<String>>,
|
||||
|
||||
/// Optional whitelist of allowed content types (collections) for reference fields. If set and non-empty, only entries from these collections are valid (intersection with collection/collections).
|
||||
#[serde(rename = "allowedCollections", skip_serializing_if = "Option::is_none", default)]
|
||||
pub allowed_collections: Option<Vec<String>>,
|
||||
|
||||
/// Human-readable description (appears in Swagger UI).
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub description: Option<String>,
|
||||
@@ -158,6 +166,14 @@ pub struct FieldDefinition {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub section: Option<String>,
|
||||
|
||||
/// Optional hint for admin UI how to render the field (e.g. "textarea" for string → multi-line input, "code" for code editor with syntax highlighting).
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub widget: Option<String>,
|
||||
|
||||
/// When widget is "code", language for syntax highlighting (e.g. "css", "javascript", "json"). Passed through to admin UI.
|
||||
#[serde(rename = "codeLanguage", skip_serializing_if = "Option::is_none", default)]
|
||||
pub code_language: Option<String>,
|
||||
|
||||
// ── String constraints ───────────────────────────────────────────────
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", default, rename = "minLength")]
|
||||
@@ -196,6 +212,7 @@ pub const VALID_FIELD_TYPES: &[&str] = &[
|
||||
"richtext", "html", "markdown",
|
||||
"textOrRef", // string = inline text, or "file:path" = content loaded from file
|
||||
"array", "object", "reference", "referenceOrInline",
|
||||
"multiSelect", // array of strings, each must be one of enum; UI: checkboxes or multi-select
|
||||
];
|
||||
|
||||
impl FieldDefinition {
|
||||
@@ -204,15 +221,25 @@ impl FieldDefinition {
|
||||
}
|
||||
|
||||
/// Collections to try for reference resolution/validation (polymorphic or single).
|
||||
/// When `allowed_collections` is set and non-empty, returns the intersection with collection(s).
|
||||
pub fn reference_collections(&self) -> Vec<&str> {
|
||||
if let Some(ref list) = self.collections {
|
||||
if !list.is_empty() {
|
||||
return list.iter().map(String::as_str).collect();
|
||||
let base: Vec<&str> = if let Some(ref list) = self.collections {
|
||||
if list.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
list.iter().map(String::as_str).collect()
|
||||
}
|
||||
} else if let Some(ref c) = self.collection {
|
||||
vec![c.as_str()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
if let Some(ref allowed) = self.allowed_collections {
|
||||
if !allowed.is_empty() {
|
||||
let set: std::collections::HashSet<_> = allowed.iter().map(String::as_str).collect();
|
||||
return base.into_iter().filter(|c| set.contains(c)).collect();
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = self.collection {
|
||||
return vec![c.as_str()];
|
||||
}
|
||||
vec![]
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +96,12 @@ fn validate_field(
|
||||
errors: &mut Vec<ValidationError>,
|
||||
) {
|
||||
// ── Null handling ────────────────────────────────────────────────────
|
||||
// Optional fields (not required) may be null; required fields may not.
|
||||
if value.is_null() {
|
||||
if !fd.nullable {
|
||||
if fd.required {
|
||||
errors.push(ValidationError {
|
||||
field: field_name.to_string(),
|
||||
message: "Field does not allow null (set nullable: true to permit)".to_string(),
|
||||
message: "Field is required".to_string(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -112,7 +113,7 @@ fn validate_field(
|
||||
"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(),
|
||||
"array" | "multiSelect" => value.is_array(),
|
||||
"object" => value.is_object(),
|
||||
"reference" => value.is_string(),
|
||||
"referenceOrInline" => value.is_string() || value.is_object(),
|
||||
@@ -131,13 +132,39 @@ fn validate_field(
|
||||
return; // no point checking constraints if type is wrong
|
||||
}
|
||||
|
||||
// ── Enum constraint ─────────────────────────────────────────────────
|
||||
if let Some(ref allowed) = fd.enum_values {
|
||||
if !allowed.contains(value) {
|
||||
errors.push(ValidationError {
|
||||
field: field_name.to_string(),
|
||||
message: format!("Value must be one of: {:?}", allowed),
|
||||
});
|
||||
// ── Enum constraint (single value) ───────────────────────────────────
|
||||
// Optional string+enum: empty string = no selection, allowed
|
||||
if fd.field_type != "multiSelect" {
|
||||
if let Some(ref allowed) = fd.enum_values {
|
||||
if !allowed.contains(value) {
|
||||
let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty());
|
||||
if !empty_ok {
|
||||
errors.push(ValidationError {
|
||||
field: field_name.to_string(),
|
||||
message: format!("Value must be one of: {:?}", allowed),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── multiSelect: array of strings, each must be in enum ──────────────
|
||||
if fd.field_type == "multiSelect" {
|
||||
if let Some(arr) = value.as_array() {
|
||||
let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default();
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
if !item.is_string() {
|
||||
errors.push(ValidationError {
|
||||
field: format!("{}[{}]", field_name, i),
|
||||
message: "Each item must be a string".to_string(),
|
||||
});
|
||||
} else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) {
|
||||
errors.push(ValidationError {
|
||||
field: format!("{}[{}]", field_name, i),
|
||||
message: format!("Item must be one of: {:?}", fd.enum_values),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +297,11 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if fd.field_type == "multiSelect" {
|
||||
obj.insert(name.clone(), Value::Array(vec![]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref default_value) = fd.default {
|
||||
obj.insert(name.clone(), default_value.clone());
|
||||
continue;
|
||||
@@ -285,6 +317,27 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize multiSelect: string (e.g. "") → [] before validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Coerce multiSelect fields that have a string value (e.g. from old data or form) to [].
|
||||
pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) {
|
||||
let obj = match content.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
for (field_name, fd) in &schema.fields {
|
||||
if fd.field_type == "multiSelect" {
|
||||
if let Some(v) = obj.get_mut(field_name) {
|
||||
if v.is_string() {
|
||||
*v = Value::Array(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize reference arrays (used before validation on create/update)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -458,6 +511,9 @@ pub fn validate_references(
|
||||
continue;
|
||||
}
|
||||
if let Some(Value::String(slug)) = obj.get(field_name) {
|
||||
if slug.trim().is_empty() {
|
||||
continue; // empty = no selection, valid for optional reference
|
||||
}
|
||||
let found = colls.iter().any(|c| entry_exists(c, slug));
|
||||
if !found {
|
||||
errors.push(ValidationError {
|
||||
@@ -467,6 +523,16 @@ pub fn validate_references(
|
||||
slug, colls
|
||||
),
|
||||
});
|
||||
} else if let Some(ref allowed) = fd.allowed_slugs {
|
||||
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
|
||||
errors.push(ValidationError {
|
||||
field: field_name.clone(),
|
||||
message: format!(
|
||||
"Slug '{}' is not in the allowed list for this reference",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -485,6 +551,9 @@ pub fn validate_references(
|
||||
if let Some(Value::Array(arr)) = obj.get(field_name) {
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
if let Some(slug) = item.as_str() {
|
||||
if slug.trim().is_empty() {
|
||||
continue; // empty = no selection in list
|
||||
}
|
||||
let found = colls.iter().any(|c| entry_exists(c, slug));
|
||||
if !found {
|
||||
errors.push(ValidationError {
|
||||
@@ -494,6 +563,16 @@ pub fn validate_references(
|
||||
slug, colls
|
||||
),
|
||||
});
|
||||
} else if let Some(ref allowed) = items.allowed_slugs {
|
||||
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
|
||||
errors.push(ValidationError {
|
||||
field: format!("{}[{}]", field_name, i),
|
||||
message: format!(
|
||||
"Slug '{}' is not in the allowed list for this reference",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,3 +584,77 @@ pub fn validate_references(
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract references (for reverse index / referrers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One referenced entry: (collection, slug). Used to update the referrer index.
|
||||
fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some((coll, slug)) = value.split_once(':') {
|
||||
let slug = slug.trim();
|
||||
if !slug.is_empty() && collections.contains(&coll) {
|
||||
return Some((coll.to_string(), slug.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(&coll) = collections.first() {
|
||||
return Some((coll.to_string(), value.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One reference: (referenced collection, referenced slug, field name).
|
||||
pub type ExtractedRef = (String, String, String);
|
||||
|
||||
/// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates.
|
||||
/// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref.
|
||||
pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec<ExtractedRef> {
|
||||
let mut out = Vec::new();
|
||||
let obj = match content.as_object() {
|
||||
Some(o) => o,
|
||||
None => return out,
|
||||
};
|
||||
for (field_name, fd) in &schema.fields {
|
||||
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;
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get(field_name) {
|
||||
if let Some((c, slug)) = parse_ref_value(s, &colls) {
|
||||
out.push((c, slug, field_name.clone()));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if fd.field_type == "array" {
|
||||
if let Some(ref items) = fd.items {
|
||||
let is_ref_item = items.field_type == "reference"
|
||||
|| items.field_type == "referenceOrInline";
|
||||
if !is_ref_item {
|
||||
continue;
|
||||
}
|
||||
let colls = items.reference_collections();
|
||||
if colls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(Value::Array(arr)) = obj.get(field_name) {
|
||||
for item in arr.iter() {
|
||||
if let Some(s) = item.as_str() {
|
||||
if let Some((c, slug)) = parse_ref_value(s, &colls) {
|
||||
out.push((c, slug, field_name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user