feat: full clean architecture refactor — domain → application → infrastructure → presentation

- Restructure from Modular MVC to Clean Architecture with 4 strict layers
- Domain: entities (anime/komik/proxy), Repository traits, typed errors
- Application: use case classes with proper error propagation
- Infrastructure: repository impls, parsers, Redis cache, HTTP/scraping, browser
- Presentation: Axum handlers, DTOs, AppState, router, AppError+IntoResponse
- Migrate all parsers (otakudesu, alqanime, komik) to native infra implementations
- Replace once_cell::sync::Lazy/OnceCell with std::sync::LazyLock/OnceLock
- Remove 150+ old files in modules/ and shared/ directories
- Remove once_cell from Cargo.toml dependencies
- Fix test/debug binaries to use new import paths
- Zero new clippy warnings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-21 12:54:23 +07:00
co-authored by Claude Opus 4.8
parent 80c96eaa42
commit 776fa828d8
205 changed files with 4241 additions and 14569 deletions
+30
View File
@@ -0,0 +1,30 @@
//! Common API response types.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ApiResponse<T> {
pub success: bool,
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
message: None,
data: Some(data),
}
}
pub fn error(message: String) -> Self {
Self {
success: false,
message: Some(message),
data: None,
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Komik API response DTOs.
use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::anime::Pagination;
use crate::domain::entity::komik::{ChapterData, DetailData, KomikGenre, KomikItem};
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct GenresResponse {
pub status: String,
pub data: Vec<KomikGenre>,
}
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct GenreKomikResponse {
pub status: String,
pub genre: String,
pub data: Vec<KomikItem>,
pub pagination: Pagination,
}
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct DetailResponse {
pub status: bool,
pub data: DetailData,
}
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct ChapterResponse {
pub message: String,
pub data: ChapterData,
}
#[derive(Serialize, Debug, Clone, ToSchema)]
pub struct SearchKomikResponse {
pub status: String,
pub data: Vec<KomikItem>,
pub pagination: Pagination,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod common;
pub mod komik;
+134
View File
@@ -0,0 +1,134 @@
//! Application-level HTTP error handling.
//!
//! Maps domain errors and infrastructure errors into HTTP responses.
use axum::response::IntoResponse;
use thiserror::Error;
use crate::domain::error::{DomainError, RepositoryError, ScrapingError};
/// Top-level HTTP error returned by all API handlers.
#[derive(Error, Debug)]
pub enum AppError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Scraping error: {0}")]
ScraperError(String),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("Http error: {0}")]
HttpError(#[from] http::Error),
#[error("Url parse error: {0}")]
UrlParseError(#[from] url::ParseError),
#[error("Redis error: {0}")]
RedisError(#[from] redis::RedisError),
#[error("Json error: {0}")]
SerdeJsonError(#[from] serde_json::Error),
#[error("Reqwest error: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
// ============================================================================
// From impls — convert domain/infra errors to AppError
// ============================================================================
impl From<DomainError> for AppError {
fn from(err: DomainError) -> Self {
match err {
DomainError::NotFound(msg) => AppError::NotFound(msg),
DomainError::Validation(msg) => AppError::BadRequest(msg),
DomainError::Repository(repo_err) => match repo_err {
RepositoryError::NotFound => AppError::NotFound("Resource not found".into()),
RepositoryError::Conflict(msg) => {
AppError::BadRequest(format!("Conflict: {}", msg))
}
RepositoryError::Database(msg) => AppError::DatabaseError(msg),
RepositoryError::Network(msg) => AppError::ScraperError(msg),
},
DomainError::Scraping(scrape_err) => match scrape_err {
ScrapingError::Http(msg) => AppError::ScraperError(msg),
ScrapingError::Parse(msg) => AppError::BadRequest(format!("Parse error: {}", msg)),
ScrapingError::EmptyResponse => {
AppError::NotFound("Empty response from source".into())
}
},
}
}
}
impl From<String> for AppError {
fn from(s: String) -> Self {
AppError::Internal(s)
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<deadpool_redis::PoolError> for AppError {
fn from(err: deadpool_redis::PoolError) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<tokio::task::JoinError> for AppError {
fn from(err: tokio::task::JoinError) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<&str> for AppError {
fn from(s: &str) -> Self {
AppError::Internal(s.to_string())
}
}
// ============================================================================
// IntoResponse — render AppError as HTTP response
// ============================================================================
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
use crate::presentation::dto::common::ApiResponse;
use http::StatusCode;
let (status, error_message) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::ScraperError(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
AppError::DatabaseError(_) => {
tracing::error!(%self, "Database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".into(),
)
}
AppError::Internal(_) => {
tracing::error!(%self, "Internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".into(),
)
}
_ => {
tracing::error!(%self, "Unhandled error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".into(),
)
}
};
let body = axum::Json(ApiResponse::<()>::error(error_message));
(status, body).into_response()
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Anime (Otakudesu) API handlers.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use tracing::info;
use utoipa::ToSchema;
use crate::application::anime::use_cases::AnimeUseCases;
use crate::domain::entity::anime::*;
use crate::infrastructure::repository::OtakudesuRepository;
use crate::presentation::error::AppError;
use crate::presentation::state::AppState;
// ============================================================================
// Response DTOs
// ============================================================================
#[derive(Serialize, ToSchema)]
pub struct GenresResponse {
pub status: String,
pub data: Vec<Genre>,
}
#[derive(Serialize, ToSchema)]
pub struct DetailResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub data: AnimeDetailData,
}
#[derive(Serialize, ToSchema)]
pub struct ListResponse {
pub message: String,
pub data: Vec<CompleteAnimeListItem>,
pub total: Option<i64>,
pub pagination: Option<Pagination>,
}
#[derive(Serialize, ToSchema)]
pub struct FullResponse {
pub status: String,
pub data: AnimeFullData,
}
#[derive(Serialize, ToSchema)]
pub struct OngoingAnimeResponse {
pub status: String,
pub data: Vec<OngoingAnimeListItem>,
pub pagination: Pagination,
}
#[derive(Serialize, ToSchema)]
pub struct LatestAnimeResponse {
pub status: String,
pub data: Vec<LatestAnimeItem>,
pub pagination: Pagination,
}
#[derive(Serialize, ToSchema)]
pub struct SearchResponse {
pub status: String,
pub data: Vec<SearchAnimeItem>,
pub pagination: Pagination,
}
#[derive(Serialize, ToSchema)]
pub struct GenreListResponse {
pub status: String,
pub data: Vec<GenreAnimeItem>,
pub pagination: Pagination,
}
// ============================================================================
// Helper
// ============================================================================
fn make_use_cases(state: &Arc<AppState>) -> AnimeUseCases {
AnimeUseCases::new(
OtakudesuRepository::new(),
state.redis_pool.clone(),
state.db.clone(),
Some(state.image_processing_semaphore.clone()),
)
}
// ============================================================================
// Handlers
// ============================================================================
#[utoipa::path(
get,
path = "/api/anime",
tag = "anime",
responses(
(status = 200, description = "Anime index", body = AnimeData),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn anime_index(
State(app_state): State<Arc<AppState>>,
) -> Result<Json<AnimeData>, AppError> {
info!("Handling request for anime index");
let data = make_use_cases(&app_state).get_anime_index().await?;
Ok(Json(data))
}
#[utoipa::path(
get,
path = "/api/anime/genre_list",
tag = "anime",
responses(
(status = 200, description = "Genre list"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genres(
State(app_state): State<Arc<AppState>>,
) -> Result<Json<GenresResponse>, AppError> {
info!("Handling request for anime genres");
let data = make_use_cases(&app_state).get_genres().await?;
Ok(Json(GenresResponse {
status: "Ok".to_string(),
data,
}))
}
#[utoipa::path(
get,
path = "/api/anime/detail/{slug}",
tag = "anime",
responses(
(status = 200, description = "Anime detail"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn detail_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<DetailResponse>, AppError> {
info!("Starting request for detail slug: {}", slug);
let data = make_use_cases(&app_state).get_anime_detail(slug).await?;
Ok(Json(DetailResponse {
status: Some("Ok".to_string()),
data,
}))
}
#[utoipa::path(
get,
path = "/api/anime/complete_anime/{slug}",
tag = "anime",
responses(
(status = 200, description = "Complete anime page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn complete_anime_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ListResponse>, AppError> {
info!("Starting request for complete_anime slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state)
.get_complete_anime_page(slug)
.await?;
let total = data.len() as i64;
Ok(Json(ListResponse {
message: "Success".to_string(),
data,
total: Some(total),
pagination: Some(pagination),
}))
}
#[utoipa::path(
get,
path = "/api/anime/full/{slug}",
tag = "anime",
responses(
(status = 200, description = "Full episode details"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn full_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<FullResponse>, AppError> {
info!("Starting request for full slug: {}", slug);
let data = make_use_cases(&app_state).get_anime_full(slug).await?;
Ok(Json(FullResponse {
status: "Ok".to_string(),
data,
}))
}
#[utoipa::path(
get,
path = "/api/anime/ongoing_anime/{slug}",
tag = "anime",
responses(
(status = 200, description = "Ongoing anime page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn ongoing_anime_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<OngoingAnimeResponse>, AppError> {
info!("Starting request for ongoing_anime slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state)
.get_ongoing_anime_page(slug)
.await?;
Ok(Json(OngoingAnimeResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
#[utoipa::path(
get,
path = "/api/anime/latest/{slug}",
tag = "anime",
responses(
(status = 200, description = "Latest anime page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn latest_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<LatestAnimeResponse>, AppError> {
info!("Starting request for latest slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state)
.get_latest_anime_page(slug)
.await?;
Ok(Json(LatestAnimeResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
#[utoipa::path(
get,
path = "/api/anime/search/{slug}",
tag = "anime",
responses(
(status = 200, description = "Search results"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug_index(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<SearchResponse>, AppError> {
info!("Starting request for search slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state)
.get_search_anime_page(slug, "1".to_string())
.await?;
Ok(Json(SearchResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
#[utoipa::path(
get,
path = "/api/anime/search/{slug}/{page}",
tag = "anime",
responses(
(status = 200, description = "Search results with page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, String)>,
) -> Result<Json<SearchResponse>, AppError> {
info!("Starting request for search slug: {} page: {}", slug, page);
let (data, pagination) = make_use_cases(&app_state)
.get_search_anime_page(slug, page)
.await?;
Ok(Json(SearchResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
#[utoipa::path(
get,
path = "/api/anime/genre/{slug}",
tag = "anime",
responses(
(status = 200, description = "Genre page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug_index(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreListResponse>, AppError> {
info!("Starting request for genre slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state)
.get_genre_anime_page(slug, "1".to_string())
.await?;
Ok(Json(GenreListResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
#[utoipa::path(
get,
path = "/api/anime/genre/{slug}/{page}",
tag = "anime",
responses(
(status = 200, description = "Genre page with page"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, String)>,
) -> Result<Json<GenreListResponse>, AppError> {
info!("Starting request for genre slug: {} page: {}", slug, page);
let (data, pagination) = make_use_cases(&app_state)
.get_genre_anime_page(slug, page)
.await?;
Ok(Json(GenreListResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
+294
View File
@@ -0,0 +1,294 @@
//! Anime2 (Alqanime) API handlers.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use tracing::info;
use utoipa::{IntoParams, ToSchema};
use crate::application::anime2::use_cases::Anime2UseCases;
use crate::application::anime2::use_cases::{
Anime2Response, DetailResponse, FilterResponse, GenresResponse,
};
use crate::domain::entity::anime::{
CompleteAnimeItem, GenreAnimeItem, LatestAnimeItem, OngoingAnimeItemWithScore, SearchAnimeItem,
};
use crate::infrastructure::repository::AlqanimeRepository;
use crate::presentation::dto::common::ApiResponse;
use crate::presentation::error::AppError;
use crate::presentation::state::AppState;
// ============================================================================
// Request DTOs
// ============================================================================
/// Filter query parameters for the anime2 filter endpoint.
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct FilterQuery {
pub page: Option<u32>,
pub genre: Option<String>,
pub status: Option<String>,
pub r#type: Option<String>,
pub order: Option<String>,
}
// ============================================================================
// Helper
// ============================================================================
fn make_use_cases(state: &Arc<AppState>) -> Anime2UseCases {
Anime2UseCases::new(
AlqanimeRepository::new(),
state.redis_pool.clone(),
state.db.clone(),
Some(state.image_processing_semaphore.clone()),
)
}
// ============================================================================
// Handlers
// ============================================================================
/// GET /api/anime2 — Anime2 index (ongoing + complete).
#[utoipa::path(
get,
path = "/api/anime2",
tag = "anime2",
operation_id = "anime2_index",
responses(
(status = 200, description = "Anime2 index", body = Anime2Response),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn index(
State(app_state): State<Arc<AppState>>,
) -> Result<Json<Anime2Response>, AppError> {
info!("Handling request for anime2 index");
let data = make_use_cases(&app_state).index().await?;
Ok(Json(data))
}
/// GET /api/anime2/genre_list — List all genres.
#[utoipa::path(
get,
path = "/api/anime2/genre_list",
tag = "anime2",
operation_id = "anime2_genre_list",
responses(
(status = 200, description = "Genre list", body = GenresResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_list(
State(app_state): State<Arc<AppState>>,
) -> Result<Json<GenresResponse>, AppError> {
info!("Handling request for anime2 genre list");
let data = make_use_cases(&app_state).genre_list().await?;
Ok(Json(data))
}
/// GET /api/anime2/filter?page=&genre=&status=&type=&order= — Filter anime.
#[utoipa::path(
get,
path = "/api/anime2/filter",
tag = "anime2",
operation_id = "anime2_filter",
params(FilterQuery),
responses(
(status = 200, description = "Filter results", body = FilterResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn filter(
State(app_state): State<Arc<AppState>>,
Query(params): Query<FilterQuery>,
) -> Result<Json<FilterResponse>, AppError> {
info!("Handling request for anime2 filter");
let page = params.page.unwrap_or(1);
let genre = params.genre.clone();
let status = params.status.clone();
let anime_type = params.r#type.clone();
let order = params.order.clone().unwrap_or_else(|| "update".to_string());
let data = make_use_cases(&app_state)
.filter(page, genre, status, anime_type, order)
.await?;
Ok(Json(data))
}
/// GET /api/anime2/detail/{slug} — Anime detail by slug.
#[utoipa::path(
get,
path = "/api/anime2/detail/{slug}",
tag = "anime2",
operation_id = "anime2_detail_slug",
responses(
(status = 200, description = "Anime detail", body = DetailResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn detail_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<DetailResponse>, AppError> {
info!("Handling request for anime2 detail slug: {}", slug);
let data = make_use_cases(&app_state).detail(slug).await?;
Ok(Json(data))
}
/// GET /api/anime2/genre/{slug} — First page of genre-filtered results.
#[utoipa::path(
get,
path = "/api/anime2/genre/{slug}",
tag = "anime2",
operation_id = "anime2_genre_slug_index",
responses(
(status = 200, description = "Genre page", body = ApiResponse<Vec<GenreAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug_index(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ApiResponse<Vec<GenreAnimeItem>>>, AppError> {
info!("Handling request for anime2 genre slug: {}", slug);
let data = make_use_cases(&app_state).genre_slug(slug, 1).await?;
Ok(Json(data))
}
/// GET /api/anime2/genre/{slug}/{page} — Paginated genre results.
#[utoipa::path(
get,
path = "/api/anime2/genre/{slug}/{page}",
tag = "anime2",
operation_id = "anime2_genre_slug_page",
responses(
(status = 200, description = "Genre page with page", body = ApiResponse<Vec<GenreAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, u32)>,
) -> Result<Json<ApiResponse<Vec<GenreAnimeItem>>>, AppError> {
info!(
"Handling request for anime2 genre slug: {} page: {}",
slug, page
);
let data = make_use_cases(&app_state).genre_slug(slug, page).await?;
Ok(Json(data))
}
/// GET /api/anime2/search/{slug} — Search anime (first page).
#[utoipa::path(
get,
path = "/api/anime2/search/{slug}",
tag = "anime2",
operation_id = "anime2_search_slug_index",
responses(
(status = 200, description = "Search results", body = ApiResponse<Vec<SearchAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug_index(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ApiResponse<Vec<SearchAnimeItem>>>, AppError> {
info!("Handling request for anime2 search slug: {}", slug);
let data = make_use_cases(&app_state).search(slug, 1).await?;
Ok(Json(data))
}
/// GET /api/anime2/search/{slug}/{page} — Paginated search results.
#[utoipa::path(
get,
path = "/api/anime2/search/{slug}/{page}",
tag = "anime2",
operation_id = "anime2_search_slug_page",
responses(
(status = 200, description = "Search results with page", body = ApiResponse<Vec<SearchAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, u32)>,
) -> Result<Json<ApiResponse<Vec<SearchAnimeItem>>>, AppError> {
info!(
"Handling request for anime2 search slug: {} page: {}",
slug, page
);
let data = make_use_cases(&app_state).search(slug, page).await?;
Ok(Json(data))
}
/// GET /api/anime2/latest/{slug} — Latest anime (slug is the page number).
#[utoipa::path(
get,
path = "/api/anime2/latest/{slug}",
tag = "anime2",
operation_id = "anime2_latest_slug",
responses(
(status = 200, description = "Latest anime page", body = ApiResponse<Vec<LatestAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn latest_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ApiResponse<Vec<LatestAnimeItem>>>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?;
info!("Handling request for anime2 latest page: {}", page);
let data = make_use_cases(&app_state).latest(page).await?;
Ok(Json(data))
}
/// GET /api/anime2/ongoing_anime/{slug} — Ongoing anime list (slug is the page number).
#[utoipa::path(
get,
path = "/api/anime2/ongoing_anime/{slug}",
tag = "anime2",
operation_id = "anime2_ongoing_anime_slug",
responses(
(status = 200, description = "Ongoing anime page", body = ApiResponse<Vec<OngoingAnimeItemWithScore>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn ongoing_anime_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ApiResponse<Vec<OngoingAnimeItemWithScore>>>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?;
info!("Handling request for anime2 ongoing page: {}", page);
let data = make_use_cases(&app_state).ongoing_anime(page).await?;
Ok(Json(data))
}
/// GET /api/anime2/complete_anime/{slug} — Complete anime list (slug is the page number).
#[utoipa::path(
get,
path = "/api/anime2/complete_anime/{slug}",
tag = "anime2",
operation_id = "anime2_complete_anime_slug",
responses(
(status = 200, description = "Complete anime page", body = ApiResponse<Vec<CompleteAnimeItem>>),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn complete_anime_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ApiResponse<Vec<CompleteAnimeItem>>>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?;
info!("Handling request for anime2 complete page: {}", page);
let data = make_use_cases(&app_state).complete_anime(page).await?;
Ok(Json(data))
}
+20
View File
@@ -0,0 +1,20 @@
//! Health check endpoint.
use axum::{http::StatusCode, Json};
use serde::Serialize;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
}
pub async fn health_check() -> (StatusCode, Json<HealthResponse>) {
(
StatusCode::OK,
Json(HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
}),
)
}
+338
View File
@@ -0,0 +1,338 @@
//! Komik API handlers.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use tracing::info;
use crate::application::komik::use_cases::KomikUseCases;
use crate::infrastructure::repository::KomikRepository;
use crate::presentation::dto::komik::{
ChapterResponse, DetailResponse, GenreKomikResponse, GenresResponse, SearchKomikResponse,
};
use crate::presentation::error::AppError;
use crate::presentation::state::AppState;
// ============================================================================
// Response DTOs
// ============================================================================
// Response types re-exported from application::komik::use_cases.
// ============================================================================
// Helper
// ============================================================================
fn make_use_cases(state: &Arc<AppState>) -> KomikUseCases {
KomikUseCases::new(
KomikRepository::new(),
state.redis_pool.clone(),
state.db.clone(),
Some(state.image_processing_semaphore.clone()),
)
}
// ============================================================================
// Handlers
// ============================================================================
/// GET /api/komik/genre_list — List all komik genres.
#[utoipa::path(
get,
path = "/api/komik/genre_list",
tag = "komik",
operation_id = "komik_genre_list",
responses(
(status = 200, description = "Genre list", body = GenresResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_list(
State(app_state): State<Arc<AppState>>,
) -> Result<Json<GenresResponse>, AppError> {
info!("Handling request for komik genre list");
let data = make_use_cases(&app_state).genre_list().await?;
Ok(Json(GenresResponse {
status: "Ok".to_string(),
data,
}))
}
/// GET /api/komik/chapter/{slug} — Read a chapter.
#[utoipa::path(
get,
path = "/api/komik/chapter/{slug}",
tag = "komik",
operation_id = "komik_chapter_slug",
responses(
(status = 200, description = "Chapter data", body = ChapterResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn chapter_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<ChapterResponse>, AppError> {
info!("Handling request for komik chapter slug: {}", slug);
let data = make_use_cases(&app_state).chapter_slug(slug).await?;
Ok(Json(ChapterResponse {
message: "Ok".to_string(),
data,
}))
}
/// GET /api/komik/detail/{slug} — Komik detail by slug.
#[utoipa::path(
get,
path = "/api/komik/detail/{slug}",
tag = "komik",
operation_id = "komik_detail_slug",
responses(
(status = 200, description = "Komik detail", body = DetailResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn detail_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<DetailResponse>, AppError> {
info!("Handling request for komik detail slug: {}", slug);
let data = make_use_cases(&app_state).detail_slug(slug).await?;
Ok(Json(DetailResponse { status: true, data }))
}
/// GET /api/komik/genre/{slug} — Genre-filtered komik list (first page).
#[utoipa::path(
get,
path = "/api/komik/genre/{slug}",
tag = "komik",
operation_id = "komik_genre_slug_index",
responses(
(status = 200, description = "Genre page", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreKomikResponse>, AppError> {
info!("Handling request for komik genre slug: {}", slug);
let slug_clone = slug.clone();
let (data, pagination) = make_use_cases(&app_state).genre_slug(slug).await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: slug_clone,
data,
pagination,
}))
}
/// GET /api/komik/genre/{slug}/{page} — Paginated genre results.
#[utoipa::path(
get,
path = "/api/komik/genre/{slug}/{page}",
tag = "komik",
operation_id = "komik_genre_slug_page",
responses(
(status = 200, description = "Genre page with page", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn genre_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, String)>,
) -> Result<Json<GenreKomikResponse>, AppError> {
let page_num = page
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!(
"Handling request for komik genre slug: {} page: {}",
slug, page_num
);
let (data, pagination) = make_use_cases(&app_state)
.genre_slug_page(slug.clone(), page_num)
.await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: slug,
data,
pagination,
}))
}
/// GET /api/komik/manga/{slug} — Manga list (slug is the page number).
#[utoipa::path(
get,
path = "/api/komik/manga/{slug}",
tag = "komik",
operation_id = "komik_manga_slug",
responses(
(status = 200, description = "Manga list", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn manga_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreKomikResponse>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!("Handling request for komik manga page: {}", page);
let (data, pagination) = make_use_cases(&app_state)
.manga_slug(page.to_string())
.await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: "manga".to_string(),
data,
pagination,
}))
}
/// GET /api/komik/manhua/{slug} — Manhua list (slug is the page number).
#[utoipa::path(
get,
path = "/api/komik/manhua/{slug}",
tag = "komik",
operation_id = "komik_manhua_slug",
responses(
(status = 200, description = "Manhua list", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn manhua_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreKomikResponse>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!("Handling request for komik manhua page: {}", page);
let (data, pagination) = make_use_cases(&app_state)
.manhua_slug(page.to_string())
.await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: "manhua".to_string(),
data,
pagination,
}))
}
/// GET /api/komik/manhwa/{slug} — Manhwa list (slug is the page number).
#[utoipa::path(
get,
path = "/api/komik/manhwa/{slug}",
tag = "komik",
operation_id = "komik_manhwa_slug",
responses(
(status = 200, description = "Manhwa list", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn manhwa_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreKomikResponse>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!("Handling request for komik manhwa page: {}", page);
let (data, pagination) = make_use_cases(&app_state)
.manhwa_slug(page.to_string())
.await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: "manhwa".to_string(),
data,
pagination,
}))
}
/// GET /api/komik/popular/{slug} — Popular komik list (slug is the page number).
#[utoipa::path(
get,
path = "/api/komik/popular/{slug}",
tag = "komik",
operation_id = "komik_popular_slug",
responses(
(status = 200, description = "Popular komik list", body = GenreKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn popular_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<GenreKomikResponse>, AppError> {
let page = slug
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!("Handling request for komik popular page: {}", page);
let (data, pagination) = make_use_cases(&app_state)
.popular_slug(page.to_string())
.await?;
Ok(Json(GenreKomikResponse {
status: "Ok".to_string(),
genre: "popular".to_string(),
data,
pagination,
}))
}
/// GET /api/komik/search/{slug} — Search komik (first page).
#[utoipa::path(
get,
path = "/api/komik/search/{slug}",
tag = "komik",
operation_id = "komik_search_slug_index",
responses(
(status = 200, description = "Search results", body = SearchKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug(
State(app_state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> Result<Json<SearchKomikResponse>, AppError> {
info!("Handling request for komik search slug: {}", slug);
let (data, pagination) = make_use_cases(&app_state).search_slug(slug).await?;
Ok(Json(SearchKomikResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
/// GET /api/komik/search/{slug}/{page} — Paginated search results.
#[utoipa::path(
get,
path = "/api/komik/search/{slug}/{page}",
tag = "komik",
operation_id = "komik_search_slug_page",
responses(
(status = 200, description = "Search results with page", body = SearchKomikResponse),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn search_slug_page(
State(app_state): State<Arc<AppState>>,
Path((slug, page)): Path<(String, String)>,
) -> Result<Json<SearchKomikResponse>, AppError> {
let page_num = page
.parse::<u32>()
.map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?;
info!(
"Handling request for komik search slug: {} page: {}",
slug, page_num
);
let (data, pagination) = make_use_cases(&app_state)
.search_slug_page(slug, page_num)
.await?;
Ok(Json(SearchKomikResponse {
status: "Ok".to_string(),
data,
pagination,
}))
}
+5
View File
@@ -0,0 +1,5 @@
pub mod anime;
pub mod anime2;
pub mod health;
pub mod komik;
pub mod proxy;
+131
View File
@@ -0,0 +1,131 @@
//! Proxy and image cache API handlers.
use std::sync::Arc;
use axum::extract::{Json, Query, State};
use axum::response::Response;
use serde::Deserialize;
use tracing::info;
use utoipa::{IntoParams, ToSchema};
use crate::application::proxy::use_cases::{
AuditImageCacheResult, ImageCacheResult, ProxyUseCases,
};
use crate::infrastructure::repository::image_cache_seaorm::SeaOrmImageCacheRepository;
use crate::infrastructure::repository::ProxyRepository;
use crate::presentation::error::AppError;
use crate::presentation::state::AppState;
// ============================================================================
// Request DTOs
// ============================================================================
/// Query parameters for proxy fetch (GET).
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
pub struct ProxyParams {
/// URL to fetch via proxy.
pub url: String,
}
/// Request body for image cache (POST).
#[derive(Debug, Deserialize, ToSchema)]
pub struct ImageCacheRequest {
/// Original image URL to cache.
pub url: String,
/// If true, returns original URL immediately and caches in background.
#[serde(default)]
pub lazy: bool,
}
/// Request body for auditing image cache (POST).
#[derive(Debug, Deserialize, ToSchema)]
pub struct AuditImageCacheRequest {
/// Original image URL to audit.
pub url: String,
}
// ============================================================================
// Helper
// ============================================================================
fn make_use_cases(state: &Arc<AppState>) -> ProxyUseCases {
let repo = Arc::new(SeaOrmImageCacheRepository::new(
state.db.clone(),
state.redis_pool.clone(),
));
ProxyUseCases::new(ProxyRepository::new(), repo)
}
// ============================================================================
// Handlers
// ============================================================================
/// GET /api/proxy/croxy — Fetch a URL through the proxy and return raw bytes.
#[utoipa::path(
get,
path = "/api/proxy/croxy",
tag = "proxy",
operation_id = "proxy_croxy",
params(ProxyParams),
responses(
(status = 200, description = "Proxied response", body = Vec::<u8>, content_type = "application/octet-stream"),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn fetch_with_proxy_only(
State(state): State<Arc<AppState>>,
Query(params): Query<ProxyParams>,
) -> Result<Response, AppError> {
info!("Handling proxy fetch for URL: {}", params.url);
let response = make_use_cases(&state)
.fetch_with_proxy_only(params.url)
.await?;
Ok(response)
}
/// POST /api/proxy/image-cache — Cache an image URL to CDN.
#[utoipa::path(
post,
path = "/api/proxy/image-cache",
tag = "proxy",
operation_id = "proxy_image_cache",
request_body = ImageCacheRequest,
responses(
(status = 200, description = "Image cache result", body = ImageCacheResult),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn image_cache(
State(state): State<Arc<AppState>>,
Json(req): Json<ImageCacheRequest>,
) -> Result<Json<ImageCacheResult>, AppError> {
info!(
"Handling image cache for URL: {} (lazy: {})",
req.url, req.lazy
);
let result = make_use_cases(&state)
.image_cache(req.url, req.lazy)
.await?;
Ok(Json(result))
}
/// POST /api/proxy/image-cache/audit — Audit and repair a cached image.
#[utoipa::path(
post,
path = "/api/proxy/image-cache/audit",
tag = "proxy",
operation_id = "proxy_image_cache_audit",
request_body = AuditImageCacheRequest,
responses(
(status = 200, description = "Audit result", body = AuditImageCacheResult),
(status = 500, description = "Internal Server Error"),
)
)]
pub async fn audit_image_cache(
State(state): State<Arc<AppState>>,
Json(req): Json<AuditImageCacheRequest>,
) -> Result<Json<AuditImageCacheResult>, AppError> {
info!("Handling audit cache for URL: {}", req.url);
let result = make_use_cases(&state).audit_image_cache(req.url).await?;
Ok(Json(result))
}
+13
View File
@@ -0,0 +1,13 @@
//! Logging middleware for request/response tracing.
use axum::{extract::Request, middleware::Next, response::Response};
use tracing::info;
pub async fn request_logging_middleware(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
info!("→ {} {}", method, uri);
let response = next.run(request).await;
info!("← {} {} → {}", method, uri, response.status());
response
}
+2
View File
@@ -0,0 +1,2 @@
pub mod logging;
pub mod ratelimit;
+62
View File
@@ -0,0 +1,62 @@
//! Rate limiting middleware.
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::presentation::dto::common::ApiResponse;
/// Simple in-memory rate limiter.
pub struct RateLimiter {
max_requests: u64,
window_secs: u64,
counter: AtomicU64,
window_start: Mutex<Instant>,
}
impl RateLimiter {
pub fn new(max_requests: u64, window_secs: u64) -> Arc<Self> {
Arc::new(Self {
max_requests,
window_secs,
counter: AtomicU64::new(0),
window_start: Mutex::new(Instant::now()),
})
}
pub fn check(&self) -> bool {
let Ok(mut window_guard) = self.window_start.lock() else {
return false;
};
let window = &mut *window_guard;
if window.elapsed().as_secs() >= self.window_secs {
*window = Instant::now();
self.counter.store(0, Ordering::SeqCst);
}
let count = self.counter.fetch_add(1, Ordering::SeqCst);
count < self.max_requests
}
}
pub async fn rate_limit_middleware(
state: axum::extract::State<Arc<RateLimiter>>,
request: Request,
next: Next,
) -> Response {
if state.check() {
next.run(request).await
} else {
(
StatusCode::TOO_MANY_REQUESTS,
Json(ApiResponse::<()>::error("Rate limit exceeded".to_string())),
)
.into_response()
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod dto;
pub mod error;
pub mod handler;
pub mod middleware;
pub mod router;
pub mod state;
+184
View File
@@ -0,0 +1,184 @@
//! Axum router assembly.
use std::sync::Arc;
use axum::Router;
use tower_http::compression::{CompressionLayer, CompressionLevel};
use tower_http::cors::CorsLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::observability::openapi::ApiDoc;
use crate::observability::openapi_modules::ModuleApiDoc;
use crate::presentation::state::AppState;
/// Build the main application router with all routes, middleware, and Swagger UI.
pub fn build_router(app_state: Arc<AppState>) -> anyhow::Result<Router> {
let mut openapi = ApiDoc::openapi();
openapi.merge(ModuleApiDoc::openapi());
let app = Router::new()
// Anime routes
.route(
"/api/anime",
axum::routing::get(crate::presentation::handler::anime::anime_index),
)
.route(
"/api/anime/genre_list",
axum::routing::get(crate::presentation::handler::anime::genres),
)
.route(
"/api/anime/detail/{slug}",
axum::routing::get(crate::presentation::handler::anime::detail_slug),
)
.route(
"/api/anime/complete_anime/{slug}",
axum::routing::get(crate::presentation::handler::anime::complete_anime_slug),
)
.route(
"/api/anime/full/{slug}",
axum::routing::get(crate::presentation::handler::anime::full_slug),
)
.route(
"/api/anime/ongoing_anime/{slug}",
axum::routing::get(crate::presentation::handler::anime::ongoing_anime_slug),
)
.route(
"/api/anime/latest/{slug}",
axum::routing::get(crate::presentation::handler::anime::latest_slug),
)
.route(
"/api/anime/search/{slug}",
axum::routing::get(crate::presentation::handler::anime::search_slug_index),
)
.route(
"/api/anime/search/{slug}/{page}",
axum::routing::get(crate::presentation::handler::anime::search_slug_page),
)
.route(
"/api/anime/genre/{slug}",
axum::routing::get(crate::presentation::handler::anime::genre_slug_index),
)
.route(
"/api/anime/genre/{slug}/{page}",
axum::routing::get(crate::presentation::handler::anime::genre_slug_page),
)
// Anime2 routes
.route(
"/api/anime2",
axum::routing::get(crate::presentation::handler::anime2::index),
)
.route(
"/api/anime2/complete_anime/{slug}",
axum::routing::get(crate::presentation::handler::anime2::complete_anime_slug),
)
.route(
"/api/anime2/detail/{slug}",
axum::routing::get(crate::presentation::handler::anime2::detail_slug),
)
.route(
"/api/anime2/filter",
axum::routing::get(crate::presentation::handler::anime2::filter),
)
.route(
"/api/anime2/genre_list",
axum::routing::get(crate::presentation::handler::anime2::genre_list),
)
.route(
"/api/anime2/genre/{slug}",
axum::routing::get(crate::presentation::handler::anime2::genre_slug_index),
)
.route(
"/api/anime2/genre/{slug}/{page}",
axum::routing::get(crate::presentation::handler::anime2::genre_slug_page),
)
.route(
"/api/anime2/latest/{slug}",
axum::routing::get(crate::presentation::handler::anime2::latest_slug),
)
.route(
"/api/anime2/ongoing_anime/{slug}",
axum::routing::get(crate::presentation::handler::anime2::ongoing_anime_slug),
)
.route(
"/api/anime2/search/{slug}",
axum::routing::get(crate::presentation::handler::anime2::search_slug_index),
)
.route(
"/api/anime2/search/{slug}/{page}",
axum::routing::get(crate::presentation::handler::anime2::search_slug_page),
)
// Komik routes
.route(
"/api/komik/genre_list",
axum::routing::get(crate::presentation::handler::komik::genre_list),
)
.route(
"/api/komik/chapter/{slug}",
axum::routing::get(crate::presentation::handler::komik::chapter_slug),
)
.route(
"/api/komik/detail/{slug}",
axum::routing::get(crate::presentation::handler::komik::detail_slug),
)
.route(
"/api/komik/genre/{slug}",
axum::routing::get(crate::presentation::handler::komik::genre_slug),
)
.route(
"/api/komik/genre/{slug}/{page}",
axum::routing::get(crate::presentation::handler::komik::genre_slug_page),
)
.route(
"/api/komik/manga/{slug}",
axum::routing::get(crate::presentation::handler::komik::manga_slug),
)
.route(
"/api/komik/manhua/{slug}",
axum::routing::get(crate::presentation::handler::komik::manhua_slug),
)
.route(
"/api/komik/manhwa/{slug}",
axum::routing::get(crate::presentation::handler::komik::manhwa_slug),
)
.route(
"/api/komik/popular/{slug}",
axum::routing::get(crate::presentation::handler::komik::popular_slug),
)
.route(
"/api/komik/search/{slug}",
axum::routing::get(crate::presentation::handler::komik::search_slug),
)
.route(
"/api/komik/search/{slug}/{page}",
axum::routing::get(crate::presentation::handler::komik::search_slug_page),
)
// Proxy routes
.route(
"/api/proxy/croxy",
axum::routing::get(crate::presentation::handler::proxy::fetch_with_proxy_only),
)
.route(
"/api/proxy/image-cache",
axum::routing::post(crate::presentation::handler::proxy::image_cache),
)
.route(
"/api/proxy/image-cache/audit",
axum::routing::post(crate::presentation::handler::proxy::audit_image_cache),
)
// Health
.route(
"/health",
axum::routing::get(crate::presentation::handler::health::health_check),
)
// Swagger UI
.merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", openapi))
.with_state(app_state)
.layer(axum::middleware::from_fn(
crate::observability::metrics::otel_metrics_middleware,
))
.layer(CompressionLayer::new().quality(CompressionLevel::Fastest))
.layer(CorsLayer::permissive());
Ok(app)
}
+28
View File
@@ -0,0 +1,28 @@
//! Application state shared across all handlers.
use std::sync::Arc;
use deadpool_redis::Pool;
use sea_orm::DatabaseConnection;
use crate::events::bus::EventBus;
use crate::infrastructure::repository::SeaOrmImageCacheRepository;
/// Shared application state injected into every handler via Axum State.
///
/// Contains the infrastructure dependencies that handlers and use cases
/// need to serve requests.
#[derive(Clone)]
pub struct AppState {
pub redis_pool: Pool,
pub db: Arc<DatabaseConnection>,
pub image_processing_semaphore: Arc<tokio::sync::Semaphore>,
pub event_bus: Arc<EventBus>,
pub image_cache_repo: Arc<SeaOrmImageCacheRepository>,
}
impl AppState {
pub fn sea_orm(&self) -> &DatabaseConnection {
&self.db
}
}