puerto 0.17.0

Puerto — Rust full-stack DDD framework CLI. Scaffolds Clean Architecture projects with auto-wired DI, SQLx support, and zero manual wiring. Formerly puerto-framework.
use std::sync::Arc;

use business::domain::{{ snake }}::model::{{ pascal }}Id;
use business::domain::{{ snake }}::use_cases::{
    create_{{ snake }}::{Create{{ pascal }}Params, Create{{ pascal }}UseCaseTrait},
    delete_{{ snake }}::{Delete{{ pascal }}Params, Delete{{ pascal }}UseCaseTrait},
    get_{{ snake }}::{Get{{ pascal }}Params, Get{{ pascal }}UseCaseTrait},
    list_{{ plural }}::{List{{ plural_pascal }}Params, List{{ plural_pascal }}UseCaseTrait},
    update_{{ snake }}::{Update{{ pascal }}Params, Update{{ pascal }}UseCaseTrait},
};
use poem_openapi::{
    OpenApi,
    param::{Path, Query},
    payload::Json,
};
use puerto_core::PageRequest;
use uuid::Uuid;

use crate::api::error::IntoErrorResponse;
use crate::api::tags::ApiTags;
use crate::api::{{ snake }}::dto::{
    Create{{ pascal }}Request, Update{{ pascal }}Request, {{ pascal }}Dto, {{ pascal }}PageDto,
};
use crate::api::{{ snake }}::responses::{
    Create{{ pascal }}Response, Delete{{ pascal }}Response, Get{{ pascal }}Response, List{{ plural_pascal }}Response,
    Update{{ pascal }}Response,
};

/// Holds the use case ports; `generated/bootstrap.rs` plugs in the implementations.
pub struct {{ pascal }}Api {
    pub create_{{ snake }}: Arc<dyn Create{{ pascal }}UseCaseTrait>,
    pub get_{{ snake }}: Arc<dyn Get{{ pascal }}UseCaseTrait>,
    pub list_{{ plural }}: Arc<dyn List{{ plural_pascal }}UseCaseTrait>,
    pub update_{{ snake }}: Arc<dyn Update{{ pascal }}UseCaseTrait>,
    pub delete_{{ snake }}: Arc<dyn Delete{{ pascal }}UseCaseTrait>,
}

#[OpenApi]
impl {{ pascal }}Api {
    /// Create a new {{ pascal }}
    #[oai(path = "/{{ plural }}", method = "post", tag = "ApiTags::{{ pascal }}")]
    async fn create(&self, body: Json<Create{{ pascal }}Request>) -> Create{{ pascal }}Response {
        match self
            .create_{{ snake }}
            .execute(Create{{ pascal }}Params {
{% for f in fields %}                {{ f.name }}: body.{{ f.name }}{% if f.needs_clone %}.clone(){% endif %},
{% endfor %}            })
            .await
        {
            Ok(entity) => Create{{ pascal }}Response::Created(Json({{ pascal }}Dto::from_domain(&entity))),
            Err(err) => {
                let (status, error) = err.into_error_response();
                tracing::warn!(code = %error.0.name, "create_{{ snake }} failed");
                Create{{ pascal }}Response::from_status(status, error)
            }
        }
    }

    /// Get a {{ pascal }} by ID
    #[oai(path = "/{{ plural }}/:id", method = "get", tag = "ApiTags::{{ pascal }}")]
    async fn get_by_id(&self, id: Path<Uuid>) -> Get{{ pascal }}Response {
        match self
            .get_{{ snake }}
            .execute(Get{{ pascal }}Params { id: {{ pascal }}Id::from_uuid(id.0) })
            .await
        {
            Ok(entity) => Get{{ pascal }}Response::Ok(Json({{ pascal }}Dto::from_domain(&entity))),
            Err(err) => {
                let (status, error) = err.into_error_response();
                tracing::warn!(code = %error.0.name, "get_{{ snake }} failed");
                Get{{ pascal }}Response::from_status(status, error)
            }
        }
    }

