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;
{% if auth %}use puerto_web::auth::BearerAuth;
{% endif %}{% if any_role %}use puerto_web::require_role;
{% endif %}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 }}")]
{% for role in access.create.roles %} #[require_role("{{ role }}")]
{% endfor %} async fn create(&self, {% if access.create.protected %}auth: BearerAuth, {% endif %}body: Json<Create{{ pascal }}Request>) -> Create{{ pascal }}Response {
match self
.create_{{ snake }}
.execute(Create{{ pascal }}Params {
{% if access.create.protected %} actor: auth.0,
{% endif %}{% 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 }}")]
{% for role in access.get.roles %} #[require_role("{{ role }}")]
{% endfor %} async fn get_by_id(&self, {% if access.get.protected %}auth: BearerAuth, {% endif %}id: Path<Uuid>) -> Get{{ pascal }}Response {
match self
.get_{{ snake }}
.execute(Get{{ pascal }}Params { {% if access.get.protected %}actor: auth.0, {% endif %}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 }}")]
{% for role in access.list.roles %} #[require_role("{{ role }}")]
{% endfor %} async fn list(&self, {% if access.list.protected %}auth: BearerAuth, {% endif %}limit: Query<Option<u32>>, offset: Query<Option<u32>>) -> List{{ plural_pascal }}Response {
match self
.list_{{ plural }}
.execute(List{{ plural_pascal }}Params {
{% if access.list.protected %} actor: auth.0,
{% endif %} 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 }}")]
{% for role in access.update.roles %} #[require_role("{{ role }}")]
{% endfor %} async fn update(&self, {% if access.update.protected %}auth: BearerAuth, {% endif %}id: Path<Uuid>, body: Json<Update{{ pascal }}Request>) -> Update{{ pascal }}Response {
match self
.update_{{ snake }}
.execute(Update{{ pascal }}Params {
{% if access.update.protected %} actor: auth.0,
{% endif %} 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 }}")]
{% for role in access.delete.roles %} #[require_role("{{ role }}")]
{% endfor %} async fn delete(&self, {% if access.delete.protected %}auth: BearerAuth, {% endif %}id: Path<Uuid>) -> Delete{{ pascal }}Response {
match self
.delete_{{ snake }}
.execute(Delete{{ pascal }}Params { {% if access.delete.protected %}actor: auth.0, {% endif %}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;{% if auth %}
use business::tests::mothers::principal_mother::PrincipalMother;
use poem::{EndpointExt, Route, http::StatusCode, test::TestClient};
use puerto_core::auth::mocks::MockAuthenticator;
use puerto_core::auth::{AuthError, Authenticator};{% else %}
use poem::{Route, http::StatusCode, test::TestClient};{% endif %}
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,
}
{% if auth %} /// Accepts `caller` (every role a route of this API requires) and `nobody` (no role).
fn authenticator() -> Arc<dyn Authenticator> {
let mut authenticator = MockAuthenticator::new();
authenticator.expect_authenticate().returning(|token| match token {
"caller" => Ok({% if caller_roles %}PrincipalMother::new()
.with_roles([{% for role in caller_roles %}"{{ role }}"{% if not loop.last %}, {% endif %}{% endfor %}])
.build(){% else %}PrincipalMother::random(){% endif %}),
"nobody" => Ok(PrincipalMother::random()),
_ => Err(AuthError::Invalid),
});
Arc::new(authenticator)
}
{% endif %} impl Mocks {
{% if auth %} /// Every request signed in as `caller`.
fn client(self) -> TestClient<impl poem::Endpoint> {
self.client_with(Some("Bearer caller"))
}
/// `authorization`: the header every request sends; `None` sends none.
fn client_with(self, authorization: Option<&str>) -> TestClient<impl poem::Endpoint> {
{% else %} fn client(self) -> TestClient<Route> {
{% endif %} 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),
};
{% if auth %} let client = TestClient::new(
Route::new()
.nest("/", OpenApiService::new(api, "test", "1.0"))
.data(authenticator()),
);
match authorization {
Some(value) => client.default_header("Authorization", value),
None => client,
}
{% else %} TestClient::new(Route::new().nest("/", OpenApiService::new(api, "test", "1.0")))
{% endif %} }
}
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);
}
{% if access.create.protected %}
#[tokio::test]
async fn should_respond_401_when_creating_without_a_token() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(None)
.post("/{{ plural }}")
.body_json(&create_request().to_json())
.send()
.await;
// Assert
response.assert_status(StatusCode::UNAUTHORIZED);
}
{% endif %}{% if access.create.roles %}
#[tokio::test]
async fn should_respond_403_when_creating_without_the_role() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(Some("Bearer nobody"))
.post("/{{ plural }}")
.body_json(&create_request().to_json())
.send()
.await;
// Assert
response.assert_status(StatusCode::FORBIDDEN);
response
.json()
.await
.value()
.object()
.get("name")
.assert_string("auth.forbidden");
}
{% endif %}{% if access.get.protected %}
#[tokio::test]
async fn should_respond_401_when_getting_without_a_token() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(None)
.get(item_path())
.send()
.await;
// Assert
response.assert_status(StatusCode::UNAUTHORIZED);
}
{% endif %}{% if access.get.roles %}
#[tokio::test]
async fn should_respond_403_when_getting_without_the_role() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(Some("Bearer nobody"))
.get(item_path())
.send()
.await;
// Assert
response.assert_status(StatusCode::FORBIDDEN);
response
.json()
.await
.value()
.object()
.get("name")
.assert_string("auth.forbidden");
}
{% endif %}{% if access.list.protected %}
#[tokio::test]
async fn should_respond_401_when_listing_without_a_token() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(None)
.get("/{{ plural }}")
.send()
.await;
// Assert
response.assert_status(StatusCode::UNAUTHORIZED);
}
{% endif %}{% if access.list.roles %}
#[tokio::test]
async fn should_respond_403_when_listing_without_the_role() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(Some("Bearer nobody"))
.get("/{{ plural }}")
.send()
.await;
// Assert
response.assert_status(StatusCode::FORBIDDEN);
response
.json()
.await
.value()
.object()
.get("name")
.assert_string("auth.forbidden");
}
{% endif %}{% if access.update.protected %}
#[tokio::test]
async fn should_respond_401_when_updating_without_a_token() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(None)
.put(item_path())
.body_json(&update_request().to_json())
.send()
.await;
// Assert
response.assert_status(StatusCode::UNAUTHORIZED);
}
{% endif %}{% if access.update.roles %}
#[tokio::test]
async fn should_respond_403_when_updating_without_the_role() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(Some("Bearer nobody"))
.put(item_path())
.body_json(&update_request().to_json())
.send()
.await;
// Assert
response.assert_status(StatusCode::FORBIDDEN);
response
.json()
.await
.value()
.object()
.get("name")
.assert_string("auth.forbidden");
}
{% endif %}{% if access.delete.protected %}
#[tokio::test]
async fn should_respond_401_when_deleting_without_a_token() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(None)
.delete(item_path())
.send()
.await;
// Assert
response.assert_status(StatusCode::UNAUTHORIZED);
}
{% endif %}{% if access.delete.roles %}
#[tokio::test]
async fn should_respond_403_when_deleting_without_the_role() {
// Arrange — no expectation: the use case is never reached
let mocks = Mocks::default();
// Act
let response = mocks
.client_with(Some("Bearer nobody"))
.delete(item_path())
.send()
.await;
// Assert
response.assert_status(StatusCode::FORBIDDEN);
response
.json()
.await
.value()
.object()
.get("name")
.assert_string("auth.forbidden");
}
{% endif %}}