cruxi 0.2.0

Minimal, transport-agnostic hexagonal architecture framework
Documentation
//! Service trait for business logic orchestration.
//!
//! Services contain the core business logic and coordinate repositories.
//! They are the application layer in the hexagonal architecture.

use crate::Context;

/// Orchestrates business logic and coordinates repositories.
///
/// Services are the application layer in the hexagonal architecture. They:
/// - Contain business logic orchestration
/// - Check authorization
/// - Coordinate multiple repository calls
/// - Maintain domain invariants
///
/// Services should NOT directly access I/O; use repositories for that.
///
/// # Type Parameters
///
/// - `Req`: The request type
/// - `Resp`: The response type
///
/// # Example
///
/// ```
/// use cruxi::{Context, Service, ServiceFn};
///
/// struct CreateOrderReq {
///     user_id: u64,
///     items: Vec<u64>,
/// }
///
/// struct Order {
///     id: u64,
///     total: f64,
/// }
///
/// #[derive(Debug)]
/// enum CreateOrderError {
///     EmptyOrder,
/// }
///
/// impl std::fmt::Display for CreateOrderError {
///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
///         match self {
///             Self::EmptyOrder => write!(f, "order must have at least one item"),
///         }
///     }
/// }
///
/// impl std::error::Error for CreateOrderError {}
///
/// let order_service = ServiceFn::new(|ctx: &Context, req: CreateOrderReq| -> Result<Order, CreateOrderError> {
///     // Business logic: validate, calculate total, etc.
///     if req.items.is_empty() {
///         return Err(CreateOrderError::EmptyOrder);
///     }
///
///     Ok(Order {
///         id: 1,
///         total: req.items.len() as f64 * 10.0,
///     })
/// });
///
/// let ctx = Context::new();
/// let result = order_service.execute(&ctx, CreateOrderReq { user_id: 1, items: vec![1, 2, 3] });
/// assert!(result.is_ok());
/// ```
pub trait Service<Req, Resp> {
    /// The error type returned by this service.
    type Error: std::error::Error;

    /// Executes the business logic for the given request.
    ///
    /// # Errors
    ///
    /// Returns an error when business logic execution fails.
    fn execute(&self, ctx: &Context, req: Req) -> Result<Resp, Self::Error>;
}

/// Adapts a function to the [`Service`] trait.
///
/// This allows using closures and functions as services without implementing
/// the trait manually.
///
/// # Example
///
/// ```
/// use cruxi::{Context, Service, ServiceFn};
///
/// let service = ServiceFn::new(|_ctx: &Context, req: String| -> Result<usize, std::convert::Infallible> {
///     Ok(req.len())
/// });
///
/// let result = service.execute(&Context::new(), "hello".to_string());
/// assert_eq!(result.ok(), Some(5));
/// ```
pub struct ServiceFn<F, Resp, E>
where
    E: std::error::Error,
{
    f: F,
    _marker: std::marker::PhantomData<(Resp, E)>,
}

impl<F, Resp, E> ServiceFn<F, Resp, E>
where
    E: std::error::Error,
{
    /// Creates a new service from a function.
    pub fn new<Req>(f: F) -> Self
    where
        F: Fn(&Context, Req) -> Result<Resp, E>,
    {
        Self {
            f,
            _marker: std::marker::PhantomData,
        }
    }
}

impl<F, Req, Resp, E> Service<Req, Resp> for ServiceFn<F, Resp, E>
where
    F: Fn(&Context, Req) -> Result<Resp, E>,
    E: std::error::Error,
{
    type Error = E;

    fn execute(&self, ctx: &Context, req: Req) -> Result<Resp, Self::Error> {
        (self.f)(ctx, req)
    }
}

// Async variants

#[cfg(feature = "async")]
use async_trait::async_trait;

/// Async version of [`Service`] for use with async runtimes.
///
/// This trait is only available with the `async` feature.
#[cfg(feature = "async")]
#[async_trait]
pub trait AsyncService<Req, Resp>: Send + Sync
where
    Req: Send,
    Resp: Send,
{
    /// The error type returned by this service.
    type Error: std::error::Error + Send;

    /// Executes the business logic asynchronously.
    ///
    /// # Errors
    ///
    /// Returns an error when business logic execution fails.
    async fn execute(&self, ctx: &Context, req: Req) -> Result<Resp, Self::Error>;
}

/// Blanket implementation allowing sync services to be used as async services.
#[cfg(feature = "async")]
#[async_trait]
impl<S, Req, Resp> AsyncService<Req, Resp> for S
where
    S: Service<Req, Resp> + Send + Sync,
    S::Error: Send,
    Req: Send + 'static,
    Resp: Send,
{
    type Error = S::Error;

    async fn execute(&self, ctx: &Context, req: Req) -> Result<Resp, Self::Error> {
        Service::execute(self, ctx, req)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::CodedError;

    #[test]
    fn service_fn_basic() {
        let service =
            ServiceFn::new(|_ctx: &Context, req: i32| -> Result<i32, CodedError> { Ok(req * 2) });

        let result = Service::execute(&service, &Context::new(), 21);
        assert_eq!(result.ok(), Some(42));
    }

    #[test]
    fn service_fn_error() {
        let service = ServiceFn::new(|_ctx: &Context, req: i32| -> Result<i32, CodedError> {
            if req < 0 {
                Err(CodedError::new("NEGATIVE_VALUE"))
            } else {
                Ok(req)
            }
        });

        let result = Service::execute(&service, &Context::new(), -1);
        assert!(result.is_err());
    }

    #[test]
    fn service_fn_with_context() {
        let service = ServiceFn::new(|ctx: &Context, _req: ()| -> Result<bool, CodedError> {
            Ok(ctx.is_done())
        });

        let ctx = Context::new();
        let result = Service::execute(&service, &ctx, ());
        assert_eq!(result.ok(), Some(false));
    }
}