/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
v0.2.0
engine/src/dispatch.rs
203 строки
8 KB
mcmare
Add OpenAPI spec generation and Swagger UI at /docs
18 июл 2026, 16:52
18 июл 2026, 16:52
3cd5502
Код
Авторство
О чём код?
use arc_swap::ArcSwap; use axum::{ body::Bytes, extract::{Request, State}, http::{HeaderMap, Method, StatusCode}, middleware, response::{IntoResponse, Response}, Json, Router, }; use rustapi_core::{ApiError, AuthConfig}; use serde_json::Value; use std::{collections::HashMap, sync::Arc}; use crate::{ config::{Config, CrudOperation, EndpointDef, EndpointKind, HttpMethod}, db::{crud, DbPool}, script::{ScriptEngine, ScriptRequest}, }; /// `{id}` (our config/OpenAPI-friendly syntax) -> `:id` (matchit's route syntax). fn to_matchit_path(path: &str) -> String { let mut out = String::with_capacity(path.len()); let mut chars = path.chars(); while let Some(c) = chars.next() { if c == '{' { out.push(':'); for c2 in chars.by_ref() { if c2 == '}' { break; } out.push(c2); } } else { out.push(c); } } out } fn to_http_method(method: &Method) -> Option<HttpMethod> { match method.as_str() { "GET" => Some(HttpMethod::Get), "POST" => Some(HttpMethod::Post), "PUT" => Some(HttpMethod::Put), "PATCH" => Some(HttpMethod::Patch), "DELETE" => Some(HttpMethod::Delete), _ => None, } } /// The current set of declared endpoints, matched by path via `matchit`. Rebuilt from a /// fresh `Config` on every hot reload and swapped in atomically via `EngineState::routes` /// — in-flight requests keep using whichever table they loaded at the start of dispatch. pub struct RouteTable { matcher: matchit::Router<HashMap<HttpMethod, Arc<EndpointDef>>>, /// Same endpoints the matcher was built from, kept around for introspection (OpenAPI). pub endpoints: Vec<Arc<EndpointDef>>, } impl RouteTable { pub fn build(config: &Config) -> anyhow::Result<Self> { let mut by_path: HashMap<String, HashMap<HttpMethod, Arc<EndpointDef>>> = HashMap::new(); let mut endpoints = Vec::with_capacity(config.endpoints.len()); for ep in &config.endpoints { let ep = Arc::new(ep.clone()); by_path.entry(to_matchit_path(&ep.path)).or_default().insert(ep.method, ep.clone()); endpoints.push(ep); } let mut matcher = matchit::Router::new(); for (path, methods) in by_path { matcher .insert(&path, methods) .map_err(|e| anyhow::anyhow!("route conflict at '{path}': {e}"))?; } Ok(Self { matcher, endpoints }) } } #[derive(Clone)] pub struct EngineState { pub pools: Arc<HashMap<String, DbPool>>, pub scripts: Arc<ScriptEngine>, pub routes: Arc<ArcSwap<RouteTable>>, } /// Builds the request router for every declared endpoint: a single fallback handler that /// looks up the current `RouteTable` on each request (so hot reload just swaps the /// `ArcSwap` — no `Router` rebuild/re-serve needed), dispatching to the CRUD engine or a /// Rhai script. Every route here requires auth; `/health` and friends live on a separate, /// open router merged in by the caller. pub fn build_router(state: EngineState, auth: AuthConfig) -> Router { Router::new() .fallback(dispatch) .with_state(state) .layer(middleware::from_fn_with_state(auth, rustapi_core::require_auth)) } async fn dispatch(State(state): State<EngineState>, req: Request) -> Result<Response, ApiError> { let method = to_http_method(req.method()).ok_or(ApiError::NotFound)?; let path = req.uri().path().to_string(); let query = req.uri().query().unwrap_or("").to_string(); let table = state.routes.load(); let matched = table.matcher.at(&path).map_err(|_| ApiError::NotFound)?; let ep = matched.value.get(&method).cloned().ok_or(ApiError::NotFound)?; let path_params: HashMap<String, String> = matched.params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); let query_params: HashMap<String, String> = serde_urlencoded::from_str(&query).unwrap_or_default(); let headers = req.headers().clone(); let body = axum::body::to_bytes(req.into_body(), 2 * 1024 * 1024) .await .map_err(|e| ApiError::Validation(format!("failed to read request body: {e}")))?; handle_request(ep, state, path_params, query_params, headers, body).await } async fn handle_request( ep: Arc<EndpointDef>, state: EngineState, path_params: HashMap<String, String>, query_params: HashMap<String, String>, headers: HeaderMap, body: Bytes, ) -> Result<Response, ApiError> { let body_json: Value = if body.is_empty() { Value::Null } else { serde_json::from_slice(&body).map_err(|e| ApiError::Validation(format!("invalid JSON body: {e}")))? }; match &ep.kind { EndpointKind::Crud { connection, table, operation, primary_key, input, output, } => { let pool = state .pools .get(connection) .expect("endpoint connection existence validated at config load"); let path_id = || { path_params .get(primary_key) .cloned() .ok_or_else(|| ApiError::Validation(format!("missing path parameter '{{{primary_key}}}'"))) }; let (status, body) = match operation { CrudOperation::Create => { let created = crud::create(pool, table, primary_key, input, output, &body_json).await?; (StatusCode::CREATED, created) } CrudOperation::Read => { let id = path_id()?; let row = crud::read(pool, table, primary_key, &id, output).await?; (StatusCode::OK, row) } CrudOperation::List => { let limit = query_params.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50); let offset = query_params.get("offset").and_then(|s| s.parse().ok()).unwrap_or(0); let mut filters = query_params.clone(); filters.remove("limit"); filters.remove("offset"); let rows = crud::list(pool, table, output, limit, offset, &filters).await?; (StatusCode::OK, rows) } CrudOperation::Update => { let id = path_id()?; let updated = crud::update(pool, table, primary_key, &id, input, output, &body_json).await?; (StatusCode::OK, updated) } CrudOperation::Delete => { let id = path_id()?; crud::delete(pool, table, primary_key, &id, output).await?; (StatusCode::OK, serde_json::json!({})) } }; Ok((status, Json(body)).into_response()) } EndpointKind::Script { script_path } => { let mut headers_map = HashMap::with_capacity(headers.len()); for (name, value) in headers.iter() { if let Ok(v) = value.to_str() { headers_map.insert(name.to_string(), v.to_string()); } } let request = ScriptRequest { body: body_json, query: query_params, params: path_params, headers: headers_map, }; let resp = state.scripts.run(script_path, request).await?; let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK); Ok((status, Json(resp.body)).into_response()) } } }