diff --git a/crates/rustapi-core/src/action.rs b/crates/rustapi-core/src/action.rs new file mode 100644 index 0000000..4205ed4 --- /dev/null +++ b/crates/rustapi-core/src/action.rs @@ -0,0 +1,96 @@ +//! Action registry and dispatch for server actions. + +use crate::error::ApiError; +use crate::extract::{Body, Headers, Path}; +use crate::response::IntoResponse; +use crate::response::Response; +use bytes::Bytes; +use http::{header, HeaderMap}; +use inventory::collect; +use serde::de::DeserializeOwned; +use std::future::Future; +use std::pin::Pin; + +/// The route path used for action dispatch. +pub const ACTIONS_PATH: &str = "/__actions/{action_id}"; + +/// A request payload passed to action handlers. +#[derive(Debug, Clone)] +pub struct ActionRequest { + pub body: Bytes, +} + +/// A boxed future returned by action handlers. +pub type ActionFuture = Pin + Send>>; + +/// Function pointer for action handlers. +pub type ActionHandlerFn = fn(ActionRequest) -> ActionFuture; + +/// Registry entry for a server action. +pub struct ActionDefinition { + pub id: &'static str, + pub handler: ActionHandlerFn, +} + +/// Convert an incoming request body into a typed input payload. +pub fn decode_action_input(body: Bytes) -> Result { + serde_json::from_slice(&body) + .map_err(|err| ApiError::bad_request(format!("Invalid action payload: {}", err))) +} + +collect!(ActionDefinition); + +/// Find a registered action by id. +pub fn find_action(id: &str) -> Option<&'static ActionDefinition> { + inventory::iter:: + .into_iter() + .find(|action| action.id == id) +} + +/// Handle an action POST request. +pub async fn action_handler( + Path(action_id): Path, + Headers(headers): Headers, + Body(body): Body, +) -> Response { + if let Err(err) = enforce_csrf(&headers) { + return err.into_response(); + } + + let Some(action) = find_action(&action_id) else { + return ApiError::not_found(format!("Action '{}' not found", action_id)).into_response(); + }; + + (action.handler)(ActionRequest { body }).await +} + +fn enforce_csrf(headers: &HeaderMap) -> Result<(), ApiError> { + let header_token = headers + .get("x-csrf-token") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + let cookie_token = extract_cookie(headers, "csrf_token"); + + match (cookie_token, header_token) { + (Some(cookie), Some(header)) if cookie == header => Ok(()), + _ => Err(ApiError::forbidden("Invalid CSRF token")), + } +} + +fn extract_cookie(headers: &HeaderMap, name: &str) -> Option { + let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; + + cookie_header + .split(';') + .map(str::trim) + .filter(|pair| !pair.is_empty()) + .find_map(|pair| { + let (key, value) = pair.split_once('=')?; + if key.trim() == name { + Some(value.trim().to_string()) + } else { + None + } + }) +} diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index a01de30..ca80446 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -6,6 +6,7 @@ use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BOD use crate::response::IntoResponse; use crate::router::{MethodRouter, Router}; use crate::server::Server; +use crate::{action_handler, ACTIONS_PATH}; use std::collections::HashMap; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -390,6 +391,14 @@ impl RustApi { self } + /// Enable server actions dispatch endpoint. + /// + /// Registers a POST handler at `/__actions/{action_id}` that dispatches to + /// action definitions registered by `#[rustapi::action]`. + pub fn actions(self) -> Self { + self.route(ACTIONS_PATH, crate::post(action_handler)) + } + /// Add a typed route pub fn typed(self, method_router: MethodRouter) -> Self { self.route(P::PATH, method_router) diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 7353c23..28fff85 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -7,6 +7,7 @@ //! - **Application Builder**: [`RustApi`] - The main entry point for building web applications //! - **Routing**: [`Router`], [`get`], [`post`], [`put`], [`patch`], [`delete`] - HTTP routing primitives //! - **Extractors**: [`Json`], [`Query`], [`Path`], [`State`], [`Body`], [`Headers`] - Request data extraction +//! - **Actions**: [`action_handler`], [`ActionDefinition`] - Server action registration/dispatch //! - **Responses**: [`IntoResponse`], [`Created`], [`NoContent`], [`Html`], [`Redirect`] - Response types //! - **Middleware**: [`BodyLimitLayer`], [`RequestIdLayer`], [`TracingLayer`] - Request processing layers //! - **Error Handling**: [`ApiError`], [`Result`] - Structured error responses @@ -49,6 +50,7 @@ //! full framework experience with all features and re-exports. mod app; +mod action; pub mod auto_route; pub use auto_route::collect_auto_routes; pub mod auto_schema; @@ -83,11 +85,15 @@ pub mod __private { pub use crate::auto_route::AUTO_ROUTES; pub use crate::auto_schema::AUTO_SCHEMAS; pub use linkme; + pub use inventory; pub use rustapi_openapi; } // Public API pub use app::{RustApi, RustApiConfig}; +pub use action::{ + action_handler, decode_action_input, find_action, ActionDefinition, ActionRequest, ACTIONS_PATH, +}; pub use error::{get_environment, ApiError, Environment, FieldError, Result}; #[cfg(feature = "cookies")] pub use extract::Cookies; diff --git a/crates/rustapi-macros/src/lib.rs b/crates/rustapi-macros/src/lib.rs index 4186183..398bdd1 100644 --- a/crates/rustapi-macros/src/lib.rs +++ b/crates/rustapi-macros/src/lib.rs @@ -487,6 +487,102 @@ pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream { generate_route_handler("DELETE", attr, item) } +/// Register a server action handler. +/// +/// The annotated async function should accept a single input type that +/// implements `serde::Deserialize` and return a type implementing +/// `IntoResponse` (commonly `Result, ApiError>` or `Redirect`). +/// +/// The action will be auto-registered and can be invoked via the +/// `/__actions/{action_id}` endpoint. +#[proc_macro_attribute] +pub fn action(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemFn); + + if input.sig.asyncness.is_none() { + return syn::Error::new_spanned( + &input.sig, + "#[rustapi::action] functions must be async", + ) + .to_compile_error() + .into(); + } + + if !input.sig.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.sig.generics, + "#[rustapi::action] functions do not support generics", + ) + .to_compile_error() + .into(); + } + + let fn_name = &input.sig.ident; + let fn_vis = &input.vis; + let fn_attrs = &input.attrs; + let fn_inputs = &input.sig.inputs; + let fn_output = &input.sig.output; + let fn_block = &input.block; + + let first_arg = match fn_inputs.first() { + Some(FnArg::Typed(pat_ty)) => pat_ty.ty.clone(), + _ => { + return syn::Error::new_spanned( + &input.sig.inputs, + "#[rustapi::action] requires a single typed argument", + ) + .to_compile_error() + .into(); + } + }; + + if fn_inputs.len() != 1 { + return syn::Error::new_spanned( + &input.sig.inputs, + "#[rustapi::action] requires exactly one argument", + ) + .to_compile_error() + .into(); + } + + let action_id = fn_name.to_string(); + let registrar_name = syn::Ident::new(&format!("__RUSTAPI_ACTION_{}", fn_name), fn_name.span()); + let wrapper_name = syn::Ident::new(&format!("__rustapi_action_wrapper_{}", fn_name), fn_name.span()); + + let expanded = quote! { + #(#fn_attrs)* + #fn_vis async fn #fn_name(#fn_inputs) #fn_output #fn_block + + #[doc(hidden)] + #fn_vis fn #wrapper_name( + req: ::rustapi_rs::ActionRequest, + ) -> ::rustapi_rs::ActionFuture { + Box::pin(async move { + let input: #first_arg = match ::rustapi_rs::decode_action_input(req.body) { + Ok(value) => value, + Err(err) => { + return err.into_response(); + } + }; + let result = #fn_name(input).await; + ::rustapi_rs::IntoResponse::into_response(result) + }) + } + + #[allow(non_upper_case_globals)] + static #registrar_name: ::rustapi_rs::ActionDefinition = ::rustapi_rs::ActionDefinition { + id: #action_id, + handler: #wrapper_name, + }; + + ::rustapi_rs::__private::inventory::submit! { #registrar_name } + }; + + debug_output(&format!("action {}", action_id), &expanded); + + TokenStream::from(expanded) +} + // ============================================ // Route Metadata Macros // ============================================ diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index 9bd5ed5..c2555f7 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -228,6 +228,10 @@ pub mod prelude { Body, ClientIp, Created, + ActionDefinition, + ActionRequest, + ACTIONS_PATH, + decode_action_input, Extension, HeaderValue, Headers,