    /// List {{ plural_pascal }}, one page at a time (`limit` is capped by the domain)
    #[oai(path = "/{{ plural }}", method = "get", tag = "ApiTags::{{ pascal }}")]
    async fn list(&self, limit: Query<Option<u32>>, offset: Query<Option<u32>>) -> List{{ plural_pascal }}Response {
        match self
            .list_{{ plural }}
            .execute(List{{ plural_pascal }}Params {
                page: PageRequest::new(limit.0, offset.0),
            })
            .await
        {
            Ok(page) => List{{ plural_pascal }}Response::Ok(Json({{ pascal }}PageDto::from_domain(&page))),
            Err(err) => {
                let (status, error) = err.into_error_response();
                tracing::error!(code = %error.0.name, "list_{{ plural }} failed");
                List{{ plural_pascal }}Response::from_status(status, error)
            }
        }
    }

    /// Update a {{ pascal }}
    #[oai(path = "/{{ plural }}/:id", method = "put", tag = "ApiTags::{{ pascal }}")]
    async fn update(&self, id: Path<Uuid>, body: Json<Update{{ pascal }}Request>) -> Update{{ pascal }}Response {
        match self
            .update_{{ snake }}
            .execute(Update{{ pascal }}Params {
                id: {{ pascal }}Id::from_uuid(id.0),
{% for f in fields %}                {{ f.name }}: body.{{ f.name }}{% if f.needs_clone %}.clone(){% endif %},
{% endfor %}            })
            .await
        {
            Ok(entity) => Update{{ pascal }}Response::Ok(Json({{ pascal }}Dto::from_domain(&entity))),
            Err(err) => {
                let (status, error) = err.into_error_response();
                tracing::warn!(code = %error.0.name, "update_{{ snake }} failed");
                Update{{ pascal }}Response::from_status(status, error)
            }
        }
    }

    /// Delete a {{ pascal }}
    #[oai(path = "/{{ plural }}/:id", method = "delete", tag = "ApiTags::{{ pascal }}")]
    async fn delete(&self, id: Path<Uuid>) -> Delete{{ pascal }}Response {
        match self
            .delete_{{ snake }}
            .execute(Delete{{ pascal }}Params { id: {{ pascal }}Id::from_uuid(id.0) })
            .await
        {
            Ok(()) => Delete{{ pascal }}Response::NoContent,
            Err(err) => {
                let (status, error) = err.into_error_response();
                tracing::warn!(code = %error.0.name, "delete_{{ snake }} failed");
                Delete{{ pascal }}Response::from_status(status, error)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use business::domain::{{ snake }}::errors::{{ pascal }}Error;
    use business::domain::{{ snake }}::use_cases::{
        create_{{ snake }}::mocks::MockCreate{{ pascal }}UseCase, delete_{{ snake }}::mocks::MockDelete{{ pascal }}UseCase,
        get_{{ snake }}::mocks::MockGet{{ pascal }}UseCase, list_{{ plural }}::mocks::MockList{{ plural_pascal }}UseCase,
        update_{{ snake }}::mocks::MockUpdate{{ pascal }}UseCase,
    };
    use business::tests::mothers::{{ snake }}_mother::{{ pascal }}Mother;
    use poem::{Route, http::StatusCode, test::TestClient};
    use poem_openapi::{OpenApiService, types::ToJSON};
    use puerto_core::Page;{% for import in test_imports %}
    use {{ import }};{% endfor %}

    use super::*;

    /// One mock per use case. A test sets expectations only on the one its route should call: a
    /// route that reaches for another use case panics instead of passing.
    #[derive(Default)]
    struct Mocks {
        create: MockCreate{{ pascal }}UseCase,
        get: MockGet{{ pascal }}UseCase,
        list: MockList{{ plural_pascal }}UseCase,
        update: MockUpdate{{ pascal }}UseCase,
        delete: MockDelete{{ pascal }}UseCase,
    }

    impl Mocks {
        fn client(self) -> TestClient<Route> {
            let api = {{ pascal }}Api {
                create_{{ snake }}: Arc::new(self.create),
                get_{{ snake }}: Arc::new(self.get),
                list_{{ plural }}: Arc::new(self.list),
                update_{{ snake }}: Arc::new(self.update),
                delete_{{ snake }}: Arc::new(self.delete),
            };
            TestClient::new(Route::new().nest("/", OpenApiService::new(api, "test", "1.0")))
        }
    }

    fn create_request() -> Create{{ pascal }}Request {
        Create{{ pascal }}Request {
{% for f in fields %}            {{ f.name }}: {{ f.default_literal }},
{% endfor %}        }
    }

    fn update_request() -> Update{{ pascal }}Request {
        Update{{ pascal }}Request {
{% for f in fields %}            {{ f.name }}: {{ f.default_literal }},
{% endfor %}        }
    }

    fn item_path() -> String {
        format!("/{{ plural }}/{}", Uuid::new_v4())
    }

    #[tokio::test]
    async fn should_respond_201_when_{{ snake }}_is_created() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.create.expect_execute().returning(|_| Ok({{ pascal }}Mother::random()));

        // Act
        let response = mocks
            .client()
            .post("/{{ plural }}")
            .body_json(&create_request().to_json())
            .send()
            .await;

        // Assert
        response.assert_status(StatusCode::CREATED);
    }

    #[tokio::test]
    async fn should_respond_422_when_{{ snake }}_is_invalid() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks
            .create
            .expect_execute()
            .returning(|_| Err({{ pascal }}Error::ValidationError("invalid".to_string())));

        // Act
        let response = mocks
            .client()
            .post("/{{ plural }}")
            .body_json(&create_request().to_json())
            .send()
            .await;

        // Assert
        response.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn should_respond_200_when_{{ snake }}_is_found() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.get.expect_execute().returning(|_| Ok({{ pascal }}Mother::random()));

        // Act
        let response = mocks.client().get(item_path()).send().await;

        // Assert
        response.assert_status(StatusCode::OK);
    }

    #[tokio::test]
    async fn should_respond_404_when_{{ snake }}_does_not_exist() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.get.expect_execute().returning(|_| Err({{ pascal }}Error::NotFound));

        // Act
        let response = mocks.client().get(item_path()).send().await;

        // Assert
        response.assert_status(StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn should_respond_200_with_a_page_of_{{ plural }}_when_listing() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks
            .list
            .expect_execute()
            .returning(|params| Ok(Page::new({{ pascal }}Mother::random_vec(2), 7, params.page)));

        // Act
        let response = mocks.client().get("/{{ plural }}").send().await;

        // Assert
        response.assert_status(StatusCode::OK);
        let json = response.json().await;
        let body = json.value().object();
        body.get("items").array().assert_len(2);
        body.get("total").assert_i64(7);
    }

    #[tokio::test]
    async fn should_pass_limit_and_offset_to_the_use_case() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks
            .list
            .expect_execute()
            .withf(|params| params.page == PageRequest::new(Some(5), Some(10)))
            .returning(|params| Ok(Page::new(vec![], 0, params.page)));

        // Act
        let response = mocks.client().get("/{{ plural }}?limit=5&offset=10").send().await;

        // Assert
        response.assert_status(StatusCode::OK);
    }

