lxy 0.1.1

A convenient async http and RPC framework in Rust
Documentation
//! Error handling integration for gRPC.
//!
//! This module provides conversion from lxy errors to gRPC Status.

use tonic::Status;

use crate::error::{Error, Result};

/// Converts an lxy error into a gRPC Status.
///
/// This implementation allows lxy errors to be returned from gRPC service methods.
/// The error details (type and data) are encoded as JSON and stored in the
/// "error-details-bin" binary metadata field to support UTF-8 content reliably.
///
/// # Examples
///
/// ```
/// use lxy::{define_error, error::{ErrorCode, TypedError}};
/// use tonic::{Request, Response, Status};
///
/// define_error!(ErrorCode::InvalidInput, InvalidName);
///
/// struct HelloRequest { name: String }
/// struct HelloReply { message: String }
///
/// async fn greet(request: Request<HelloRequest>) -> Result<Response<HelloReply>, Status> {
///     let name = request.into_inner().name;
///     if name.is_empty() {
///         return Err(InvalidName::error("Name cannot be empty").into());
///     }
///     Ok(Response::new(HelloReply {
///         message: format!("Hello, {}", name),
///     }))
/// }
/// ```
impl From<Error> for Status {
  fn from(error: Error) -> Self {
    let code = error.code().to_grpc_code();
    let message = error.message().to_string();

    let mut status = Status::new(code, message);

    if let Ok(details_json) = serde_json::to_vec(&serde_json::json!({
        "type": error.error_type(),
        "data": error.data(),
    }))
      && let Ok(metadata_value) = details_json.try_into()
    {
      status
        .metadata_mut()
        .insert_bin("error-details-bin", metadata_value);
    }

    status
  }
}

/// A convenient `Result` type for gRPC service methods.
///
/// This type alias simplifies gRPC service method signatures by using
/// `Status` as the error type, which lxy errors can be converted into.
///
/// # Examples
///
/// ```
/// use lxy::{define_error, error::{ErrorCode, TypedError}};
/// use lxy::grpc::GrpcResult;
/// use tonic::{Request, Response};
///
/// define_error!(ErrorCode::InvalidInput, InvalidRequest);
///
/// struct HelloRequest { name: String }
/// struct HelloReply { message: String }
///
/// async fn say_hello(request: Request<HelloRequest>) -> GrpcResult<Response<HelloReply>> {
///     let name = request.into_inner().name;
///     if name.is_empty() {
///         return Err(InvalidRequest::error("Name is required").into());
///     }
///     Ok(Response::new(HelloReply {
///         message: format!("Hello, {}", name),
///     }))
/// }
/// ```
pub type GrpcResult<T> = Result<T, Status>;

#[cfg(test)]
mod tests {
  use super::*;
  use crate::{
    define_error,
    error::{ErrorCode, TypedError},
  };
  use tonic::{Code, Response, Status};

  define_error!(ErrorCode::InvalidInput, InvalidRequest);
  define_error!(ErrorCode::ResourceNotFound, NotFound);

  #[test]
  fn test_error_to_grpc_status() {
    let error = InvalidRequest::error("Invalid field: email");
    let status: Status = error.into();

    assert_eq!(status.code(), Code::InvalidArgument);
    assert_eq!(status.message(), "Invalid field: email");

    let metadata = status.metadata();
    assert!(metadata.get_bin("error-details-bin").is_some());
  }

  #[test]
  fn test_error_to_grpc_status_with_data() {
    let error = InvalidRequest::error_with_data(
      "Invalid fields",
      serde_json::json!({
          "fields": ["email", "password"]
      }),
    );
    let status: Status = error.into();

    assert_eq!(status.code(), Code::InvalidArgument);
    assert_eq!(status.message(), "Invalid fields");

    let metadata = status.metadata();
    let details_bytes = metadata.get_bin("error-details-bin").unwrap();
    let byte_data = details_bytes.to_bytes().unwrap();
    let details: serde_json::Value = serde_json::from_slice(&byte_data).unwrap();

    assert_eq!(details["type"], "InvalidRequest");
    assert!(details["data"]["fields"].is_array());
  }

  #[test]
  fn test_all_grpc_code_mappings() {
    let test_cases = vec![
      (ErrorCode::InvalidInput, Code::InvalidArgument),
      (ErrorCode::Unauthorized, Code::Unauthenticated),
      (ErrorCode::Forbidden, Code::PermissionDenied),
      (ErrorCode::ResourceNotFound, Code::NotFound),
      (ErrorCode::Conflict, Code::AlreadyExists),
      (ErrorCode::RateLimited, Code::ResourceExhausted),
      (ErrorCode::Internal, Code::Internal),
      (ErrorCode::Unavailable, Code::Unavailable),
      (ErrorCode::Timeout, Code::DeadlineExceeded),
    ];

    for (error_code, expected_grpc_code) in test_cases {
      let error = Error::new(error_code, "test_type", "Test message");
      let status: Status = error.into();
      assert_eq!(
        status.code(),
        expected_grpc_code,
        "Failed for code {:?}",
        error_code
      );
    }
  }

  #[tokio::test]
  async fn test_grpc_result_with_error() {
    async fn greet(name: String) -> GrpcResult<Response<String>> {
      if name.is_empty() {
        return Err(InvalidRequest::error("Name cannot be empty").into());
      }
      Ok(Response::new(format!("Hello, {}", name)))
    }

    let result = greet("".to_string()).await;
    assert!(result.is_err());

    let status = result.unwrap_err();
    assert_eq!(status.code(), Code::InvalidArgument);
    assert_eq!(status.message(), "Name cannot be empty");
  }

  #[tokio::test]
  async fn test_error_propagation() {
    async fn find_user(id: u32) -> Result<String> {
      if id == 0 {
        return Err(NotFound::error(format!("User {} not found", id)));
      }
      Ok(format!("User {}", id))
    }

    async fn get_user(id: u32) -> GrpcResult<Response<String>> {
      let user = find_user(id).await?;
      Ok(Response::new(user))
    }

    let result = get_user(0).await;
    assert!(result.is_err());

    let status = result.unwrap_err();
    assert_eq!(status.code(), Code::NotFound);
    assert_eq!(status.message(), "User 0 not found");
  }

  #[test]
  fn test_error_metadata_contains_type() {
    let error = NotFound::error("Resource not found");
    let status: Status = error.into();

    let metadata = status.metadata();
    let details_bytes = metadata.get_bin("error-details-bin").unwrap();
    let byte_data = details_bytes.to_bytes().unwrap();
    let details_json: serde_json::Value = serde_json::from_slice(&byte_data).unwrap();

    assert_eq!(details_json["type"], "NotFound");
  }
}