lenso-capability-http-endpoint 0.2.5

Portable HTTP Endpoint Capability contract for Lenso backend Modules.
Documentation
use std::{cell::RefCell, rc::Rc};

use futures::executor::block_on;
use lenso_capability_http_endpoint::{
    DescribeRequest, EndpointHandleInvocationError, EndpointProvider, HandleRequest,
    HandleRequestHeadersItem, HandleResponse, HandleResponseHeadersItem, Json, MiddlewareOutcome,
    Path, Query, RequestId, endpoint,
};
use lenso_kernel::{CancellationToken, InvocationContext};
use serde::Deserialize;

#[derive(Clone, Debug, Default)]
struct OrdersHttp {
    handled: Rc<RefCell<Vec<&'static str>>>,
}

#[endpoint]
#[middleware(trace_all)]
impl OrdersHttp {
    async fn trace_all(
        &self,
        context: InvocationContext,
        request: HandleRequest,
    ) -> Result<MiddlewareOutcome, EndpointHandleInvocationError> {
        futures::future::ready(()).await;
        self.handled.borrow_mut().push("global");
        Ok(MiddlewareOutcome::next(context, request))
    }

    async fn observe(
        &self,
        context: InvocationContext,
        request: HandleRequest,
    ) -> Result<MiddlewareOutcome, EndpointHandleInvocationError> {
        futures::future::ready(()).await;
        self.handled.borrow_mut().push("middleware");
        Ok(MiddlewareOutcome::next(context, request))
    }

    #[post("orders.create", "/orders")]
    #[openapi({
        summary: "Create an order",
        tags: ["orders"],
        deprecated: false,
        "x-display-order": -1,
        "x-example": null,
        responses: {
            "201": { description: "Created" }
        }
    })]
    async fn create(
        &self,
        _context: InvocationContext,
        Json(order): Json<CreateOrder>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        futures::future::ready(()).await;
        self.handled.borrow_mut().push("create");
        Ok(json_response(201, &format!(r#"{{"id":"{}"}}"#, order.id)))
    }

    #[middleware(observe)]
    #[get("orders.read", "/orders/{order_id}")]
    async fn read(
        &self,
        _context: InvocationContext,
        Path(path): Path<OrderPath>,
        RequestId(request_id): RequestId,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        futures::future::ready(()).await;
        assert_eq!(request_id, "request-1");
        self.handled.borrow_mut().push("read");
        Ok(json_response(
            200,
            &format!(r#"{{"id":"{}"}}"#, path.order_id),
        ))
    }

    #[get("orders.list", "/orders")]
    async fn list(
        &self,
        Query(query): Query<ListOrders>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        futures::future::ready(()).await;
        Ok(json_response(
            200,
            &format!(r#"{{"limit":{}}}"#, query.limit),
        ))
    }
}

#[derive(Debug, Deserialize)]
struct CreateOrder {
    id: String,
}

#[derive(Debug, Deserialize)]
struct OrderPath {
    order_id: String,
}

#[derive(Debug, Deserialize)]
struct ListOrders {
    limit: u16,
}

#[test]
fn handler_attributes_generate_description_and_dispatch() {
    let endpoint = OrdersHttp::default();
    let description = block_on(endpoint.describe(context(1), DescribeRequest {}))
        .unwrap()
        .unwrap();
    assert_eq!(description.routes.len(), 3);
    assert_eq!(description.routes[0].route_id, "orders.create");
    assert_eq!(description.routes[0].method, "POST");
    assert_eq!(description.routes[0].path, "/orders");
    assert_eq!(
        description.routes[0].openapi.as_ref().unwrap()["summary"],
        "Create an order"
    );
    assert_eq!(
        description.routes[0].openapi.as_ref().unwrap()["tags"],
        serde_json::json!(["orders"])
    );
    assert_eq!(
        description.routes[0].openapi.as_ref().unwrap()["deprecated"],
        false
    );
    assert_eq!(
        description.routes[0].openapi.as_ref().unwrap()["x-display-order"],
        -1
    );
    assert!(description.routes[0].openapi.as_ref().unwrap()["x-example"].is_null());
    assert_eq!(description.routes[1].route_id, "orders.read");

    let response = block_on(endpoint.handle(
        context(2),
        request("orders.read").with_path_parameter("order_id", "order-42"),
    ))
    .unwrap()
    .unwrap();
    assert_eq!(response.status, 200);
    assert_eq!(response.body.as_slice(), br#"{"id":"order-42"}"#);
    assert_eq!(*endpoint.handled.borrow(), ["global", "middleware", "read"]);
}

#[test]
fn query_extractor_decodes_typed_values_and_rejects_invalid_input() {
    let endpoint = OrdersHttp::default();
    let mut accepted = request("orders.list");
    accepted.query = Some("limit=25".to_owned());
    let response = block_on(endpoint.handle(context(5), accepted))
        .unwrap()
        .unwrap();
    assert_eq!(response.body.as_slice(), br#"{"limit":25}"#);

    let mut rejected = request("orders.list");
    rejected.query = Some("limit=many".to_owned());
    let response = block_on(endpoint.handle(context(6), rejected))
        .unwrap()
        .unwrap();
    assert_eq!(response.status, 400);
}

#[test]
fn json_extractor_rejects_invalid_content_type_before_the_handler() {
    let endpoint = OrdersHttp::default();
    let rejected = block_on(endpoint.handle(context(3), request("orders.create")))
        .unwrap()
        .unwrap();
    assert_eq!(rejected.status, 415);
    assert_eq!(*endpoint.handled.borrow(), ["global"]);

    let mut accepted = request("orders.create");
    accepted.body = br#"{"id":"order-42"}"#.as_slice().into();
    accepted.headers.push(HandleRequestHeadersItem {
        name: "content-type".to_owned(),
        value: "application/json; charset=utf-8".to_owned(),
    });
    let response = block_on(endpoint.handle(context(4), accepted))
        .unwrap()
        .unwrap();
    assert_eq!(response.status, 201);
    assert_eq!(response.body.as_slice(), br#"{"id":"order-42"}"#);
    assert_eq!(*endpoint.handled.borrow(), ["global", "global", "create"]);
}

fn request(route_id: &str) -> HandleRequest {
    HandleRequest {
        body: Vec::new().into(),
        credential: None,
        headers: Vec::new(),
        method: "GET".to_owned(),
        path: "/orders/order-42".to_owned(),
        path_parameters: Vec::new(),
        query: None,
        request_id: "request-1".to_owned(),
        route_id: route_id.to_owned(),
    }
}

trait RequestExt {
    fn with_path_parameter(self, name: &str, value: &str) -> Self;
}

impl RequestExt for HandleRequest {
    fn with_path_parameter(mut self, name: &str, value: &str) -> Self {
        self.path_parameters.push(
            lenso_capability_http_endpoint::HandleRequestPathParametersItem {
                name: name.to_owned(),
                value: value.to_owned(),
            },
        );
        self
    }
}

fn context(invocation_id: u64) -> InvocationContext {
    InvocationContext::new(invocation_id, None, CancellationToken::new())
}

fn json_response(status: i64, body: &str) -> HandleResponse {
    HandleResponse {
        body: body.as_bytes().into(),
        headers: vec![HandleResponseHeadersItem {
            name: "content-type".to_owned(),
            value: "application/json".to_owned(),
        }],
        status,
    }
}