    #[tokio::test]
    async fn should_respond_500_when_listing_fails_in_the_repository() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.list.expect_execute().returning(|_| Err({{ pascal }}Error::RepositoryError));

        // Act
        let response = mocks.client().get("/{{ plural }}").send().await;

        // Assert
        response.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
    }

    #[tokio::test]
    async fn should_respond_200_when_{{ snake }}_is_updated() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.update.expect_execute().returning(|_| Ok({{ pascal }}Mother::random()));

        // Act
        let response = mocks
            .client()
            .put(item_path())
            .body_json(&update_request().to_json())
            .send()
            .await;

        // Assert
        response.assert_status(StatusCode::OK);
    }

    #[tokio::test]
    async fn should_respond_404_when_updating_a_missing_{{ snake }}() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.update.expect_execute().returning(|_| Err({{ pascal }}Error::NotFound));

        // Act
        let response = mocks
            .client()
            .put(item_path())
            .body_json(&update_request().to_json())
            .send()
            .await;

        // Assert
        response.assert_status(StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn should_respond_204_when_{{ snake }}_is_deleted() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.delete.expect_execute().returning(|_| Ok(()));

        // Act
        let response = mocks.client().delete(item_path()).send().await;

        // Assert
        response.assert_status(StatusCode::NO_CONTENT);
    }

    #[tokio::test]
    async fn should_respond_404_when_deleting_a_missing_{{ snake }}() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.delete.expect_execute().returning(|_| Err({{ pascal }}Error::NotFound));

        // Act
        let response = mocks.client().delete(item_path()).send().await;

        // Assert
        response.assert_status(StatusCode::NOT_FOUND);
    }
}