puerto 0.11.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 }}::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, payload::Json};
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};
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: 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 all {{ plural_pascal }}
    #[oai(path = "/{{ plural }}", method = "get", tag = "ApiTags::{{ pascal }}")]
    async fn list(&self) -> List{{ plural_pascal }}Response {
        match self.list_{{ plural }}.execute(List{{ plural_pascal }}Params).await {
            Ok(entities) => {
                List{{ plural_pascal }}Response::Ok(Json(entities.iter().map({{ pascal }}Dto::from_domain).collect()))
            }
            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: 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: 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};{% 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_every_{{ snake }}_when_listing() {
        // Arrange
        let mut mocks = Mocks::default();
        mocks.list.expect_execute().returning(|_| Ok({{ pascal }}Mother::random_vec(2)));

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

        // Assert
        response.assert_status(StatusCode::OK);
        response.json().await.value().array().assert_len(2);
    }

    #[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);
    }
}