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:
co-authored by
Claude Opus 4.8
parent
80c96eaa42
commit
776fa828d8
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod common;
|
||||
pub mod komik;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod anime;
|
||||
pub mod anime2;
|
||||
pub mod health;
|
||||
pub mod komik;
|
||||
pub mod proxy;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod logging;
|
||||
pub mod ratelimit;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod middleware;
|
||||
pub mod router;
|
||||
pub mod state;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user