/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
engine/src/webui/endpoints.rs
322 строки
11 KB
mcmare
Add input/output schema for script endpoints, redesign WebUI with Tailwind
19 июл 2026, 09:28
19 июл 2026, 09:28
f3df0d8
Код
Авторство
О чём код?
use axum::{ extract::{Path as AxumPath, State}, http::StatusCode, Json, }; use rustapi_core::ApiError; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, path::PathBuf}; use super::{scripts::write_script, write_validated, WebuiState}; use crate::config::{CrudOperation, EndpointDef, EndpointKind, FieldType, HttpMethod}; fn ui_dir(config_dir: &std::path::Path) -> PathBuf { config_dir.join("endpoints").join("ui") } fn slugify(method: &str, path: &str) -> String { let mut slug = String::new(); for c in path.chars() { if c.is_ascii_alphanumeric() { slug.push(c.to_ascii_lowercase()); } else if !slug.ends_with('_') { slug.push('_'); } } format!("{}_{}", slug.trim_matches('_'), method.to_lowercase()) } fn method_str(m: HttpMethod) -> &'static str { match m { HttpMethod::Get => "GET", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Patch => "PATCH", HttpMethod::Delete => "DELETE", } } fn operation_str(op: CrudOperation) -> &'static str { match op { CrudOperation::Create => "create", CrudOperation::Read => "read", CrudOperation::Update => "update", CrudOperation::Delete => "delete", CrudOperation::List => "list", } } fn field_type_str(ty: &FieldType) -> String { match ty { FieldType::String => "string".to_string(), FieldType::Number => "number".to_string(), FieldType::Bool => "bool".to_string(), FieldType::Uuid => "uuid".to_string(), FieldType::Datetime => "datetime".to_string(), FieldType::Object => "object".to_string(), FieldType::Array(inner) => format!("array<{}>", field_type_str(inner)), } } fn fields_to_map(fields: &std::collections::HashMap<String, FieldType>) -> BTreeMap<String, String> { fields.iter().map(|(k, v)| (k.clone(), field_type_str(v))).collect() } #[derive(Serialize)] pub struct EndpointOut { /// `Some` (and therefore editable/deletable through this API) only for endpoints the /// WebUI itself created, under `endpoints/ui/`. Anything hand-authored elsewhere is /// listed read-only, so the UI never silently rewrites a file it didn't create. slug: Option<String>, method: &'static str, path: String, kind: &'static str, #[serde(skip_serializing_if = "Option::is_none")] connection: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] table: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] operation: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] primary_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] input: Option<BTreeMap<String, String>>, #[serde(skip_serializing_if = "Option::is_none")] output: Option<BTreeMap<String, String>>, #[serde(skip_serializing_if = "Option::is_none")] script: Option<String>, } fn to_out(ep: &EndpointDef, ui_root: &std::path::Path) -> EndpointOut { let slug = ep .source_file .starts_with(ui_root) .then(|| ep.source_file.file_stem().map(|s| s.to_string_lossy().to_string())) .flatten(); match &ep.kind { EndpointKind::Crud { connection, table, operation, primary_key, input, output } => EndpointOut { slug, method: method_str(ep.method), path: ep.path.clone(), kind: "crud", connection: Some(connection.clone()), table: Some(table.clone()), operation: Some(operation_str(*operation)), primary_key: Some(primary_key.clone()), input: Some(fields_to_map(input)), output: Some(fields_to_map(output)), script: None, }, EndpointKind::Script { script_path, input, output } => EndpointOut { slug, method: method_str(ep.method), path: ep.path.clone(), kind: "script", connection: None, table: None, operation: None, primary_key: None, input: Some(fields_to_map(input)), output: Some(fields_to_map(output)), script: script_path.file_name().map(|f| f.to_string_lossy().to_string()), }, } } pub async fn list(State(state): State<WebuiState>) -> Result<Json<Vec<EndpointOut>>, ApiError> { let config = crate::config::load_config(&state.config_dir).map_err(ApiError::Internal)?; let ui_root = ui_dir(&state.config_dir); Ok(Json(config.endpoints.iter().map(|ep| to_out(ep, &ui_root)).collect())) } #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum CreateEndpointBody { Crud { method: String, path: String, connection: String, table: String, operation: String, #[serde(default)] primary_key: Option<String>, #[serde(default)] input: BTreeMap<String, String>, #[serde(default)] output: BTreeMap<String, String>, }, Script { method: String, path: String, /// Filename only, e.g. "login.rhai" — resolved to `scripts/<filename>`. script: String, #[serde(default)] script_source: Option<String>, #[serde(default)] input: BTreeMap<String, String>, #[serde(default)] output: BTreeMap<String, String>, }, } #[derive(Serialize)] struct CrudYamlFile { endpoint: CrudYamlFields, } #[derive(Serialize)] struct CrudYamlFields { method: String, path: String, connection: String, table: String, operation: String, #[serde(skip_serializing_if = "Option::is_none")] primary_key: Option<String>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] input: BTreeMap<String, String>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] output: BTreeMap<String, String>, } #[derive(Serialize)] struct ScriptYamlFile { endpoint: ScriptYamlFields, } #[derive(Serialize)] struct ScriptYamlFields { method: String, path: String, script: String, #[serde(skip_serializing_if = "BTreeMap::is_empty")] input: BTreeMap<String, String>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] output: BTreeMap<String, String>, } pub async fn create( State(state): State<WebuiState>, Json(body): Json<CreateEndpointBody>, ) -> Result<Json<EndpointOut>, ApiError> { let (method, path) = match &body { CreateEndpointBody::Crud { method, path, .. } => (method.clone(), path.clone()), CreateEndpointBody::Script { method, path, .. } => (method.clone(), path.clone()), }; if !path.starts_with('/') { return Err(ApiError::Validation("path must start with '/'".to_string())); } let slug = slugify(&method, &path); let file_path = ui_dir(&state.config_dir).join(format!("{slug}.yaml")); if file_path.exists() { return Err(ApiError::Conflict(format!("an endpoint already exists at {method} {path}"))); } let yaml = match &body { CreateEndpointBody::Crud { method, path, connection, table, operation, primary_key, input, output } => { serde_yaml::to_string(&CrudYamlFile { endpoint: CrudYamlFields { method: method.clone(), path: path.clone(), connection: connection.clone(), table: table.clone(), operation: operation.clone(), primary_key: primary_key.clone(), input: input.clone(), output: output.clone(), }, }) } CreateEndpointBody::Script { method, path, script, script_source, input, output } => { if let Some(source) = script_source { write_script(&state.config_dir, script, source)?; } serde_yaml::to_string(&ScriptYamlFile { endpoint: ScriptYamlFields { method: method.clone(), path: path.clone(), script: format!("scripts/{script}"), input: input.clone(), output: output.clone(), }, }) } } .map_err(|e| ApiError::Internal(e.into()))?; write_validated(&state.config_dir, &file_path, Some(yaml))?; let config = crate::config::load_config(&state.config_dir).map_err(ApiError::Internal)?; let ui_root = ui_dir(&state.config_dir); let created = config .endpoints .iter() .find(|ep| ep.source_file == file_path) .map(|ep| to_out(ep, &ui_root)) .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("endpoint written but not found after reload")))?; Ok(Json(created)) } pub async fn update( State(state): State<WebuiState>, AxumPath(slug): AxumPath<String>, Json(body): Json<CreateEndpointBody>, ) -> Result<Json<EndpointOut>, ApiError> { let file_path = ui_dir(&state.config_dir).join(format!("{slug}.yaml")); if !file_path.exists() { return Err(ApiError::NotFound); } let yaml = match &body { CreateEndpointBody::Crud { method, path, connection, table, operation, primary_key, input, output } => { serde_yaml::to_string(&CrudYamlFile { endpoint: CrudYamlFields { method: method.clone(), path: path.clone(), connection: connection.clone(), table: table.clone(), operation: operation.clone(), primary_key: primary_key.clone(), input: input.clone(), output: output.clone(), }, }) } CreateEndpointBody::Script { method, path, script, script_source, input, output } => { if let Some(source) = script_source { write_script(&state.config_dir, script, source)?; } serde_yaml::to_string(&ScriptYamlFile { endpoint: ScriptYamlFields { method: method.clone(), path: path.clone(), script: format!("scripts/{script}"), input: input.clone(), output: output.clone(), }, }) } } .map_err(|e| ApiError::Internal(e.into()))?; write_validated(&state.config_dir, &file_path, Some(yaml))?; let config = crate::config::load_config(&state.config_dir).map_err(ApiError::Internal)?; let ui_root = ui_dir(&state.config_dir); let updated = config .endpoints .iter() .find(|ep| ep.source_file == file_path) .map(|ep| to_out(ep, &ui_root)) .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("endpoint written but not found after reload")))?; Ok(Json(updated)) } pub async fn delete(State(state): State<WebuiState>, AxumPath(slug): AxumPath<String>) -> Result<StatusCode, ApiError> { let file_path = ui_dir(&state.config_dir).join(format!("{slug}.yaml")); if !file_path.exists() { return Err(ApiError::NotFound); } write_validated(&state.config_dir, &file_path, None)?; Ok(StatusCode::NO_CONTENT) }