feat(presentation): handlers, OpenAPI/Scalar, routes, extractors
This commit is contained in:
19
crates/presentation/Cargo.toml
Normal file
19
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { path = "../api-types" }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-scalar = { workspace = true }
|
||||
25
crates/presentation/src/errors.rs
Normal file
25
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use domain::errors::DomainError;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct AppError(DomainError);
|
||||
|
||||
impl From<DomainError> for AppError {
|
||||
fn from(e: DomainError) -> Self { Self(e) }
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self.0 {
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
DomainError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
DomainError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
|
||||
DomainError::Internal(msg) => {
|
||||
tracing::error!("Internal error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
38
crates/presentation/src/extractors/auth.rs
Normal file
38
crates/presentation/src/extractors/auth.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use domain::value_objects::{Role, UserId};
|
||||
use serde_json::json;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct JwtClaims {
|
||||
pub user_id: UserId,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for JwtClaims {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Missing Authorization header" }))).into_response()
|
||||
})?;
|
||||
|
||||
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid Authorization format" }))).into_response()
|
||||
})?;
|
||||
|
||||
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid or expired token" }))).into_response()
|
||||
})?;
|
||||
|
||||
Ok(JwtClaims { user_id, role })
|
||||
}
|
||||
}
|
||||
28
crates/presentation/src/extractors/json.rs
Normal file
28
crates/presentation/src/extractors/json.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use axum::{
|
||||
extract::{rejection::JsonRejection, FromRequest, Request},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct ValidatedJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidatedJson<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
Json::<T>::from_request(req, state)
|
||||
.await
|
||||
.map(|Json(value)| ValidatedJson(value))
|
||||
.map_err(|rejection| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": rejection.body_text() }))).into_response()
|
||||
})
|
||||
}
|
||||
}
|
||||
5
crates/presentation/src/extractors/mod.rs
Normal file
5
crates/presentation/src/extractors/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod json;
|
||||
|
||||
pub use auth::JwtClaims;
|
||||
pub use json::ValidatedJson;
|
||||
56
crates/presentation/src/handlers/auth.rs
Normal file
56
crates/presentation/src/handlers/auth.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use api_types::{
|
||||
requests::{LoginRequest, RegisterRequest},
|
||||
responses::{AuthResponse, UserResponse},
|
||||
};
|
||||
use crate::{errors::AppError, extractors::{JwtClaims, ValidatedJson}, state::AppState};
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered", body = AuthResponse),
|
||||
(status = 409, description = "Email already taken"),
|
||||
(status = 422, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ValidatedJson(req): ValidatedJson<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
|
||||
let user = state.register_uc.execute(&req.email, &req.password).await?;
|
||||
let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?;
|
||||
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = AuthResponse),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ValidatedJson(req): ValidatedJson<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let (user, token) = state.login_uc.execute(&req.email, &req.password).await?;
|
||||
Ok(Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/auth/me",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Current user profile", body = UserResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<UserResponse>, AppError> {
|
||||
let user = state.get_profile_uc.execute(&claims.user_id).await?;
|
||||
Ok(Json(UserResponse::from_domain(&user)))
|
||||
}
|
||||
7
crates/presentation/src/handlers/health.rs
Normal file
7
crates/presentation/src/handlers/health.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde_json::json;
|
||||
|
||||
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service is healthy")))]
|
||||
pub async fn health() -> (StatusCode, Json<serde_json::Value>) {
|
||||
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||||
}
|
||||
2
crates/presentation/src/handlers/mod.rs
Normal file
2
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
6
crates/presentation/src/lib.rs
Normal file
6
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
41
crates/presentation/src/openapi/mod.rs
Normal file
41
crates/presentation/src/openapi/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use utoipa::{openapi::security::{Http, HttpAuthScheme, SecurityScheme}, Modify, OpenApi};
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
use axum::Router;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::health::health,
|
||||
crate::handlers::auth::register,
|
||||
crate::handlers::auth::login,
|
||||
crate::handlers::auth::me,
|
||||
),
|
||||
components(schemas(
|
||||
api_types::requests::RegisterRequest,
|
||||
api_types::requests::LoginRequest,
|
||||
api_types::responses::AuthResponse,
|
||||
api_types::responses::UserResponse,
|
||||
)),
|
||||
modifiers(&SecurityAddon),
|
||||
info(title = "k-template", version = "0.1.0")
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"bearer_token",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openapi_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.route("/api-docs/openapi.json", axum::routing::get(|| async { axum::Json(ApiDoc::openapi()) }))
|
||||
}
|
||||
16
crates/presentation/src/routes.rs
Normal file
16
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use crate::{handlers::{auth, health}, openapi::openapi_router, state::AppState};
|
||||
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/health", get(health::health))
|
||||
}
|
||||
|
||||
pub fn app_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/api/v1", api_v1_router())
|
||||
.merge(openapi_router())
|
||||
}
|
||||
22
crates/presentation/src/state.rs
Normal file
22
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
|
||||
use domain::ports::TokenIssuer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub register_uc: Arc<RegisterUser>,
|
||||
pub login_uc: Arc<LoginUser>,
|
||||
pub get_profile_uc: Arc<GetProfile>,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
register_uc: Arc<RegisterUser>,
|
||||
login_uc: Arc<LoginUser>,
|
||||
get_profile_uc: Arc<GetProfile>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
) -> Self {
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user