/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
engine/src/config/endpoint.rs
265 строк
8 KB
mcmare
Add input/output schema for script endpoints, redesign WebUI with Tailwind
19 июл 2026, 09:28
19 июл 2026, 09:28
f3df0d8
Код
Авторство
О чём код?
use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::{ collections::HashMap, path::{Path, PathBuf}, }; use super::field_type::FieldType; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HttpMethod { Get, Post, Put, Patch, Delete, } impl HttpMethod { fn parse(raw: &str) -> Result<Self, String> { match raw.to_ascii_uppercase().as_str() { "GET" => Ok(HttpMethod::Get), "POST" => Ok(HttpMethod::Post), "PUT" => Ok(HttpMethod::Put), "PATCH" => Ok(HttpMethod::Patch), "DELETE" => Ok(HttpMethod::Delete), other => Err(format!( "unknown method '{other}' (expected one of: GET, POST, PUT, PATCH, DELETE)" )), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CrudOperation { Create, Read, Update, Delete, List, } impl CrudOperation { fn parse(raw: &str) -> Result<Self, String> { match raw { "create" => Ok(CrudOperation::Create), "read" => Ok(CrudOperation::Read), "update" => Ok(CrudOperation::Update), "delete" => Ok(CrudOperation::Delete), "list" => Ok(CrudOperation::List), other => Err(format!( "unknown operation '{other}' (expected one of: create, read, update, delete, list)" )), } } /// CRUD operations that address a single row via `{id}` in the path. fn needs_primary_key(self) -> bool { matches!( self, CrudOperation::Read | CrudOperation::Update | CrudOperation::Delete ) } } #[derive(Debug, Clone)] pub enum EndpointKind { Crud { connection: String, table: String, operation: CrudOperation, primary_key: String, input: HashMap<String, FieldType>, output: HashMap<String, FieldType>, }, Script { /// Absolute path to the `.rhai` file, resolved against the config directory. script_path: PathBuf, input: HashMap<String, FieldType>, output: HashMap<String, FieldType>, }, } #[derive(Debug, Clone)] pub struct EndpointDef { pub method: HttpMethod, pub path: String, pub kind: EndpointKind, /// Config file this endpoint was declared in, kept for error messages and hot reload. pub source_file: PathBuf, } #[derive(Debug, Deserialize)] struct RawEndpointFile { #[serde(default)] endpoint: Option<RawEndpoint>, #[serde(default)] endpoints: Option<Vec<RawEndpoint>>, } #[derive(Debug, Deserialize)] struct RawEndpoint { method: String, path: String, #[serde(default)] connection: Option<String>, #[serde(default)] table: Option<String>, #[serde(default)] operation: Option<String>, #[serde(default)] primary_key: Option<String>, #[serde(default)] input: HashMap<String, FieldType>, #[serde(default)] output: HashMap<String, FieldType>, #[serde(default)] script: Option<String>, } fn build_endpoint(raw: RawEndpoint, source_file: &Path, config_dir: &Path) -> Result<EndpointDef> { let ctx = |field: &str| format!("{}: field '{field}'", source_file.display()); let method = HttpMethod::parse(&raw.method).map_err(|msg| anyhow::anyhow!("{}: {msg}", ctx("method")))?; if !raw.path.starts_with('/') { bail!("{}: path must start with '/', got '{}'", ctx("path"), raw.path); } let kind = match raw.script { Some(script) => { if raw.table.is_some() || raw.operation.is_some() { bail!( "{}: endpoint has both 'script' and CRUD fields ('table'/'operation') — pick one", source_file.display() ); } EndpointKind::Script { script_path: config_dir.join(&script), input: raw.input, output: raw.output, } } None => { let connection = raw .connection .with_context(|| format!("{} is required for a CRUD endpoint", ctx("connection")))?; let table = raw .table .with_context(|| format!("{} is required for a CRUD endpoint", ctx("table")))?; let operation_raw = raw .operation .with_context(|| format!("{} is required for a CRUD endpoint", ctx("operation")))?; let operation = CrudOperation::parse(&operation_raw) .map_err(|msg| anyhow::anyhow!("{}: {msg}", ctx("operation")))?; if matches!(operation, CrudOperation::Create) && raw.input.is_empty() { bail!("{}: 'input' must declare at least one field for operation=create", ctx("input")); } if matches!(operation, CrudOperation::Read | CrudOperation::List) && raw.output.is_empty() { bail!("{}: 'output' must declare at least one field", ctx("output")); } let primary_key = if operation.needs_primary_key() { let pk = raw.primary_key.unwrap_or_else(|| "id".to_string()); if !raw.path.contains("{id}") && !raw.path.contains(&format!("{{{pk}}}")) { bail!( "{}: operation={:?} addresses a single row but path '{}' has no '{{{}}}' placeholder", source_file.display(), operation, raw.path, pk ); } pk } else { raw.primary_key.unwrap_or_else(|| "id".to_string()) }; EndpointKind::Crud { connection, table, operation, primary_key, input: raw.input, output: raw.output, } } }; Ok(EndpointDef { method, path: raw.path, kind, source_file: source_file.to_path_buf(), }) } fn collect_yaml_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> { if !dir.exists() { return Ok(()); } for entry in std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { let entry = entry?; let path = entry.path(); if path.is_dir() { collect_yaml_files(&path, out)?; } else if matches!(path.extension().and_then(|e| e.to_str()), Some("yaml") | Some("yml")) { out.push(path); } } Ok(()) } /// Loads every `.yaml`/`.yml` file under `endpoints_dir` (recursively), each declaring /// one endpoint (`endpoint:`) or a group (`endpoints:`). Fails fast on the first /// invalid file, and on duplicate method+path across files. pub fn load_endpoints(endpoints_dir: &Path, config_dir: &Path) -> Result<Vec<EndpointDef>> { let mut files = Vec::new(); collect_yaml_files(endpoints_dir, &mut files)?; files.sort(); let mut endpoints = Vec::new(); for file in &files { let raw_text = std::fs::read_to_string(file) .with_context(|| format!("failed to read {}", file.display()))?; let parsed: RawEndpointFile = serde_yaml::from_str(&raw_text) .with_context(|| format!("failed to parse {}", file.display()))?; let raws = match (parsed.endpoint, parsed.endpoints) { (Some(one), None) => vec![one], (None, Some(many)) => many, (Some(_), Some(_)) => bail!( "{}: file has both 'endpoint' and 'endpoints' — pick one", file.display() ), (None, None) => bail!( "{}: file must declare either 'endpoint' or 'endpoints'", file.display() ), }; for raw in raws { endpoints.push(build_endpoint(raw, file, config_dir)?); } } check_no_duplicates(&endpoints)?; Ok(endpoints) } fn check_no_duplicates(endpoints: &[EndpointDef]) -> Result<()> { let mut seen: HashMap<(HttpMethod, &str), &Path> = HashMap::new(); for ep in endpoints { if let Some(existing_file) = seen.insert((ep.method, ep.path.as_str()), &ep.source_file) { bail!( "duplicate endpoint {:?} {} declared in both {} and {}", ep.method, ep.path, existing_file.display(), ep.source_file.display() ); } } Ok(()) }