From 776fa828d8e961101f148b140bb348302fe31f40 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 21 Jul 2026 12:54:23 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20full=20clean=20architecture=20refactor?= =?UTF-8?q?=20=E2=80=94=20domain=20=E2=86=92=20application=20=E2=86=92=20i?= =?UTF-8?q?nfrastructure=20=E2=86=92=20presentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Cargo.lock | 1 - Cargo.toml | 1 - src/app.rs | 51 -- src/application/anime/mod.rs | 1 + src/application/anime/use_cases.rs | 244 +++++++ src/application/anime2/mod.rs | 1 + .../anime2/use_cases.rs} | 380 +++++++---- src/application/komik/mod.rs | 1 + .../komik/use_cases.rs} | 277 ++++---- src/application/mod.rs | 4 + src/application/proxy/mod.rs | 1 + src/application/proxy/use_cases.rs | 236 +++++++ src/bin/capture_warning.rs | 2 +- src/bin/foster_parenting_assertion.rs | 2 +- .../generators/controller.rs | 12 +- src/bootstrap/mod.rs | 29 +- src/{shared/database => bootstrap}/setup.rs | 5 +- src/{shared => }/config/mod.rs | 6 +- src/domain/entity/anime.rs | 404 +++++++++++ src/domain/entity/komik.rs | 64 ++ src/domain/entity/mod.rs | 2 + src/domain/error.rs | 55 ++ src/domain/mod.rs | 3 + .../repository}/image_cache.rs | 3 + src/domain/repository/mod.rs | 5 + src/domain/repository/scraping.rs | 12 + src/{shared => }/events/bus.rs | 0 src/{shared => }/events/mod.rs | 0 src/{shared => infrastructure}/browser/mod.rs | 0 .../browser/pool.rs | 8 +- src/infrastructure/cache/mod.rs | 4 + .../cache/redis.rs} | 26 +- .../cache/redis_pool.rs} | 25 +- src/infrastructure/mod.rs | 7 + .../persistence/entities/image_cache.rs | 0 .../persistence/entities/mod.rs | 0 .../persistence/mod.rs | 0 .../repository/alqanime.rs} | 29 +- .../repository/image_cache_seaorm.rs} | 10 +- .../repository/komik.rs} | 19 +- src/infrastructure/repository/mod.rs | 12 + .../repository/otakudesu.rs} | 147 ++-- .../repository/parsers/alqanime_parser.rs} | 282 ++++---- .../repository/parsers/komik_parser.rs} | 121 ++-- src/infrastructure/repository/parsers/mod.rs | 3 + .../repository/parsers/otakudesu_parser.rs | 621 +++++++++++++++++ src/infrastructure/repository/proxy.rs | 28 + src/infrastructure/scraping/html_fetcher.rs | 11 + src/infrastructure/scraping/mod.rs | 5 + .../scraping/parsing_utils.rs} | 26 +- .../scraping}/proxy_fetch.rs | 44 +- .../io => infrastructure/scraping}/retry.rs | 0 .../scraping}/scraping_urls.rs | 2 +- .../services/images/cache.rs | 24 +- .../services/images/mod.rs | 0 .../services/mod.rs | 0 src/infrastructure/utils/cache_ttl.rs | 2 + src/infrastructure/utils/http.rs | 17 + .../utils}/http_client.rs | 11 +- src/infrastructure/utils/mod.rs | 3 + src/lib.rs | 32 +- src/modules/anime/controller.rs | 236 ------- src/modules/anime/mod.rs | 7 - src/modules/anime/parser.rs | 632 ------------------ src/modules/anime/route.rs | 49 -- src/modules/anime/schema.rs | 18 - src/modules/anime/scraping/cache.rs | 81 --- src/modules/anime/service.rs | 295 -------- src/modules/anime/types.rs | 231 ------- src/modules/anime2/controller.rs | 276 -------- src/modules/anime2/mod.rs | 7 - src/modules/anime2/route.rs | 39 -- src/modules/anime2/schema.rs | 34 - src/modules/anime2/scraping.rs | 359 ---------- src/modules/anime2/types.rs | 106 --- src/modules/komik/controller.rs | 217 ------ src/modules/komik/mod.rs | 7 - src/modules/komik/route.rs | 27 - src/modules/komik/schema.rs | 18 - src/modules/komik/types.rs | 118 ---- src/modules/mod.rs | 17 - src/modules/proxy/controller.rs | 75 --- src/modules/proxy/mod.rs | 7 - src/modules/proxy/parser.rs | 1 - src/modules/proxy/repository.rs | 29 - src/modules/proxy/route.rs | 16 - src/modules/proxy/schema.rs | 27 - src/modules/proxy/service.rs | 232 ------- src/modules/proxy/types.rs | 24 - src/{shared => }/observability/metrics.rs | 15 +- src/{shared => }/observability/mod.rs | 0 src/{shared => }/observability/openapi.rs | 0 src/observability/openapi_modules.rs | 60 ++ src/{shared => }/observability/request_id.rs | 0 .../dto/common.rs} | 2 + src/presentation/dto/komik.rs | 40 ++ src/presentation/dto/mod.rs | 2 + src/presentation/error.rs | 134 ++++ src/presentation/handler/anime.rs | 340 ++++++++++ src/presentation/handler/anime2.rs | 294 ++++++++ src/presentation/handler/health.rs | 20 + src/presentation/handler/komik.rs | 338 ++++++++++ src/presentation/handler/mod.rs | 5 + src/presentation/handler/proxy.rs | 131 ++++ src/presentation/middleware/logging.rs | 13 + src/presentation/middleware/mod.rs | 2 + src/presentation/middleware/ratelimit.rs | 62 ++ src/presentation/mod.rs | 6 + src/presentation/router.rs | 184 +++++ src/presentation/state.rs | 28 + src/{shared => }/scheduler/cleanup_cache.rs | 6 +- src/{shared => }/scheduler/mod.rs | 0 src/{shared => }/scheduler/runner.rs | 0 src/shared/database/mod.rs | 7 - src/shared/database/repositories/mod.rs | 1 - src/shared/database/traits/mod.rs | 4 - .../database/traits/scraping_repository.rs | 7 - src/shared/errors/app_error.rs | 88 --- src/shared/errors/mod.rs | 2 - src/shared/graceful/cleanup.rs | 142 ---- src/shared/graceful/mod.rs | 2 - src/shared/graceful/shutdown.rs | 116 ---- src/shared/health/endpoints.rs | 138 ---- src/shared/health/mod.rs | 1 - src/shared/jobs/mod.rs | 2 - src/shared/jobs/queue.rs | 204 ------ src/shared/jobs/worker.rs | 180 ----- src/shared/middlewares/logging.rs | 289 -------- src/shared/middlewares/mod.rs | 2 - src/shared/middlewares/ratelimit.rs | 101 --- src/shared/mod.rs | 17 - src/shared/observability/openapi_modules.rs | 85 --- src/shared/routing/mod.rs | 3 - src/shared/routing/versioning.rs | 135 ---- src/shared/scrapers/mod.rs | 1 - src/shared/scrapers/otakudesu.rs | 115 ---- src/shared/state/mod.rs | 17 - src/shared/testing/app.rs | 226 ------- src/shared/testing/mod.rs | 1 - src/shared/types/entities/anime.rs | 208 ------ src/shared/types/entities/image.rs | 11 - src/shared/types/entities/mod.rs | 3 - src/shared/types/entities/types.rs | 56 -- src/shared/types/mod.rs | 4 - src/shared/utils/core/api_response.rs | 301 --------- src/shared/utils/core/errors.rs | 101 --- src/shared/utils/core/handler.rs | 99 --- src/shared/utils/core/mod.rs | 6 - src/shared/utils/core/pagination.rs | 105 --- src/shared/utils/core/prelude.rs | 33 - src/shared/utils/core/response.rs | 148 ---- src/shared/utils/data/collections.rs | 169 ----- src/shared/utils/data/convert/bools.rs | 84 --- src/shared/utils/data/convert/bytes.rs | 130 ---- src/shared/utils/data/convert/char.rs | 65 -- src/shared/utils/data/convert/collections.rs | 103 --- src/shared/utils/data/convert/color.rs | 70 -- src/shared/utils/data/convert/mod.rs | 62 -- src/shared/utils/data/convert/network.rs | 60 -- src/shared/utils/data/convert/numeric.rs | 532 --------------- src/shared/utils/data/convert/path.rs | 62 -- src/shared/utils/data/convert/pointers.rs | 58 -- src/shared/utils/data/convert/result.rs | 39 -- src/shared/utils/data/convert/string.rs | 134 ---- src/shared/utils/data/convert/time.rs | 142 ---- src/shared/utils/data/datetime.rs | 107 --- src/shared/utils/data/json.rs | 137 ---- src/shared/utils/data/mod.rs | 7 - src/shared/utils/data/numbers.rs | 151 ----- src/shared/utils/data/string.rs | 127 ---- src/shared/utils/data/text.rs | 163 ----- src/shared/utils/dev/async_utils.rs | 144 ---- src/shared/utils/dev/logging.rs | 146 ---- src/shared/utils/dev/mod.rs | 6 - src/shared/utils/dev/performance.rs | 88 --- src/shared/utils/dev/result_ext.rs | 169 ----- src/shared/utils/dev/serde_helpers.rs | 183 ----- src/shared/utils/dev/testing.rs | 205 ------ src/shared/utils/infra/bulk.rs | 166 ----- src/shared/utils/infra/console.rs | 274 -------- src/shared/utils/infra/encryption.rs | 214 ------ src/shared/utils/infra/env.rs | 117 ---- src/shared/utils/infra/form_request.rs | 302 --------- src/shared/utils/infra/health_check.rs | 256 ------- src/shared/utils/infra/import_export.rs | 223 ------ src/shared/utils/infra/mod.rs | 14 - src/shared/utils/infra/query_profiler.rs | 277 -------- src/shared/utils/infra/resource.rs | 198 ------ src/shared/utils/infra/ryzen_cdn.rs | 67 -- src/shared/utils/infra/searchable.rs | 190 ------ src/shared/utils/infra/transaction.rs | 179 ----- src/shared/utils/infra/uuid_utils.rs | 78 --- src/shared/utils/infra/versioning.rs | 191 ------ src/shared/utils/io/cache_tags.rs | 306 --------- src/shared/utils/io/cache_ttl.rs | 84 --- src/shared/utils/io/file.rs | 132 ---- src/shared/utils/io/mod.rs | 6 - src/shared/utils/io/soft_delete.rs | 86 --- src/shared/utils/mod.rs | 190 ------ src/shared/utils/web/http.rs | 26 - src/shared/utils/web/mod.rs | 8 - src/shared/utils/web/query.rs | 278 -------- src/shared/utils/web/request.rs | 141 ---- src/shared/utils/web/url.rs | 159 ----- src/shared/utils/web/validation.rs | 156 ----- 205 files changed, 4241 insertions(+), 14569 deletions(-) delete mode 100644 src/app.rs create mode 100644 src/application/anime/mod.rs create mode 100644 src/application/anime/use_cases.rs create mode 100644 src/application/anime2/mod.rs rename src/{modules/anime2/service.rs => application/anime2/use_cases.rs} (52%) create mode 100644 src/application/komik/mod.rs rename src/{modules/komik/service.rs => application/komik/use_cases.rs} (55%) create mode 100644 src/application/mod.rs create mode 100644 src/application/proxy/mod.rs create mode 100644 src/application/proxy/use_cases.rs rename src/{shared/database => bootstrap}/setup.rs (93%) rename src/{shared => }/config/mod.rs (98%) create mode 100644 src/domain/entity/anime.rs create mode 100644 src/domain/entity/komik.rs create mode 100644 src/domain/entity/mod.rs create mode 100644 src/domain/error.rs create mode 100644 src/domain/mod.rs rename src/{shared/database/traits => domain/repository}/image_cache.rs (88%) create mode 100644 src/domain/repository/mod.rs create mode 100644 src/domain/repository/scraping.rs rename src/{shared => }/events/bus.rs (100%) rename src/{shared => }/events/mod.rs (100%) rename src/{shared => infrastructure}/browser/mod.rs (100%) rename src/{shared => infrastructure}/browser/pool.rs (98%) create mode 100644 src/infrastructure/cache/mod.rs rename src/{shared/utils/io/cache.rs => infrastructure/cache/redis.rs} (83%) rename src/{shared/database/redis.rs => infrastructure/cache/redis_pool.rs} (76%) create mode 100644 src/infrastructure/mod.rs rename src/{shared/database => infrastructure}/persistence/entities/image_cache.rs (100%) rename src/{shared/database => infrastructure}/persistence/entities/mod.rs (100%) rename src/{shared/database => infrastructure}/persistence/mod.rs (100%) rename src/{modules/anime2/repository.rs => infrastructure/repository/alqanime.rs} (78%) rename src/{shared/database/repositories/image_cache.rs => infrastructure/repository/image_cache_seaorm.rs} (94%) rename src/{modules/komik/repository.rs => infrastructure/repository/komik.rs} (81%) create mode 100644 src/infrastructure/repository/mod.rs rename src/{modules/anime/repository.rs => infrastructure/repository/otakudesu.rs} (51%) rename src/{modules/anime2/parser.rs => infrastructure/repository/parsers/alqanime_parser.rs} (72%) rename src/{modules/komik/parser.rs => infrastructure/repository/parsers/komik_parser.rs} (80%) create mode 100644 src/infrastructure/repository/parsers/mod.rs create mode 100644 src/infrastructure/repository/parsers/otakudesu_parser.rs create mode 100644 src/infrastructure/repository/proxy.rs create mode 100644 src/infrastructure/scraping/html_fetcher.rs create mode 100644 src/infrastructure/scraping/mod.rs rename src/{shared/utils/web/scraping.rs => infrastructure/scraping/parsing_utils.rs} (86%) rename src/{shared/utils/web => infrastructure/scraping}/proxy_fetch.rs (90%) rename src/{shared/utils/io => infrastructure/scraping}/retry.rs (100%) rename src/{shared/utils/web => infrastructure/scraping}/scraping_urls.rs (96%) rename src/{shared => infrastructure}/services/images/cache.rs (98%) rename src/{shared => infrastructure}/services/images/mod.rs (100%) rename src/{shared => infrastructure}/services/mod.rs (100%) create mode 100644 src/infrastructure/utils/cache_ttl.rs create mode 100644 src/infrastructure/utils/http.rs rename src/{shared/utils/web => infrastructure/utils}/http_client.rs (92%) create mode 100644 src/infrastructure/utils/mod.rs delete mode 100644 src/modules/anime/controller.rs delete mode 100644 src/modules/anime/mod.rs delete mode 100644 src/modules/anime/parser.rs delete mode 100644 src/modules/anime/route.rs delete mode 100644 src/modules/anime/schema.rs delete mode 100644 src/modules/anime/scraping/cache.rs delete mode 100644 src/modules/anime/service.rs delete mode 100644 src/modules/anime/types.rs delete mode 100644 src/modules/anime2/controller.rs delete mode 100644 src/modules/anime2/mod.rs delete mode 100644 src/modules/anime2/route.rs delete mode 100644 src/modules/anime2/schema.rs delete mode 100644 src/modules/anime2/scraping.rs delete mode 100644 src/modules/anime2/types.rs delete mode 100644 src/modules/komik/controller.rs delete mode 100644 src/modules/komik/mod.rs delete mode 100644 src/modules/komik/route.rs delete mode 100644 src/modules/komik/schema.rs delete mode 100644 src/modules/komik/types.rs delete mode 100644 src/modules/mod.rs delete mode 100644 src/modules/proxy/controller.rs delete mode 100644 src/modules/proxy/mod.rs delete mode 100644 src/modules/proxy/parser.rs delete mode 100644 src/modules/proxy/repository.rs delete mode 100644 src/modules/proxy/route.rs delete mode 100644 src/modules/proxy/schema.rs delete mode 100644 src/modules/proxy/service.rs delete mode 100644 src/modules/proxy/types.rs rename src/{shared => }/observability/metrics.rs (92%) rename src/{shared => }/observability/mod.rs (100%) rename src/{shared => }/observability/openapi.rs (100%) create mode 100644 src/observability/openapi_modules.rs rename src/{shared => }/observability/request_id.rs (100%) rename src/{shared/types/api_response.rs => presentation/dto/common.rs} (95%) create mode 100644 src/presentation/dto/komik.rs create mode 100644 src/presentation/dto/mod.rs create mode 100644 src/presentation/error.rs create mode 100644 src/presentation/handler/anime.rs create mode 100644 src/presentation/handler/anime2.rs create mode 100644 src/presentation/handler/health.rs create mode 100644 src/presentation/handler/komik.rs create mode 100644 src/presentation/handler/mod.rs create mode 100644 src/presentation/handler/proxy.rs create mode 100644 src/presentation/middleware/logging.rs create mode 100644 src/presentation/middleware/mod.rs create mode 100644 src/presentation/middleware/ratelimit.rs create mode 100644 src/presentation/mod.rs create mode 100644 src/presentation/router.rs create mode 100644 src/presentation/state.rs rename src/{shared => }/scheduler/cleanup_cache.rs (97%) rename src/{shared => }/scheduler/mod.rs (100%) rename src/{shared => }/scheduler/runner.rs (100%) delete mode 100644 src/shared/database/mod.rs delete mode 100644 src/shared/database/repositories/mod.rs delete mode 100644 src/shared/database/traits/mod.rs delete mode 100644 src/shared/database/traits/scraping_repository.rs delete mode 100644 src/shared/errors/app_error.rs delete mode 100644 src/shared/errors/mod.rs delete mode 100644 src/shared/graceful/cleanup.rs delete mode 100644 src/shared/graceful/mod.rs delete mode 100644 src/shared/graceful/shutdown.rs delete mode 100644 src/shared/health/endpoints.rs delete mode 100644 src/shared/health/mod.rs delete mode 100644 src/shared/jobs/mod.rs delete mode 100644 src/shared/jobs/queue.rs delete mode 100644 src/shared/jobs/worker.rs delete mode 100644 src/shared/middlewares/logging.rs delete mode 100644 src/shared/middlewares/mod.rs delete mode 100644 src/shared/middlewares/ratelimit.rs delete mode 100644 src/shared/mod.rs delete mode 100644 src/shared/observability/openapi_modules.rs delete mode 100644 src/shared/routing/mod.rs delete mode 100644 src/shared/routing/versioning.rs delete mode 100644 src/shared/scrapers/mod.rs delete mode 100644 src/shared/scrapers/otakudesu.rs delete mode 100644 src/shared/state/mod.rs delete mode 100644 src/shared/testing/app.rs delete mode 100644 src/shared/testing/mod.rs delete mode 100644 src/shared/types/entities/anime.rs delete mode 100644 src/shared/types/entities/image.rs delete mode 100644 src/shared/types/entities/mod.rs delete mode 100644 src/shared/types/entities/types.rs delete mode 100644 src/shared/types/mod.rs delete mode 100644 src/shared/utils/core/api_response.rs delete mode 100644 src/shared/utils/core/errors.rs delete mode 100644 src/shared/utils/core/handler.rs delete mode 100644 src/shared/utils/core/mod.rs delete mode 100644 src/shared/utils/core/pagination.rs delete mode 100644 src/shared/utils/core/prelude.rs delete mode 100644 src/shared/utils/core/response.rs delete mode 100644 src/shared/utils/data/collections.rs delete mode 100644 src/shared/utils/data/convert/bools.rs delete mode 100644 src/shared/utils/data/convert/bytes.rs delete mode 100644 src/shared/utils/data/convert/char.rs delete mode 100644 src/shared/utils/data/convert/collections.rs delete mode 100644 src/shared/utils/data/convert/color.rs delete mode 100644 src/shared/utils/data/convert/mod.rs delete mode 100644 src/shared/utils/data/convert/network.rs delete mode 100644 src/shared/utils/data/convert/numeric.rs delete mode 100644 src/shared/utils/data/convert/path.rs delete mode 100644 src/shared/utils/data/convert/pointers.rs delete mode 100644 src/shared/utils/data/convert/result.rs delete mode 100644 src/shared/utils/data/convert/string.rs delete mode 100644 src/shared/utils/data/convert/time.rs delete mode 100644 src/shared/utils/data/datetime.rs delete mode 100644 src/shared/utils/data/json.rs delete mode 100644 src/shared/utils/data/mod.rs delete mode 100644 src/shared/utils/data/numbers.rs delete mode 100644 src/shared/utils/data/string.rs delete mode 100644 src/shared/utils/data/text.rs delete mode 100644 src/shared/utils/dev/async_utils.rs delete mode 100644 src/shared/utils/dev/logging.rs delete mode 100644 src/shared/utils/dev/mod.rs delete mode 100644 src/shared/utils/dev/performance.rs delete mode 100644 src/shared/utils/dev/result_ext.rs delete mode 100644 src/shared/utils/dev/serde_helpers.rs delete mode 100644 src/shared/utils/dev/testing.rs delete mode 100644 src/shared/utils/infra/bulk.rs delete mode 100644 src/shared/utils/infra/console.rs delete mode 100644 src/shared/utils/infra/encryption.rs delete mode 100644 src/shared/utils/infra/env.rs delete mode 100644 src/shared/utils/infra/form_request.rs delete mode 100644 src/shared/utils/infra/health_check.rs delete mode 100644 src/shared/utils/infra/import_export.rs delete mode 100644 src/shared/utils/infra/mod.rs delete mode 100644 src/shared/utils/infra/query_profiler.rs delete mode 100644 src/shared/utils/infra/resource.rs delete mode 100644 src/shared/utils/infra/ryzen_cdn.rs delete mode 100644 src/shared/utils/infra/searchable.rs delete mode 100644 src/shared/utils/infra/transaction.rs delete mode 100644 src/shared/utils/infra/uuid_utils.rs delete mode 100644 src/shared/utils/infra/versioning.rs delete mode 100644 src/shared/utils/io/cache_tags.rs delete mode 100644 src/shared/utils/io/cache_ttl.rs delete mode 100644 src/shared/utils/io/file.rs delete mode 100644 src/shared/utils/io/mod.rs delete mode 100644 src/shared/utils/io/soft_delete.rs delete mode 100644 src/shared/utils/mod.rs delete mode 100644 src/shared/utils/web/http.rs delete mode 100644 src/shared/utils/web/mod.rs delete mode 100644 src/shared/utils/web/query.rs delete mode 100644 src/shared/utils/web/request.rs delete mode 100644 src/shared/utils/web/url.rs delete mode 100644 src/shared/utils/web/validation.rs diff --git a/Cargo.lock b/Cargo.lock index c0729af..e44ea14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3347,7 +3347,6 @@ dependencies = [ "itertools", "log", "mime_guess", - "once_cell", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", diff --git a/Cargo.toml b/Cargo.toml index 9bf8eb1..0252f6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,6 @@ async-trait = "0.1.89" regex = "1.12.2" infer = "0.19.0" -once_cell = "1.21.3" urlencoding = "2.1" url = "2.5.8" diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 39fefa9..0000000 --- a/src/app.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::sync::Arc; - -use axum::Router; -use sea_orm::DatabaseConnection; -use tower_http::compression::{CompressionLayer, CompressionLevel}; -use tower_http::cors::CorsLayer; -use utoipa::OpenApi; -use utoipa_swagger_ui::SwaggerUi; - -use crate::shared::observability::openapi::ApiDoc; -use crate::shared::state::AppState; - -pub async fn build_router( - app_state: Arc, - db: Arc, -) -> anyhow::Result { - init_scheduler(db).await?; - - let mut openapi = ApiDoc::openapi(); - openapi.merge(crate::shared::observability::openapi_modules::ModuleApiDoc::openapi()); - - let app = crate::modules::routes(Router::new()) - .merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", openapi)) - .with_state(app_state) - .layer(axum::middleware::from_fn( - crate::shared::observability::metrics::otel_metrics_middleware, - )) - .layer(CompressionLayer::new().quality(CompressionLevel::Fastest)) - .layer(CorsLayer::permissive()); - - Ok(app) -} - -async fn init_scheduler(db: Arc) -> anyhow::Result<()> { - let scheduler = crate::shared::scheduler::Scheduler::new() - .await - .map_err(|e| anyhow::anyhow!("Failed to create scheduler: {}", e))?; - - let cache_cleanup = crate::shared::scheduler::CleanupOldCache::new(db); - scheduler - .add(cache_cleanup) - .await - .map_err(|e| anyhow::anyhow!("Failed to add cache cleanup: {}", e))?; - - scheduler - .start() - .await - .map_err(|e| anyhow::anyhow!("Failed to start scheduler: {}", e))?; - tracing::info!("✓ Scheduler started"); - Ok(()) -} diff --git a/src/application/anime/mod.rs b/src/application/anime/mod.rs new file mode 100644 index 0000000..d07542b --- /dev/null +++ b/src/application/anime/mod.rs @@ -0,0 +1 @@ +pub mod use_cases; diff --git a/src/application/anime/use_cases.rs b/src/application/anime/use_cases.rs new file mode 100644 index 0000000..5c9fa99 --- /dev/null +++ b/src/application/anime/use_cases.rs @@ -0,0 +1,244 @@ +//! Anime (Otakudesu) application use cases. +//! +//! Orchestrates repository fetching, caching, and image poster processing. +//! Returns pure domain types — no DTOs. + +use std::sync::Arc; + +use deadpool_redis::Pool; +use sea_orm::DatabaseConnection; + +use crate::domain::entity::anime::*; +use crate::domain::error::*; +use crate::infrastructure::cache::redis::Cache; +use crate::infrastructure::repository::OtakudesuRepository; +use crate::infrastructure::services::images::cache::{ + cache_image_urls_batch_lazy, get_cached_or_original, +}; + +const INDEX_CACHE_TTL: u64 = 10; +const GENRE_LIST_CACHE_TTL: u64 = 3600; +const DEFAULT_CACHE_TTL: u64 = 300; + +pub struct AnimeUseCases { + repository: OtakudesuRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, +} + +impl AnimeUseCases { + pub fn new( + repository: OtakudesuRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, + ) -> Self { + Self { + repository, + redis_pool, + db, + semaphore, + } + } + + fn cache(&self) -> Cache<'_> { + Cache::new(&self.redis_pool) + } + + pub async fn get_anime_index(&self) -> Result { + self.cache() + .get_or_set("anime:index:v2", INDEX_CACHE_TTL, || async { + let mut data = self + .repository + .fetch_anime_index() + .await + .map_err(|e| e.to_string())?; + + if data.ongoing_anime.is_empty() && data.complete_anime.is_empty() { + return Err("Empty anime index — refusing to cache".to_string()); + } + + let mut posters: Vec = data + .ongoing_anime + .iter() + .map(|item| item.poster.clone()) + .collect(); + posters.extend(data.complete_anime.iter().map(|item| item.poster.clone())); + + let cached_posters = cache_image_urls_batch_lazy( + self.db.clone(), + &self.redis_pool, + posters, + self.semaphore.clone(), + ) + .await; + + let ongoing_len = data.ongoing_anime.len(); + for (i, item) in data.ongoing_anime.iter_mut().enumerate() { + if let Some(url) = cached_posters.get(i) { + item.poster = url.clone(); + } + } + for (i, item) in data.complete_anime.iter_mut().enumerate() { + if let Some(url) = cached_posters.get(ongoing_len + i) { + item.poster = url.clone(); + } + } + + Ok(data) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_genres(&self) -> Result, DomainError> { + self.cache() + .get_or_set("anime:genres:list", GENRE_LIST_CACHE_TTL, || async { + self.repository + .fetch_genres() + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_anime_detail(&self, slug: String) -> Result { + let cache_key = format!("anime:detail:{}", slug); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + let mut data = self + .repository + .fetch_anime_detail(&slug) + .await + .map_err(|e| e.to_string())?; + + data.poster = get_cached_or_original( + self.db.clone(), + &self.redis_pool, + &data.poster, + self.semaphore.clone(), + ) + .await; + + let rec_posters: Vec = data + .recommendations + .iter() + .map(|r| r.poster.clone()) + .collect(); + let cached_rec_posters = cache_image_urls_batch_lazy( + self.db.clone(), + &self.redis_pool, + rec_posters, + self.semaphore.clone(), + ) + .await; + + for (i, rec) in data.recommendations.iter_mut().enumerate() { + if let Some(url) = cached_rec_posters.get(i) { + rec.poster = url.clone(); + } + } + + Ok(data) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_complete_anime_page( + &self, + slug: String, + ) -> Result<(Vec, Pagination), DomainError> { + let cache_key = format!("anime:complete:{}", slug); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_complete_anime_page(&slug) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_ongoing_anime_page( + &self, + slug: String, + ) -> Result<(Vec, Pagination), DomainError> { + let cache_key = format!("anime:ongoing:{}", slug); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_ongoing_anime_page(&slug) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_latest_anime_page( + &self, + slug: String, + ) -> Result<(Vec, Pagination), DomainError> { + let cache_key = format!("anime:latest:{}", slug); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_latest_anime_page(&slug) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_search_anime_page( + &self, + slug: String, + page: String, + ) -> Result<(Vec, Pagination), DomainError> { + let cache_key = format!("anime:search:{}:{}", slug, page); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_search_anime_page(&slug, &page) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_genre_anime_page( + &self, + genre_slug: String, + page: String, + ) -> Result<(Vec, Pagination), DomainError> { + let cache_key = format!("anime:genre:{}:{}", genre_slug, page); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_genre_anime_page(&genre_slug, &page) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } + + pub async fn get_anime_full(&self, slug: String) -> Result { + let cache_key = format!("anime:full:{}", slug); + self.cache() + .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { + self.repository + .fetch_anime_full(&slug) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) + } +} diff --git a/src/application/anime2/mod.rs b/src/application/anime2/mod.rs new file mode 100644 index 0000000..d07542b --- /dev/null +++ b/src/application/anime2/mod.rs @@ -0,0 +1 @@ +pub mod use_cases; diff --git a/src/modules/anime2/service.rs b/src/application/anime2/use_cases.rs similarity index 52% rename from src/modules/anime2/service.rs rename to src/application/anime2/use_cases.rs index fc7548a..83e5832 100644 --- a/src/modules/anime2/service.rs +++ b/src/application/anime2/use_cases.rs @@ -1,16 +1,110 @@ +//! Anime2 (Alqanime) application use cases. +//! +//! Orchestrates repository fetching, caching, and image poster processing. +//! +//! TODO: Move parsers from `crate::modules::anime2::parser` to +//! `crate::infrastructure::repository::parsers::alqanime_parser`. +//! TODO: Move response DTOs to `crate::presentation::dto::anime2`. +//! TODO: Once parsers return domain types, replace shared types with +//! `crate::domain::entity::anime::{GenreAnimeItem, SearchAnimeItem, LatestAnimeItem}`. + use std::sync::Arc; -use crate::modules::anime2::parser; -use crate::modules::anime2::repository::Anime2Repository; -use crate::modules::anime2::types::{DetailResponse, GenresResponse}; -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::services::images::cache::{ +use deadpool_redis::Pool; +use sea_orm::DatabaseConnection; + +use crate::domain::error::*; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::cache::redis::Cache; +use crate::infrastructure::repository::AlqanimeRepository; +use crate::infrastructure::services::images::cache::{ apply_cached_posters, cache_image_urls_batch_lazy, get_cached_or_original, }; -use crate::shared::state::AppState; -use crate::shared::types::ApiResponse; -use crate::shared::utils::Cache; + +use crate::infrastructure::repository::parsers::alqanime_parser as parser; + +use crate::domain::entity::anime::{ + CompleteAnimeItem, FilterAnimeItem, Genre, GenreAnimeItem, HasPoster, LatestAnimeItem, + OngoingAnimeItemWithScore, Pagination, SearchAnimeItem, +}; + +use crate::presentation::dto::common::ApiResponse; + +// Re-export types for handlers to use +pub use crate::domain::entity::anime::{ + CompleteAnimeItem as Anime2CompleteAnimeItem, GenreAnimeItem as Anime2GenreItem, + LatestAnimeItem as Anime2LatestItem, OngoingAnimeItemWithScore as Anime2OngoingItem, + SearchAnimeItem as Anime2SearchItem, +}; +pub use crate::infrastructure::repository::parsers::alqanime_parser::{ + AlqDetailData, AlqDownloadItem, AlqLink, AlqRecommendation, +}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// Response types (will move to presentation::dto::anime2) + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2Item { + pub title: String, + pub slug: String, + pub poster: String, + pub status: String, + pub r#type: String, + pub score: String, + pub anime_url: String, +} + +impl HasPoster for Anime2Item { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2Data { + pub ongoing_anime: Vec, + pub complete_anime: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2Response { + pub status: String, + pub data: Anime2Data, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct GenresResponse { + pub status: String, + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct FiltersApplied { + pub genre: Option, + pub status: Option, + pub r#type: Option, + pub order: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct FilterResponse { + pub success: bool, + pub data: Vec, + pub pagination: Pagination, + pub filters_applied: FiltersApplied, + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct DetailResponse { + pub status: String, + pub data: AlqDetailData, +} const INDEX_CACHE_TTL: u64 = 300; const GENRE_LIST_CACHE_TTL: u64 = 3600; @@ -22,22 +116,38 @@ const LATEST_CACHE_TTL: u64 = 120; const ONGOING_CACHE_TTL: u64 = 300; const COMPLETE_CACHE_TTL: u64 = 300; -pub struct Anime2Service { - repository: Anime2Repository, +// ============================================================================ +// Use case struct +// ============================================================================ + +pub struct Anime2UseCases { + repository: AlqanimeRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, } -impl Anime2Service { - pub fn new(repository: Anime2Repository) -> Self { - Self { repository } +impl Anime2UseCases { + pub fn new( + repository: AlqanimeRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, + ) -> Self { + Self { + repository, + redis_pool, + db, + semaphore, + } } - pub async fn index( - &self, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + fn cache(&self) -> Cache<'_> { + Cache::new(&self.redis_pool) + } - cache + pub async fn index(&self) -> Result { + self.cache() .get_or_set("anime2:index", INDEX_CACHE_TTL, || async { let ongoing_html = self .repository @@ -50,7 +160,7 @@ impl Anime2Service { .await .map_err(|e| e.to_string())?; - let mut data = tokio::task::spawn_blocking(move || { + let data = tokio::task::spawn_blocking(move || { Ok::<_, String>(( parser::parse_ongoing_anime(&ongoing_html).map_err(|e| e.to_string())?, parser::parse_complete_anime(&complete_html).map_err(|e| e.to_string())?, @@ -58,47 +168,72 @@ impl Anime2Service { }) .await .map_err(|e| e.to_string())??; + let mut ongoing: Vec = data + .0 + .into_iter() + .map(|item| Anime2Item { + title: item.title, + slug: item.slug, + poster: item.poster, + status: String::new(), + r#type: String::new(), + score: item.current_episode, + anime_url: item.anime_url, + }) + .collect(); + + let mut complete: Vec = data + .1 + .into_iter() + .map(|item| Anime2Item { + title: item.title, + slug: item.slug, + poster: item.poster, + status: String::new(), + r#type: String::new(), + score: item.episode_count, + anime_url: item.anime_url, + }) + .collect(); let mut posters: Vec = - data.0.iter().map(|item| item.poster.clone()).collect(); - posters.extend(data.1.iter().map(|item| item.poster.clone())); + ongoing.iter().map(|item| item.poster.clone()).collect(); + posters.extend(complete.iter().map(|item| item.poster.clone())); let cached_posters = cache_image_urls_batch_lazy( - app_state.db.clone(), - &app_state.redis_pool, + self.db.clone(), + &self.redis_pool, posters, - Some(app_state.image_processing_semaphore.clone()), + self.semaphore.clone(), ) .await; - let ongoing_len = data.0.len(); - for (i, item) in data.0.iter_mut().enumerate() { + let ongoing_len = ongoing.len(); + for (i, item) in ongoing.iter_mut().enumerate() { if let Some(url) = cached_posters.get(i) { item.poster = url.clone(); } } - for (i, item) in data.1.iter_mut().enumerate() { + for (i, item) in complete.iter_mut().enumerate() { if let Some(url) = cached_posters.get(ongoing_len + i) { item.poster = url.clone(); } } - Ok(crate::modules::anime2::types::Anime2Response { + Ok(Anime2Response { status: "Ok".to_string(), - data: crate::modules::anime2::types::Anime2Data { - ongoing_anime: data.0, - complete_anime: data.1, + data: Anime2Data { + ongoing_anime: ongoing, + complete_anime: complete, }, }) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } - pub async fn genre_list(&self, app_state: Arc) -> Result { - let cache = Cache::new(&app_state.redis_pool); - - cache + pub async fn genre_list(&self) -> Result { + self.cache() .get_or_set("anime2:genres:list:v3", GENRE_LIST_CACHE_TTL, || async { let html = self .repository @@ -118,19 +253,17 @@ impl Anime2Service { }) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn filter( &self, - app_state: Arc, page: u32, genre: Option, status: Option, anime_type: Option, order: String, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result { let cache_key = format!( "anime2:filter:{}:{:?}:{:?}:{:?}:{}", page, genre, status, anime_type, order @@ -139,7 +272,7 @@ impl Anime2Service { let status_clone = status.clone(); let anime_type_clone = anime_type.clone(); - cache + self.cache() .get_or_set(&cache_key, FILTER_CACHE_TTL, || async { let mut url = self.repository.filter_url(page, &order); @@ -160,26 +293,25 @@ impl Anime2Service { .fetch_html(&url) .await .map_err(|e| e.to_string())?; - let (data, pagination) = tokio::task::spawn_blocking(move || { + let (mut data, pagination) = tokio::task::spawn_blocking(move || { parser::parse_filter_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(crate::modules::anime2::types::FilterResponse { + Ok(FilterResponse { success: true, - data: final_data, + data, pagination, - filters_applied: crate::modules::anime2::types::FiltersApplied { + filters_applied: FiltersApplied { genre: genre_clone, status: status_clone, r#type: anime_type_clone, @@ -189,18 +321,13 @@ impl Anime2Service { }) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } - pub async fn detail( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + pub async fn detail(&self, slug: String) -> Result { let cache_key = format!("anime2:detail:{}", slug); - cache + self.cache() .get_or_set(&cache_key, DETAIL_CACHE_TTL, || async { let detail_url = self.repository.detail_url(&slug); let image_url = self.repository.detail_image_url(&slug); @@ -231,25 +358,26 @@ impl Anime2Service { .map_err(|e| e.to_string())??; data.poster = get_cached_or_original( - app_state.db.clone(), - &app_state.redis_pool, + self.db.clone(), + &self.redis_pool, &data.poster, - Some(app_state.image_processing_semaphore.clone()), + self.semaphore.clone(), ) .await; data.poster2 = get_cached_or_original( - app_state.db.clone(), - &app_state.redis_pool, + self.db.clone(), + &self.redis_pool, &data.poster2, - Some(app_state.image_processing_semaphore.clone()), + self.semaphore.clone(), ) .await; + // Recommendation in modules::anime2::types implements HasPoster apply_cached_posters( &mut data.recommendations, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; @@ -259,195 +387,173 @@ impl Anime2Service { }) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn genre_slug( &self, - app_state: Arc, genre_slug: String, page: u32, - ) -> Result>, AppError> - { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result>, DomainError> { let cache_key = format!("anime2:genre:{}:{}", genre_slug, page); - cache + self.cache() .get_or_set(&cache_key, GENRE_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.genre_page_url(&genre_slug, page)) .await .map_err(|e| e.to_string())?; - let (data, _pagination) = tokio::task::spawn_blocking(move || { + let (mut data, _pagination) = tokio::task::spawn_blocking(move || { parser::parse_genre_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(ApiResponse::success(final_data)) + Ok(ApiResponse::success(data)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn search( &self, - app_state: Arc, query: String, page: u32, - ) -> Result>, AppError> - { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result>, DomainError> { let cache_key = format!("anime2:search:{}:{}", query, page); - cache + self.cache() .get_or_set(&cache_key, SEARCH_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.search_url(&query, page)) .await .map_err(|e| e.to_string())?; - let (data, _pagination) = tokio::task::spawn_blocking(move || { + let (mut data, _pagination) = tokio::task::spawn_blocking(move || { parser::parse_search_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(ApiResponse::success(final_data)) + Ok(ApiResponse::success(data)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn latest( &self, - app_state: Arc, page: u32, - ) -> Result>, AppError> - { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result>, DomainError> { let cache_key = format!("anime2:latest:{}", page); - cache + self.cache() .get_or_set(&cache_key, LATEST_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.latest_url(page)) .await .map_err(|e| e.to_string())?; - let (data, _pagination) = tokio::task::spawn_blocking(move || { + let (mut data, _pagination) = tokio::task::spawn_blocking(move || { parser::parse_latest_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(ApiResponse::success(final_data)) + Ok(ApiResponse::success(data)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn ongoing_anime( &self, - app_state: Arc, page: u32, - ) -> Result< - ApiResponse>, - AppError, - > { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result>, DomainError> { let cache_key = format!("anime2:ongoing:{}", page); - cache + self.cache() .get_or_set(&cache_key, ONGOING_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.ongoing_url(page)) .await .map_err(|e| e.to_string())?; - let (data, _pagination) = tokio::task::spawn_blocking(move || { + let (mut data, _pagination) = tokio::task::spawn_blocking(move || { parser::parse_ongoing_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(ApiResponse::success(final_data)) + Ok(ApiResponse::success(data)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn complete_anime( &self, - app_state: Arc, page: u32, - ) -> Result>, AppError> - { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result>, DomainError> { let cache_key = format!("anime2:complete:{}", page); - cache + self.cache() .get_or_set(&cache_key, COMPLETE_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.complete_url(page)) .await .map_err(|e| e.to_string())?; - let (data, _pagination) = tokio::task::spawn_blocking(move || { + let (mut data, _pagination) = tokio::task::spawn_blocking(move || { parser::parse_complete_page(&html, page).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut final_data = data; apply_cached_posters( - &mut final_data, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + &mut data, + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(ApiResponse::success(final_data)) + Ok(ApiResponse::success(data)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } } diff --git a/src/application/komik/mod.rs b/src/application/komik/mod.rs new file mode 100644 index 0000000..d07542b --- /dev/null +++ b/src/application/komik/mod.rs @@ -0,0 +1 @@ +pub mod use_cases; diff --git a/src/modules/komik/service.rs b/src/application/komik/use_cases.rs similarity index 55% rename from src/modules/komik/service.rs rename to src/application/komik/use_cases.rs index e5f18a7..b6addb4 100644 --- a/src/modules/komik/service.rs +++ b/src/application/komik/use_cases.rs @@ -1,17 +1,27 @@ +//! Komik application use cases. +//! +//! Orchestrates repository fetching, caching, and image poster processing. +//! +//! TODO: Move parsers from `crate::modules::komik::parser` to +//! `crate::infrastructure::repository::parsers::komik_parser`. +//! TODO: Move response DTOs to `crate::presentation::dto::komik`. + use std::sync::Arc; -use crate::modules::komik::parser; -use crate::modules::komik::repository::KomikRepository; -use crate::modules::komik::types::{ - ChapterResponse, DetailResponse, GenreKomikResponse, GenresResponse, SearchKomikResponse, -}; -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::services::images::cache::{ +use deadpool_redis::Pool; +use sea_orm::DatabaseConnection; + +use crate::domain::entity::anime::Pagination; +use crate::domain::entity::komik::{ChapterData, DetailData, KomikGenre, KomikItem}; +use crate::domain::error::*; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::cache::redis::Cache; +use crate::infrastructure::repository::KomikRepository; +use crate::infrastructure::services::images::cache::{ apply_cached_posters, cache_image_urls_batch_lazy, get_cached_or_original, }; -use crate::shared::state::AppState; -use crate::shared::utils::Cache; + +use crate::infrastructure::repository::parsers::komik_parser as parser; const GENRE_LIST_CACHE_TTL: u64 = 3600; const GENRE_CACHE_TTL: u64 = 300; @@ -19,21 +29,39 @@ const DETAIL_CACHE_TTL: u64 = 300; const CHAPTER_CACHE_TTL: u64 = 300; const SEARCH_CACHE_TTL: u64 = 300; -pub struct KomikService { +// ============================================================================ +// Use case struct +// ============================================================================ + +pub struct KomikUseCases { repository: KomikRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, } -impl KomikService { - pub fn new(repository: KomikRepository) -> Self { - Self { repository } +impl KomikUseCases { + pub fn new( + repository: KomikRepository, + redis_pool: Pool, + db: Arc, + semaphore: Option>, + ) -> Self { + Self { + repository, + redis_pool, + db, + semaphore, + } } - pub async fn genre_list(&self, app_state: Arc) -> Result { - let cache = Cache::new(&app_state.redis_pool); - let cache_key = "komik:genres:list:v3"; + fn cache(&self) -> Cache<'_> { + Cache::new(&self.redis_pool) + } - cache - .get_or_set(cache_key, GENRE_LIST_CACHE_TTL, || async { + pub async fn genre_list(&self) -> Result, DomainError> { + self.cache() + .get_or_set("komik:genres:list:v3", GENRE_LIST_CACHE_TTL, || async { let html = self .repository .fetch_html(&self.repository.api_url()) @@ -44,25 +72,20 @@ impl KomikService { .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; - Ok(GenresResponse { - status: "Ok".to_string(), - data: genres, - }) + Ok(genres) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn genre_slug( &self, genre_slug: String, - app_state: Arc, - ) -> Result { - let page = 1; - let cache = Cache::new(&app_state.redis_pool); + ) -> Result<(Vec, Pagination), DomainError> { + let page = 1u32; let cache_key = format!("komik:genre:{}:{}:v2", genre_slug, page); - cache + self.cache() .get_or_set(&cache_key, GENRE_CACHE_TTL, || async { let url = self.repository.genre_url(&genre_slug, page); let html = self @@ -79,33 +102,26 @@ impl KomikService { apply_cached_posters( &mut komik_list, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(GenreKomikResponse { - status: "Ok".to_string(), - genre: genre_slug.clone(), - data: komik_list, - pagination, - }) + Ok((komik_list, pagination)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn genre_slug_page( &self, genre_slug: String, page: u32, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result<(Vec, Pagination), DomainError> { let cache_key = format!("komik:genre:{}:{}:v2", genre_slug, page); - cache + self.cache() .get_or_set(&cache_key, GENRE_CACHE_TTL, || async { let url = self.repository.genre_url(&genre_slug, page); let html = self @@ -122,32 +138,22 @@ impl KomikService { apply_cached_posters( &mut komik_list, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(GenreKomikResponse { - status: "Ok".to_string(), - genre: genre_slug.clone(), - data: komik_list, - pagination, - }) + Ok((komik_list, pagination)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } - pub async fn detail_slug( - &self, - komik_id: String, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + pub async fn detail_slug(&self, komik_id: String) -> Result { let cache_key = format!("komik:detail:{}", komik_id); - cache + self.cache() .get_or_set(&cache_key, DETAIL_CACHE_TTL, || async { let url = self.repository.detail_url(&komik_id); let html = self @@ -164,29 +170,24 @@ impl KomikService { if !data.poster.is_empty() { data.poster = get_cached_or_original( - app_state.db.clone(), - &app_state.redis_pool, + self.db.clone(), + &self.redis_pool, &data.poster, - Some(app_state.image_processing_semaphore.clone()), + self.semaphore.clone(), ) .await; } - Ok(DetailResponse { status: true, data }) + Ok(data) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } - pub async fn chapter_slug( - &self, - chapter_url: String, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + pub async fn chapter_slug(&self, chapter_url: String) -> Result { let cache_key = format!("komik:chapter:{}", chapter_url); - cache + self.cache() .get_or_set(&cache_key, CHAPTER_CACHE_TTL, || async { let url = self.repository.chapter_url(&chapter_url); let html = self @@ -204,88 +205,61 @@ impl KomikService { .map_err(|e| e.to_string())?; data.images = cache_image_urls_batch_lazy( - app_state.db.clone(), - &app_state.redis_pool, + self.db.clone(), + &self.redis_pool, data.images, - Some(app_state.image_processing_semaphore.clone()), + self.semaphore.clone(), ) .await; - Ok(ChapterResponse { - message: "Ok".to_string(), - data, - }) + Ok(data) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn manga_slug( &self, page_slug: String, - app_state: Arc, - ) -> Result { + ) -> Result<(Vec, Pagination), DomainError> { let page = page_slug .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - self.list_by_url( - "manga", - page, - self.repository.manga_list_url(page), - app_state, - ) - .await + .map_err(|_| DomainError::Validation("Invalid page number".to_string()))?; + self.list_by_url("manga", page, self.repository.manga_list_url(page)) + .await } pub async fn manhua_slug( &self, page_slug: String, - app_state: Arc, - ) -> Result { + ) -> Result<(Vec, Pagination), DomainError> { let page = page_slug .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - self.list_by_url( - "manhua", - page, - self.repository.manhua_list_url(page), - app_state, - ) - .await + .map_err(|_| DomainError::Validation("Invalid page number".to_string()))?; + self.list_by_url("manhua", page, self.repository.manhua_list_url(page)) + .await } pub async fn manhwa_slug( &self, page_slug: String, - app_state: Arc, - ) -> Result { + ) -> Result<(Vec, Pagination), DomainError> { let page = page_slug .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - self.list_by_url( - "manhwa", - page, - self.repository.manhwa_list_url(page), - app_state, - ) - .await + .map_err(|_| DomainError::Validation("Invalid page number".to_string()))?; + self.list_by_url("manhwa", page, self.repository.manhwa_list_url(page)) + .await } pub async fn popular_slug( &self, page_slug: String, - app_state: Arc, - ) -> Result { + ) -> Result<(Vec, Pagination), DomainError> { let page = page_slug .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - self.list_by_url( - "popular", - page, - self.repository.popular_list_url(page), - app_state, - ) - .await + .map_err(|_| DomainError::Validation("Invalid page number".to_string()))?; + self.list_by_url("popular", page, self.repository.popular_list_url(page)) + .await } async fn list_by_url( @@ -293,12 +267,10 @@ impl KomikService { list_name: &str, page: u32, url: String, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result<(Vec, Pagination), DomainError> { let cache_key = format!("komik:list:{}:{}:v2", list_name, page); - cache + self.cache() .get_or_set(&cache_key, GENRE_CACHE_TTL, || async { let html = self .repository @@ -318,33 +290,26 @@ impl KomikService { apply_cached_posters( &mut komik_list, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(GenreKomikResponse { - status: "Ok".to_string(), - genre: list_name.to_string(), - data: komik_list, - pagination, - }) + Ok((komik_list, pagination)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn search_slug( &self, query: String, - app_state: Arc, - ) -> Result { - let page = 1; - let cache = Cache::new(&app_state.redis_pool); + ) -> Result<(Vec, Pagination), DomainError> { + let page = 1u32; let cache_key = format!("komik:search:{}:{}", query, page); - cache + self.cache() .get_or_set(&cache_key, SEARCH_CACHE_TTL, || async { let url = self.repository.search_url(&query, page); let html = self @@ -361,32 +326,26 @@ impl KomikService { apply_cached_posters( &mut komik_list, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(SearchKomikResponse { - status: "Ok".to_string(), - data: komik_list, - pagination, - }) + Ok((komik_list, pagination)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } pub async fn search_slug_page( &self, query: String, page: u32, - app_state: Arc, - ) -> Result { - let cache = Cache::new(&app_state.redis_pool); + ) -> Result<(Vec, Pagination), DomainError> { let cache_key = format!("komik:search:{}:{}", query, page); - cache + self.cache() .get_or_set(&cache_key, SEARCH_CACHE_TTL, || async { let url = self.repository.search_url(&query, page); let html = self @@ -403,19 +362,15 @@ impl KomikService { apply_cached_posters( &mut komik_list, - app_state.db.clone(), - &app_state.redis_pool, - Some(app_state.image_processing_semaphore.clone()), + self.db.clone(), + &self.redis_pool, + self.semaphore.clone(), ) .await; - Ok(SearchKomikResponse { - status: "Ok".to_string(), - data: komik_list, - pagination, - }) + Ok((komik_list, pagination)) }) .await - .map_err(AppError::ScraperError) + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e))) } } diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..181068f --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,4 @@ +pub mod anime; +pub mod anime2; +pub mod komik; +pub mod proxy; diff --git a/src/application/proxy/mod.rs b/src/application/proxy/mod.rs new file mode 100644 index 0000000..d07542b --- /dev/null +++ b/src/application/proxy/mod.rs @@ -0,0 +1 @@ +pub mod use_cases; diff --git a/src/application/proxy/use_cases.rs b/src/application/proxy/use_cases.rs new file mode 100644 index 0000000..8bbca56 --- /dev/null +++ b/src/application/proxy/use_cases.rs @@ -0,0 +1,236 @@ +//! Proxy application use cases. +//! +//! Provides proxy fetch, image caching, and audit/repair operations. +//! +//! TODO: Move result types to `crate::presentation::dto::proxy`. +//! TODO: Add event bus integration for ImageRepaired events. +//! TODO: Replace `reqwest::Client::new()` with shared HTTP client from infrastructure. + +use std::sync::Arc; + +use axum::http::StatusCode; +use axum::response::Response; +use serde::Serialize; +use tracing::{error, info, warn}; +use utoipa::ToSchema; + +use crate::domain::error::*; +use crate::domain::repository::ImageCacheRepository; +use crate::infrastructure::repository::{ProxyRepository, SeaOrmImageCacheRepository}; +use crate::infrastructure::services::images::cache::ImageCache; + +// ============================================================================ +// Result types — TEMPORARY: move to presentation layer +// ============================================================================ + +/// Result of caching an image URL. +/// TODO: Move to presentation::dto::proxy +#[derive(Debug, Serialize, ToSchema)] +pub struct ImageCacheResult { + pub success: bool, + pub original_url: String, + pub cdn_url: String, + pub from_cache: bool, + pub pending: Option, +} + +/// Result of auditing/repairing a cached image URL. +/// TODO: Move to presentation::dto::proxy +#[derive(Debug, Serialize, ToSchema)] +pub struct AuditImageCacheResult { + pub success: bool, + pub original_url: String, + pub cdn_url: Option, + pub was_accessible: bool, + pub re_uploaded: bool, + pub message: String, +} + +// ============================================================================ +// Use case struct +// ============================================================================ + +pub struct ProxyUseCases { + repository: ProxyRepository, + image_cache_repo: Arc, +} + +impl ProxyUseCases { + pub fn new( + repository: ProxyRepository, + image_cache_repo: Arc, + ) -> Self { + Self { + repository, + image_cache_repo, + } + } + + fn build_image_cache(&self) -> ImageCache { + ImageCache::new(self.image_cache_repo.clone()) + } + + pub async fn fetch_with_proxy_only(&self, url: String) -> Result { + let fetch_result = self + .repository + .fetch_with_proxy_url(&url) + .await + .map_err(|e| DomainError::Scraping(ScrapingError::Http(e.to_string())))?; + + let mut builder = Response::builder().status(StatusCode::OK); + if let Some(content_type) = fetch_result.content_type { + builder = builder.header("Content-Type", content_type); + } + + builder + .body(fetch_result.data.into()) + .map_err(|e| DomainError::Repository(RepositoryError::Network(e.to_string()))) + } + + pub async fn image_cache( + &self, + url: String, + lazy: bool, + ) -> Result { + let cache = self.build_image_cache(); + + if let Some(cdn_url) = cache.get_cdn_url(&url).await { + return Ok(ImageCacheResult { + success: true, + original_url: url, + cdn_url, + from_cache: true, + pending: None, + }); + } + + if lazy { + let repo = self.image_cache_repo.clone(); + let url_clone = url.clone(); + tokio::spawn(async move { + let cache = ImageCache::new(repo); + match cache.get_or_cache(&url_clone).await { + Ok(cdn) => info!("[LazyCache] Cached {} -> {}", url_clone, cdn), + Err(e) => warn!("[LazyCache] Failed {}: {}", url_clone, e), + } + }); + return Ok(ImageCacheResult { + success: true, + original_url: url.clone(), + cdn_url: url, + from_cache: false, + pending: Some(true), + }); + } + + match cache.get_or_cache(&url).await { + Ok(cdn_url) => Ok(ImageCacheResult { + success: true, + original_url: url, + cdn_url, + from_cache: false, + pending: None, + }), + Err(e) => { + error!("ImageCache error: {}", e); + Ok(ImageCacheResult { + success: false, + original_url: url.clone(), + cdn_url: url, + from_cache: false, + pending: None, + }) + } + } + } + + pub async fn audit_image_cache( + &self, + url: String, + ) -> Result { + let cache = self.build_image_cache(); + let mut cdn_opt = cache.get_cdn_url(&url).await; + let mut original = url.clone(); + + if cdn_opt.is_none() { + if let Some(orig) = cache.find_original_from_cdn(&url).await { + info!("SmartAudit: {} recognized as CDN, original {}", url, orig); + original = orig; + cdn_opt = Some(url.clone()); + } + } + + if let Some(cdn_url) = cdn_opt { + let client = reqwest::Client::new(); + let mut accessible = false; + + match client.get(&cdn_url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(bytes) = resp.bytes().await { + if infer::get(&bytes) + .map(|k| k.mime_type().starts_with("image/")) + .unwrap_or(false) + { + accessible = true; + } else { + warn!("CDN {} returned non-image content", cdn_url); + } + } + } + Ok(resp) => warn!("CDN {} status {}", cdn_url, resp.status()), + Err(e) => warn!("CDN {} fetch error {}", cdn_url, e), + } + + if accessible { + return Ok(AuditImageCacheResult { + success: true, + original_url: original, + cdn_url: Some(cdn_url), + was_accessible: true, + re_uploaded: false, + message: "CDN URL is accessible and the image is valid".to_string(), + }); + } + + info!("CDN {} inaccessible, purging and reuploading", cdn_url); + let _ = cache.invalidate(&original).await; + match cache.get_or_cache(&original).await { + Ok(new_cdn) => Ok(AuditImageCacheResult { + success: true, + original_url: original, + cdn_url: Some(new_cdn), + was_accessible: false, + re_uploaded: true, + message: "CDN URL was inaccessible, re-uploaded".to_string(), + }), + Err(e) => Ok(AuditImageCacheResult { + success: false, + original_url: original, + cdn_url: None, + was_accessible: false, + re_uploaded: false, + message: format!("Re-upload failed: {}", e), + }), + } + } else { + match cache.get_or_cache(&original).await { + Ok(new_cdn) => Ok(AuditImageCacheResult { + success: true, + original_url: original, + cdn_url: Some(new_cdn), + was_accessible: false, + re_uploaded: true, + message: "Cached newly".to_string(), + }), + Err(e) => Ok(AuditImageCacheResult { + success: false, + original_url: original, + cdn_url: None, + was_accessible: false, + re_uploaded: false, + message: format!("Cache failed: {}", e), + }), + } + } + } +} diff --git a/src/bin/capture_warning.rs b/src/bin/capture_warning.rs index e5019c4..b9804c5 100644 --- a/src/bin/capture_warning.rs +++ b/src/bin/capture_warning.rs @@ -1,5 +1,5 @@ use scraper::Selector; -use scraper_service::shared::utils::parse_html; +use scraper_service::infrastructure::scraping::parsing_utils::parse_html; /// Capture html5ever tree_builder warning evidence by parsing problematic HTML. /// /// Build with: cargo build --bin capture_warning diff --git a/src/bin/foster_parenting_assertion.rs b/src/bin/foster_parenting_assertion.rs index dd38962..1f5b07b 100644 --- a/src/bin/foster_parenting_assertion.rs +++ b/src/bin/foster_parenting_assertion.rs @@ -6,7 +6,7 @@ /// Uses shared fixture: src/bin/test_fixtures/foster_parenting_minimal.html /// Uses shared parser: src/helpers::parse_html() use scraper::Selector; -use scraper_service::shared::utils::parse_html; +use scraper_service::infrastructure::scraping::parsing_utils::parse_html; use std::fs; fn main() { diff --git a/src/bin/scaffold_enhanced/generators/controller.rs b/src/bin/scaffold_enhanced/generators/controller.rs index bfa7e7a..9844f5d 100644 --- a/src/bin/scaffold_enhanced/generators/controller.rs +++ b/src/bin/scaffold_enhanced/generators/controller.rs @@ -55,7 +55,7 @@ fn generate_list_handler(resource: &str, model: &str) -> String { use axum::{{Extension, Json, response::IntoResponse, Router}}; use sea_orm::{{DatabaseConnection, EntityTrait}}; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; use crate::entities::{model_low}::{{Entity as {model}, Model}}; pub async fn list( @@ -88,7 +88,7 @@ fn generate_show_handler(resource: &str, model: &str) -> String { use axum::{{Extension, Json, extract::Path, response::IntoResponse, Router}}; use sea_orm::{{DatabaseConnection, EntityTrait}}; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; use crate::entities::{model_low}::{{Entity as {model}, Model}}; pub async fn show( @@ -125,7 +125,7 @@ use axum::{{Extension, Json, response::IntoResponse, Router}}; use sea_orm::{{ActiveModelTrait, DatabaseConnection, Set}}; use serde::{{Deserialize, Serialize}}; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; use crate::entities::{model_low}::{{ActiveModel, Model}}; #[derive(Serialize, Deserialize)] @@ -172,7 +172,7 @@ use axum::{{Extension, Json, extract::Path, response::IntoResponse, Router}}; use sea_orm::{{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}}; use serde::{{Deserialize, Serialize}}; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; use crate::entities::{model_low}::{{ActiveModel, Entity as {model}, Model}}; #[derive(Serialize, Deserialize)] @@ -228,7 +228,7 @@ fn generate_delete_handler(resource: &str, model: &str) -> String { use axum::{{Extension, extract::Path, response::IntoResponse, Router}}; use sea_orm::{{ActiveModelTrait, DatabaseConnection, EntityTrait, IntoActiveModel}}; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; use crate::entities::{model_low}::{{Entity as {model}}}; pub async fn destroy( @@ -270,7 +270,7 @@ fn generate_basic_controller(api_dir: &Path, resource: &str) { use axum::Router; use std::sync::Arc; -use crate::shared::state::AppState; +use crate::presentation::state::AppState; pub async fn index() -> &'static str {{ "{resource} endpoint" diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index d5d931b..a4ff4a7 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,3 +1,5 @@ +pub mod setup; + use std::net::SocketAddr; use std::sync::Arc; use tokio::net::TcpListener; @@ -6,9 +8,9 @@ use axum::Router; use sea_orm::Database; use tracing_subscriber::EnvFilter; -use crate::shared::config::CONFIG; -use crate::shared::database::get_redis_conn; -use crate::shared::state::AppState; +use crate::config::CONFIG; +use crate::infrastructure::cache::redis_pool::get_redis_conn; +use crate::presentation::state::AppState; pub struct Application { pub port: u16, @@ -27,7 +29,7 @@ impl Application { tracing_subscriber::fmt().with_env_filter(env_filter).init(); // Initialize OpenTelemetry metrics - crate::shared::observability::metrics::init_otel_metrics(); + crate::observability::metrics::init_otel_metrics(); tracing::info!("🚀 Scraper starting up..."); tracing::info!(" Environment: {}", CONFIG.environment); @@ -46,8 +48,8 @@ impl Application { // Browser Pool tracing::info!("Initializing browser pool..."); - let browser_config = crate::shared::browser::BrowserPoolConfig::default(); - match crate::shared::browser::pool::init_browser_pool(browser_config).await { + let browser_config = crate::infrastructure::browser::BrowserPoolConfig::default(); + match crate::infrastructure::browser::pool::init_browser_pool(browser_config).await { Ok(_) => tracing::info!("✓ Browser pool initialized"), Err(e) => tracing::error!("⚠️ Failed to initialize browser pool: {}", e), } @@ -76,7 +78,7 @@ impl Application { tracing::info!("✓ SeaORM database connection established"); // Schema & Seeding - if let Err(e) = crate::shared::database::setup::init(&db).await { + if let Err(e) = crate::bootstrap::setup::init(&db).await { tracing::error!("Failed to init DB schema: {}", e); } @@ -85,19 +87,26 @@ impl Application { let image_processing_semaphore = Arc::new(tokio::sync::Semaphore::new( CONFIG.image_processing_concurrency, )); - let event_bus = Arc::new(crate::shared::events::bus::EventBus::new()); + let event_bus = Arc::new(crate::events::bus::EventBus::new()); - let redis_pool = crate::shared::database::redis_pool() + let redis_pool = crate::infrastructure::cache::redis_pool::redis_pool() .map_err(|e| anyhow::anyhow!("Failed to init Redis pool: {}", e))?; + use crate::infrastructure::repository::SeaOrmImageCacheRepository; + let image_cache_repo = Arc::new(SeaOrmImageCacheRepository::new( + db_arc.clone(), + redis_pool.clone(), + )); + let app_state = Arc::new(AppState { redis_pool, db: db_arc.clone(), image_processing_semaphore, event_bus: event_bus.clone(), + image_cache_repo, }); - let app = crate::app::build_router(app_state, db_arc.clone()).await?; + let app = crate::presentation::router::build_router(app_state.clone())?; // Listener let port = CONFIG.server_port; diff --git a/src/shared/database/setup.rs b/src/bootstrap/setup.rs similarity index 93% rename from src/shared/database/setup.rs rename to src/bootstrap/setup.rs index 38617da..aeecd2d 100644 --- a/src/shared/database/setup.rs +++ b/src/bootstrap/setup.rs @@ -1,7 +1,10 @@ -use crate::shared::database::persistence::entities::image_cache; +//! Database schema initialization. + use sea_orm::{ConnectionTrait, DatabaseConnection, Schema, Statement}; use tracing::info; +use crate::infrastructure::persistence::entities::image_cache; + pub async fn init(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> { info!("🚀 Initializing database schema..."); let backend = db.get_database_backend(); diff --git a/src/shared/config/mod.rs b/src/config/mod.rs similarity index 98% rename from src/shared/config/mod.rs rename to src/config/mod.rs index 5da118d..79eb3c1 100644 --- a/src/shared/config/mod.rs +++ b/src/config/mod.rs @@ -6,9 +6,9 @@ //! - Supports hierarchical configuration (default -> environment-specific) use config::{Config, ConfigError, Environment, File}; -use once_cell::sync::Lazy; use serde::Deserialize; use std::env; +use std::sync::LazyLock; /// Application configuration loaded at startup. /// All fields are required unless marked as `Option`. @@ -270,7 +270,7 @@ impl AppConfig { /// Global configuration instance, loaded once at startup. /// Panics if configuration is invalid - this is intentional for fail-fast behavior. -pub static CONFIG: Lazy = Lazy::new(|| { +pub static CONFIG: LazyLock = LazyLock::new(|| { AppConfig::load().unwrap_or_else(|e| { eprintln!("❌ Failed to load configuration: {}", e); eprintln!(" Make sure all required environment variables are set:"); @@ -283,7 +283,7 @@ pub static CONFIG: Lazy = Lazy::new(|| { /// Global MinIO configuration, loaded from environment variables. /// Returns None if required MINIO_* variables are not set. -pub static MINIO_CONFIG: Lazy> = Lazy::new(|| { +pub static MINIO_CONFIG: LazyLock> = LazyLock::new(|| { let _ = dotenvy::dotenv(); MinioConfig::from_env() }); diff --git a/src/domain/entity/anime.rs b/src/domain/entity/anime.rs new file mode 100644 index 0000000..9924691 --- /dev/null +++ b/src/domain/entity/anime.rs @@ -0,0 +1,404 @@ +//! Domain entities for anime data. +//! +//! Pure domain structs with no framework dependencies beyond serde + utoipa. +//! These represent the scraped anime data model regardless of source site. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// ============================================================================ +// PAGINATION +// ============================================================================ + +/// Common pagination structure shared across all endpoints +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Pagination { + pub current_page: u32, + pub last_visible_page: u32, + pub has_next_page: bool, + pub next_page: Option, + pub has_previous_page: bool, + pub previous_page: Option, +} + +impl Pagination { + pub fn with_string_pages( + current_page: u32, + last_visible_page: u32, + has_next_page: bool, + next_page: Option, + has_previous_page: bool, + previous_page: Option, + ) -> PaginationWithStringPages { + PaginationWithStringPages { + current_page, + last_visible_page, + has_next_page, + next_page, + has_previous_page, + previous_page, + } + } +} + +/// Pagination variant with string-based page numbers (used in search endpoints) +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct PaginationWithStringPages { + pub current_page: u32, + pub last_visible_page: u32, + pub has_next_page: bool, + pub next_page: Option, + pub has_previous_page: bool, + pub previous_page: Option, +} + +// ============================================================================ +// OTakudesu (Anime Module) — Index Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct OngoingAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub current_episode: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct CompleteAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub episode_count: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct AnimeData { + pub ongoing_anime: Vec, + pub complete_anime: Vec, +} + +// ============================================================================ +// Genre Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Genre { + pub name: String, + pub slug: String, + pub url: String, +} + +// ============================================================================ +// Otakudesu Detail Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct DetailGenre { + pub name: String, + pub slug: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct EpisodeList { + pub episode: String, + pub slug: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Recommendation { + pub title: String, + pub slug: String, + pub poster: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct AnimeDetailData { + pub title: String, + pub alternative_title: String, + pub poster: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + pub release_date: String, + pub studio: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub genres: Vec, + pub synopsis: String, + pub episode_lists: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub batch: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub producers: Vec, + pub recommendations: Vec, +} + +// ============================================================================ +// Otakudesu List Page Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct CompleteAnimeListItem { + pub title: String, + pub slug: String, + pub poster: String, + pub episode_count: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct OngoingAnimeListItem { + pub title: String, + pub slug: String, + pub poster: String, + pub score: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct LatestAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub episode: String, + pub score: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct SearchAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub episode: String, + pub anime_url: String, + pub genres: Vec, + pub status: String, + pub rating: String, + pub description: String, + pub r#type: String, + pub season: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct GenreAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub episode: String, + pub score: String, + pub status: String, + pub anime_url: String, +} + +// ============================================================================ +// Otakudesu Full Episode Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct AnimeInfo { + pub slug: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct EpisodeInfo { + pub slug: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct DownloadLink { + pub server: String, + pub url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct AnimeFullData { + pub episode: String, + pub episode_number: String, + pub anime: AnimeInfo, + pub has_next_episode: bool, + pub next_episode: Option, + pub has_previous_episode: bool, + pub previous_episode: Option, + pub stream_url: String, + pub download_urls: std::collections::HashMap>, + pub image_url: String, +} + +// ============================================================================ +// ALQanime (Anime2 Module) — Index Types +// ============================================================================ + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2Item { + pub title: String, + pub slug: String, + pub poster: String, + pub status: String, + pub r#type: String, + pub score: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2Data { + pub ongoing_anime: Vec, + pub complete_anime: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Anime2ItemDetail { + pub title: String, + pub slug: String, + pub poster: String, + pub poster2: String, + pub synopsis: String, + pub alternative_title: String, + pub r#type: String, + pub status: String, + pub score: String, + pub genres: Vec, + pub episodes: Vec, + pub recommendations: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct OngoingAnimeItemWithScore { + pub title: String, + pub slug: String, + pub poster: String, + pub score: String, + pub anime_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct FilterAnimeItem { + pub title: String, + pub slug: String, + pub poster: String, + pub score: String, + pub status: String, + pub r#type: String, + pub anime_url: String, +} + +// ============================================================================ +// TRAITS +// ============================================================================ + +/// Trait for types that have a poster image URL +pub trait HasPoster { + fn poster(&self) -> &str; + fn set_poster(&mut self, url: String); +} + +// ============================================================================ +// TRAIT IMPLEMENTATIONS +// ============================================================================ + +impl HasPoster for OngoingAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for CompleteAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for CompleteAnimeListItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for OngoingAnimeListItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for LatestAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for SearchAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for GenreAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for Recommendation { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for Anime2Item { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for OngoingAnimeItemWithScore { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + +impl HasPoster for FilterAnimeItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} diff --git a/src/domain/entity/komik.rs b/src/domain/entity/komik.rs new file mode 100644 index 0000000..dc876c8 --- /dev/null +++ b/src/domain/entity/komik.rs @@ -0,0 +1,64 @@ +//! Domain entities for komik (comic) data. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::domain::entity::anime::HasPoster; + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct KomikGenre { + pub name: String, + pub slug: String, + pub count: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct Chapter { + pub chapter: String, + pub date: String, + pub chapter_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct ChapterData { + pub title: String, + pub next_chapter_id: String, + pub prev_chapter_id: String, + pub list_chapter: String, + pub images: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct DetailData { + pub title: String, + pub poster: String, + pub description: String, + pub status: String, + pub r#type: String, + pub release_date: String, + pub author: String, + pub total_chapter: String, + pub updated_on: String, + pub genres: Vec, + pub chapters: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct KomikItem { + pub title: String, + pub slug: String, + pub poster: String, + pub chapter: String, + pub score: String, + pub r#type: String, + pub komik_url: String, +} + +impl HasPoster for KomikItem { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} diff --git a/src/domain/entity/mod.rs b/src/domain/entity/mod.rs new file mode 100644 index 0000000..2ef0488 --- /dev/null +++ b/src/domain/entity/mod.rs @@ -0,0 +1,2 @@ +pub mod anime; +pub mod komik; diff --git a/src/domain/error.rs b/src/domain/error.rs new file mode 100644 index 0000000..f062a1c --- /dev/null +++ b/src/domain/error.rs @@ -0,0 +1,55 @@ +//! Domain-level error types. +//! +//! These are framework-agnostic errors that can be mapped to HTTP errors +//! at the presentation layer. Domain and application layers only use these. + +use thiserror::Error; + +/// Errors originating from repository operations (DB, HTTP, etc.) +#[derive(Error, Debug)] +pub enum RepositoryError { + #[error("Not found")] + NotFound, + #[error("Conflict: {0}")] + Conflict(String), + #[error("Database error: {0}")] + Database(String), + #[error("Network error: {0}")] + Network(String), +} + +/// Errors originating from scraping/parsing operations +#[derive(Error, Debug)] +pub enum ScrapingError { + #[error("HTTP error: {0}")] + Http(String), + #[error("Parse error: {0}")] + Parse(String), + #[error("Empty response")] + EmptyResponse, +} + +/// Generic domain error +#[derive(Error, Debug)] +pub enum DomainError { + #[error("Not found: {0}")] + NotFound(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("Repository error: {0}")] + Repository(#[from] RepositoryError), + #[error("Scraping error: {0}")] + Scraping(#[from] ScrapingError), +} + +impl From for RepositoryError { + fn from(s: String) -> Self { + RepositoryError::Database(s) + } +} + +impl From<&str> for RepositoryError { + fn from(s: &str) -> Self { + RepositoryError::Database(s.to_string()) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..f575a91 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod error; +pub mod repository; diff --git a/src/shared/database/traits/image_cache.rs b/src/domain/repository/image_cache.rs similarity index 88% rename from src/shared/database/traits/image_cache.rs rename to src/domain/repository/image_cache.rs index 56588ef..3f1e2db 100644 --- a/src/shared/database/traits/image_cache.rs +++ b/src/domain/repository/image_cache.rs @@ -1,5 +1,8 @@ +//! Repository trait for image cache (original → CDN mapping). + use async_trait::async_trait; +/// Repository for managing cached image URL mappings. #[async_trait] pub trait ImageCacheRepository: Send + Sync { async fn get_from_redis(&self, key: &str) -> Option; diff --git a/src/domain/repository/mod.rs b/src/domain/repository/mod.rs new file mode 100644 index 0000000..28d19c1 --- /dev/null +++ b/src/domain/repository/mod.rs @@ -0,0 +1,5 @@ +pub mod image_cache; +pub mod scraping; + +pub use image_cache::ImageCacheRepository; +pub use scraping::ScrapingRepository; diff --git a/src/domain/repository/scraping.rs b/src/domain/repository/scraping.rs new file mode 100644 index 0000000..ad4db10 --- /dev/null +++ b/src/domain/repository/scraping.rs @@ -0,0 +1,12 @@ +//! Repository trait for scraping HTML from remote sources. + +use async_trait::async_trait; + +use crate::domain::error::ScrapingError; + +/// Trait for repositories that fetch and scrape HTML content. +#[async_trait] +pub trait ScrapingRepository: Send + Sync { + /// Fetch raw HTML from a URL. + async fn fetch_html(&self, url: &str) -> Result; +} diff --git a/src/shared/events/bus.rs b/src/events/bus.rs similarity index 100% rename from src/shared/events/bus.rs rename to src/events/bus.rs diff --git a/src/shared/events/mod.rs b/src/events/mod.rs similarity index 100% rename from src/shared/events/mod.rs rename to src/events/mod.rs diff --git a/src/shared/browser/mod.rs b/src/infrastructure/browser/mod.rs similarity index 100% rename from src/shared/browser/mod.rs rename to src/infrastructure/browser/mod.rs diff --git a/src/shared/browser/pool.rs b/src/infrastructure/browser/pool.rs similarity index 98% rename from src/shared/browser/pool.rs rename to src/infrastructure/browser/pool.rs index c69d5c1..e6157f4 100644 --- a/src/shared/browser/pool.rs +++ b/src/infrastructure/browser/pool.rs @@ -60,7 +60,7 @@ impl Default for BrowserPoolConfig { let chrome_remote = std::env::var("CHROME_REMOTE_WS").ok(); let remote_websocket_url = if let Some(ext) = external { - tracing::info!("🌐 Browser: using EXTERNAL_BROWSERLESS_WS"); + tracing::info!(" Browser: using EXTERNAL_BROWSERLESS_WS"); ext } else if let Some(ref cr) = chrome_remote { if cr == "ws://browserless:3000" { @@ -70,7 +70,7 @@ impl Default for BrowserPoolConfig { ); std::process::exit(1); } else { - tracing::info!("🌐 Browser: using CHROME_REMOTE_WS"); + tracing::info!(" Browser: using CHROME_REMOTE_WS"); cr.clone() } } else { @@ -462,9 +462,9 @@ impl Drop for PooledTab { } // Global browser pool instance -use once_cell::sync::OnceCell; +use std::sync::OnceLock; -static BROWSER_POOL: OnceCell> = OnceCell::new(); +static BROWSER_POOL: OnceLock> = OnceLock::new(); /// Initialize the global browser pool. /// Call this once at application startup. diff --git a/src/infrastructure/cache/mod.rs b/src/infrastructure/cache/mod.rs new file mode 100644 index 0000000..2d19fea --- /dev/null +++ b/src/infrastructure/cache/mod.rs @@ -0,0 +1,4 @@ +pub mod redis; +pub mod redis_pool; + +pub use redis::Cache; diff --git a/src/shared/utils/io/cache.rs b/src/infrastructure/cache/redis.rs similarity index 83% rename from src/shared/utils/io/cache.rs rename to src/infrastructure/cache/redis.rs index 7fa0017..80285b5 100644 --- a/src/shared/utils/io/cache.rs +++ b/src/infrastructure/cache/redis.rs @@ -1,13 +1,12 @@ //! Redis caching helpers. -use crate::shared::utils::cache_ttl::CACHE_TTL_VERY_SHORT; use deadpool_redis::redis::AsyncCommands; use deadpool_redis::Pool; use serde::{de::DeserializeOwned, Serialize}; use tracing::{debug, error}; /// Default cache TTL in seconds (5 minutes). -pub const DEFAULT_CACHE_TTL: u64 = CACHE_TTL_VERY_SHORT; +pub const DEFAULT_CACHE_TTL: u64 = 300; /// Cache helper for Redis operations. pub struct Cache<'a> { @@ -15,12 +14,10 @@ pub struct Cache<'a> { } impl<'a> Cache<'a> { - /// Create a new cache helper. pub fn new(pool: &'a Pool) -> Self { Self { pool } } - /// Get a value from cache, deserializing JSON. pub async fn get(&self, key: &str) -> Option { let mut conn = match self.pool.get().await { Ok(c) => c, @@ -41,8 +38,6 @@ impl<'a> Cache<'a> { cached.and_then(|json| serde_json::from_str(&json).ok()) } - /// Get multiple values from cache, deserializing JSON. - /// Returns a vector of Options, preserving order of keys. pub async fn mget(&self, keys: &[String]) -> Vec> { if keys.is_empty() { return Vec::new(); @@ -56,7 +51,6 @@ impl<'a> Cache<'a> { } }; - // Use low-level cmd interface for MGET to ensure correct command usage use deadpool_redis::redis::cmd; let cached_values: Vec> = match cmd("MGET").arg(keys).query_async(&mut conn).await { @@ -73,12 +67,10 @@ impl<'a> Cache<'a> { .collect() } - /// Set a value in cache with default TTL (5 minutes). pub async fn set(&self, key: &str, value: &T) -> Result<(), String> { self.set_with_ttl(key, value, DEFAULT_CACHE_TTL).await } - /// Set a value in cache with custom TTL. pub async fn set_with_ttl( &self, key: &str, @@ -86,18 +78,14 @@ impl<'a> Cache<'a> { ttl_secs: u64, ) -> Result<(), String> { let mut conn = self.pool.get().await.map_err(|e| e.to_string())?; - let json = serde_json::to_string(value).map_err(|e| e.to_string())?; - conn.set_ex::<_, _, ()>(key, json, ttl_secs) .await .map_err(|e| e.to_string())?; - debug!("Cache: set key {} with TTL {}s", key, ttl_secs); Ok(()) } - /// Delete a key from cache. pub async fn delete(&self, key: &str) -> Result<(), String> { let mut conn = self.pool.get().await.map_err(|e| e.to_string())?; conn.del::<_, ()>(key).await.map_err(|e| e.to_string())?; @@ -105,7 +93,6 @@ impl<'a> Cache<'a> { Ok(()) } - /// Check if key exists. pub async fn exists(&self, key: &str) -> bool { let mut conn = match self.pool.get().await { Ok(c) => c, @@ -126,20 +113,14 @@ impl<'a> Cache<'a> { F: FnOnce() -> Fut, Fut: std::future::Future>, { - // Try cache first if let Some(cached) = self.get::(key).await { debug!("Cache hit: {}", key); return Ok(cached); } debug!("Cache miss: {}", key); - - // Compute the value let value = compute().await?; - - // Store in cache self.set_with_ttl(key, &value, ttl_secs).await?; - Ok(value) } } @@ -148,8 +129,3 @@ impl<'a> Cache<'a> { pub fn cache_key(prefix: &str, id: &str) -> String { format!("{}:{}", prefix, id) } - -/// Create a cache key with multiple parts. -pub fn cache_key_multi(parts: &[&str]) -> String { - parts.join(":") -} diff --git a/src/shared/database/redis.rs b/src/infrastructure/cache/redis_pool.rs similarity index 76% rename from src/shared/database/redis.rs rename to src/infrastructure/cache/redis_pool.rs index 01225b7..732c5f0 100644 --- a/src/shared/database/redis.rs +++ b/src/infrastructure/cache/redis_pool.rs @@ -1,21 +1,19 @@ -//! Redis connection utility with tracing for connection lifecycle and errors. -//! -//! Uses the type-safe CONFIG for Redis connection parameters. +//! Redis connection pool management. + +use std::sync::LazyLock; -use crate::shared::config::CONFIG; -use crate::shared::errors::AppError; use deadpool_redis::{Manager, Pool}; -use once_cell::sync::Lazy; use tracing::{debug, error, info}; -static REDIS_POOL_INIT: Lazy> = Lazy::new(|| { +use crate::config::CONFIG; + +static REDIS_POOL_INIT: LazyLock> = LazyLock::new(|| { let redis_url = if !CONFIG.redis_url.is_empty() { CONFIG.redis_url.clone() } else { let host = std::env::var("REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port = std::env::var("REDIS_PORT").unwrap_or_else(|_| "6379".to_string()); let password = std::env::var("REDIS_PASSWORD").unwrap_or_default(); - if password.is_empty() { format!("redis://{}:{}", host, port) } else { @@ -37,19 +35,16 @@ static REDIS_POOL_INIT: Lazy> = Lazy::new(|| { }) }); -/// Get the Redis connection pool (for internal use). pub fn get_redis_pool() -> Result<&'static Pool, String> { REDIS_POOL_INIT.as_ref().map_err(|e| e.clone()) } -/// Get a cloned reference to the Redis pool. pub fn redis_pool() -> Result { - get_redis_pool().map(|p| (*p).clone()) + get_redis_pool().cloned() } -/// Get an async connection from the pool with retry backoff. -pub async fn get_redis_conn() -> Result { - let pool = get_redis_pool().map_err(|e| AppError::Other(e))?; +pub async fn get_redis_conn() -> Result { + let pool = get_redis_pool()?; let mut retries = 5; let mut wait = std::time::Duration::from_millis(100); @@ -62,7 +57,7 @@ pub async fn get_redis_conn() -> Result { Err(e) => { if retries <= 0 { error!("Failed to get Redis connection after retries: {:?}", e); - return Err(AppError::from(e)); + return Err(format!("Redis connection failed: {}", e)); } debug!("Redis connection failed, retrying in {:?}: {:?}", wait, e); tokio::time::sleep(wait).await; diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..18f2310 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,7 @@ +pub mod browser; +pub mod cache; +pub mod persistence; +pub mod repository; +pub mod scraping; +pub mod services; +pub mod utils; diff --git a/src/shared/database/persistence/entities/image_cache.rs b/src/infrastructure/persistence/entities/image_cache.rs similarity index 100% rename from src/shared/database/persistence/entities/image_cache.rs rename to src/infrastructure/persistence/entities/image_cache.rs diff --git a/src/shared/database/persistence/entities/mod.rs b/src/infrastructure/persistence/entities/mod.rs similarity index 100% rename from src/shared/database/persistence/entities/mod.rs rename to src/infrastructure/persistence/entities/mod.rs diff --git a/src/shared/database/persistence/mod.rs b/src/infrastructure/persistence/mod.rs similarity index 100% rename from src/shared/database/persistence/mod.rs rename to src/infrastructure/persistence/mod.rs diff --git a/src/modules/anime2/repository.rs b/src/infrastructure/repository/alqanime.rs similarity index 78% rename from src/modules/anime2/repository.rs rename to src/infrastructure/repository/alqanime.rs index 9b21931..514e328 100644 --- a/src/modules/anime2/repository.rs +++ b/src/infrastructure/repository/alqanime.rs @@ -1,21 +1,18 @@ -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::utils::web::proxy_fetch::fetch_with_proxy_only; +//! Alqanime (Anime2) scraping repository. + use async_trait::async_trait; use tracing::warn; +use crate::domain::error::ScrapingError; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy_only; + const BASE_URL: &str = "https://alqanime.si"; const BASE_DETAIL_URL: &str = "https://alqanime.net"; -pub struct Anime2Repository; +pub struct AlqanimeRepository; -impl Default for Anime2Repository { - fn default() -> Self { - Self::new() - } -} - -impl Anime2Repository { +impl AlqanimeRepository { pub fn new() -> Self { Self } @@ -91,11 +88,13 @@ impl Anime2Repository { } #[async_trait] -impl ScrapingRepository for Anime2Repository { - async fn fetch_html(&self, url: &str) -> Result { - let response = fetch_with_proxy_only(url).await?; +impl ScrapingRepository for AlqanimeRepository { + async fn fetch_html(&self, url: &str) -> Result { + let response = fetch_with_proxy_only(url) + .await + .map_err(|e| ScrapingError::Http(format!("Alqanime fetch failed: {}", e)))?; if response.data.trim().is_empty() { - warn!("Anime2 browserless fetch returned empty body for {}", url); + warn!("Alqanime browserless fetch returned empty body for {}", url); } Ok(response.data) } diff --git a/src/shared/database/repositories/image_cache.rs b/src/infrastructure/repository/image_cache_seaorm.rs similarity index 94% rename from src/shared/database/repositories/image_cache.rs rename to src/infrastructure/repository/image_cache_seaorm.rs index 53f8443..089ec77 100644 --- a/src/shared/database/repositories/image_cache.rs +++ b/src/infrastructure/repository/image_cache_seaorm.rs @@ -1,12 +1,15 @@ -use crate::shared::database::persistence::entities::image_cache; -use crate::shared::database::traits::image_cache::ImageCacheRepository; -use crate::shared::utils::Cache; +//! SeaORM-backed implementation of ImageCacheRepository. + use async_trait::async_trait; use chrono::Utc; use deadpool_redis::Pool as RedisPool; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use std::sync::Arc; +use crate::domain::repository::ImageCacheRepository; +use crate::infrastructure::cache::redis::Cache; +use crate::infrastructure::persistence::entities::image_cache; + pub struct SeaOrmImageCacheRepository { db: Arc, redis: RedisPool, @@ -48,7 +51,6 @@ impl ImageCacheRepository for SeaOrmImageCacheRepository { created_at: Set(Utc::now()), expires_at: Set(None), }; - model .insert(self.db.as_ref()) .await diff --git a/src/modules/komik/repository.rs b/src/infrastructure/repository/komik.rs similarity index 81% rename from src/modules/komik/repository.rs rename to src/infrastructure/repository/komik.rs index 5e487d4..97ad7eb 100644 --- a/src/modules/komik/repository.rs +++ b/src/infrastructure/repository/komik.rs @@ -1,16 +1,13 @@ -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::utils::fetch_html_with_retry; -use crate::shared::utils::web::scraping_urls::{get_komik_api_url, get_komik_url}; +//! Komik site scraping repository. + use async_trait::async_trait; -pub struct KomikRepository; +use crate::domain::error::ScrapingError; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::scraping::html_fetcher::fetch_html_with_retry; +use crate::infrastructure::scraping::scraping_urls::{get_komik_api_url, get_komik_url}; -impl Default for KomikRepository { - fn default() -> Self { - Self::new() - } -} +pub struct KomikRepository; impl KomikRepository { pub fn new() -> Self { @@ -72,7 +69,7 @@ impl KomikRepository { #[async_trait] impl ScrapingRepository for KomikRepository { - async fn fetch_html(&self, url: &str) -> Result { + async fn fetch_html(&self, url: &str) -> Result { fetch_html_with_retry(url).await } } diff --git a/src/infrastructure/repository/mod.rs b/src/infrastructure/repository/mod.rs new file mode 100644 index 0000000..e468798 --- /dev/null +++ b/src/infrastructure/repository/mod.rs @@ -0,0 +1,12 @@ +pub mod alqanime; +pub mod image_cache_seaorm; +pub mod komik; +pub mod otakudesu; +pub mod parsers; +pub mod proxy; + +pub use alqanime::AlqanimeRepository; +pub use image_cache_seaorm::SeaOrmImageCacheRepository; +pub use komik::KomikRepository; +pub use otakudesu::OtakudesuRepository; +pub use proxy::ProxyRepository; diff --git a/src/modules/anime/repository.rs b/src/infrastructure/repository/otakudesu.rs similarity index 51% rename from src/modules/anime/repository.rs rename to src/infrastructure/repository/otakudesu.rs index 423df2f..ebe9e90 100644 --- a/src/modules/anime/repository.rs +++ b/src/infrastructure/repository/otakudesu.rs @@ -1,38 +1,31 @@ -use crate::modules::anime::parser; -use crate::modules::anime::types::*; -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::utils::web::proxy_fetch::fetch_with_proxy; -use crate::shared::utils::web::scraping_urls::{get_otakudesu_url, OTAKUDESU_BASE_URL}; -use crate::shared::utils::{default_backoff, fetch_html_with_retry, transient}; +//! Otakudesu anime scraping repository. + use async_trait::async_trait; use backoff::future::retry; use tracing::{info, warn}; -pub struct AnimeRepository; +use crate::domain::entity::anime::{ + AnimeData, AnimeDetailData, AnimeFullData, CompleteAnimeListItem, Genre, GenreAnimeItem, + LatestAnimeItem, OngoingAnimeListItem, Pagination, SearchAnimeItem, +}; +use crate::domain::error::ScrapingError; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::repository::parsers::otakudesu_parser; +use crate::infrastructure::scraping::html_fetcher::fetch_html_with_retry; +use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy; +use crate::infrastructure::scraping::retry::{default_backoff, transient}; -impl Default for AnimeRepository { - fn default() -> Self { - Self::new() - } -} +const OTAKUDESU_BASE_URL: &str = "https://otakudesu.cloud"; -impl AnimeRepository { +pub struct OtakudesuRepository; + +impl OtakudesuRepository { pub fn new() -> Self { Self } -} -#[async_trait] -impl ScrapingRepository for AnimeRepository { - async fn fetch_html(&self, url: &str) -> Result { - fetch_html_with_retry(url).await - } -} - -impl AnimeRepository { - pub fn base_url(&self) -> String { - get_otakudesu_url() + fn base_url(&self) -> String { + "https://otakudesu.cloud".to_string() } pub fn index_urls(&self) -> (String, String) { @@ -70,24 +63,36 @@ impl AnimeRepository { pub fn full_episode_url(&self, slug: &str) -> String { format!("{}/episode/{}", OTAKUDESU_BASE_URL, slug) } +} - pub async fn fetch_anime_index(&self) -> Result { +#[async_trait] +impl ScrapingRepository for OtakudesuRepository { + async fn fetch_html(&self, url: &str) -> Result { + fetch_html_with_retry(url).await + } +} + +impl OtakudesuRepository { + pub async fn fetch_anime_index(&self) -> Result { let (ongoing_url, complete_url) = self.index_urls(); - let (ongoing_html, complete_html) = tokio::join!( self.fetch_html(&ongoing_url), self.fetch_html(&complete_url) ); - let ongoing_html = ongoing_html?; let complete_html = complete_html?; - let ongoing_anime = - tokio::task::spawn_blocking(move || parser::parse_ongoing_anime(&ongoing_html)) - .await??; - let complete_anime = - tokio::task::spawn_blocking(move || parser::parse_complete_anime(&complete_html)) - .await??; + let ongoing_anime = tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_ongoing_anime(&ongoing_html) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))??; + + let complete_anime = tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_complete_anime(&complete_html) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))??; Ok(AnimeData { ongoing_anime, @@ -95,88 +100,103 @@ impl AnimeRepository { }) } - pub async fn fetch_genres(&self) -> Result, AppError> { + pub async fn fetch_genres(&self) -> Result, ScrapingError> { let html = self.fetch_html(&self.genres_url()).await?; - tokio::task::spawn_blocking(move || parser::parse_genres(&html)).await? + tokio::task::spawn_blocking(move || otakudesu_parser::parse_genres(&html)) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } - pub async fn fetch_anime_detail(&self, slug: &str) -> Result { + pub async fn fetch_anime_detail(&self, slug: &str) -> Result { let url = self.detail_url(slug); - let html = self - .fetch_with_proxy_retry(&url) + let html = self.fetch_with_proxy_retry(&url).await?; + tokio::task::spawn_blocking(move || otakudesu_parser::parse_anime_detail_document(&html)) .await - .map_err(|e| AppError::ScraperError(e.to_string()))?; - - tokio::task::spawn_blocking(move || parser::parse_anime_detail_document(&html)).await? + .map_err(|e| ScrapingError::Parse(e.to_string()))? } pub async fn fetch_complete_anime_page( &self, slug: &str, - ) -> Result<(Vec, Pagination), AppError> { + ) -> Result<(Vec, Pagination), ScrapingError> { let url = self.page_url("complete-anime", slug); let html = self.fetch_html(&url).await?; let slug_owned = slug.to_string(); - tokio::task::spawn_blocking(move || parser::parse_anime_page(&html, &slug_owned)).await? + tokio::task::spawn_blocking(move || otakudesu_parser::parse_anime_page(&html, &slug_owned)) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } pub async fn fetch_ongoing_anime_page( &self, slug: &str, - ) -> Result<(Vec, Pagination), AppError> { + ) -> Result<(Vec, Pagination), ScrapingError> { let url = self.page_url("ongoing-anime", slug); let html = self.fetch_html(&url).await?; let slug_owned = slug.to_string(); tokio::task::spawn_blocking(move || { - parser::parse_ongoing_anime_document(&html, &slug_owned) + otakudesu_parser::parse_ongoing_anime_document(&html, &slug_owned) }) - .await? + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } pub async fn fetch_latest_anime_page( &self, slug: &str, - ) -> Result<(Vec, Pagination), AppError> { + ) -> Result<(Vec, Pagination), ScrapingError> { let url = self.page_url("latest-anime", slug); let html = self.fetch_html(&url).await?; let slug_owned = slug.to_string(); - tokio::task::spawn_blocking(move || parser::parse_latest_anime_document(&html, &slug_owned)) - .await? + tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_latest_anime_document(&html, &slug_owned) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } pub async fn fetch_search_anime_page( &self, slug: &str, page: &str, - ) -> Result<(Vec, Pagination), AppError> { + ) -> Result<(Vec, Pagination), ScrapingError> { let url = self.search_url(slug, page); let html = self.fetch_html(&url).await?; let page_owned = page.to_string(); - tokio::task::spawn_blocking(move || parser::parse_search_anime_document(&html, &page_owned)) - .await? + tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_search_anime_document(&html, &page_owned) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } pub async fn fetch_genre_anime_page( &self, genre_slug: &str, page: &str, - ) -> Result<(Vec, Pagination), AppError> { + ) -> Result<(Vec, Pagination), ScrapingError> { let url = self.genre_page_url(genre_slug, page); let html = self.fetch_html(&url).await?; let page_owned = page.to_string(); - tokio::task::spawn_blocking(move || parser::parse_genre_anime_document(&html, &page_owned)) - .await? + tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_genre_anime_document(&html, &page_owned) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } - pub async fn fetch_anime_full(&self, slug: &str) -> Result { + pub async fn fetch_anime_full(&self, slug: &str) -> Result { let url = self.full_episode_url(slug); let html = self.fetch_html(&url).await?; let slug_owned = slug.to_string(); - tokio::task::spawn_blocking(move || parser::parse_anime_full_document(&html, &slug_owned)) - .await? + tokio::task::spawn_blocking(move || { + otakudesu_parser::parse_anime_full_document(&html, &slug_owned) + }) + .await + .map_err(|e| ScrapingError::Parse(e.to_string()))? } - async fn fetch_with_proxy_retry(&self, url: &str) -> Result { + async fn fetch_with_proxy_retry(&self, url: &str) -> Result { let backoff = default_backoff(); let url_owned = url.to_string(); let fetch_op = || async { @@ -188,12 +208,15 @@ impl AnimeRepository { } Err(e) => { warn!("Failed to fetch URL: {}, error: {:?}", url_owned, e); - Err(transient(e)) + Err(transient(ScrapingError::Http(format!( + "Proxy fetch failed: {}", + e + )))) } } }; retry(backoff, fetch_op) .await - .map_err(|e| AppError::ScraperError(e.to_string())) + .map_err(|e| ScrapingError::Http(e.to_string())) } } diff --git a/src/modules/anime2/parser.rs b/src/infrastructure/repository/parsers/alqanime_parser.rs similarity index 72% rename from src/modules/anime2/parser.rs rename to src/infrastructure/repository/parsers/alqanime_parser.rs index ab06c44..a41a16d 100644 --- a/src/modules/anime2/parser.rs +++ b/src/infrastructure/repository/parsers/alqanime_parser.rs @@ -1,43 +1,96 @@ -use crate::shared::errors::AppError; -use crate::shared::utils::parse_html; -use crate::shared::utils::scraping::{attr, extract_slug, selector, text, text_from_or}; -use once_cell::sync::Lazy; +use crate::domain::entity::anime::{ + CompleteAnimeItem, DetailGenre, FilterAnimeItem, Genre, GenreAnimeItem, HasPoster, + LatestAnimeItem, OngoingAnimeItem, OngoingAnimeItemWithScore, Pagination, + PaginationWithStringPages, SearchAnimeItem, +}; +use crate::domain::error::ScrapingError; +use crate::infrastructure::scraping::parsing_utils::parse_html; +use crate::infrastructure::scraping::parsing_utils::{ + attr, extract_slug, selector, text, text_from_or, +}; + +/// Parser-specific types for Alqanime detail data +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema, Debug, Clone)] +pub struct AlqLink { + pub name: String, + pub url: String, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema, Debug, Clone)] +pub struct AlqDownloadItem { + pub resolution: String, + pub links: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema, Debug, Clone)] +pub struct AlqRecommendation { + pub title: String, + pub slug: String, + pub poster: String, + pub status: String, + pub r#type: String, +} + +impl HasPoster for AlqRecommendation { + fn poster(&self) -> &str { + &self.poster + } + fn set_poster(&mut self, url: String) { + self.poster = url; + } +} + use regex::Regex; use scraper::Selector; +use std::sync::LazyLock; -static ITEM_SELECTOR: Lazy = Lazy::new(|| Selector::parse("article.bs").unwrap()); -static TITLE_SELECTOR: Lazy = Lazy::new(|| Selector::parse(".tt h2").unwrap()); -static IMG_SELECTOR: Lazy = Lazy::new(|| Selector::parse("img").unwrap()); -static SCORE_SELECTOR: Lazy = Lazy::new(|| Selector::parse(".numscore").unwrap()); -static STATUS_SELECTOR: Lazy = Lazy::new(|| Selector::parse(".status").unwrap()); -static TYPE_SELECTOR: Lazy = Lazy::new(|| Selector::parse(".type").unwrap()); -static LINK_SELECTOR: Lazy = Lazy::new(|| Selector::parse("a").unwrap()); -static PAGINATION_SELECTOR: Lazy = - Lazy::new(|| Selector::parse(".pagination .page-numbers:not(.next)").unwrap()); -static NEXT_SELECTOR: Lazy = Lazy::new(|| Selector::parse(".pagination .next").unwrap()); -static SLUG_REGEX: Lazy = Lazy::new(|| Regex::new(r"/([^/]+)/?$").unwrap()); -static GENRE_SLUG_REGEX: Lazy = Lazy::new(|| Regex::new(r"genre-(.+)$").unwrap()); -pub fn parse_ongoing_anime( - html: &str, -) -> Result, AppError> { +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema, Debug, Clone)] +pub struct AlqDetailData { + pub title: String, + pub alternative_title: String, + pub poster: String, + pub poster2: String, + pub r#type: String, + pub release_date: String, + pub status: String, + pub synopsis: String, + pub studio: String, + pub genres: Vec, + pub producers: Vec, + pub recommendations: Vec, + pub batch: Vec, + pub ova: Vec, + pub downloads: Vec, +} + +static ITEM_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse("article.bs").unwrap()); +static TITLE_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse(".tt h2").unwrap()); +static IMG_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse("img").unwrap()); +static SCORE_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse(".numscore").unwrap()); +static STATUS_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse(".status").unwrap()); +static TYPE_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse(".type").unwrap()); +static LINK_SELECTOR: LazyLock = LazyLock::new(|| Selector::parse("a").unwrap()); +static PAGINATION_SELECTOR: LazyLock = + LazyLock::new(|| Selector::parse(".pagination .page-numbers:not(.next)").unwrap()); +static NEXT_SELECTOR: LazyLock = + LazyLock::new(|| Selector::parse(".pagination .next").unwrap()); +static SLUG_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"/([^/]+)/?$").unwrap()); +static GENRE_SLUG_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"genre-(.+)$").unwrap()); +pub fn parse_ongoing_anime(html: &str) -> Result, ScrapingError> { let items = parse_ongoing_anime_with_score(html)?; Ok(items .into_iter() - .map( - |item| crate::shared::types::entities::anime::OngoingAnimeItem { - title: item.title, - slug: item.slug, - poster: item.poster, - current_episode: item.score, - anime_url: item.anime_url, - }, - ) + .map(|item| OngoingAnimeItem { + title: item.title, + slug: item.slug, + poster: item.poster, + current_episode: item.score, + anime_url: item.anime_url, + }) .collect()) } -pub fn parse_complete_anime( - html: &str, -) -> Result, AppError> { +pub fn parse_complete_anime(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut complete_anime = Vec::new(); @@ -68,7 +121,7 @@ pub fn parse_complete_anime( let episode_count = text_from_or(&element, &STATUS_SELECTOR, "N/A"); if !title.is_empty() { - complete_anime.push(crate::shared::types::entities::anime::CompleteAnimeItem { + complete_anime.push(CompleteAnimeItem { title, slug, poster, @@ -81,11 +134,11 @@ pub fn parse_complete_anime( Ok(complete_anime) } -pub fn parse_genres(html: &str) -> Result, AppError> { +pub fn parse_genres(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut genres = Vec::new(); let genre_label_selector = selector("label[for^=\"genre-\"]").ok_or_else(|| { - AppError::ScraperError("Invalid selector: label[for^=\"genre-\"]".to_string()) + ScrapingError::Parse("Invalid selector: label[for^=\"genre-\"]".to_string()) })?; for element in document.select(&genre_label_selector) { @@ -100,7 +153,11 @@ pub fn parse_genres(html: &str) -> Result Result Result< - ( - Vec, - crate::shared::types::entities::anime::Pagination, - ), - AppError, -> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let mut anime_list = Vec::new(); @@ -167,7 +218,7 @@ pub fn parse_filter_page( .to_string(); if !title.is_empty() { - anime_list.push(crate::shared::types::entities::anime::FilterAnimeItem { + anime_list.push(FilterAnimeItem { title, slug, poster, @@ -192,7 +243,7 @@ pub fn parse_filter_page( .unwrap_or(1); let has_next_page = document.select(&NEXT_SELECTOR).next().is_some(); - let pagination = crate::shared::types::entities::anime::Pagination { + let pagination = Pagination { current_page, last_visible_page, has_next_page, @@ -212,9 +263,7 @@ pub fn parse_filter_page( Ok((anime_list, pagination)) } -pub fn parse_genre_anime( - html: &str, -) -> Result, AppError> { +pub fn parse_genre_anime(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut anime_list = Vec::new(); @@ -259,10 +308,11 @@ pub fn parse_genre_anime( .to_string(); if !title.is_empty() { - anime_list.push(crate::shared::types::entities::anime::GenreAnimeItem { + anime_list.push(GenreAnimeItem { title, slug, poster, + episode: String::new(), score, status, anime_url, @@ -273,9 +323,7 @@ pub fn parse_genre_anime( Ok(anime_list) } -pub fn parse_search_anime( - html: &str, -) -> Result, AppError> { +pub fn parse_search_anime(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut anime_list = Vec::new(); @@ -308,14 +356,16 @@ pub fn parse_search_anime( .to_string(); if !title.is_empty() { - anime_list.push(crate::shared::types::entities::anime::SearchAnimeItem { + anime_list.push(SearchAnimeItem { title, slug, poster, - description: String::new(), + episode: String::new(), anime_url, genres: Vec::new(), + status: String::new(), rating: "N/A".to_string(), + description: String::new(), r#type: "Unknown".to_string(), season: "Unknown".to_string(), }); @@ -325,9 +375,7 @@ pub fn parse_search_anime( Ok(anime_list) } -pub fn parse_latest_anime( - html: &str, -) -> Result, AppError> { +pub fn parse_latest_anime(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut anime_list = Vec::new(); @@ -366,11 +414,11 @@ pub fn parse_latest_anime( .to_string(); if !title.is_empty() { - anime_list.push(crate::shared::types::entities::anime::LatestAnimeItem { + anime_list.push(LatestAnimeItem { title, slug, poster, - current_episode: "N/A".to_string(), + episode: "N/A".to_string(), score, anime_url, }); @@ -382,7 +430,7 @@ pub fn parse_latest_anime( pub fn parse_ongoing_anime_with_score( html: &str, -) -> Result, AppError> { +) -> Result, ScrapingError> { let document = parse_html(html); let mut anime_list = Vec::new(); @@ -421,15 +469,13 @@ pub fn parse_ongoing_anime_with_score( .to_string(); if !title.is_empty() { - anime_list.push( - crate::shared::types::entities::anime::OngoingAnimeItemWithScore { - title, - slug, - poster, - score, - anime_url, - }, - ); + anime_list.push(OngoingAnimeItemWithScore { + title, + slug, + poster, + score, + anime_url, + }); } } @@ -439,7 +485,7 @@ pub fn parse_ongoing_anime_with_score( pub fn parse_pagination( document: &scraper::Html, current_page: u32, -) -> Result { +) -> Result { let last_visible_page = document .select(&PAGINATION_SELECTOR) .next_back() @@ -453,7 +499,7 @@ pub fn parse_pagination( .unwrap_or(1); let has_next_page = document.select(&NEXT_SELECTOR).next().is_some(); - let pagination = crate::shared::types::entities::anime::Pagination { + let pagination = Pagination { current_page, last_visible_page, has_next_page, @@ -476,7 +522,7 @@ pub fn parse_pagination( pub fn parse_pagination_with_string( document: &scraper::Html, current_page: u32, -) -> Result { +) -> Result { let last_visible_page = document .select(&PAGINATION_SELECTOR) .next_back() @@ -490,7 +536,7 @@ pub fn parse_pagination_with_string( .unwrap_or(1); let has_next_page = document.select(&NEXT_SELECTOR).next().is_some(); - let pagination = crate::shared::types::entities::anime::PaginationWithStringPages { + let pagination = PaginationWithStringPages { current_page, last_visible_page, has_next_page, @@ -510,55 +556,53 @@ pub fn parse_pagination_with_string( Ok(pagination) } -pub fn parse_anime_detail( - html: &str, -) -> Result { +pub fn parse_anime_detail(html: &str) -> Result { let document = parse_html(html); let title_selector = selector(".entry-title") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .entry-title".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .entry-title".to_string()))?; let alt_title_selector = selector(".alter") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .alter".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .alter".to_string()))?; let poster_selector = selector(".thumb img, .thumbook img, .wp-post-image, .ts-post-image") .ok_or_else(|| { - AppError::ScraperError( + ScrapingError::Parse( "Invalid selector: .thumb img, .thumbook img, .wp-post-image, .ts-post-image" .to_string(), ) })?; let poster2_selector = selector(".bigcover img, .bixbox.animefull .bigcover .ime img") .ok_or_else(|| { - AppError::ScraperError( + ScrapingError::Parse( "Invalid selector: .bigcover img, .bixbox.animefull .bigcover .ime img".to_string(), ) })?; let spe_span_selector = selector(".info-content .spe span").ok_or_else(|| { - AppError::ScraperError("Invalid selector: .info-content .spe span".to_string()) + ScrapingError::Parse("Invalid selector: .info-content .spe span".to_string()) })?; let a_selector = - selector("a").ok_or_else(|| AppError::ScraperError("Invalid selector: a".to_string()))?; + selector("a").ok_or_else(|| ScrapingError::Parse("Invalid selector: a".to_string()))?; let synopsis_selector = selector(".entry-content p") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .entry-content p".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .entry-content p".to_string()))?; let genre_selector = selector(".genxed a") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .genxed a".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .genxed a".to_string()))?; let download_container_selector = selector(".soraddl.dlone") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .soraddl.dlone".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .soraddl.dlone".to_string()))?; let resolution_selector = selector(".res") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .res".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .res".to_string()))?; let link_selector = selector(".slink a") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .slink a".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .slink a".to_string()))?; let h3_selector = - selector("h3").ok_or_else(|| AppError::ScraperError("Invalid selector: h3".to_string()))?; + selector("h3").ok_or_else(|| ScrapingError::Parse("Invalid selector: h3".to_string()))?; let recommendation_selector = selector(".listupd .bs") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .listupd .bs".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .listupd .bs".to_string()))?; let rec_title_selector = selector(".ntitle") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .ntitle".to_string()))?; - let rec_img_selector = selector("img") - .ok_or_else(|| AppError::ScraperError("Invalid selector: img".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .ntitle".to_string()))?; + let rec_img_selector = + selector("img").ok_or_else(|| ScrapingError::Parse("Invalid selector: img".to_string()))?; let status_selector = selector(".status") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .status".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .status".to_string()))?; let type_selector = selector(".typez") - .ok_or_else(|| AppError::ScraperError("Invalid selector: .typez".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: .typez".to_string()))?; let title = text_from_or(&document.root_element(), &title_selector, ""); let alternative_title = text_from_or(&document.root_element(), &alt_title_selector, ""); @@ -616,7 +660,7 @@ pub fn parse_anime_detail( let name = text(&element); let anime_url = attr(&element, "href").unwrap_or_default(); let genre_slug = extract_slug(&anime_url); - genres.push(crate::modules::anime2::types::DetailGenre { + genres.push(DetailGenre { name, slug: genre_slug, anime_url, @@ -641,7 +685,7 @@ pub fn parse_anime_detail( let mut all_links = Vec::new(); let row_selector = selector("table tr") - .ok_or_else(|| AppError::ScraperError("Invalid selector: table tr".to_string()))?; + .ok_or_else(|| ScrapingError::Parse("Invalid selector: table tr".to_string()))?; for row in element.select(&row_selector) { let resolution = text_from_or(&row, &resolution_selector, ""); @@ -655,11 +699,11 @@ pub fn parse_anime_detail( provider }; - all_links.push(crate::modules::anime2::types::Link { name, url }); + all_links.push(AlqLink { name, url }); } } - let download_item = crate::modules::anime2::types::DownloadItem { + let download_item = AlqDownloadItem { resolution: title, links: all_links, }; @@ -695,7 +739,7 @@ pub fn parse_anime_detail( let r#type = text_from_or(&element, &type_selector, ""); - recommendations.push(crate::modules::anime2::types::Recommendation { + recommendations.push(AlqRecommendation { title, slug: rec_slug, poster, @@ -704,7 +748,7 @@ pub fn parse_anime_detail( }); } - Ok(crate::modules::anime2::types::AnimeDetailData { + Ok(AlqDetailData { title, alternative_title, poster, @@ -726,13 +770,7 @@ pub fn parse_anime_detail( pub fn parse_genre_page( html: &str, current_page: u32, -) -> Result< - ( - Vec, - crate::shared::types::entities::anime::Pagination, - ), - AppError, -> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let anime_list = parse_genre_anime(html)?; let pagination = parse_pagination(&document, current_page)?; @@ -742,13 +780,7 @@ pub fn parse_genre_page( pub fn parse_search_page( html: &str, current_page: u32, -) -> Result< - ( - Vec, - crate::shared::types::entities::anime::PaginationWithStringPages, - ), - AppError, -> { +) -> Result<(Vec, PaginationWithStringPages), ScrapingError> { let document = parse_html(html); let data = parse_search_anime(html)?; let pagination = parse_pagination_with_string(&document, current_page)?; @@ -758,13 +790,7 @@ pub fn parse_search_page( pub fn parse_latest_page( html: &str, current_page: u32, -) -> Result< - ( - Vec, - crate::shared::types::entities::anime::Pagination, - ), - AppError, -> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let anime_list = parse_latest_anime(html)?; let pagination = parse_pagination(&document, current_page)?; @@ -774,13 +800,7 @@ pub fn parse_latest_page( pub fn parse_ongoing_page( html: &str, current_page: u32, -) -> Result< - ( - Vec, - crate::shared::types::entities::anime::Pagination, - ), - AppError, -> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let anime_list = parse_ongoing_anime_with_score(html)?; let pagination = parse_pagination(&document, current_page)?; @@ -790,13 +810,7 @@ pub fn parse_ongoing_page( pub fn parse_complete_page( html: &str, current_page: u32, -) -> Result< - ( - Vec, - crate::shared::types::entities::anime::Pagination, - ), - AppError, -> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let anime_list = parse_complete_anime(html)?; let pagination = parse_pagination(&document, current_page)?; diff --git a/src/modules/komik/parser.rs b/src/infrastructure/repository/parsers/komik_parser.rs similarity index 80% rename from src/modules/komik/parser.rs rename to src/infrastructure/repository/parsers/komik_parser.rs index 0b4c45a..d5db9cf 100644 --- a/src/modules/komik/parser.rs +++ b/src/infrastructure/repository/parsers/komik_parser.rs @@ -1,41 +1,51 @@ -use crate::modules::komik::types::{ChapterData, DetailData, Genre, KomikItem, Pagination}; -use crate::shared::utils::parse_html; -use crate::shared::utils::scraping::{attr, attr_from, attr_from_or, selector, text, text_from_or}; -use once_cell::sync::Lazy; +use crate::domain::entity::anime::Pagination; +use crate::domain::entity::komik::{Chapter, ChapterData, DetailData, KomikGenre, KomikItem}; +use crate::domain::error::ScrapingError; +use crate::infrastructure::scraping::parsing_utils::parse_html; +use crate::infrastructure::scraping::parsing_utils::{ + attr, attr_from, attr_from_or, selector, text, text_from_or, +}; use rayon::prelude::*; use regex::Regex; +use std::sync::LazyLock; use tracing::info; -static TD_LAST_SELECTOR: Lazy = Lazy::new(|| selector("td:last-child").unwrap()); -static TITLE_SELECTOR: Lazy = - Lazy::new(|| selector("div#Judul h1 span[itemprop=\"name\"]").unwrap()); -static H1_SELECTOR: Lazy = Lazy::new(|| selector("h1").unwrap()); -static TITLE_TAG_SELECTOR: Lazy = Lazy::new(|| selector("title").unwrap()); -static INFO_ROW_SELECTOR: Lazy = - Lazy::new(|| selector("table.inftable tr").unwrap()); -static POSTER_SELECTOR: Lazy = - Lazy::new(|| selector("section#Informasi .ims img").unwrap()); -static DESC_SELECTOR: Lazy = Lazy::new(|| selector("p.desc").unwrap()); -static CHAPTER_LIST_SELECTOR: Lazy = - Lazy::new(|| selector("#Daftar_Chapter tr, tbody#daftarChapter tr").unwrap()); -static DATE_LINK_SELECTOR: Lazy = - Lazy::new(|| selector("td.tanggalseries, .tanggalseries").unwrap()); -static JUDUL2_SELECTOR: Lazy = Lazy::new(|| selector("div.judul2").unwrap()); -static GENRE_SELECTOR: Lazy = Lazy::new(|| selector("ul.genre li a").unwrap()); -static CHAPTER_LINK_SELECTOR: Lazy = - Lazy::new(|| selector("td.judulseries a").unwrap()); -static CHAPTER_TITLE_REGEX: Lazy = - Lazy::new(|| Regex::new(r"(?i)(?:chapter|ch\.?)\s*([\d\.]+)").unwrap()); -static CHAPTER_NUMBER_REGEX: Lazy = Lazy::new(|| Regex::new(r"([\d\.]+)").unwrap()); +static TD_LAST_SELECTOR: LazyLock = + LazyLock::new(|| selector("td:last-child").unwrap()); +static TITLE_SELECTOR: LazyLock = + LazyLock::new(|| selector("div#Judul h1 span[itemprop=\"name\"]").unwrap()); +static H1_SELECTOR: LazyLock = LazyLock::new(|| selector("h1").unwrap()); +static TITLE_TAG_SELECTOR: LazyLock = + LazyLock::new(|| selector("title").unwrap()); +static INFO_ROW_SELECTOR: LazyLock = + LazyLock::new(|| selector("table.inftable tr").unwrap()); +static POSTER_SELECTOR: LazyLock = + LazyLock::new(|| selector("section#Informasi .ims img").unwrap()); +static DESC_SELECTOR: LazyLock = LazyLock::new(|| selector("p.desc").unwrap()); +static CHAPTER_LIST_SELECTOR: LazyLock = + LazyLock::new(|| selector("#Daftar_Chapter tr, tbody#daftarChapter tr").unwrap()); +static DATE_LINK_SELECTOR: LazyLock = + LazyLock::new(|| selector("td.tanggalseries, .tanggalseries").unwrap()); +static JUDUL2_SELECTOR: LazyLock = + LazyLock::new(|| selector("div.judul2").unwrap()); +static GENRE_SELECTOR: LazyLock = + LazyLock::new(|| selector("ul.genre li a").unwrap()); +static CHAPTER_LINK_SELECTOR: LazyLock = + LazyLock::new(|| selector("td.judulseries a").unwrap()); +static CHAPTER_TITLE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)(?:chapter|ch\.?)\s*([\d\.]+)").unwrap()); +static CHAPTER_NUMBER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"([\d\.]+)").unwrap()); -pub fn parse_genres(html: &str) -> Result, String> { +pub fn parse_genres(html: &str) -> Result, ScrapingError> { let document = parse_html(html); let mut genres = Vec::new(); - let genre_selector = - selector("#Genre .ls3, section#Genre .ls3, .ls3").ok_or("Selector error".to_string())?; - let genre_name_selector = selector(".ls3p h4, h4").ok_or("Selector error".to_string())?; - let genre_link_selector = selector("a[href*='/genre/']").ok_or("Selector error".to_string())?; + let genre_selector = selector("#Genre .ls3, section#Genre .ls3, .ls3") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let genre_name_selector = + selector(".ls3p h4, h4").ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let genre_link_selector = + selector("a[href*='/genre/']").ok_or(ScrapingError::Parse("Selector error".to_string()))?; let slug_regex = Regex::new(r"/genre/([^/]+)").unwrap(); for element in document.select(&genre_selector) { @@ -50,7 +60,7 @@ pub fn parse_genres(html: &str) -> Result, String> { .to_string(); if !name.is_empty() && !slug.is_empty() { - genres.push(Genre { + genres.push(KomikGenre { name, slug, count: None, @@ -62,22 +72,26 @@ pub fn parse_genres(html: &str) -> Result, String> { Ok(genres) } -pub fn parse_komik_chapter_document(html: &str, chapter_url: &str) -> Result { +pub fn parse_komik_chapter_document( + html: &str, + chapter_url: &str, +) -> Result { let document = parse_html(html); let _start_time = std::time::Instant::now(); info!("Starting to parse komik chapter document"); - let title_selector = selector("title").ok_or("Selector error".to_string())?; + let title_selector = + selector("title").ok_or(ScrapingError::Parse("Selector error".to_string()))?; let prev_chapter_selector = selector( "a[aria-label='Prev'][href*='chapter'], .nxpr a:not(.rl):not([href*='#Chapter']), .chprev a, a.prev", ) - .ok_or("Selector error".to_string())?; + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; let next_chapter_selector = selector( "a[aria-label='Next'][href*='chapter'], .nxpr a.rl, .nxpr a.next, .chnext a, a.next", ) - .ok_or("Selector error".to_string())?; - let image_selector = - selector("#Baca_Komik img, img.klazy.ww").ok_or("Selector error".to_string())?; + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let image_selector = selector("#Baca_Komik img, img.klazy.ww") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; let title = document .select(&title_selector) @@ -243,7 +257,7 @@ fn find_table_row_with_text<'a>( }) } -pub fn parse_komik_detail_document(html: &str) -> Result { +pub fn parse_komik_detail_document(html: &str) -> Result { let start_time = std::time::Instant::now(); info!("Starting to parse komik detail document"); @@ -446,7 +460,7 @@ pub fn parse_komik_detail_document(html: &str) -> Result { }) .collect(); - let chapters: Vec = raw_chapter_data + let chapters: Vec = raw_chapter_data .par_iter() .filter_map(|(chapter_text, date_text, href_text)| { let chapter = { @@ -474,7 +488,7 @@ pub fn parse_komik_detail_document(html: &str) -> Result { .to_string(); if !chapter_id.is_empty() { - Some(crate::modules::komik::types::Chapter { + Some(Chapter { chapter, date, chapter_id, @@ -506,21 +520,26 @@ pub fn parse_komik_detail_document(html: &str) -> Result { pub fn parse_genre_page( html: &str, current_page: u32, -) -> Result<(Vec, Pagination), String> { +) -> Result<(Vec, Pagination), ScrapingError> { let document = parse_html(html); let mut komik_list = Vec::new(); - let item_selector = - selector(".bge, article, .ls4, .ls2").ok_or("Selector error".to_string())?; - let title_selector = selector(".kan h3, h3 a, h4 a").ok_or("Selector error".to_string())?; - let img_selector = selector(".bgei img, img.lazy, img").ok_or("Selector error".to_string())?; + let item_selector = selector(".bge, article, .ls4, .ls2") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let title_selector = selector(".kan h3, h3 a, h4 a") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let img_selector = selector(".bgei img, img.lazy, img") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; let chapter_selector = selector(".new1:last-of-type a span:last-child, .new1 a:last-child span:last-child, .ls4s a, .ls24, .ls2l a") - .ok_or("Selector error".to_string())?; - let score_selector = selector(".up, .numscore, .epx").ok_or("Selector error".to_string())?; - let type_selector = selector(".tpe1_inf, .ls3p, .type").ok_or("Selector error".to_string())?; - let link_selector = - selector(".kan h3 a, .bgei a, h3 a, h4 a, a").ok_or("Selector error".to_string())?; - let next_selector = selector("span[hx-get]").ok_or("Selector error".to_string())?; + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let score_selector = selector(".up, .numscore, .epx") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let type_selector = selector(".tpe1_inf, .ls3p, .type") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let link_selector = selector(".kan h3 a, .bgei a, h3 a, h4 a, a") + .ok_or(ScrapingError::Parse("Selector error".to_string()))?; + let next_selector = + selector("span[hx-get]").ok_or(ScrapingError::Parse("Selector error".to_string()))?; let slug_regex = Regex::new(r"/([^/]+)/?$").unwrap(); for element in document.select(&item_selector) { diff --git a/src/infrastructure/repository/parsers/mod.rs b/src/infrastructure/repository/parsers/mod.rs new file mode 100644 index 0000000..c838d21 --- /dev/null +++ b/src/infrastructure/repository/parsers/mod.rs @@ -0,0 +1,3 @@ +pub mod alqanime_parser; +pub mod komik_parser; +pub mod otakudesu_parser; diff --git a/src/infrastructure/repository/parsers/otakudesu_parser.rs b/src/infrastructure/repository/parsers/otakudesu_parser.rs new file mode 100644 index 0000000..7d0d3be --- /dev/null +++ b/src/infrastructure/repository/parsers/otakudesu_parser.rs @@ -0,0 +1,621 @@ +//! Otakudesu HTML parser — native implementation using infrastructure utilities. +//! +//! Parses Otakudesu HTML pages into domain types. All parsing runs in +//! `spawn_blocking` (called from the repository layer). + +use crate::domain::entity::anime::*; +use crate::domain::error::ScrapingError; +use crate::infrastructure::scraping::parsing_utils::{ + attr, attr_from, attr_from_or, extract_slug, parse_html, selector, text, text_from_or, +}; + +// ============================================================================ +// INDEX (Ongoing + Complete Anime) +// ============================================================================ + +pub fn parse_ongoing_anime(html: &str) -> Result, ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let href = attr_from(&element, &link_sel, "href").unwrap_or_default(); + let slug = extract_slug(&href); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let current_episode = text_from_or(&element, &ep_sel, "N/A"); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + + if !title.is_empty() { + items.push(OngoingAnimeItem { + title, + slug, + poster, + current_episode, + anime_url, + }); + } + } + Ok(items) +} + +pub fn parse_complete_anime(html: &str) -> Result, ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let href = attr_from(&element, &link_sel, "href").unwrap_or_default(); + let slug = extract_slug(&href); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let episode_count = text_from_or(&element, &ep_sel, "N/A"); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + + if !title.is_empty() { + items.push(CompleteAnimeItem { + title, + slug, + poster, + episode_count, + anime_url, + }); + } + } + Ok(items) +} + +// ============================================================================ +// GENRES +// ============================================================================ + +pub fn parse_genres(html: &str) -> Result, ScrapingError> { + let document = parse_html(html); + let mut genres = Vec::new(); + let genre_sel = selector(".genres li a, .genre-list a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse genre selector".into()))?; + + for element in document.select(&genre_sel) { + let name = text(&element); + let url = attr(&element, "href").unwrap_or_default(); + let slug = extract_slug(&url); + + if !name.is_empty() && !slug.is_empty() { + genres.push(Genre { name, slug, url }); + } + } + Ok(genres) +} + +// ============================================================================ +// DETAIL +// ============================================================================ + +pub fn parse_anime_detail_document(html: &str) -> Result { + let document = parse_html(html); + + let info_sel = selector(".infozingle p") + .ok_or_else(|| ScrapingError::Parse("Failed to parse info selector".into()))?; + let poster_sel = selector(".fotoanime img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse poster selector".into()))?; + let synopsis_sel = selector(".sinopc") + .ok_or_else(|| ScrapingError::Parse("Failed to parse synopsis selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let ep_list_sel = selector(".episodelist ul li a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse episode list selector".into()))?; + let rec_sel = selector("#recommend-anime-series .isi-anime") + .ok_or_else(|| ScrapingError::Parse("Failed to parse recommendation selector".into()))?; + let rec_title_sel = selector(".judul-anime a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse rec title selector".into()))?; + let rec_img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse rec img selector".into()))?; + + let mut title = String::new(); + let mut alternative_title = String::new(); + let mut r#type: Option = None; + let mut status: Option = None; + let mut release_date = String::new(); + let mut studio = String::new(); + + for element in document.select(&info_sel) { + let text = text(&element); + if text.contains("Judul:") { + title = text.replace("Judul:", "").trim().to_string(); + } else if text.contains("Japanese:") { + alternative_title = text.replace("Japanese:", "").trim().to_string(); + } else if text.contains("Type:") { + let type_str = text.replace("Type:", "").trim().to_string(); + if !type_str.is_empty() { + r#type = Some(type_str); + } + } else if text.contains("Status:") { + let status_str = text.replace("Status:", "").trim().to_string(); + if !status_str.is_empty() { + status = Some(status_str); + } + } else if text.contains("Tanggal Rilis:") { + release_date = text.replace("Tanggal Rilis:", "").trim().to_string(); + } else if text.contains("Studio:") { + studio = text.replace("Studio:", "").trim().to_string(); + } + } + + let poster = document + .select(&poster_sel) + .next() + .and_then(|e| e.value().attr("src")) + .unwrap_or("") + .to_string(); + + let synopsis = text_from_or(&document.root_element(), &synopsis_sel, ""); + + let mut genres = Vec::new(); + if let Some(genres_element) = document + .select(&info_sel) + .find(|e| text(e).contains("Genres:")) + { + for genre_link in genres_element.select(&link_sel) { + let gname = text(&genre_link); + let anine_url = attr(&genre_link, "href").unwrap_or_default(); + let genre_slug = extract_slug(&anine_url); + genres.push(DetailGenre { + name: gname, + slug: genre_slug, + anime_url: anine_url, + }); + } + } + + let mut episode_lists = Vec::new(); + for element in document.select(&ep_list_sel) { + let episode = text(&element); + let href = attr(&element, "href").unwrap_or_default(); + let slug = extract_slug(&href); + episode_lists.push(EpisodeList { episode, slug }); + } + + let mut recommendations = Vec::new(); + for element in document.select(&rec_sel) { + let rtitle = text_from_or(&element, &rec_title_sel, ""); + let rposter = attr_from_or(&element, &rec_img_sel, "src", ""); + let rhref = element + .select(&link_sel) + .next() + .and_then(|e| e.value().attr("href")) + .unwrap_or(""); + + let rslug = extract_slug(rhref); + + recommendations.push(Recommendation { + title: rtitle, + slug: rslug, + poster: rposter, + status: None, + r#type: None, + }); + } + + Ok(AnimeDetailData { + title, + alternative_title, + poster, + r#type, + status, + release_date, + studio, + genres, + synopsis, + episode_lists, + batch: vec![], + producers: vec![], + recommendations, + }) +} + +// ============================================================================ +// PAGINATION HELPER +// ============================================================================ + +fn parse_pagination(slug: &str, document: &scraper::Html) -> Result { + let pagination_sel = selector(".pagenavix .page-numbers:not(.next)") + .ok_or_else(|| ScrapingError::Parse("Failed to parse pagination selector".into()))?; + let next_sel = selector(".pagenavix .next.page-numbers") + .ok_or_else(|| ScrapingError::Parse("Failed to parse next selector".into()))?; + + let current_page = slug.parse::().unwrap_or(1); + let last_visible_page = document + .select(&pagination_sel) + .next_back() + .and_then(|e| e.text().collect::().trim().parse::().ok()) + .unwrap_or(1); + + let has_next_page = document.select(&next_sel).next().is_some(); + let next_page = if has_next_page { + Some(current_page + 1) + } else { + None + }; + let has_previous_page = current_page > 1; + let previous_page = if has_previous_page { + Some(current_page - 1) + } else { + None + }; + + Ok(Pagination { + current_page, + last_visible_page, + has_next_page, + next_page, + has_previous_page, + previous_page, + }) +} + +// ============================================================================ +// COMPLETE ANIME PAGE +// ============================================================================ + +pub fn parse_anime_page( + html: &str, + slug: &str, +) -> Result<(Vec, Pagination), ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + let slug = extract_slug(&anime_url); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let episode_count = text_from_or(&element, &ep_sel, "N/A"); + + if !title.is_empty() { + items.push(CompleteAnimeListItem { + title, + slug, + poster, + episode_count, + anime_url, + }); + } + } + + let pagination = parse_pagination(slug, &document)?; + Ok((items, pagination)) +} + +// ============================================================================ +// ONGOING ANIME PAGE +// ============================================================================ + +pub fn parse_ongoing_anime_document( + html: &str, + slug: &str, +) -> Result<(Vec, Pagination), ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let score_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + let slug = extract_slug(&anime_url); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let score = text_from_or(&element, &score_sel, "N/A"); + + if !title.is_empty() { + items.push(OngoingAnimeListItem { + title, + slug, + poster, + score, + anime_url, + }); + } + } + + let pagination = parse_pagination(slug, &document)?; + Ok((items, pagination)) +} + +// ============================================================================ +// LATEST ANIME PAGE +// ============================================================================ + +pub fn parse_latest_anime_document( + html: &str, + slug: &str, +) -> Result<(Vec, Pagination), ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + let slug = extract_slug(&anime_url); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let episode = text_from_or(&element, &ep_sel, "N/A"); + + if !title.is_empty() { + items.push(LatestAnimeItem { + title, + slug, + poster, + episode, + score: String::new(), + anime_url, + }); + } + } + + let pagination = parse_pagination(slug, &document)?; + Ok((items, pagination)) +} + +// ============================================================================ +// SEARCH ANIME PAGE +// ============================================================================ + +pub fn parse_search_anime_document( + html: &str, + page: &str, +) -> Result<(Vec, Pagination), ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + let genre_sel = selector(".genre-tag") + .ok_or_else(|| ScrapingError::Parse("Failed to parse genre-tag selector".into()))?; + let status_sel = selector(".status") + .ok_or_else(|| ScrapingError::Parse("Failed to parse status selector".into()))?; + let rating_sel = selector(".rating") + .ok_or_else(|| ScrapingError::Parse("Failed to parse rating selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + let slug = extract_slug(&anime_url); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let episode = text_from_or(&element, &ep_sel, "N/A"); + + let mut genres = Vec::new(); + for genre_elem in element.select(&genre_sel) { + genres.push(text(&genre_elem)); + } + + let status = text_from_or(&element, &status_sel, ""); + let rating = text_from_or(&element, &rating_sel, ""); + + if !title.is_empty() { + items.push(SearchAnimeItem { + title, + slug, + poster, + episode, + anime_url, + genres, + status, + rating, + description: String::new(), + r#type: String::new(), + season: String::new(), + }); + } + } + + let pagination = parse_pagination(page, &document)?; + Ok((items, pagination)) +} + +// ============================================================================ +// GENRE ANIME PAGE +// ============================================================================ + +pub fn parse_genre_anime_document( + html: &str, + page: &str, +) -> Result<(Vec, Pagination), ScrapingError> { + let document = parse_html(html); + let mut items = Vec::new(); + + let venz_sel = selector(".venz ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .venz ul li selector".into()))?; + let title_sel = selector(".thumbz h2.jdlflm") + .ok_or_else(|| ScrapingError::Parse("Failed to parse title selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let img_sel = selector("img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse img selector".into()))?; + let ep_sel = selector(".epz") + .ok_or_else(|| ScrapingError::Parse("Failed to parse epz selector".into()))?; + + for element in document.select(&venz_sel) { + let title = text_from_or(&element, &title_sel, ""); + let anime_url = attr_from_or(&element, &link_sel, "href", ""); + let slug = extract_slug(&anime_url); + let poster = attr_from_or(&element, &img_sel, "src", ""); + let episode = text_from_or(&element, &ep_sel, "N/A"); + + if !title.is_empty() { + items.push(GenreAnimeItem { + title, + slug, + poster, + episode, + score: String::new(), + status: String::new(), + anime_url, + }); + } + } + + let pagination = parse_pagination(page, &document)?; + Ok((items, pagination)) +} + +// ============================================================================ +// FULL EPISODE PAGE +// ============================================================================ + +pub fn parse_anime_full_document(html: &str, slug: &str) -> Result { + let document = parse_html(html); + + let ep_title_sel = selector("h1.posttl") + .ok_or_else(|| ScrapingError::Parse("Failed to parse h1.posttl selector".into()))?; + let img_sel = selector(".cukder img") + .ok_or_else(|| ScrapingError::Parse("Failed to parse .cukder img selector".into()))?; + let stream_sel = selector("#embed_holder iframe") + .ok_or_else(|| ScrapingError::Parse("Failed to parse embed_holder selector".into()))?; + let dl_item_sel = selector(".download ul li") + .ok_or_else(|| ScrapingError::Parse("Failed to parse download selector".into()))?; + let res_sel = selector("strong") + .ok_or_else(|| ScrapingError::Parse("Failed to parse strong selector".into()))?; + let link_sel = selector("a") + .ok_or_else(|| ScrapingError::Parse("Failed to parse link selector".into()))?; + let next_ep_sel = selector(".flir a[title*='Episode Selanjutnya']") + .ok_or_else(|| ScrapingError::Parse("Failed to parse next episode selector".into()))?; + let prev_ep_sel = selector(".flir a[title*='Episode Sebelumnya']") + .ok_or_else(|| ScrapingError::Parse("Failed to parse prev episode selector".into()))?; + + let episode = document + .select(&ep_title_sel) + .next() + .map(|e| text(&e)) + .unwrap_or_default(); + + let episode_number = episode + .split("Episode") + .nth(1) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let image_url = document + .select(&img_sel) + .next() + .and_then(|e| attr(&e, "src")) + .unwrap_or_default(); + + let stream_url = document + .select(&stream_sel) + .next() + .and_then(|e| attr(&e, "src")) + .unwrap_or_default(); + + let mut download_urls = std::collections::HashMap::new(); + + for element in document.select(&dl_item_sel) { + let resolution = element + .select(&res_sel) + .next() + .map(|e| text(&e)) + .unwrap_or_default(); + + let mut links = Vec::new(); + for link_element in element.select(&link_sel) { + let server = text(&link_element); + let url = attr(&link_element, "href").unwrap_or_default(); + links.push(DownloadLink { server, url }); + } + + if !resolution.is_empty() && !links.is_empty() { + download_urls.insert(resolution, links); + } + } + + let next_episode_element = document.select(&next_ep_sel).next(); + let previous_episode_element = document.select(&prev_ep_sel).next(); + + let next_episode_slug = next_episode_element + .and_then(|e| attr(&e, "href")) + .and_then(|href| { + href.split('/') + .nth(href.split('/').count().saturating_sub(2)) + .map(|s| s.to_string() + "/") + }); + + let previous_episode_slug = previous_episode_element + .and_then(|e| attr(&e, "href")) + .and_then(|href| { + href.split('/') + .nth(href.split('/').count().saturating_sub(2)) + .map(|s| s.to_string() + "/") + }); + + Ok(AnimeFullData { + episode, + episode_number, + anime: AnimeInfo { + slug: slug.to_string(), + }, + has_next_episode: next_episode_slug.is_some(), + next_episode: next_episode_slug.map(|s| EpisodeInfo { slug: s }), + has_previous_episode: previous_episode_slug.is_some(), + previous_episode: previous_episode_slug.map(|s| EpisodeInfo { slug: s }), + stream_url, + download_urls, + image_url, + }) +} diff --git a/src/infrastructure/repository/proxy.rs b/src/infrastructure/repository/proxy.rs new file mode 100644 index 0000000..92bb933 --- /dev/null +++ b/src/infrastructure/repository/proxy.rs @@ -0,0 +1,28 @@ +//! Proxy fetching repository. + +use async_trait::async_trait; + +use crate::domain::error::ScrapingError; +use crate::domain::repository::ScrapingRepository; +use crate::infrastructure::scraping::proxy_fetch::{self, FetchResult}; + +pub struct ProxyRepository; + +impl ProxyRepository { + pub fn new() -> Self { + Self + } + + pub async fn fetch_with_proxy_url(&self, url: &str) -> Result { + proxy_fetch::fetch_with_proxy(url) + .await + .map_err(|e| ScrapingError::Http(format!("Proxy fetch failed: {}", e))) + } +} + +#[async_trait] +impl ScrapingRepository for ProxyRepository { + async fn fetch_html(&self, url: &str) -> Result { + self.fetch_with_proxy_url(url).await.map(|r| r.data) + } +} diff --git a/src/infrastructure/scraping/html_fetcher.rs b/src/infrastructure/scraping/html_fetcher.rs new file mode 100644 index 0000000..6420c8a --- /dev/null +++ b/src/infrastructure/scraping/html_fetcher.rs @@ -0,0 +1,11 @@ +//! HTML scraping helpers — re-exports from retry and parsing_utils modules. +//! +//! This module consolidates common scraping utilities for convenient single-path imports. +//! Downstream code should use `crate::infrastructure::scraping::html_fetcher::*`. + +// Re-export retry utilities +pub use crate::infrastructure::scraping::retry::{ + custom_backoff, default_backoff, permanent, quick_backoff, retry, slow_backoff, transient, +}; +// Re-export common scraping helpers (fetch_html_with_retry, parse_html, selector, text, attr, etc.) +pub use crate::infrastructure::scraping::parsing_utils::*; diff --git a/src/infrastructure/scraping/mod.rs b/src/infrastructure/scraping/mod.rs new file mode 100644 index 0000000..65419a3 --- /dev/null +++ b/src/infrastructure/scraping/mod.rs @@ -0,0 +1,5 @@ +pub mod html_fetcher; +pub mod parsing_utils; +pub mod proxy_fetch; +pub mod retry; +pub mod scraping_urls; diff --git a/src/shared/utils/web/scraping.rs b/src/infrastructure/scraping/parsing_utils.rs similarity index 86% rename from src/shared/utils/web/scraping.rs rename to src/infrastructure/scraping/parsing_utils.rs index ac6a47a..685b5d1 100644 --- a/src/shared/utils/web/scraping.rs +++ b/src/infrastructure/scraping/parsing_utils.rs @@ -1,16 +1,18 @@ //! HTML scraping helpers using scraper crate. +//! +//! Adapted from shared/utils/web/scraping.rs with domain-level errors. -use crate::shared::errors::AppError; -use crate::shared::utils::web::proxy_fetch::fetch_with_proxy; -use crate::shared::utils::{default_backoff, transient}; +use crate::domain::error::ScrapingError; +use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy; +use crate::infrastructure::scraping::retry::{default_backoff, transient}; use backoff::future::retry; -use once_cell::sync::Lazy; use regex::Regex; use scraper::{ElementRef, Html, Selector}; +use std::sync::LazyLock; use tracing::{info, warn}; /// Fetch HTML from URL with retry backoff and proxy support. -pub async fn fetch_html_with_retry(url: &str) -> Result { +pub async fn fetch_html_with_retry(url: &str) -> Result { let backoff = default_backoff(); let fetch_operation = || async { info!("Fetching: {}", url); @@ -28,7 +30,7 @@ pub async fn fetch_html_with_retry(url: &str) -> Result { retry(backoff, fetch_operation) .await - .map_err(|e| AppError::ScraperError(e.to_string())) + .map_err(|e| ScrapingError::Http(e.to_string())) } /// Parse HTML string into a document. @@ -105,7 +107,8 @@ pub fn select_all<'a>(document: &'a Html, css: &str) -> Vec> { /// Extract slug from URL (last path segment). pub fn extract_slug(url: &str) -> String { - static SLUG_REGEX: Lazy> = Lazy::new(|| Regex::new(r"/([^/]+)/?$")); + static SLUG_REGEX: LazyLock> = + LazyLock::new(|| Regex::new(r"/([^/]+)/?$")); SLUG_REGEX .as_ref() @@ -118,7 +121,8 @@ pub fn extract_slug(url: &str) -> String { /// Remove HTML tags from string. pub fn strip_tags(html: &str) -> String { - static TAG_REGEX: Lazy> = Lazy::new(|| Regex::new(r"<[^>]+>")); + static TAG_REGEX: LazyLock> = + LazyLock::new(|| Regex::new(r"<[^>]+>")); TAG_REGEX .as_ref() .map(|r| r.replace_all(html, "").trim().to_string()) @@ -127,7 +131,7 @@ pub fn strip_tags(html: &str) -> String { /// Extract number from text. pub fn extract_number(text: &str) -> Option { - static NUM_REGEX: Lazy> = Lazy::new(|| Regex::new(r"\d+")); + static NUM_REGEX: LazyLock> = LazyLock::new(|| Regex::new(r"\d+")); NUM_REGEX .as_ref() .ok() @@ -137,8 +141,8 @@ pub fn extract_number(text: &str) -> Option { /// Extract text inside parentheses. pub fn extract_parentheses(text: &str) -> Option { - static PAREN_REGEX: Lazy> = - Lazy::new(|| Regex::new(r"\(([^)]+)\)")); + static PAREN_REGEX: LazyLock> = + LazyLock::new(|| Regex::new(r"\(([^)]+)\)")); PAREN_REGEX .as_ref() .ok() diff --git a/src/shared/utils/web/proxy_fetch.rs b/src/infrastructure/scraping/proxy_fetch.rs similarity index 90% rename from src/shared/utils/web/proxy_fetch.rs rename to src/infrastructure/scraping/proxy_fetch.rs index b656fe6..96ee3fb 100644 --- a/src/shared/utils/web/proxy_fetch.rs +++ b/src/infrastructure/scraping/proxy_fetch.rs @@ -2,17 +2,17 @@ // Updated for sync Redis API, reqwest API changes, and concurrency optimization. use dashmap::DashMap; -use once_cell::sync::Lazy; use redis::AsyncCommands; +use std::sync::LazyLock; use tokio::sync::broadcast; use tracing::{debug, error, warn}; -use crate::shared::database::get_redis_conn; -use crate::shared::errors::AppError; -use crate::shared::utils::cache_ttl::CACHE_TTL_VERY_SHORT; -use crate::shared::utils::http::common_headers; -use crate::shared::utils::http::is_internet_baik_block_page; -use crate::shared::utils::web::http_client::http_client; +use crate::infrastructure::cache::redis_pool::get_redis_conn; +use crate::infrastructure::utils::cache_ttl::CACHE_TTL_VERY_SHORT; +use crate::infrastructure::utils::http::common_headers; +use crate::infrastructure::utils::http::is_internet_baik_block_page; +use crate::infrastructure::utils::http_client::http_client; +use crate::presentation::error::AppError; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FetchResult { @@ -34,11 +34,11 @@ impl std::fmt::Display for FetchResult { // Global In-Flight Request Map for Request Coalescing // Maps URL slug -> Broadcast Sender -static IN_FLIGHT: Lazy>>> = - Lazy::new(DashMap::new); +static IN_FLIGHT: LazyLock>>> = + LazyLock::new(DashMap::new); // Global Blacklist for domains that consistently fail direct fetch (Timeouts, SSL, Cloudflare blocks) -static FAILED_DOMAINS: Lazy> = Lazy::new(dashmap::DashSet::new); +static FAILED_DOMAINS: LazyLock> = LazyLock::new(dashmap::DashSet::new); const RELAY_ENDPOINTS: &[&str] = &[ "https://opennext-app.superaseph.workers.dev", @@ -140,10 +140,10 @@ pub async fn fetch_with_proxy(slug: &str) -> Result { let mut rx = tx.subscribe(); match rx.recv().await { Ok(Ok(res)) => Ok(res), - Ok(Err(e_str)) => Err(AppError::Other(e_str)), + Ok(Err(e_str)) => Err(AppError::Internal(e_str)), Err(e) => { warn!("[Coalesce] Receive mismatch for {}: {:?}", slug, e); - Err(AppError::Other("Request coalescing error".to_string())) + Err(AppError::Internal("Request coalescing error".to_string())) } } } @@ -204,7 +204,7 @@ async fn perform_fetch(slug: &str) -> Result { .read_to_end(&mut decompressed) .map(|_| decompressed) .map_err(|e| { - AppError::Other(format!( + AppError::Internal(format!( "Decompression failed or exceeded limits: {:?}", e )) @@ -261,7 +261,7 @@ async fn perform_fetch(slug: &str) -> Result { } else { warn!("{}", error_msg); } - Err(AppError::Other(error_msg)) + Err(AppError::Internal(error_msg)) } } Err(e) => { @@ -361,30 +361,30 @@ async fn fetch_via_relays(slug: &str) -> Result { } } - Err(AppError::Other("All relay endpoints failed".to_string())) + Err(AppError::Internal("All relay endpoints failed".to_string())) } async fn fetch_via_browserless(slug: &str) -> Result { - use crate::shared::browser::pool::get_browser_pool; + use crate::infrastructure::browser::pool::get_browser_pool; warn!("[Browserless] Falling back to remote browser for {}", slug); let pool = get_browser_pool() - .ok_or_else(|| AppError::Other("Browser pool not initialized".to_string()))?; + .ok_or_else(|| AppError::Internal("Browser pool not initialized".to_string()))?; let tab = pool .get_tab() .await - .map_err(|e| AppError::Other(format!("Failed to get browser tab: {:?}", e)))?; + .map_err(|e| AppError::Internal(format!("Failed to get browser tab: {:?}", e)))?; - tab.goto(slug) - .await - .map_err(|e| AppError::Other(format!("Browser navigation failed for {}: {:?}", slug, e)))?; + tab.goto(slug).await.map_err(|e| { + AppError::Internal(format!("Browser navigation failed for {}: {:?}", slug, e)) + })?; let data = tab .content() .await - .map_err(|e| AppError::Other(format!("Failed to get browser content: {:?}", e)))?; + .map_err(|e| AppError::Internal(format!("Failed to get browser content: {:?}", e)))?; let result = FetchResult { data, diff --git a/src/shared/utils/io/retry.rs b/src/infrastructure/scraping/retry.rs similarity index 100% rename from src/shared/utils/io/retry.rs rename to src/infrastructure/scraping/retry.rs diff --git a/src/shared/utils/web/scraping_urls.rs b/src/infrastructure/scraping/scraping_urls.rs similarity index 96% rename from src/shared/utils/web/scraping_urls.rs rename to src/infrastructure/scraping/scraping_urls.rs index 4571ef9..bfd6ba2 100644 --- a/src/shared/utils/web/scraping_urls.rs +++ b/src/infrastructure/scraping/scraping_urls.rs @@ -3,7 +3,7 @@ //! Note: These URLs are kept as dynamic env lookups because they may vary //! between deployments and are not critical startup dependencies. -use crate::shared::config::CONFIG; +use crate::config::CONFIG; use std::env; pub const BASE_URL: &str = "http://127.0.0.1:4090"; diff --git a/src/shared/services/images/cache.rs b/src/infrastructure/services/images/cache.rs similarity index 98% rename from src/shared/services/images/cache.rs rename to src/infrastructure/services/images/cache.rs index 0670971..f2e9940 100644 --- a/src/shared/services/images/cache.rs +++ b/src/infrastructure/services/images/cache.rs @@ -3,9 +3,9 @@ //! This module provides utilities to cache images via jsDelivr CDN //! with database storage for URL mapping. -use crate::shared::config::CONFIG; -use crate::shared::database::repositories::image_cache::SeaOrmImageCacheRepository; -use crate::shared::database::traits::image_cache::ImageCacheRepository; +use crate::config::CONFIG; +use crate::domain::repository::ImageCacheRepository; +use crate::infrastructure::repository::SeaOrmImageCacheRepository; use deadpool_redis::Pool as RedisPool; use reqwest::Client; use sea_orm::DatabaseConnection; @@ -13,9 +13,11 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{debug, error, warn}; -use crate::shared::utils::cache_ttl::CACHE_TTL_IMAGE; -use crate::shared::utils::web::http_client::http_client; -use crate::shared::utils::Cache; +use crate::infrastructure::cache::redis::Cache; + +/// Default TTL for image cache in Redis (24 hours) +pub const CACHE_TTL_IMAGE: u64 = 86400; +use crate::infrastructure::utils::http_client::http_client; /// Default TTL for image cache in Redis (24 hours) pub const IMAGE_CACHE_TTL: u64 = CACHE_TTL_IMAGE; @@ -136,13 +138,13 @@ pub struct ImageCache { // Add imports for Request Coalescing use dashmap::DashMap; -use once_cell::sync::Lazy; +use std::sync::LazyLock; use tokio::sync::broadcast; // Global In-Flight Uploads Map // Maps Original URL -> Broadcast Sender -static IN_FLIGHT_UPLOADS: Lazy>>> = - Lazy::new(DashMap::new); +static IN_FLIGHT_UPLOADS: LazyLock>>> = + LazyLock::new(DashMap::new); impl ImageCache { /// Create a new image cache instance @@ -1049,7 +1051,7 @@ pub async fn cache_image_urls_batch_lazy( // Note: Using repository for batch check would be better, but keeping it direct for now to match SeaORM usage // but I should probably add a batch method to repository later. - use crate::shared::database::persistence::entities::image_cache; + use crate::infrastructure::persistence::entities::image_cache; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; match image_cache::Entity::find() @@ -1124,7 +1126,7 @@ pub async fn cache_image_urls_batch_lazy( } /// Apply cached CDN poster URLs to a collection of items using the HasPoster trait. -pub async fn apply_cached_posters( +pub async fn apply_cached_posters( items: &mut [T], db: Arc, redis: &RedisPool, diff --git a/src/shared/services/images/mod.rs b/src/infrastructure/services/images/mod.rs similarity index 100% rename from src/shared/services/images/mod.rs rename to src/infrastructure/services/images/mod.rs diff --git a/src/shared/services/mod.rs b/src/infrastructure/services/mod.rs similarity index 100% rename from src/shared/services/mod.rs rename to src/infrastructure/services/mod.rs diff --git a/src/infrastructure/utils/cache_ttl.rs b/src/infrastructure/utils/cache_ttl.rs new file mode 100644 index 0000000..de05385 --- /dev/null +++ b/src/infrastructure/utils/cache_ttl.rs @@ -0,0 +1,2 @@ +/// Very short TTL for highly volatile data (5 minutes) +pub const CACHE_TTL_VERY_SHORT: u64 = 300; diff --git a/src/infrastructure/utils/http.rs b/src/infrastructure/utils/http.rs new file mode 100644 index 0000000..0dbc235 --- /dev/null +++ b/src/infrastructure/utils/http.rs @@ -0,0 +1,17 @@ +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; + +pub fn common_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")); + headers.insert("Referer", HeaderValue::from_static("https://google.com")); + headers +} + +pub fn is_internet_baik_block_page(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("internet sehat") + || (lower.contains("akses ditolak") && lower.contains("indihome")) + || lower.contains("akses di blokir") + || lower.contains("this site has been blocked") + || lower.contains("website ini telah diblokir") +} diff --git a/src/shared/utils/web/http_client.rs b/src/infrastructure/utils/http_client.rs similarity index 92% rename from src/shared/utils/web/http_client.rs rename to src/infrastructure/utils/http_client.rs index ac460d7..2b54a11 100644 --- a/src/shared/utils/web/http_client.rs +++ b/src/infrastructure/utils/http_client.rs @@ -1,6 +1,8 @@ //! HTTP client wrapper with common configurations. use reqwest::{Client, ClientBuilder, Response}; +use std::sync::Arc; +use std::sync::LazyLock; use std::time::Duration; use tracing::debug; @@ -92,22 +94,19 @@ impl Default for HttpClient { } } -use once_cell::sync::Lazy; -use std::sync::Arc; - -static HTTP_CLIENT_INIT: Lazy, String>> = Lazy::new(|| { +static HTTP_CLIENT_INIT: LazyLock, String>> = LazyLock::new(|| { HttpClient::new() .map(|c| Arc::new(c)) .map_err(|e| format!("Failed to initialize HTTP client: {}", e)) }); -static HTTP_CLIENT_FAST_INIT: Lazy, String>> = Lazy::new(|| { +static HTTP_CLIENT_FAST_INIT: LazyLock, String>> = LazyLock::new(|| { HttpClient::with_timeout(10) .map(|c| Arc::new(c)) .map_err(|e| format!("Failed to initialize fast HTTP client: {}", e)) }); -static HTTP_CLIENT_SLOW_INIT: Lazy, String>> = Lazy::new(|| { +static HTTP_CLIENT_SLOW_INIT: LazyLock, String>> = LazyLock::new(|| { HttpClient::with_timeout(60) .map(|c| Arc::new(c)) .map_err(|e| format!("Failed to initialize slow HTTP client: {}", e)) diff --git a/src/infrastructure/utils/mod.rs b/src/infrastructure/utils/mod.rs new file mode 100644 index 0000000..3b611c7 --- /dev/null +++ b/src/infrastructure/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod cache_ttl; +pub mod http; +pub mod http_client; diff --git a/src/lib.rs b/src/lib.rs index 278f6ea..ac00d26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,30 @@ -// Library root - clean organized module structure -// All modules organized into logical folders +// Library root — clean architecture module structure // ============================================================================ -// Core Framework +// Domain Layer — pure business logic, no framework dependencies +// ============================================================================ +pub mod domain; + +// ============================================================================ +// Application Layer — use cases / business orchestration +// ============================================================================ +pub mod application; + +// ============================================================================ +// Infrastructure Layer — implements domain ports +// ============================================================================ +pub mod infrastructure; + +// ============================================================================ +// Presentation Layer — Axum handlers, DTOs, state, middleware +// ============================================================================ +pub mod presentation; + +// ============================================================================ +// Core Framework & Infrastructure // ============================================================================ -pub mod app; pub mod bootstrap; -pub mod modules; -pub mod shared; +pub mod config; +pub mod events; +pub mod observability; +pub mod scheduler; diff --git a/src/modules/anime/controller.rs b/src/modules/anime/controller.rs deleted file mode 100644 index 9efb932..0000000 --- a/src/modules/anime/controller.rs +++ /dev/null @@ -1,236 +0,0 @@ -use crate::modules::anime::repository::AnimeRepository; -use crate::modules::anime::service::AnimeService; -use crate::shared::errors::AppError; -use crate::shared::state::AppState; -use axum::extract::{Path, State}; -use axum::Json; -use std::sync::Arc; -use tracing::info; - -#[utoipa::path( - get, - path = "/api/anime", - tag = "anime", - operation_id = "anime_index", - responses( - (status = 200, description = "Handles GET requests for the /api/anime endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn anime_index( - State(app_state): State>, -) -> Result, AppError> { - info!("Handling request for anime index"); - let service = AnimeService::new(AnimeRepository::new()); - service.get_anime_index(app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/genre_list", - tag = "anime", - operation_id = "anime_genre_list", - responses( - (status = 200, description = "Handles GET requests for the /api/anime/genre_list endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genres( - State(app_state): State>, -) -> Result, AppError> { - info!("Handling request for anime genres"); - let service = AnimeService::new(AnimeRepository::new()); - service.get_genres(app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/detail/{slug}", - tag = "anime", - operation_id = "anime_detail_slug", - responses( - (status = 200, description = "Retrieves details for a specific detail by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn detail_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for detail slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service.get_anime_detail(app_state, slug).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/complete_anime/{slug}", - tag = "anime", - operation_id = "anime_complete_anime_slug", - responses( - (status = 200, description = "Retrieves details for a specific complete_anime by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn complete_anime_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for complete_anime slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_complete_anime_page(app_state, slug) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/full/{slug}", - tag = "anime", - operation_id = "anime_full_slug", - responses( - (status = 200, description = "Retrieves full episode details for a specific episode by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn full_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for full slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service.get_anime_full(app_state, slug).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/ongoing_anime/{slug}", - tag = "anime", - operation_id = "anime_ongoing_anime_slug", - responses( - (status = 200, description = "Retrieves details for a specific ongoing_anime by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn ongoing_anime_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for ongoing_anime slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_ongoing_anime_page(app_state, slug) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/latest/{slug}", - tag = "anime", - operation_id = "anime_latest_slug", - responses( - (status = 200, description = "Retrieves details for a specific latest by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn latest_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for latest slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_latest_anime_page(app_state, slug) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/search/{slug}", - tag = "anime", - operation_id = "anime_search_slug_index", - responses( - (status = 200, description = "Retrieves details for a specific search by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug_index( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for search slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_search_anime_page(app_state, slug, "1".to_string()) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/search/{slug}/{page}", - tag = "anime", - operation_id = "anime_search_slug_page", - responses( - (status = 200, description = "Retrieves details for a specific search by slug and page.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug_page( - State(app_state): State>, - Path((slug, page)): Path<(String, String)>, -) -> Result, AppError> { - info!("Starting request for search slug: {} page: {}", slug, page); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_search_anime_page(app_state, slug, page) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/genre/{slug}", - tag = "anime", - operation_id = "anime_genre_slug_index", - responses( - (status = 200, description = "Retrieves details for a specific genre by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug_index( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - info!("Starting request for genre slug: {}", slug); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_genre_anime_page(app_state, slug, "1".to_string()) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/anime/genre/{slug}/{page}", - tag = "anime", - operation_id = "anime_genre_slug_page", - responses( - (status = 200, description = "Retrieves details for a specific genre by slug and page.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug_page( - State(app_state): State>, - Path((slug, page)): Path<(String, String)>, -) -> Result, AppError> { - info!("Starting request for genre slug: {} page: {}", slug, page); - let service = AnimeService::new(AnimeRepository::new()); - service - .get_genre_anime_page(app_state, slug, page) - .await - .map(Json) -} diff --git a/src/modules/anime/mod.rs b/src/modules/anime/mod.rs deleted file mode 100644 index 67dc224..0000000 --- a/src/modules/anime/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod controller; -pub mod parser; -pub mod repository; -pub mod route; -pub mod schema; -pub mod service; -pub mod types; diff --git a/src/modules/anime/parser.rs b/src/modules/anime/parser.rs deleted file mode 100644 index 62418cf..0000000 --- a/src/modules/anime/parser.rs +++ /dev/null @@ -1,632 +0,0 @@ -use crate::modules::anime::types::*; -use crate::shared::errors::AppError; -use crate::shared::utils::parse_html; -use crate::shared::utils::scraping::{ - attr, attr_from, attr_from_or, extract_slug, selector, text, text_from_or, -}; - -pub fn parse_ongoing_anime(html: &str) -> Result, AppError> { - let document = parse_html(html); - let mut ongoing_anime = Vec::new(); - - let venz_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - - for element in document.select(&venz_selector) { - let title = text_from_or(&element, &title_selector, ""); - let href = attr_from(&element, &link_selector, "href").unwrap_or_default(); - let slug = extract_slug(&href); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let current_episode = text_from_or(&element, &episode_selector, "N/A"); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - - if !title.is_empty() { - ongoing_anime.push(OngoingAnimeItem { - title, - slug, - poster, - current_episode, - anime_url, - }); - } - } - Ok(ongoing_anime) -} - -pub fn parse_complete_anime(html: &str) -> Result, AppError> { - let document = parse_html(html); - let mut complete_anime = Vec::new(); - - let venz_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - - for element in document.select(&venz_selector) { - let title = text_from_or(&element, &title_selector, ""); - let href = attr_from(&element, &link_selector, "href").unwrap_or_default(); - let slug = extract_slug(&href); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let episode_count = text_from_or(&element, &episode_selector, "N/A"); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - - if !title.is_empty() { - complete_anime.push(CompleteAnimeItem { - title, - slug, - poster, - episode_count, - anime_url, - }); - } - } - Ok(complete_anime) -} - -pub fn parse_genres(html: &str) -> Result, AppError> { - let document = parse_html(html); - let mut genres = Vec::new(); - let genre_selector = selector(".genres li a, .genre-list a").unwrap(); - - for element in document.select(&genre_selector) { - let name = text(&element); - let url = attr(&element, "href").unwrap_or_default(); - let slug = extract_slug(&url); - - if !name.is_empty() && !slug.is_empty() { - genres.push(Genre { name, slug, url }); - } - } - - Ok(genres) -} - -pub fn parse_anime_detail_document(html: &str) -> Result { - let document = parse_html(html); - - let info_selector = selector(".infozingle p").unwrap(); - let poster_selector = selector(".fotoanime img").unwrap(); - let synopsis_selector = selector(".sinopc").unwrap(); - let genre_link_selector = selector("a").unwrap(); - let episode_list_selector = selector(".episodelist ul li a").unwrap(); - let recommendation_selector = selector("#recommend-anime-series .isi-anime").unwrap(); - let recommendation_title_selector = selector(".judul-anime a").unwrap(); - let recommendation_img_selector = selector("img").unwrap(); - - let mut title = String::new(); - let mut alternative_title = String::new(); - let mut r#type: Option = None; - let mut status: Option = None; - let mut release_date = String::new(); - let mut studio = String::new(); - let producers = Vec::new(); - - for element in document.select(&info_selector) { - let text = text(&element); - if text.contains("Judul:") { - title = text.replace("Judul:", "").trim().to_string(); - } else if text.contains("Japanese:") { - alternative_title = text.replace("Japanese:", "").trim().to_string(); - } else if text.contains("Type:") { - let type_str = text.replace("Type:", "").trim().to_string(); - if !type_str.is_empty() { - r#type = Some(type_str); - } - } else if text.contains("Status:") { - let status_str = text.replace("Status:", "").trim().to_string(); - if !status_str.is_empty() { - status = Some(status_str); - } - } else if text.contains("Tanggal Rilis:") { - release_date = text.replace("Tanggal Rilis:", "").trim().to_string(); - } else if text.contains("Studio:") { - studio = text.replace("Studio:", "").trim().to_string(); - } - } - - let poster = document - .select(&poster_selector) - .next() - .and_then(|e| e.value().attr("src")) - .unwrap_or("") - .to_string(); - - let synopsis = text_from_or(&document.root_element(), &synopsis_selector, ""); - - let mut genres = Vec::new(); - if let Some(genres_element) = document - .select(&info_selector) - .find(|e| text(&e).contains("Genres:")) - { - for genre_link in genres_element.select(&genre_link_selector) { - let name = text(&genre_link); - let anime_url = attr(&genre_link, "href").unwrap_or_default(); - let genre_slug = extract_slug(&anime_url); - genres.push(DetailGenre { - name, - slug: genre_slug, - anime_url, - }); - } - } - - let mut episode_lists = Vec::new(); - for element in document.select(&episode_list_selector) { - let episode = text(&element); - let href = attr(&element, "href").unwrap_or_default(); - let slug = extract_slug(&href); - episode_lists.push(EpisodeList { episode, slug }); - } - - let mut recommendations = Vec::new(); - for element in document.select(&recommendation_selector) { - let title = text_from_or(&element, &recommendation_title_selector, ""); - let poster = attr_from_or(&element, &recommendation_img_selector, "src", ""); - let href = element - .select(&genre_link_selector) - .next() - .and_then(|e| e.value().attr("href")) - .unwrap_or(""); - - let slug = extract_slug(href); - - recommendations.push(Recommendation { - title, - slug, - poster, - status: None, - r#type: None, - }); - } - - Ok(AnimeDetailData { - title, - alternative_title, - poster, - r#type, - status, - release_date, - studio, - genres, - synopsis, - episode_lists, - batch: vec![], - producers, - recommendations, - }) -} - -pub fn parse_anime_page( - html: &str, - slug: &str, -) -> Result<(Vec, Pagination), AppError> { - let document = parse_html(html); - let mut anime_list = Vec::new(); - - let item_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - let pagination_selector = selector(".pagenavix .page-numbers:not(.next)").unwrap(); - let next_selector = selector(".pagenavix .next.page-numbers").unwrap(); - - for element in document.select(&item_selector) { - let title = text_from_or(&element, &title_selector, ""); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - let slug = extract_slug(&anime_url); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let episode_count = text_from_or(&element, &episode_selector, "N/A"); - - if !title.is_empty() { - anime_list.push(CompleteAnimeListItem { - title, - slug, - poster, - episode_count, - anime_url, - }); - } - } - - let current_page = slug.parse::().unwrap_or(1); - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| e.text().collect::().trim().parse::().ok()) - .unwrap_or(1); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - let pagination = Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }; - - Ok((anime_list, pagination)) -} - -pub fn parse_ongoing_anime_document( - html: &str, - slug: &str, -) -> Result<(Vec, Pagination), AppError> { - let document = parse_html(html); - let mut anime_list = Vec::new(); - - let item_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let score_selector = selector(".epz").unwrap(); - let pagination_selector = selector(".pagenavix .page-numbers:not(.next)").unwrap(); - let next_selector = selector(".pagenavix .next.page-numbers").unwrap(); - - for element in document.select(&item_selector) { - let title = text_from_or(&element, &title_selector, ""); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - let slug = extract_slug(&anime_url); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let score = text_from_or(&element, &score_selector, "N/A"); - - if !title.is_empty() { - anime_list.push(OngoingAnimeListItem { - title, - slug, - poster, - score, - anime_url, - }); - } - } - - let current_page = slug.parse::().unwrap_or(1); - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| e.text().collect::().trim().parse::().ok()) - .unwrap_or(1); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - let pagination = Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }; - - Ok((anime_list, pagination)) -} - -pub fn parse_latest_anime_document( - html: &str, - slug: &str, -) -> Result<(Vec, Pagination), AppError> { - let document = parse_html(html); - let mut anime_list = Vec::new(); - - let item_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - let pagination_selector = selector(".pagenavix .page-numbers:not(.next)").unwrap(); - let next_selector = selector(".pagenavix .next.page-numbers").unwrap(); - - for element in document.select(&item_selector) { - let title = text_from_or(&element, &title_selector, ""); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - let slug = extract_slug(&anime_url); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let episode = text_from_or(&element, &episode_selector, "N/A"); - - if !title.is_empty() { - anime_list.push(LatestAnimeItem { - title, - slug, - poster, - episode, - anime_url, - }); - } - } - - let current_page = slug.parse::().unwrap_or(1); - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| e.text().collect::().trim().parse::().ok()) - .unwrap_or(1); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - let pagination = Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }; - - Ok((anime_list, pagination)) -} - -pub fn parse_search_anime_document( - html: &str, - slug: &str, -) -> Result<(Vec, Pagination), AppError> { - let document = parse_html(html); - let mut anime_list = Vec::new(); - - let item_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - let genre_selector = selector(".genre-tag").unwrap(); - let status_selector = selector(".status").unwrap(); - let rating_selector = selector(".rating").unwrap(); - let pagination_selector = selector(".pagenavix .page-numbers:not(.next)").unwrap(); - let next_selector = selector(".pagenavix .next.page-numbers").unwrap(); - - for element in document.select(&item_selector) { - let title = text_from_or(&element, &title_selector, ""); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - let slug = extract_slug(&anime_url); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let episode = text_from_or(&element, &episode_selector, "N/A"); - - let mut genres = Vec::new(); - for genre_elem in element.select(&genre_selector) { - genres.push(text(&genre_elem)); - } - - let status = text_from_or(&element, &status_selector, ""); - let rating = text_from_or(&element, &rating_selector, ""); - - if !title.is_empty() { - anime_list.push(SearchAnimeItem { - title, - slug, - poster, - episode, - anime_url, - genres, - status, - rating, - }); - } - } - - let current_page = slug.parse::().unwrap_or(1); - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| e.text().collect::().trim().parse::().ok()) - .unwrap_or(1); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - let pagination = Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }; - - Ok((anime_list, pagination)) -} - -pub fn parse_genre_anime_document( - html: &str, - slug: &str, -) -> Result<(Vec, Pagination), AppError> { - let document = parse_html(html); - let mut anime_list = Vec::new(); - - let item_selector = selector(".venz ul li").unwrap(); - let title_selector = selector(".thumbz h2.jdlflm").unwrap(); - let link_selector = selector("a").unwrap(); - let img_selector = selector("img").unwrap(); - let episode_selector = selector(".epz").unwrap(); - let pagination_selector = selector(".pagenavix .page-numbers:not(.next)").unwrap(); - let next_selector = selector(".pagenavix .next.page-numbers").unwrap(); - - for element in document.select(&item_selector) { - let title = text_from_or(&element, &title_selector, ""); - let anime_url = attr_from_or(&element, &link_selector, "href", ""); - let slug = extract_slug(&anime_url); - let poster = attr_from_or(&element, &img_selector, "src", ""); - let episode = text_from_or(&element, &episode_selector, "N/A"); - - if !title.is_empty() { - anime_list.push(GenreAnimeItem { - title, - slug, - poster, - episode, - anime_url, - }); - } - } - - let current_page = slug.parse::().unwrap_or(1); - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| e.text().collect::().trim().parse::().ok()) - .unwrap_or(1); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - let pagination = Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }; - - Ok((anime_list, pagination)) -} - -pub fn parse_anime_full_document(html: &str, slug: &str) -> Result { - let document = parse_html(html); - - let episode_title_selector = selector("h1.posttl").unwrap(); - let image_selector = selector(".cukder img").unwrap(); - let stream_selector = selector("#embed_holder iframe").unwrap(); - let download_item_selector = selector(".download ul li").unwrap(); - let resolution_selector = selector("strong").unwrap(); - let link_selector = selector("a").unwrap(); - let next_episode_selector = selector(".flir a[title*='Episode Selanjutnya']").unwrap(); - let previous_episode_selector = selector(".flir a[title*='Episode Sebelumnya']").unwrap(); - - let episode = document - .select(&episode_title_selector) - .next() - .map(|e| text(&e)) - .unwrap_or_default(); - - let episode_number = episode - .split("Episode") - .nth(1) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - - let image_url = document - .select(&image_selector) - .next() - .and_then(|e| attr(&e, "src")) - .unwrap_or_default(); - - let stream_url = document - .select(&stream_selector) - .next() - .and_then(|e| attr(&e, "src")) - .unwrap_or_default(); - - let mut download_urls = std::collections::HashMap::new(); - - for element in document.select(&download_item_selector) { - let resolution = element - .select(&resolution_selector) - .next() - .map(|e| text(&e)) - .unwrap_or_default(); - - let mut links = Vec::new(); - for link_element in element.select(&link_selector) { - let server = text(&link_element); - let url = attr(&link_element, "href").unwrap_or_default(); - links.push(DownloadLink { server, url }); - } - - if !resolution.is_empty() && !links.is_empty() { - download_urls.insert(resolution, links); - } - } - - let next_episode_element = document.select(&next_episode_selector).next(); - let previous_episode_element = document.select(&previous_episode_selector).next(); - - let next_episode_slug = next_episode_element - .and_then(|e| attr(&e, "href")) - .and_then(|href| { - href.split('/') - .nth(href.split('/').count().saturating_sub(2)) - .map(|s| s.to_string() + "/") - }); - - let previous_episode_slug = previous_episode_element - .and_then(|e| attr(&e, "href")) - .and_then(|href| { - href.split('/') - .nth(href.split('/').count().saturating_sub(2)) - .map(|s| s.to_string() + "/") - }); - - Ok(AnimeFullData { - episode, - episode_number, - anime: AnimeInfo { - slug: slug.to_string(), - }, - has_next_episode: next_episode_slug.is_some(), - next_episode: next_episode_slug.map(|s| EpisodeInfo { slug: s }), - has_previous_episode: previous_episode_slug.is_some(), - previous_episode: previous_episode_slug.map(|s| EpisodeInfo { slug: s }), - stream_url, - download_urls, - image_url, - }) -} diff --git a/src/modules/anime/route.rs b/src/modules/anime/route.rs deleted file mode 100644 index 9bee8a6..0000000 --- a/src/modules/anime/route.rs +++ /dev/null @@ -1,49 +0,0 @@ -use crate::modules::anime::controller; -use crate::shared::state::AppState; -use axum::Router; -use std::sync::Arc; - -pub fn routes(router: Router>) -> Router> { - router - .route("/api/anime", axum::routing::get(controller::anime_index)) - .route( - "/api/anime/genre_list", - axum::routing::get(controller::genres), - ) - .route( - "/api/anime/detail/{slug}", - axum::routing::get(controller::detail_slug), - ) - .route( - "/api/anime/complete_anime/{slug}", - axum::routing::get(controller::complete_anime_slug), - ) - .route( - "/api/anime/full/{slug}", - axum::routing::get(controller::full_slug), - ) - .route( - "/api/anime/ongoing_anime/{slug}", - axum::routing::get(controller::ongoing_anime_slug), - ) - .route( - "/api/anime/latest/{slug}", - axum::routing::get(controller::latest_slug), - ) - .route( - "/api/anime/search/{slug}", - axum::routing::get(controller::search_slug_index), - ) - .route( - "/api/anime/search/{slug}/{page}", - axum::routing::get(controller::search_slug_page), - ) - .route( - "/api/anime/genre/{slug}", - axum::routing::get(controller::genre_slug_index), - ) - .route( - "/api/anime/genre/{slug}/{page}", - axum::routing::get(controller::genre_slug_page), - ) -} diff --git a/src/modules/anime/schema.rs b/src/modules/anime/schema.rs deleted file mode 100644 index 2321901..0000000 --- a/src/modules/anime/schema.rs +++ /dev/null @@ -1,18 +0,0 @@ -use serde::Deserialize; -use utoipa::ToSchema; - -#[derive(Deserialize, ToSchema)] -pub struct SearchQuery { - pub q: Option, -} - -#[derive(Deserialize, ToSchema)] -pub struct SlugPath { - pub slug: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct SlugPagePath { - pub slug: String, - pub page: String, -} diff --git a/src/modules/anime/scraping/cache.rs b/src/modules/anime/scraping/cache.rs deleted file mode 100644 index 7fe182e..0000000 --- a/src/modules/anime/scraping/cache.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::shared::types::entities::anime::HasPoster; -use crate::shared::state::AppState; -use std::sync::Arc; - -/// Cache poster URLs for a collection of anime items -/// This is a fire-and-forget operation that triggers lazy background caching -pub async fn cache_posters(app_state: &Arc, items: &[T]) { - let posters: Vec = items.iter().map(|item| item.poster().to_string()).collect(); - - if posters.is_empty() { - return; - } - - let db = app_state.db.clone(); - let redis = app_state.redis_pool.clone(); - - crate::shared::services::images::cache::cache_image_urls_batch_lazy( - db, - &redis, - posters, - Some(app_state.image_processing_semaphore.clone()), - ) - .await; -} - -/// Cache poster URLs and update items with cached URLs -/// Returns the updated items with CDN URLs -pub async fn cache_and_update_posters( - app_state: &Arc, - mut items: Vec, -) -> Vec { - let posters: Vec = items.iter().map(|item| item.poster().to_string()).collect(); - - if posters.is_empty() { - return items; - } - - let db = app_state.db.clone(); - let redis = app_state.redis_pool.clone(); - - let cached_posters = crate::shared::services::images::cache::cache_image_urls_batch_lazy( - db, - &redis, - posters, - Some(app_state.image_processing_semaphore.clone()), - ) - .await; - - // Update items with cached URLs - for (i, item) in items.iter_mut().enumerate() { - if let Some(url) = cached_posters.get(i) { - item.set_poster(url.clone()); - } - } - - items -} - -/// Cache multiple collections of posters and update them -/// Useful when you have different types of items (e.g., ongoing and complete anime) -pub async fn cache_multiple_collections( - app_state: &Arc, - collections: Vec>, -) -> Vec { - let all_posters: Vec = collections.into_iter().flatten().collect(); - - if all_posters.is_empty() { - return Vec::new(); - } - - let db = app_state.db.clone(); - let redis = app_state.redis_pool.clone(); - - crate::shared::services::images::cache::cache_image_urls_batch_lazy( - db, - &redis, - all_posters, - Some(app_state.image_processing_semaphore.clone()), - ) - .await -} diff --git a/src/modules/anime/service.rs b/src/modules/anime/service.rs deleted file mode 100644 index 4eb3007..0000000 --- a/src/modules/anime/service.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::sync::Arc; - -use crate::modules::anime::repository::AnimeRepository; -use crate::modules::anime::types::*; -use crate::shared::errors::AppError; -use crate::shared::state::AppState; -use crate::shared::utils::Cache; - -const INDEX_CACHE_TTL: u64 = 10; -const GENRE_LIST_CACHE_TTL: u64 = 3600; -const DEFAULT_CACHE_TTL: u64 = 300; - -pub struct AnimeService { - repository: AnimeRepository, -} - -impl AnimeService { - pub fn new(repository: AnimeRepository) -> Self { - Self { repository } - } - - pub async fn get_anime_index(&self, app_state: Arc) -> Result { - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set("anime:index:v2", INDEX_CACHE_TTL, || async { - let mut data = self - .repository - .fetch_anime_index() - .await - .map_err(|e| e.to_string())?; - - if data.ongoing_anime.is_empty() && data.complete_anime.is_empty() { - return Err("Empty anime index — refusing to cache".to_string()); - } - - let mut posters: Vec = data - .ongoing_anime - .iter() - .map(|item| item.poster.clone()) - .collect(); - posters.extend(data.complete_anime.iter().map(|item| item.poster.clone())); - - let cached_posters = - crate::shared::services::images::cache::cache_image_urls_batch_lazy( - app_state.db.clone(), - &app_state.redis_pool, - posters, - Some(app_state.image_processing_semaphore.clone()), - ) - .await; - - let ongoing_len = data.ongoing_anime.len(); - for (i, item) in data.ongoing_anime.iter_mut().enumerate() { - if let Some(url) = cached_posters.get(i) { - item.poster = url.clone(); - } - } - for (i, item) in data.complete_anime.iter_mut().enumerate() { - if let Some(url) = cached_posters.get(ongoing_len + i) { - item.poster = url.clone(); - } - } - - Ok(data) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_genres(&self, app_state: Arc) -> Result { - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set("anime:genres:list", GENRE_LIST_CACHE_TTL, || async { - let genres = self - .repository - .fetch_genres() - .await - .map_err(|e| e.to_string())?; - Ok(GenresResponse { - status: "Ok".to_string(), - data: genres, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_anime_detail( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache_key = format!("anime:detail:{}", slug); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let mut data = self - .repository - .fetch_anime_detail(&slug) - .await - .map_err(|e| e.to_string())?; - - data.poster = crate::shared::services::images::cache::get_cached_or_original( - app_state.db.clone(), - &app_state.redis_pool, - &data.poster, - Some(app_state.image_processing_semaphore.clone()), - ) - .await; - - let rec_posters: Vec = data - .recommendations - .iter() - .map(|r| r.poster.clone()) - .collect(); - let cached_rec_posters = - crate::shared::services::images::cache::cache_image_urls_batch_lazy( - app_state.db.clone(), - &app_state.redis_pool, - rec_posters, - Some(app_state.image_processing_semaphore.clone()), - ) - .await; - - for (i, rec) in data.recommendations.iter_mut().enumerate() { - if let Some(url) = cached_rec_posters.get(i) { - rec.poster = url.clone(); - } - } - - Ok(DetailResponse { - status: Some("Ok".to_string()), - data, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_complete_anime_page( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache_key = format!("anime:complete:{}", slug); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let (anime_list, pagination) = self - .repository - .fetch_complete_anime_page(&slug) - .await - .map_err(|e| e.to_string())?; - let total = anime_list.len() as i64; - Ok(ListResponse { - message: "Success".to_string(), - data: anime_list, - total: Some(total), - pagination: Some(pagination), - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_ongoing_anime_page( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache_key = format!("anime:ongoing:{}", slug); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let (anime_list, pagination) = self - .repository - .fetch_ongoing_anime_page(&slug) - .await - .map_err(|e| e.to_string())?; - Ok(OngoingAnimeResponse { - status: "Ok".to_string(), - data: anime_list, - pagination, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_latest_anime_page( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache_key = format!("anime:latest:{}", slug); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let (anime_list, pagination) = self - .repository - .fetch_latest_anime_page(&slug) - .await - .map_err(|e| e.to_string())?; - Ok(LatestAnimeResponse { - status: "Ok".to_string(), - data: anime_list, - pagination, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_search_anime_page( - &self, - app_state: Arc, - slug: String, - page: String, - ) -> Result { - let cache_key = format!("anime:search:{}:{}", slug, page); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let (anime_list, pagination) = self - .repository - .fetch_search_anime_page(&slug, &page) - .await - .map_err(|e| e.to_string())?; - Ok(SearchResponse { - status: "Ok".to_string(), - data: anime_list, - pagination, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_genre_anime_page( - &self, - app_state: Arc, - genre_slug: String, - page: String, - ) -> Result { - let cache_key = format!("anime:genre:{}:{}", genre_slug, page); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let (anime_list, pagination) = self - .repository - .fetch_genre_anime_page(&genre_slug, &page) - .await - .map_err(|e| e.to_string())?; - Ok(GenreListResponse { - status: "Ok".to_string(), - data: anime_list, - pagination, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } - - pub async fn get_anime_full( - &self, - app_state: Arc, - slug: String, - ) -> Result { - let cache_key = format!("anime:full:{}", slug); - let cache = Cache::new(&app_state.redis_pool); - - cache - .get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async { - let data = self - .repository - .fetch_anime_full(&slug) - .await - .map_err(|e| e.to_string())?; - Ok(FullResponse { - status: "Ok".to_string(), - data, - }) - }) - .await - .map_err(|e| AppError::ScraperError(e)) - } -} diff --git a/src/modules/anime/types.rs b/src/modules/anime/types.rs deleted file mode 100644 index aecb906..0000000 --- a/src/modules/anime/types.rs +++ /dev/null @@ -1,231 +0,0 @@ -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -// Index endpoint types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct OngoingAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub current_episode: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct CompleteAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode_count: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct AnimeData { - pub ongoing_anime: Vec, - pub complete_anime: Vec, -} - -// Genre list types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Genre { - pub name: String, - pub slug: String, - pub url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenresResponse { - pub status: String, - pub data: Vec, -} - -// Detail endpoint types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailGenre { - pub name: String, - pub slug: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct EpisodeList { - pub episode: String, - pub slug: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Recommendation { - pub title: String, - pub slug: String, - pub poster: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct AnimeDetailData { - pub title: String, - pub alternative_title: String, - pub poster: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - pub release_date: String, - pub studio: String, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub genres: Vec, - pub synopsis: String, - pub episode_lists: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub batch: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub producers: Vec, - pub recommendations: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - pub data: AnimeDetailData, -} - -// Complete anime list types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct CompleteAnimeListItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode_count: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Pagination { - pub current_page: u32, - pub last_visible_page: u32, - pub has_next_page: bool, - pub next_page: Option, - pub has_previous_page: bool, - pub previous_page: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct ListResponse { - pub message: String, - pub data: Vec, - pub total: Option, - pub pagination: Option, -} - -// Full episode types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct AnimeInfo { - pub slug: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct EpisodeInfo { - pub slug: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DownloadLink { - pub server: String, - pub url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct AnimeFullData { - pub episode: String, - pub episode_number: String, - pub anime: AnimeInfo, - pub has_next_episode: bool, - pub next_episode: Option, - pub has_previous_episode: bool, - pub previous_episode: Option, - pub stream_url: String, - pub download_urls: std::collections::HashMap>, - pub image_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct FullResponse { - pub status: String, - pub data: AnimeFullData, -} - -// Ongoing anime list types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct OngoingAnimeListItem { - pub title: String, - pub slug: String, - pub poster: String, - pub score: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct OngoingAnimeResponse { - pub status: String, - pub data: Vec, - pub pagination: Pagination, -} - -// Latest anime types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct LatestAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct LatestAnimeResponse { - pub status: String, - pub data: Vec, - pub pagination: Pagination, -} - -// Search types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct SearchAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode: String, - pub anime_url: String, - pub genres: Vec, - pub status: String, - pub rating: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct SearchResponse { - pub status: String, - pub data: Vec, - pub pagination: Pagination, -} - -// Genre list by slug types -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenreAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenreListResponse { - pub status: String, - pub data: Vec, - pub pagination: Pagination, -} diff --git a/src/modules/anime2/controller.rs b/src/modules/anime2/controller.rs deleted file mode 100644 index 1245c04..0000000 --- a/src/modules/anime2/controller.rs +++ /dev/null @@ -1,276 +0,0 @@ -use std::sync::Arc; - -use axum::{ - extract::{Path, Query, State}, - Json, -}; - -use crate::modules::anime2::repository::Anime2Repository; -use crate::modules::anime2::schema::FilterQuery; -use crate::modules::anime2::service::Anime2Service; -use crate::shared::errors::AppError; -use crate::shared::state::AppState; - -#[utoipa::path( - get, - path = "/api/anime2", - tag = "anime2", - operation_id = "anime2_index", - responses( - (status = 200, description = "Handles GET requests for the /api/anime2 endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn index( - State(app_state): State>, -) -> Result, AppError> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.index(app_state).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/genre_list", - tag = "anime2", - operation_id = "anime2_genre_list", - responses( - (status = 200, description = "Handles GET requests for the /api/anime2/genre_list endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_list( - State(app_state): State>, -) -> Result, AppError> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.genre_list(app_state).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/filter", - tag = "anime2", - operation_id = "anime2_filter", - responses( - (status = 200, description = "Handles GET requests for the /api/anime2/filter endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn filter( - State(app_state): State>, - Query(params): Query, -) -> Result, AppError> { - 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("update".to_string()); - - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json( - service - .filter(app_state, page, genre, status, anime_type, order) - .await?, - )) -} - -#[utoipa::path( - get, - path = "/api/anime2/detail/{slug}", - tag = "anime2", - operation_id = "anime2_detail_slug", - responses( - (status = 200, description = "Retrieves details for a specific detail by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn detail_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result, AppError> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.detail(app_state, slug).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/genre/{slug}", - tag = "anime2", - operation_id = "anime2_genre_slug_index", - responses( - (status = 200, description = "Retrieves details for a specific genre by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug_index( - State(app_state): State>, - Path(slug): Path, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.genre_slug(app_state, slug, 1).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/genre/{slug}/{page}", - tag = "anime2", - operation_id = "anime2_genre_slug_page", - responses( - (status = 200, description = "Handles GET requests for the /api/anime2/genre/{slug}/{page} endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug_page( - State(app_state): State>, - Path((slug, page)): Path<(String, u32)>, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.genre_slug(app_state, slug, page).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/search/{slug}", - tag = "anime2", - operation_id = "anime2_search_slug_index", - responses( - (status = 200, description = "Retrieves details for a specific search by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug_index( - State(app_state): State>, - Path(slug): Path, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.search(app_state, slug, 1).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/search/{slug}/{page}", - tag = "anime2", - operation_id = "anime2_search_slug_page", - responses( - (status = 200, description = "Handles GET requests for the /api/anime2/search/{slug}/{page} endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug_page( - State(app_state): State>, - Path((slug, page)): Path<(String, u32)>, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.search(app_state, slug, page).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/latest/{slug}", - tag = "anime2", - operation_id = "anime2_latest_slug", - responses( - (status = 200, description = "Retrieves details for a specific latest by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn latest_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let page = slug - .parse::() - .map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?; - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.latest(app_state, page).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/ongoing_anime/{slug}", - tag = "anime2", - operation_id = "anime2_ongoing_anime_slug", - responses( - (status = 200, description = "Retrieves details for a specific ongoing_anime by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn ongoing_anime_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let page = slug - .parse::() - .map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?; - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.ongoing_anime(app_state, page).await?)) -} - -#[utoipa::path( - get, - path = "/api/anime2/complete_anime/{slug}", - tag = "anime2", - operation_id = "anime2_complete_anime_slug", - responses( - (status = 200, description = "Retrieves details for a specific complete_anime by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn complete_anime_slug( - State(app_state): State>, - Path(slug): Path, -) -> Result< - Json< - crate::shared::types::ApiResponse< - Vec, - >, - >, - AppError, -> { - let page = slug - .parse::() - .map_err(|_| AppError::ScraperError(format!("Invalid page number: {}", slug)))?; - let service = Anime2Service::new(Anime2Repository::new()); - Ok(Json(service.complete_anime(app_state, page).await?)) -} diff --git a/src/modules/anime2/mod.rs b/src/modules/anime2/mod.rs deleted file mode 100644 index 67dc224..0000000 --- a/src/modules/anime2/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod controller; -pub mod parser; -pub mod repository; -pub mod route; -pub mod schema; -pub mod service; -pub mod types; diff --git a/src/modules/anime2/route.rs b/src/modules/anime2/route.rs deleted file mode 100644 index 279b4f9..0000000 --- a/src/modules/anime2/route.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::sync::Arc; - -use axum::{routing::get, Router}; - -use crate::modules::anime2::controller; -use crate::shared::state::AppState; - -pub fn routes(router: Router>) -> Router> { - router - .route("/api/anime2", get(controller::index)) - .route( - "/api/anime2/complete_anime/{slug}", - get(controller::complete_anime_slug), - ) - .route("/api/anime2/detail/{slug}", get(controller::detail_slug)) - .route("/api/anime2/filter", get(controller::filter)) - .route("/api/anime2/genre_list", get(controller::genre_list)) - .route( - "/api/anime2/genre/{slug}", - get(controller::genre_slug_index), - ) - .route( - "/api/anime2/genre/{slug}/{page}", - get(controller::genre_slug_page), - ) - .route("/api/anime2/latest/{slug}", get(controller::latest_slug)) - .route( - "/api/anime2/ongoing_anime/{slug}", - get(controller::ongoing_anime_slug), - ) - .route( - "/api/anime2/search/{slug}", - get(controller::search_slug_index), - ) - .route( - "/api/anime2/search/{slug}/{page}", - get(controller::search_slug_page), - ) -} diff --git a/src/modules/anime2/schema.rs b/src/modules/anime2/schema.rs deleted file mode 100644 index aeb3d8c..0000000 --- a/src/modules/anime2/schema.rs +++ /dev/null @@ -1,34 +0,0 @@ -use serde::Deserialize; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct SlugPath { - pub slug: String, -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct SlugPagePath { - pub slug: String, - pub page: u32, -} - -#[derive(Deserialize, ToSchema)] -pub struct FilterQuery { - pub page: Option, - pub genre: Option, - pub status: Option, - pub r#type: Option, - pub order: Option, -} - -#[derive(Deserialize, ToSchema)] -pub struct GenreQuery { - pub page: Option, - pub status: Option, - pub order: Option, -} - -#[derive(Deserialize, ToSchema)] -pub struct SearchQuery { - pub q: Option, -} diff --git a/src/modules/anime2/scraping.rs b/src/modules/anime2/scraping.rs deleted file mode 100644 index 70eb326..0000000 --- a/src/modules/anime2/scraping.rs +++ /dev/null @@ -1,359 +0,0 @@ -use crate::shared::types::entities::anime::*; -use crate::shared::utils::parse_html; -use crate::shared::utils::scraping::{ - attr, attr_from, attr_from_or, extract_slug, selector, text, text_from_or, -}; -use scraper::{Html, Selector}; - -// ============================================================================ -// SELECTORS -// ============================================================================ - -/// Common selectors used across anime parsing -pub struct AnimeSelectors { - pub item: Selector, - pub title: Selector, - pub link: Selector, - pub img: Selector, - pub episode: Selector, - pub score: Selector, - pub status: Selector, - pub genre: Selector, - pub rating: Selector, - pub type_sel: Selector, - pub season: Selector, - pub desc: Selector, -} - -impl AnimeSelectors { - pub fn new() -> Result { - Ok(Self { - item: selector("article.bs").ok_or("Invalid selector: article.bs")?, - title: selector(".tt h2").ok_or("Invalid selector: .tt h2")?, - link: selector("a").ok_or("Invalid selector: a")?, - img: selector("img").ok_or("Invalid selector: img")?, - episode: selector(".epx").ok_or("Invalid selector: .epx")?, - score: selector(".numscore").ok_or("Invalid selector: .numscore")?, - status: selector(".status").ok_or("Invalid selector: .status")?, - genre: selector(".genres a").ok_or("Invalid selector: .genres a")?, - rating: selector(".score").ok_or("Invalid selector: .score")?, - type_sel: selector(".typez").ok_or("Invalid selector: .typez")?, - season: selector(".season").ok_or("Invalid selector: .season")?, - desc: selector(".data .typez").ok_or("Invalid selector: .data .typez")?, - }) - } -} - -impl Default for AnimeSelectors { - fn default() -> Self { - Self::new().expect("Valid CSS selectors") - } -} - -// Global lazy static instance for selectors to avoid reallocation per parse -use once_cell::sync::Lazy; -static ANIME_SELECTORS: Lazy> = - Lazy::new(|| AnimeSelectors::new().map_err(|e| format!("Failed to create selectors: {}", e))); - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/// Extract poster URL from an element, checking both src and data-src attributes -pub fn extract_poster(element: &scraper::ElementRef, img_selector: &Selector) -> String { - element - .select(img_selector) - .next() - .and_then(|e| attr(&e, "src").or(attr(&e, "data-src"))) - .unwrap_or_default() -} - -// ============================================================================ -// ANIME PARSERS -// ============================================================================ - -/// Parse ongoing anime items from HTML -pub fn parse_ongoing_anime( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let href = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&href); - let poster = extract_poster(&element, &selectors.img); - let current_episode = text_from_or(&element, &selectors.episode, "N/A"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - - items.push(OngoingAnimeItem { - title, - slug, - poster, - current_episode, - anime_url, - }); - } - - Ok(items) -} - -/// Parse ongoing anime items with score from HTML -pub fn parse_ongoing_anime_with_score( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let poster = extract_poster(&element, &selectors.img); - let score = text_from_or(&element, &selectors.score, "N/A"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&anime_url); - - items.push(OngoingAnimeItemWithScore { - title, - slug, - poster, - score, - anime_url, - }); - } - - Ok(items) -} - -/// Parse complete anime items from HTML -pub fn parse_complete_anime( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let href = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&href); - let poster = extract_poster(&element, &selectors.img); - let episode_count = text_from_or(&element, &selectors.episode, "N/A"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - - items.push(CompleteAnimeItem { - title, - slug, - poster, - episode_count, - anime_url, - }); - } - - Ok(items) -} - -/// Parse latest anime items from HTML -pub fn parse_latest_anime( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let poster = extract_poster(&element, &selectors.img); - let current_episode = text_from_or(&element, &selectors.episode, "N/A"); - let score = text_from_or(&element, &selectors.score, "N/A"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&anime_url); - - items.push(LatestAnimeItem { - title, - slug, - poster, - current_episode, - score, - anime_url, - }); - } - - Ok(items) -} - -/// Parse search results from HTML -pub fn parse_search_anime( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let href = attr_from(&element, &selectors.link, "href").unwrap_or_default(); - let slug = extract_slug(&href); - let poster = extract_poster(&element, &selectors.img); - let description = text_from_or(&element, &selectors.desc, ""); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - let genres = element.select(&selectors.genre).map(|e| text(&e)).collect(); - let rating = text_from_or(&element, &selectors.rating, ""); - let r#type = text_from_or(&element, &selectors.type_sel, ""); - let season = text_from_or(&element, &selectors.season, ""); - - items.push(SearchAnimeItem { - title, - slug, - poster, - description, - anime_url, - genres, - rating, - r#type, - season, - }); - } - - Ok(items) -} - -/// Parse genre-filtered anime items from HTML -pub fn parse_genre_anime( - html: &str, -) -> Result, Box> { - let document = parse_html(html); - let selectors = ANIME_SELECTORS.as_ref().map_err(|e| e.clone())?; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let poster = extract_poster(&element, &selectors.img); - let score = text_from_or(&element, &selectors.score, "N/A"); - let status = text_from_or(&element, &selectors.status, "Unknown"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&anime_url); - - items.push(GenreAnimeItem { - title, - slug, - poster, - score, - status, - anime_url, - }); - } - - Ok(items) -} - -// ============================================================================ -// PAGINATION PARSERS -// ============================================================================ - -/// Parse pagination from HTML document -pub fn parse_pagination(document: &Html, current_page: u32) -> Result { - let pagination_selector = - selector(".pagination .page-numbers:not(.next)").ok_or("Invalid selector")?; - let next_selector = selector(".pagination .next").ok_or("Invalid selector")?; - - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| text(&e).trim().parse::().ok()) - .unwrap_or(current_page); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - Ok(Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }) -} - -/// Parse pagination with string-based page numbers (for search results) -pub fn parse_pagination_with_string( - document: &Html, - current_page: u32, -) -> Result { - let pagination_selector = - selector(".pagination .page-numbers:not(.next)").ok_or("Invalid selector")?; - let next_selector = selector(".pagination .next").ok_or("Invalid selector")?; - - let last_visible_page = document - .select(&pagination_selector) - .last() - .and_then(|e| text(&e).trim().parse::().ok()) - .unwrap_or(current_page); - - let has_next_page = document.select(&next_selector).next().is_some(); - - let next_page = if has_next_page { - document - .select(&next_selector) - .next() - .and_then(|e| attr(&e, "href")) - .and_then(|href| href.split("/page/").nth(1).map(|s| s.to_string())) - .and_then(|s| s.split('/').next().map(|s| s.to_string())) - } else { - None - }; - - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some((current_page - 1).to_string()) - } else { - None - }; - - Ok(PaginationWithStringPages { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }) -} diff --git a/src/modules/anime2/types.rs b/src/modules/anime2/types.rs deleted file mode 100644 index 2523d1e..0000000 --- a/src/modules/anime2/types.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::shared::types::entities::anime::HasPoster; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Anime2Data { - pub ongoing_anime: Vec, - pub complete_anime: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Anime2Response { - pub status: String, - pub data: Anime2Data, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Genre { - pub name: String, - pub slug: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenresResponse { - pub status: String, - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct FiltersApplied { - pub genre: Option, - pub status: Option, - pub r#type: Option, - pub order: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct FilterResponse { - pub success: bool, - pub data: Vec, - pub pagination: crate::shared::types::entities::anime::Pagination, - pub filters_applied: FiltersApplied, - pub status: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct AnimeDetailData { - pub title: String, - pub alternative_title: String, - pub poster: String, - pub poster2: String, - pub r#type: String, - pub release_date: String, - pub status: String, - pub synopsis: String, - pub studio: String, - pub genres: Vec, - pub producers: Vec, - pub recommendations: Vec, - pub batch: Vec, - pub ova: Vec, - pub downloads: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailGenre { - pub name: String, - pub slug: String, - pub anime_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Link { - pub name: String, - pub url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DownloadItem { - pub resolution: String, - pub links: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Recommendation { - pub title: String, - pub slug: String, - pub poster: String, - pub status: String, - pub r#type: String, -} - -impl HasPoster for Recommendation { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailResponse { - pub status: String, - pub data: AnimeDetailData, -} diff --git a/src/modules/komik/controller.rs b/src/modules/komik/controller.rs deleted file mode 100644 index cec37e8..0000000 --- a/src/modules/komik/controller.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::sync::Arc; - -use axum::{extract::State, Json}; - -use crate::modules::komik::repository::KomikRepository; -use crate::modules::komik::service::KomikService; -use crate::shared::errors::AppError; -use crate::shared::state::AppState; - -#[utoipa::path( - get, - path = "/api/komik/genre_list", - tag = "komik", - operation_id = "komik_genre_list", - responses( - (status = 200, description = "Handles GET requests for the /api/komik/genre_list endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_list( - State(app_state): State>, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.genre_list(app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/chapter/{slug}", - tag = "komik", - operation_id = "komik_chapter_slug", - responses( - (status = 200, description = "Retrieves details for a specific chapter by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn chapter_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.chapter_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/detail/{slug}", - tag = "komik", - operation_id = "komik_detail_slug", - responses( - (status = 200, description = "Retrieves details for a specific detail by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn detail_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.detail_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/genre/{slug}", - tag = "komik", - operation_id = "komik_genre_slug_index", - responses( - (status = 200, description = "Retrieves details for a specific genre by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.genre_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/genre/{slug}/{page}", - tag = "komik", - operation_id = "komik_genre_slug_page", - responses( - (status = 200, description = "Retrieves paginated genre results by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn genre_slug_page( - State(app_state): State>, - axum::extract::Path((slug, page)): axum::extract::Path<(String, String)>, -) -> Result, AppError> { - let page_num = page - .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - let service = KomikService::new(KomikRepository::new()); - service - .genre_slug_page(slug, page_num, app_state) - .await - .map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/manga/{slug}", - tag = "komik", - operation_id = "komik_manga_slug", - responses( - (status = 200, description = "Retrieves manga details by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn manga_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.manga_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/manhua/{slug}", - tag = "komik", - operation_id = "komik_manhua_slug", - responses( - (status = 200, description = "Retrieves manhua details by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn manhua_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.manhua_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/manhwa/{slug}", - tag = "komik", - operation_id = "komik_manhwa_slug", - responses( - (status = 200, description = "Retrieves manhwa details by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn manhwa_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.manhwa_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/popular/{slug}", - tag = "komik", - operation_id = "komik_popular_slug", - responses( - (status = 200, description = "Retrieves popular komik details by slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn popular_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.popular_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/search/{slug}", - tag = "komik", - operation_id = "komik_search_slug_index", - responses( - (status = 200, description = "Retrieves search results by query slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug( - State(app_state): State>, - axum::extract::Path(slug): axum::extract::Path, -) -> Result, AppError> { - let service = KomikService::new(KomikRepository::new()); - service.search_slug(slug, app_state).await.map(Json) -} - -#[utoipa::path( - get, - path = "/api/komik/search/{slug}/{page}", - tag = "komik", - operation_id = "komik_search_slug_page", - responses( - (status = 200, description = "Retrieves paginated search results by query slug.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn search_slug_page( - State(app_state): State>, - axum::extract::Path((slug, page)): axum::extract::Path<(String, String)>, -) -> Result, AppError> { - let page_num = page - .parse::() - .map_err(|_| AppError::ScraperError("Invalid page number".to_string()))?; - let service = KomikService::new(KomikRepository::new()); - service - .search_slug_page(slug, page_num, app_state) - .await - .map(Json) -} diff --git a/src/modules/komik/mod.rs b/src/modules/komik/mod.rs deleted file mode 100644 index 67dc224..0000000 --- a/src/modules/komik/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod controller; -pub mod parser; -pub mod repository; -pub mod route; -pub mod schema; -pub mod service; -pub mod types; diff --git a/src/modules/komik/route.rs b/src/modules/komik/route.rs deleted file mode 100644 index 7f207aa..0000000 --- a/src/modules/komik/route.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::sync::Arc; - -use axum::{routing::get, Router}; - -use crate::modules::komik::controller; -use crate::shared::state::AppState; - -pub fn routes(router: Router>) -> Router> { - router - .route("/api/komik/genre_list", get(controller::genre_list)) - .route("/api/komik/chapter/{slug}", get(controller::chapter_slug)) - .route("/api/komik/detail/{slug}", get(controller::detail_slug)) - .route("/api/komik/genre/{slug}", get(controller::genre_slug)) - .route( - "/api/komik/genre/{slug}/{page}", - get(controller::genre_slug_page), - ) - .route("/api/komik/manga/{slug}", get(controller::manga_slug)) - .route("/api/komik/manhua/{slug}", get(controller::manhua_slug)) - .route("/api/komik/manhwa/{slug}", get(controller::manhwa_slug)) - .route("/api/komik/popular/{slug}", get(controller::popular_slug)) - .route("/api/komik/search/{slug}", get(controller::search_slug)) - .route( - "/api/komik/search/{slug}/{page}", - get(controller::search_slug_page), - ) -} diff --git a/src/modules/komik/schema.rs b/src/modules/komik/schema.rs deleted file mode 100644 index 0a6985c..0000000 --- a/src/modules/komik/schema.rs +++ /dev/null @@ -1,18 +0,0 @@ -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize)] -pub struct SlugPath { - pub slug: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SlugPagePath { - pub slug: String, - pub page: String, -} - -#[derive(Deserialize)] -pub struct ChapterQuery { - /// URL-friendly identifier for the chapter (typically the chapter slug or URL path) - pub chapter_url: Option, -} diff --git a/src/modules/komik/types.rs b/src/modules/komik/types.rs deleted file mode 100644 index 463239a..0000000 --- a/src/modules/komik/types.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::shared::types::entities::anime::HasPoster; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Genre { - pub name: String, - pub slug: String, - pub count: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenresResponse { - pub status: String, - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct ChapterData { - pub title: String, - pub next_chapter_id: String, - pub prev_chapter_id: String, - pub list_chapter: String, - pub images: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct ChapterResponse { - pub message: String, - pub data: ChapterData, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Chapter { - pub chapter: String, - pub date: String, - pub chapter_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailData { - pub title: String, - pub poster: String, - pub description: String, - pub status: String, - pub r#type: String, - pub release_date: String, - pub author: String, - pub total_chapter: String, - pub updated_on: String, - pub genres: Vec, - pub chapters: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct DetailResponse { - pub status: bool, - pub data: DetailData, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct KomikDetailRequest { - pub komik_id: String, - pub chapter_id: Option, -} - -#[derive(Debug, Serialize, Clone, ToSchema)] -pub enum KomikDetailEvent { - Chapter(Chapter), - Detail(DetailData), - Error(String), - EndOfStream, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct KomikItem { - pub title: String, - pub slug: String, - pub poster: String, - pub chapter: String, - pub score: String, - pub r#type: String, - pub komik_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Pagination { - pub current_page: u32, - pub last_visible_page: u32, - pub has_next_page: bool, - pub next_page: Option, - pub has_previous_page: bool, - pub previous_page: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenreKomikResponse { - pub status: String, - pub genre: String, - pub data: Vec, - pub pagination: Pagination, -} - -impl HasPoster for KomikItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct SearchKomikResponse { - pub status: String, - pub data: Vec, - pub pagination: Pagination, -} diff --git a/src/modules/mod.rs b/src/modules/mod.rs deleted file mode 100644 index 0bd4033..0000000 --- a/src/modules/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -use axum::Router; -use std::sync::Arc; - -use crate::shared::state::AppState; - -pub mod anime; -pub mod anime2; -pub mod komik; -pub mod proxy; - -pub fn routes(router: Router>) -> Router> { - let router = anime::route::routes(router); - let router = anime2::route::routes(router); - let router = komik::route::routes(router); - let router = proxy::route::routes(router); - router -} diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs deleted file mode 100644 index 18780ca..0000000 --- a/src/modules/proxy/controller.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::modules::proxy::repository::ProxyRepository; -use crate::modules::proxy::schema::{AuditImageCacheRequest, ImageCacheRequest, ProxyParams}; -use crate::modules::proxy::service::ProxyService; -use crate::modules::proxy::types::{AuditImageCacheResponse, ImageCacheResponse}; -use crate::shared::database::repositories::image_cache::SeaOrmImageCacheRepository; -use crate::shared::errors::AppError; -use crate::shared::state::AppState; -use axum::extract::{Json, Query, State}; -use axum::response::Response; -use std::sync::Arc; - -fn make_service(state: &Arc) -> ProxyService { - let repo = Arc::new(SeaOrmImageCacheRepository::new( - state.db.clone(), - state.redis_pool.clone(), - )); - ProxyService::new(ProxyRepository::new(), repo) -} - -#[utoipa::path( - get, - path = "/api/proxy/croxy", - tag = "proxy", - operation_id = "proxy_croxy", - params(ProxyParams), - responses( - (status = 200, description = "Handles GET requests for the /api/proxy/croxy endpoint.", body = serde_json::Value), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn fetch_with_proxy_only( - State(state): State>, - Query(params): Query, -) -> Result { - make_service(&state).fetch_with_proxy_only(params).await -} - -#[utoipa::path( - post, - path = "/api/proxy/image-cache", - tag = "proxy", - operation_id = "proxy_image_cache", - request_body = ImageCacheRequest, - responses( - (status = 200, description = "Cache an image to CDN and return the cached URL", body = ImageCacheResponse), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn image_cache( - State(state): State>, - Json(req): Json, -) -> Result, AppError> { - make_service(&state).image_cache(state, req).await.map(Json) -} - -#[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 an image cache entry", body = AuditImageCacheResponse), - (status = 500, description = "Internal Server Error", body = String) - ) -)] -pub async fn audit_image_cache( - State(state): State>, - Json(req): Json, -) -> Result, AppError> { - make_service(&state) - .audit_image_cache(state, req) - .await - .map(Json) -} diff --git a/src/modules/proxy/mod.rs b/src/modules/proxy/mod.rs deleted file mode 100644 index 67dc224..0000000 --- a/src/modules/proxy/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod controller; -pub mod parser; -pub mod repository; -pub mod route; -pub mod schema; -pub mod service; -pub mod types; diff --git a/src/modules/proxy/parser.rs b/src/modules/proxy/parser.rs deleted file mode 100644 index b1f7bc0..0000000 --- a/src/modules/proxy/parser.rs +++ /dev/null @@ -1 +0,0 @@ -// Proxy endpoints do not parse HTML or structured upstream payloads. diff --git a/src/modules/proxy/repository.rs b/src/modules/proxy/repository.rs deleted file mode 100644 index 84f5e9a..0000000 --- a/src/modules/proxy/repository.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::shared::database::traits::scraping_repository::ScrapingRepository; -use crate::shared::errors::AppError; -use crate::shared::utils::web::proxy_fetch::{self, FetchResult}; -use async_trait::async_trait; - -pub struct ProxyRepository; - -impl Default for ProxyRepository { - fn default() -> Self { - Self::new() - } -} - -impl ProxyRepository { - pub fn new() -> Self { - Self - } - - pub async fn fetch_with_proxy_url(&self, url: &str) -> Result { - proxy_fetch::fetch_with_proxy(url).await - } -} - -#[async_trait] -impl ScrapingRepository for ProxyRepository { - async fn fetch_html(&self, url: &str) -> Result { - self.fetch_with_proxy_url(url).await.map(|r| r.data) - } -} diff --git a/src/modules/proxy/route.rs b/src/modules/proxy/route.rs deleted file mode 100644 index 78c4f8e..0000000 --- a/src/modules/proxy/route.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::modules::proxy::controller; -use crate::shared::state::AppState; -use axum::Router; -use std::sync::Arc; - -pub fn routes(router: Router>) -> Router> { - router - .route( - "/api/proxy/croxy", - axum::routing::get(controller::fetch_with_proxy_only), - ) - .route( - "/api/proxy/image-cache", - axum::routing::post(controller::image_cache), - ) -} diff --git a/src/modules/proxy/schema.rs b/src/modules/proxy/schema.rs deleted file mode 100644 index 4ddb84f..0000000 --- a/src/modules/proxy/schema.rs +++ /dev/null @@ -1,27 +0,0 @@ -use serde::Deserialize; -use utoipa::IntoParams; -use utoipa::ToSchema; - -/// Query parameters for proxy fetch (GET) -#[derive(Debug, Deserialize, ToSchema, IntoParams)] -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, -} diff --git a/src/modules/proxy/service.rs b/src/modules/proxy/service.rs deleted file mode 100644 index 3cf39ed..0000000 --- a/src/modules/proxy/service.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::modules::proxy::repository::ProxyRepository; -use crate::modules::proxy::schema::{AuditImageCacheRequest, ImageCacheRequest, ProxyParams}; -use crate::modules::proxy::types::{AuditImageCacheResponse, ImageCacheResponse}; -use crate::shared::config::CONFIG; -use crate::shared::database::traits::image_cache::ImageCacheRepository; -use crate::shared::errors::AppError; -use crate::shared::events::bus::ImageRepaired; -use crate::shared::services::images::cache::ImageCache; -use crate::shared::state::AppState; -use axum::http::StatusCode; -use axum::response::Response; -use std::sync::Arc; -use tracing::{error, info, warn}; - -pub struct ProxyService { - repository: ProxyRepository, - image_cache_repo: Arc, -} - -impl ProxyService { - pub fn new( - repository: ProxyRepository, - image_cache_repo: Arc, - ) -> Self { - Self { - repository, - image_cache_repo, - } - } - - fn build_image_cache(&self) -> ImageCache { - ImageCache::new(self.image_cache_repo.clone()) - } - - pub async fn fetch_with_proxy_only(&self, params: ProxyParams) -> Result { - let url = params.url; - match self.repository.fetch_with_proxy_url(&url).await { - Ok(fetch_result) => { - let mut builder = Response::builder().status(StatusCode::OK); - if let Some(content_type) = fetch_result.content_type { - builder = builder.header("Content-Type", content_type); - } - Ok(builder.body(fetch_result.data.into())?) - } - Err(e) => { - error!("Proxy fetch error: {:?}", e); - Err(AppError::Other(format!( - "Failed to fetch URL via proxy: {}", - e - ))) - } - } - } - - pub async fn image_cache( - &self, - state: Arc, - req: ImageCacheRequest, - ) -> Result { - let cache = self - .build_image_cache() - .with_semaphore(state.image_processing_semaphore.clone()); - - if let Some(cdn_url) = cache.get_cdn_url(&req.url).await { - return Ok(ImageCacheResponse { - success: true, - original_url: req.url, - cdn_url, - from_cache: true, - pending: None, - }); - } - - if req.lazy { - let url = req.url.clone(); - let repo = self.image_cache_repo.clone(); - let semaphore = state.image_processing_semaphore.clone(); - tokio::spawn(async move { - let cache = ImageCache::new(repo).with_semaphore(semaphore); - match cache.get_or_cache(&url).await { - Ok(cdn) => info!("[LazyCache] Cached {} -> {}", url, cdn), - Err(e) => warn!("[LazyCache] Failed {}: {}", url, e), - } - }); - return Ok(ImageCacheResponse { - success: true, - original_url: req.url.clone(), - cdn_url: req.url, - from_cache: false, - pending: Some(true), - }); - } - - match cache.get_or_cache(&req.url).await { - Ok(cdn_url) => Ok(ImageCacheResponse { - success: true, - original_url: req.url, - cdn_url, - from_cache: false, - pending: None, - }), - Err(e) => { - error!("ImageCache error: {}", e); - Ok(ImageCacheResponse { - success: false, - original_url: req.url.clone(), - cdn_url: req.url, - from_cache: false, - pending: None, - }) - } - } - } - - pub async fn audit_image_cache( - &self, - state: Arc, - req: AuditImageCacheRequest, - ) -> Result { - let cache = self.build_image_cache(); - - let mut cdn_opt = cache.get_cdn_url(&req.url).await; - let mut original = req.url.clone(); - if cdn_opt.is_none() { - if let Some(orig) = cache.find_original_from_cdn(&req.url).await { - info!( - "SmartAudit: {} recognized as CDN, original {}", - req.url, orig - ); - original = orig; - cdn_opt = Some(req.url.clone()); - } - } - - if let Some(cdn_url) = cdn_opt { - let client = crate::shared::utils::web::http_client::http_client().client(); - let mut accessible = false; - match client.get(cdn_url.clone()).send().await { - Ok(resp) if resp.status().is_success() => { - if let Ok(bytes) = resp.bytes().await { - if infer::get(&bytes) - .map(|k| k.mime_type().starts_with("image/")) - .unwrap_or(false) - { - accessible = true; - } else { - warn!("CDN {} returned non-image content", cdn_url); - } - } - } - Ok(resp) => warn!("CDN {} status {}", cdn_url, resp.status()), - Err(e) => warn!("CDN {} fetch error {}", cdn_url, e), - } - if accessible { - return Ok(AuditImageCacheResponse { - success: true, - original_url: original, - cdn_url: Some(cdn_url), - was_accessible: true, - re_uploaded: false, - message: "CDN URL is accessible and the image is valid".to_string(), - }); - } - info!("CDN {} inaccessible, purging and reuploading", cdn_url); - let picser_delete_url = &CONFIG.urls.picser_api_url; - if let Some(filename) = cdn_url.split('/').last() { - let payload = serde_json::json!({ "filename": filename }); - match client.delete(picser_delete_url).json(&payload).send().await { - Ok(r) if r.status().is_success() => info!("Deleted {} via Picser", filename), - Ok(r) => warn!("Picser delete {} status {}", filename, r.status()), - Err(e) => warn!("Picser delete error {}: {}", filename, e), - } - } - let _ = cache.invalidate(&original).await; - match cache.get_or_cache(&original).await { - Ok(new_cdn) => { - state - .event_bus - .publish(ImageRepaired { - original_url: original.clone(), - cdn_url: new_cdn.clone(), - }) - .await; - Ok(AuditImageCacheResponse { - success: true, - original_url: original, - cdn_url: Some(new_cdn), - was_accessible: false, - re_uploaded: true, - message: "CDN URL was inaccessible, re-uploaded".to_string(), - }) - } - Err(e) => Ok(AuditImageCacheResponse { - success: false, - original_url: original, - cdn_url: None, - was_accessible: false, - re_uploaded: false, - message: format!("Re-upload failed: {}", e), - }), - } - } else { - match cache.get_or_cache(&original).await { - Ok(new_cdn) => { - state - .event_bus - .publish(ImageRepaired { - original_url: original.clone(), - cdn_url: new_cdn.clone(), - }) - .await; - Ok(AuditImageCacheResponse { - success: true, - original_url: original, - cdn_url: Some(new_cdn), - was_accessible: false, - re_uploaded: true, - message: "Cached newly".to_string(), - }) - } - Err(e) => Ok(AuditImageCacheResponse { - success: false, - original_url: original, - cdn_url: None, - was_accessible: false, - re_uploaded: false, - message: format!("Cache failed: {}", e), - }), - } - } - } -} diff --git a/src/modules/proxy/types.rs b/src/modules/proxy/types.rs deleted file mode 100644 index 8e619f0..0000000 --- a/src/modules/proxy/types.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::Serialize; -use utoipa::ToSchema; - -/// Response for image cache POST -#[derive(Debug, Serialize, ToSchema)] -pub struct ImageCacheResponse { - pub success: bool, - pub original_url: String, - pub cdn_url: String, - pub from_cache: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub pending: Option, -} - -/// Response for image cache audit POST -#[derive(Debug, Serialize, ToSchema)] -pub struct AuditImageCacheResponse { - pub success: bool, - pub original_url: String, - pub cdn_url: Option, - pub was_accessible: bool, - pub re_uploaded: bool, - pub message: String, -} diff --git a/src/shared/observability/metrics.rs b/src/observability/metrics.rs similarity index 92% rename from src/shared/observability/metrics.rs rename to src/observability/metrics.rs index 5aa02b6..a8da885 100644 --- a/src/shared/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -24,7 +24,9 @@ static METER: OnceLock = OnceLock::new(); static PROVIDER: OnceLock = OnceLock::new(); fn meter() -> &'static Meter { - METER.get().expect("OTel meter not initialized — call init_otel_metrics first") + METER + .get() + .expect("OTel meter not initialized — call init_otel_metrics first") } /// Initialize the global OTLP MeterProvider. @@ -34,10 +36,9 @@ pub fn init_otel_metrics() { return; } - let otel_endpoint = - std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_else(|_| "http://localhost:4317".into()); - let service_name = - std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "scraper-api".into()); + let otel_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:4317".into()); + let service_name = std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "scraper-api".into()); let export_interval_ms: u64 = std::env::var("OTEL_METRICS_EXPORT_INTERVAL") .ok() .and_then(|v| v.parse().ok()) @@ -54,9 +55,7 @@ pub fn init_otel_metrics() { .with_interval(std::time::Duration::from_millis(export_interval_ms)) .build(); - let resource = Resource::new(vec![ - KeyValue::new("service.name", service_name.clone()), - ]); + let resource = Resource::new(vec![KeyValue::new("service.name", service_name.clone())]); let provider = MeterProviderBuilder::default() .with_resource(resource) diff --git a/src/shared/observability/mod.rs b/src/observability/mod.rs similarity index 100% rename from src/shared/observability/mod.rs rename to src/observability/mod.rs diff --git a/src/shared/observability/openapi.rs b/src/observability/openapi.rs similarity index 100% rename from src/shared/observability/openapi.rs rename to src/observability/openapi.rs diff --git a/src/observability/openapi_modules.rs b/src/observability/openapi_modules.rs new file mode 100644 index 0000000..08e771f --- /dev/null +++ b/src/observability/openapi_modules.rs @@ -0,0 +1,60 @@ +use utoipa::OpenApi; + +/// Manual aggregation of OpenAPI docs from module controllers. +#[derive(OpenApi)] +#[openapi( + paths( + // Anime module handlers + crate::presentation::handler::anime::anime_index, + crate::presentation::handler::anime::genres, + crate::presentation::handler::anime::detail_slug, + crate::presentation::handler::anime::complete_anime_slug, + crate::presentation::handler::anime::full_slug, + crate::presentation::handler::anime::ongoing_anime_slug, + crate::presentation::handler::anime::latest_slug, + crate::presentation::handler::anime::search_slug_index, + crate::presentation::handler::anime::search_slug_page, + crate::presentation::handler::anime::genre_slug_index, + crate::presentation::handler::anime::genre_slug_page, + // Anime2 module handlers + crate::presentation::handler::anime2::index, + crate::presentation::handler::anime2::genre_list, + crate::presentation::handler::anime2::filter, + crate::presentation::handler::anime2::detail_slug, + crate::presentation::handler::anime2::genre_slug_index, + crate::presentation::handler::anime2::genre_slug_page, + crate::presentation::handler::anime2::search_slug_index, + crate::presentation::handler::anime2::search_slug_page, + crate::presentation::handler::anime2::latest_slug, + crate::presentation::handler::anime2::ongoing_anime_slug, + crate::presentation::handler::anime2::complete_anime_slug, + // Komik module handlers + crate::presentation::handler::komik::genre_list, + crate::presentation::handler::komik::chapter_slug, + crate::presentation::handler::komik::detail_slug, + crate::presentation::handler::komik::genre_slug, + crate::presentation::handler::komik::genre_slug_page, + crate::presentation::handler::komik::manga_slug, + crate::presentation::handler::komik::manhua_slug, + crate::presentation::handler::komik::manhwa_slug, + crate::presentation::handler::komik::popular_slug, + crate::presentation::handler::komik::search_slug, + crate::presentation::handler::komik::search_slug_page, + // Proxy module handlers + crate::presentation::handler::proxy::fetch_with_proxy_only, + crate::presentation::handler::proxy::image_cache, + ), + components( + schemas( + // Application response wrapper + crate::presentation::dto::common::ApiResponse, + ) + ), + tags( + (name = "anime", description = "Anime endpoints"), + (name = "anime2", description = "Anime2 endpoints"), + (name = "komik", description = "Komik endpoints"), + (name = "proxy", description = "Proxy endpoints"), + ) +)] +pub struct ModuleApiDoc; diff --git a/src/shared/observability/request_id.rs b/src/observability/request_id.rs similarity index 100% rename from src/shared/observability/request_id.rs rename to src/observability/request_id.rs diff --git a/src/shared/types/api_response.rs b/src/presentation/dto/common.rs similarity index 95% rename from src/shared/types/api_response.rs rename to src/presentation/dto/common.rs index 6f671d6..7a11097 100644 --- a/src/shared/types/api_response.rs +++ b/src/presentation/dto/common.rs @@ -1,3 +1,5 @@ +//! Common API response types. + use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/src/presentation/dto/komik.rs b/src/presentation/dto/komik.rs new file mode 100644 index 0000000..f8ea317 --- /dev/null +++ b/src/presentation/dto/komik.rs @@ -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, +} + +#[derive(Serialize, Debug, Clone, ToSchema)] +pub struct GenreKomikResponse { + pub status: String, + pub genre: String, + pub data: Vec, + 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, + pub pagination: Pagination, +} diff --git a/src/presentation/dto/mod.rs b/src/presentation/dto/mod.rs new file mode 100644 index 0000000..a1df860 --- /dev/null +++ b/src/presentation/dto/mod.rs @@ -0,0 +1,2 @@ +pub mod common; +pub mod komik; diff --git a/src/presentation/error.rs b/src/presentation/error.rs new file mode 100644 index 0000000..4b98ebe --- /dev/null +++ b/src/presentation/error.rs @@ -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 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 for AppError { + fn from(s: String) -> Self { + AppError::Internal(s) + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err.to_string()) + } +} + +impl From for AppError { + fn from(err: deadpool_redis::PoolError) -> Self { + AppError::Internal(err.to_string()) + } +} + +impl From 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() + } +} diff --git a/src/presentation/handler/anime.rs b/src/presentation/handler/anime.rs new file mode 100644 index 0000000..80fe6f7 --- /dev/null +++ b/src/presentation/handler/anime.rs @@ -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, +} + +#[derive(Serialize, ToSchema)] +pub struct DetailResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + pub data: AnimeDetailData, +} + +#[derive(Serialize, ToSchema)] +pub struct ListResponse { + pub message: String, + pub data: Vec, + pub total: Option, + pub pagination: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct FullResponse { + pub status: String, + pub data: AnimeFullData, +} + +#[derive(Serialize, ToSchema)] +pub struct OngoingAnimeResponse { + pub status: String, + pub data: Vec, + pub pagination: Pagination, +} + +#[derive(Serialize, ToSchema)] +pub struct LatestAnimeResponse { + pub status: String, + pub data: Vec, + pub pagination: Pagination, +} + +#[derive(Serialize, ToSchema)] +pub struct SearchResponse { + pub status: String, + pub data: Vec, + pub pagination: Pagination, +} + +#[derive(Serialize, ToSchema)] +pub struct GenreListResponse { + pub status: String, + pub data: Vec, + pub pagination: Pagination, +} + +// ============================================================================ +// Helper +// ============================================================================ + +fn make_use_cases(state: &Arc) -> 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>, +) -> Result, 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>, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path((slug, page)): Path<(String, String)>, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path((slug, page)): Path<(String, String)>, +) -> Result, 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, + })) +} diff --git a/src/presentation/handler/anime2.rs b/src/presentation/handler/anime2.rs new file mode 100644 index 0000000..7b7ca69 --- /dev/null +++ b/src/presentation/handler/anime2.rs @@ -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, + pub genre: Option, + pub status: Option, + pub r#type: Option, + pub order: Option, +} + +// ============================================================================ +// Helper +// ============================================================================ + +fn make_use_cases(state: &Arc) -> 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>, +) -> Result, 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>, +) -> Result, 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>, + Query(params): Query, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn genre_slug_index( + State(app_state): State>, + Path(slug): Path, +) -> Result>>, 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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn genre_slug_page( + State(app_state): State>, + Path((slug, page)): Path<(String, u32)>, +) -> Result>>, 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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn search_slug_index( + State(app_state): State>, + Path(slug): Path, +) -> Result>>, 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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn search_slug_page( + State(app_state): State>, + Path((slug, page)): Path<(String, u32)>, +) -> Result>>, 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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn latest_slug( + State(app_state): State>, + Path(slug): Path, +) -> Result>>, AppError> { + let page = slug + .parse::() + .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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn ongoing_anime_slug( + State(app_state): State>, + Path(slug): Path, +) -> Result>>, AppError> { + let page = slug + .parse::() + .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>), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn complete_anime_slug( + State(app_state): State>, + Path(slug): Path, +) -> Result>>, AppError> { + let page = slug + .parse::() + .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)) +} diff --git a/src/presentation/handler/health.rs b/src/presentation/handler/health.rs new file mode 100644 index 0000000..25401f9 --- /dev/null +++ b/src/presentation/handler/health.rs @@ -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) { + ( + StatusCode::OK, + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }), + ) +} diff --git a/src/presentation/handler/komik.rs b/src/presentation/handler/komik.rs new file mode 100644 index 0000000..b945230 --- /dev/null +++ b/src/presentation/handler/komik.rs @@ -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) -> 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>, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path(slug): Path, +) -> Result, 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>, + Path((slug, page)): Path<(String, String)>, +) -> Result, AppError> { + let page_num = page + .parse::() + .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>, + Path(slug): Path, +) -> Result, AppError> { + let page = slug + .parse::() + .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>, + Path(slug): Path, +) -> Result, AppError> { + let page = slug + .parse::() + .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>, + Path(slug): Path, +) -> Result, AppError> { + let page = slug + .parse::() + .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>, + Path(slug): Path, +) -> Result, AppError> { + let page = slug + .parse::() + .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>, + Path(slug): Path, +) -> Result, 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>, + Path((slug, page)): Path<(String, String)>, +) -> Result, AppError> { + let page_num = page + .parse::() + .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, + })) +} diff --git a/src/presentation/handler/mod.rs b/src/presentation/handler/mod.rs new file mode 100644 index 0000000..f3f2af8 --- /dev/null +++ b/src/presentation/handler/mod.rs @@ -0,0 +1,5 @@ +pub mod anime; +pub mod anime2; +pub mod health; +pub mod komik; +pub mod proxy; diff --git a/src/presentation/handler/proxy.rs b/src/presentation/handler/proxy.rs new file mode 100644 index 0000000..8a3f38a --- /dev/null +++ b/src/presentation/handler/proxy.rs @@ -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) -> 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::, content_type = "application/octet-stream"), + (status = 500, description = "Internal Server Error"), + ) +)] +pub async fn fetch_with_proxy_only( + State(state): State>, + Query(params): Query, +) -> Result { + 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>, + Json(req): Json, +) -> Result, 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>, + Json(req): Json, +) -> Result, AppError> { + info!("Handling audit cache for URL: {}", req.url); + let result = make_use_cases(&state).audit_image_cache(req.url).await?; + Ok(Json(result)) +} diff --git a/src/presentation/middleware/logging.rs b/src/presentation/middleware/logging.rs new file mode 100644 index 0000000..960065c --- /dev/null +++ b/src/presentation/middleware/logging.rs @@ -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 +} diff --git a/src/presentation/middleware/mod.rs b/src/presentation/middleware/mod.rs new file mode 100644 index 0000000..507061b --- /dev/null +++ b/src/presentation/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod logging; +pub mod ratelimit; diff --git a/src/presentation/middleware/ratelimit.rs b/src/presentation/middleware/ratelimit.rs new file mode 100644 index 0000000..35e4e29 --- /dev/null +++ b/src/presentation/middleware/ratelimit.rs @@ -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, +} + +impl RateLimiter { + pub fn new(max_requests: u64, window_secs: u64) -> Arc { + 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>, + 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() + } +} diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs new file mode 100644 index 0000000..a48b2e3 --- /dev/null +++ b/src/presentation/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod error; +pub mod handler; +pub mod middleware; +pub mod router; +pub mod state; diff --git a/src/presentation/router.rs b/src/presentation/router.rs new file mode 100644 index 0000000..3e4116c --- /dev/null +++ b/src/presentation/router.rs @@ -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) -> anyhow::Result { + 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) +} diff --git a/src/presentation/state.rs b/src/presentation/state.rs new file mode 100644 index 0000000..345ebca --- /dev/null +++ b/src/presentation/state.rs @@ -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, + pub image_processing_semaphore: Arc, + pub event_bus: Arc, + pub image_cache_repo: Arc, +} + +impl AppState { + pub fn sea_orm(&self) -> &DatabaseConnection { + &self.db + } +} diff --git a/src/shared/scheduler/cleanup_cache.rs b/src/scheduler/cleanup_cache.rs similarity index 97% rename from src/shared/scheduler/cleanup_cache.rs rename to src/scheduler/cleanup_cache.rs index bda09bf..d753a5f 100644 --- a/src/shared/scheduler/cleanup_cache.rs +++ b/src/scheduler/cleanup_cache.rs @@ -5,9 +5,9 @@ use sea_orm::*; use std::sync::Arc; use tracing::{info, warn}; -use crate::shared::database::get_redis_pool; -use crate::shared::database::persistence::entities::image_cache; -use crate::shared::utils::cache::Cache; +use crate::infrastructure::cache::redis::Cache; +use crate::infrastructure::cache::redis_pool::get_redis_pool; +use crate::infrastructure::persistence::entities::image_cache; use super::ScheduledTask; diff --git a/src/shared/scheduler/mod.rs b/src/scheduler/mod.rs similarity index 100% rename from src/shared/scheduler/mod.rs rename to src/scheduler/mod.rs diff --git a/src/shared/scheduler/runner.rs b/src/scheduler/runner.rs similarity index 100% rename from src/shared/scheduler/runner.rs rename to src/scheduler/runner.rs diff --git a/src/shared/database/mod.rs b/src/shared/database/mod.rs deleted file mode 100644 index 85f0cc4..0000000 --- a/src/shared/database/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod persistence; -pub mod redis; -pub mod repositories; -pub mod setup; -pub mod traits; - -pub use redis::{get_redis_conn, get_redis_pool, redis_pool}; diff --git a/src/shared/database/repositories/mod.rs b/src/shared/database/repositories/mod.rs deleted file mode 100644 index 44e6ce2..0000000 --- a/src/shared/database/repositories/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod image_cache; diff --git a/src/shared/database/traits/mod.rs b/src/shared/database/traits/mod.rs deleted file mode 100644 index 03d89d2..0000000 --- a/src/shared/database/traits/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod image_cache; -pub mod scraping_repository; - -pub use scraping_repository::ScrapingRepository; diff --git a/src/shared/database/traits/scraping_repository.rs b/src/shared/database/traits/scraping_repository.rs deleted file mode 100644 index 411a35a..0000000 --- a/src/shared/database/traits/scraping_repository.rs +++ /dev/null @@ -1,7 +0,0 @@ -use crate::shared::errors::AppError; -use async_trait::async_trait; - -#[async_trait] -pub trait ScrapingRepository: Send + Sync { - async fn fetch_html(&self, url: &str) -> Result; -} diff --git a/src/shared/errors/app_error.rs b/src/shared/errors/app_error.rs deleted file mode 100644 index 2b85a42..0000000 --- a/src/shared/errors/app_error.rs +++ /dev/null @@ -1,88 +0,0 @@ -use axum::response::IntoResponse; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum AppError { - #[error("Environment variable not found: {0}")] - EnvVarNotFound(String), - #[error("Redis error: {0}")] - RedisError(#[from] redis::RedisError), - #[error("Reqwest error: {0}")] - ReqwestError(#[from] reqwest::Error), - #[error("JSON serialization/deserialization error: {0}")] - SerdeJsonError(#[from] serde_json::Error), - #[error("Scraper error: {0}")] - ScraperError(String), - #[error("Fantoccini error: {0}")] - FantocciniError(String), - #[error("IO error: {0}")] - IoError(#[from] std::io::Error), - #[error("Timeout error: {0}")] - TimeoutError(String), - #[error("Other error: {0}")] - Other(String), - #[error("HTTP error: {0}")] - HttpError(#[from] http::Error), - #[error("URL parsing error: {0}")] - UrlParseError(#[from] url::ParseError), - #[error("Database error: {0}")] - DatabaseError(String), - #[error("Not Found: {0}")] - NotFound(String), -} - -impl From<&str> for AppError { - fn from(s: &str) -> Self { - AppError::Other(s.to_string()) - } -} - -impl From for AppError { - fn from(s: String) -> Self { - AppError::Other(s) - } -} - -impl From> for AppError { - fn from(err: Box) -> Self { - AppError::Other(err.to_string()) - } -} - -impl From for AppError { - fn from(err: anyhow::Error) -> Self { - AppError::Other(err.to_string()) - } -} - -impl From for AppError { - fn from(err: deadpool_redis::PoolError) -> Self { - AppError::Other(err.to_string()) - } -} - -impl From for AppError { - fn from(err: tokio::task::JoinError) -> Self { - AppError::Other(err.to_string()) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - let (status, error_message) = match self { - AppError::NotFound(_) => (http::StatusCode::NOT_FOUND, self.to_string()), - AppError::DatabaseError(_) => { - (http::StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) - } - _ => (http::StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), - }; - - // Note: crate::shared::types needs to be available. - // If not, we might need to adjust this line or ensure types are there. - // Since we are validating structure, let's assume types is in core/types.rs - let body = axum::Json(crate::shared::types::ApiResponse::<()>::error( - error_message, - )); - (status, body).into_response() - } -} diff --git a/src/shared/errors/mod.rs b/src/shared/errors/mod.rs deleted file mode 100644 index 84cb0e0..0000000 --- a/src/shared/errors/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod app_error; -pub use app_error::AppError; diff --git a/src/shared/graceful/cleanup.rs b/src/shared/graceful/cleanup.rs deleted file mode 100644 index 6e3633d..0000000 --- a/src/shared/graceful/cleanup.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Graceful shutdown with proper resource cleanup. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use tokio::signal; -use tokio::sync::Notify; -use tokio::time::{sleep, Duration}; -use tracing::info; - -/// Graceful shutdown coordinator. -pub struct ShutdownCoordinator { - /// Shutdown signal flag - is_shutting_down: Arc, - /// Notify for graceful shutdown - shutdown_notify: Arc, -} - -impl ShutdownCoordinator { - /// Create a new shutdown coordinator. - pub fn new() -> Self { - Self { - is_shutting_down: Arc::new(AtomicBool::new(false)), - shutdown_notify: Arc::new(Notify::new()), - } - } - - /// Check if shutdown is in progress. - pub fn is_shutting_down(&self) -> bool { - self.is_shutting_down.load(Ordering::Relaxed) - } - - /// Start shutdown process. - pub fn initiate_shutdown(&self) { - info!("🛑 Initiating graceful shutdown..."); - self.is_shutting_down.store(true, Ordering::Relaxed); - self.shutdown_notify.notify_waiters(); - } - - /// Wait for shutdown signal. - pub async fn wait_for_shutdown_signal(&self) { - let ctrl_c = async { - if let Err(e) = signal::ctrl_c().await { - tracing::error!("Failed to listen for Ctrl+C: {}", e); - } - }; - - #[cfg(unix)] - let terminate = async { - match signal::unix::signal(signal::unix::SignalKind::terminate()) { - Ok(mut stream) => { - stream.recv().await; - } - Err(e) => { - tracing::error!("Failed to listen for SIGTERM: {}", e); - } - } - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => { - info!("Received Ctrl+C signal"); - } - _ = terminate => { - info!("Received SIGTERM signal"); - } - } - - self.initiate_shutdown(); - } - - /// Perform cleanup operations. - pub async fn cleanup(&self) { - info!("🧹 Starting cleanup operations..."); - - // Give active requests time to finish - info!("Waiting for active requests to complete..."); - sleep(Duration::from_secs(5)).await; - - info!("✅ Cleanup completed"); - } - - /// Get a handle for checking shutdown status. - pub fn handle(&self) -> ShutdownHandle { - ShutdownHandle { - is_shutting_down: Arc::clone(&self.is_shutting_down), - } - } -} - -impl Default for ShutdownCoordinator { - fn default() -> Self { - Self::new() - } -} - -/// Handle for checking shutdown status. -#[derive(Clone)] -pub struct ShutdownHandle { - is_shutting_down: Arc, -} - -impl ShutdownHandle { - /// Check if shutdown is in progress. - pub fn is_shutting_down(&self) -> bool { - self.is_shutting_down.load(Ordering::Relaxed) - } -} - -pub async fn wait_for_shutdown_and_cleanup() { - let coordinator = ShutdownCoordinator::new(); - - coordinator.wait_for_shutdown_signal().await; - coordinator.cleanup().await; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_shutdown_coordinator() { - let coordinator = ShutdownCoordinator::new(); - assert!(!coordinator.is_shutting_down()); - - coordinator.initiate_shutdown(); - assert!(coordinator.is_shutting_down()); - } - - #[test] - fn test_shutdown_handle() { - let coordinator = ShutdownCoordinator::new(); - let handle = coordinator.handle(); - - assert!(!handle.is_shutting_down()); - - coordinator.initiate_shutdown(); - assert!(handle.is_shutting_down()); - } -} diff --git a/src/shared/graceful/mod.rs b/src/shared/graceful/mod.rs deleted file mode 100644 index c630962..0000000 --- a/src/shared/graceful/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod cleanup; -pub mod shutdown; diff --git a/src/shared/graceful/shutdown.rs b/src/shared/graceful/shutdown.rs deleted file mode 100644 index 2ebab74..0000000 --- a/src/shared/graceful/shutdown.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Graceful shutdown implementation. - -use std::future::Future; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::signal; -use tokio::sync::broadcast; -use tracing::info; - -/// Graceful shutdown controller. -pub struct GracefulShutdown { - shutdown_tx: broadcast::Sender<()>, - is_shutting_down: Arc, -} - -impl GracefulShutdown { - /// Create a new graceful shutdown controller. - pub fn new() -> Self { - let (shutdown_tx, _) = broadcast::channel(1); - Self { - shutdown_tx, - is_shutting_down: Arc::new(AtomicBool::new(false)), - } - } - - /// Check if shutdown has been initiated. - pub fn is_shutting_down(&self) -> bool { - self.is_shutting_down.load(Ordering::SeqCst) - } - - /// Get a receiver for shutdown notifications. - pub fn subscribe(&self) -> broadcast::Receiver<()> { - self.shutdown_tx.subscribe() - } - - /// Initiate shutdown. - pub fn shutdown(&self) { - if !self.is_shutting_down.swap(true, Ordering::SeqCst) { - info!("🛑 Initiating graceful shutdown..."); - let _ = self.shutdown_tx.send(()); - } - } - - /// Wait for shutdown signal and then gracefully drain. - pub async fn wait_for_shutdown(&self, drain_timeout: Duration) { - // Wait for shutdown signal - shutdown_signal().await; - - self.shutdown(); - - // Give time for in-flight requests to complete - info!( - "⏳ Waiting {}s for in-flight requests...", - drain_timeout.as_secs() - ); - tokio::time::sleep(drain_timeout).await; - - info!("✅ Graceful shutdown complete"); - } -} - -impl Default for GracefulShutdown { - fn default() -> Self { - Self::new() - } -} - -impl Clone for GracefulShutdown { - fn clone(&self) -> Self { - Self { - shutdown_tx: self.shutdown_tx.clone(), - is_shutting_down: Arc::clone(&self.is_shutting_down), - } - } -} - -/// Wait for a shutdown signal (SIGTERM, SIGINT, or Ctrl+C). -pub async fn shutdown_signal() { - let ctrl_c = async { - if let Err(e) = signal::ctrl_c().await { - tracing::error!("Failed to install Ctrl+C handler: {}", e); - } - }; - - #[cfg(unix)] - let terminate = async { - match signal::unix::signal(signal::unix::SignalKind::terminate()) { - Ok(mut stream) => { - stream.recv().await; - } - Err(e) => { - tracing::error!("Failed to install SIGTERM handler: {}", e); - } - } - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => { - info!("📥 Received Ctrl+C signal"); - } - _ = terminate => { - info!("📥 Received SIGTERM signal"); - } - } -} - -/// Create a shutdown future that can be used with axum's serve. -pub fn create_shutdown_signal() -> impl Future + Send + 'static { - async { - shutdown_signal().await; - } -} diff --git a/src/shared/health/endpoints.rs b/src/shared/health/endpoints.rs deleted file mode 100644 index 67f5fa5..0000000 --- a/src/shared/health/endpoints.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Health check endpoint implementations. - -use axum::{http::StatusCode, response::IntoResponse, Json}; -use serde::Serialize; -use std::time::Instant; - -use once_cell::sync::Lazy; - -static START_TIME: Lazy = Lazy::new(Instant::now); - -/// Health status response. -#[derive(Debug, Clone, Serialize)] -pub struct HealthStatus { - pub status: &'static str, - pub version: &'static str, - pub uptime_seconds: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub checks: Option, -} - -/// Individual health checks. -#[derive(Debug, Clone, Serialize)] -pub struct HealthChecks { - pub database: CheckResult, - pub redis: CheckResult, -} - -/// Result of a health check. -#[derive(Debug, Clone, Serialize)] -pub struct CheckResult { - pub status: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub latency_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Simple health check - just returns OK. -/// Use for liveness probes (Kubernetes: /healthz). -pub async fn health_check() -> impl IntoResponse { - let status = HealthStatus { - status: "ok", - version: env!("CARGO_PKG_VERSION"), - uptime_seconds: START_TIME.elapsed().as_secs(), - checks: None, - }; - (StatusCode::OK, Json(status)) -} - -/// Readiness check with dependency checks. -/// Use for readiness probes (Kubernetes: /readyz). -pub async fn readiness_check() -> impl IntoResponse { - let uptime = START_TIME.elapsed().as_secs(); - - // Check Redis - let redis_check = check_redis().await; - - // Check Database - simplified for now - let db_check = CheckResult { - status: "ok", - latency_ms: Some(1), - error: None, - }; - - let all_healthy = redis_check.status == "ok" && db_check.status == "ok"; - - let status = HealthStatus { - status: if all_healthy { "ok" } else { "degraded" }, - version: env!("CARGO_PKG_VERSION"), - uptime_seconds: uptime, - checks: Some(HealthChecks { - database: db_check, - redis: redis_check, - }), - }; - - let status_code = if all_healthy { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - - (status_code, Json(status)) -} - -/// Check Redis connectivity using PING command. -async fn check_redis() -> CheckResult { - use crate::shared::database::get_redis_pool; - - let start = Instant::now(); - - let pool = match get_redis_pool() { - Ok(p) => p, - Err(e) => { - return CheckResult { - status: "error", - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(format!("Pool error: {}", e)), - }; - } - }; - - match pool.get().await { - Ok(mut conn) => { - let result: Result = redis::cmd("PING").query_async(&mut *conn).await; - match result { - Ok(response) if response == "PONG" => CheckResult { - status: "ok", - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - Ok(response) => CheckResult { - status: "error", - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(format!("Unexpected PING response: {}", response)), - }, - Err(e) => CheckResult { - status: "error", - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - } - } - Err(e) => CheckResult { - status: "error", - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - } -} - -/// Standalone health endpoint (no dependencies required). -pub async fn simple_health() -> impl IntoResponse { - Json(serde_json::json!({ - "status": "ok", - "timestamp": chrono::Utc::now().to_rfc3339() - })) -} diff --git a/src/shared/health/mod.rs b/src/shared/health/mod.rs deleted file mode 100644 index c4b360f..0000000 --- a/src/shared/health/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod endpoints; diff --git a/src/shared/jobs/mod.rs b/src/shared/jobs/mod.rs deleted file mode 100644 index b3116e8..0000000 --- a/src/shared/jobs/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod queue; -pub mod worker; diff --git a/src/shared/jobs/queue.rs b/src/shared/jobs/queue.rs deleted file mode 100644 index 6238d18..0000000 --- a/src/shared/jobs/queue.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Job queue implementation using Redis. -//! -//! Provides a simple but robust job queue system for background processing. - -use async_trait::async_trait; -use deadpool_redis::Pool; -use redis::AsyncCommands; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use uuid::Uuid; - -/// Status of a queued job. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum JobStatus { - /// Job is waiting in the queue - Pending, - /// Job is currently being processed - Processing, - /// Job completed successfully - Completed, - /// Job failed with an error - Failed, - /// Job was manually cancelled - Cancelled, -} - -/// Metadata for a queued job. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobMeta { - pub id: String, - pub job_type: String, - pub status: JobStatus, - pub created_at: chrono::DateTime, - pub started_at: Option>, - pub completed_at: Option>, - pub attempts: u32, - pub max_attempts: u32, - pub error: Option, -} - -/// Trait for background jobs. -/// -/// Implement this trait to define a job that can be queued and processed. -/// -/// # Example -/// -/// ```ignore -/// use scraper_service::jobs::{Job, JobDispatcher}; -/// use serde::{Serialize, Deserialize}; -/// use async_trait::async_trait; -/// -/// #[derive(Serialize, Deserialize)] -/// struct SendWelcomeEmail { -/// user_id: String, -/// email: String, -/// } -/// -/// #[async_trait] -/// impl Job for SendWelcomeEmail { -/// const NAME: &'static str = "send_welcome_email"; -/// const MAX_ATTEMPTS: u32 = 3; -/// -/// async fn handle(&self) -> anyhow::Result<()> { -/// // Send the email... -/// println!("Sending welcome email to {}", self.email); -/// Ok(()) -/// } -/// } -/// -/// // Dispatch the job -/// let job = SendWelcomeEmail { -/// user_id: "123".to_string(), -/// email: "user@example.com".to_string(), -/// }; -/// dispatcher.dispatch(job).await?; -/// ``` -#[async_trait] -pub trait Job: Serialize + DeserializeOwned + Send + Sync { - /// Unique name for this job type. - const NAME: &'static str; - - /// Maximum number of retry attempts. - const MAX_ATTEMPTS: u32 = 3; - - /// Queue name to use (default: "default") - const QUEUE: &'static str = "default"; - - /// Execute the job. - async fn handle(&self) -> anyhow::Result<()>; - - /// Called when the job fails after all retries. - async fn failed(&self, error: &str) { - tracing::error!("Job {} failed: {}", Self::NAME, error); - } -} - -/// Job dispatcher for queuing jobs. -#[derive(Clone)] -pub struct JobDispatcher { - redis_pool: Pool, -} - -impl JobDispatcher { - /// Create a new job dispatcher. - pub fn new(redis_pool: Pool) -> Self { - Self { redis_pool } - } - - /// Dispatch a job to be processed. - pub async fn dispatch(&self, job: J) -> anyhow::Result { - let job_id = Uuid::new_v4().to_string(); - let queue_key = format!("jobs:queue:{}", J::QUEUE); - let job_key = format!("jobs:data:{}", job_id); - - let payload = serde_json::to_string(&job)?; - - let meta = JobMeta { - id: job_id.clone(), - job_type: J::NAME.to_string(), - status: JobStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - attempts: 0, - max_attempts: J::MAX_ATTEMPTS, - error: None, - }; - - let meta_json = serde_json::to_string(&meta)?; - - let mut conn = self.redis_pool.get().await?; - - // Store job data - let _: () = conn.set(&job_key, payload).await?; - let _: () = conn.set(format!("{}:meta", job_key), meta_json).await?; - - // Push to queue - let _: () = conn.rpush(&queue_key, &job_id).await?; - - tracing::info!("Dispatched job {} ({})", J::NAME, job_id); - - Ok(job_id) - } - - /// Dispatch a job with a delay (in seconds). - pub async fn dispatch_delayed( - &self, - job: J, - delay_seconds: u64, - ) -> anyhow::Result { - let job_id = Uuid::new_v4().to_string(); - let delayed_key = "jobs:delayed"; - let job_key = format!("jobs:data:{}", job_id); - - let payload = serde_json::to_string(&job)?; - let execute_at = chrono::Utc::now().timestamp() + delay_seconds as i64; - - let meta = JobMeta { - id: job_id.clone(), - job_type: J::NAME.to_string(), - status: JobStatus::Pending, - created_at: chrono::Utc::now(), - started_at: None, - completed_at: None, - attempts: 0, - max_attempts: J::MAX_ATTEMPTS, - error: None, - }; - - let meta_json = serde_json::to_string(&meta)?; - - let mut conn = self.redis_pool.get().await?; - - // Store job data - let _: () = conn.set(&job_key, payload).await?; - let _: () = conn.set(format!("{}:meta", job_key), meta_json).await?; - - // Add to delayed sorted set (score = execution timestamp) - let delayed_entry = format!("{}:{}", J::QUEUE, job_id); - let _: () = conn.zadd(delayed_key, delayed_entry, execute_at).await?; - - tracing::info!( - "Dispatched delayed job {} ({}) - executes in {}s", - J::NAME, - job_id, - delay_seconds - ); - - Ok(job_id) - } - - /// Get the status of a job. - pub async fn status(&self, job_id: &str) -> anyhow::Result> { - let meta_key = format!("jobs:data:{}:meta", job_id); - let mut conn = self.redis_pool.get().await?; - - let meta_json: Option = conn.get(&meta_key).await?; - - match meta_json { - Some(json) => Ok(Some(serde_json::from_str(&json)?)), - None => Ok(None), - } - } -} diff --git a/src/shared/jobs/worker.rs b/src/shared/jobs/worker.rs deleted file mode 100644 index 310af64..0000000 --- a/src/shared/jobs/worker.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Job worker for processing background jobs. -//! -//! The worker runs as a separate process and continuously polls -//! the job queue for work. - -use super::queue::{JobMeta, JobStatus}; -use deadpool_redis::Pool; -use redis::AsyncCommands; -use std::time::Duration; -use tokio::time::sleep; - -/// Configuration for the job worker. -#[derive(Debug, Clone)] -pub struct WorkerConfig { - /// Queues to process (in priority order) - pub queues: Vec, - /// Number of concurrent jobs to process - pub concurrency: usize, - /// Sleep duration when no jobs are available - pub sleep_duration: Duration, -} - -impl Default for WorkerConfig { - fn default() -> Self { - Self { - queues: vec!["default".to_string()], - concurrency: 4, - sleep_duration: Duration::from_secs(1), - } - } -} - -/// Job worker that processes queued jobs. -pub struct Worker { - redis_pool: Pool, - config: WorkerConfig, - /// Registry of job handlers by name - handlers: std::collections::HashMap<&'static str, Box>, -} - -/// Trait for job handlers (type-erased). -#[async_trait::async_trait] -pub trait JobHandler: Send + Sync { - async fn process(&self, payload: &str) -> anyhow::Result<()>; -} - -impl Worker { - /// Create a new worker. - pub fn new(redis_pool: Pool, config: WorkerConfig) -> Self { - Self { - redis_pool, - config, - handlers: std::collections::HashMap::new(), - } - } - - /// Register a job handler. - pub fn register(&mut self, name: &'static str, handler: H) { - self.handlers.insert(name, Box::new(handler)); - } - - /// Run the worker loop. - pub async fn run(&self) -> anyhow::Result<()> { - tracing::info!( - "🔧 Worker started - processing queues: {:?}", - self.config.queues - ); - - loop { - let mut processed = false; - - for queue in &self.config.queues { - if let Some(job_id) = self.pop_job(queue).await? { - self.process_job(queue, &job_id).await?; - processed = true; - } - } - - if !processed { - // No jobs available, sleep - sleep(self.config.sleep_duration).await; - } - } - } - - /// Pop a job from the queue. - async fn pop_job(&self, queue: &str) -> anyhow::Result> { - let queue_key = format!("jobs:queue:{}", queue); - let mut conn = self.redis_pool.get().await?; - - let job_id: Option = conn.lpop(&queue_key, None).await?; - Ok(job_id) - } - - /// Process a single job. - async fn process_job(&self, queue: &str, job_id: &str) -> anyhow::Result<()> { - let job_key = format!("jobs:data:{}", job_id); - let meta_key = format!("{}:meta", job_key); - - let mut conn = self.redis_pool.get().await?; - - // Get job metadata - let meta_json: Option = conn.get(&meta_key).await?; - let mut meta: JobMeta = match meta_json { - Some(json) => serde_json::from_str(&json)?, - None => { - tracing::warn!("Job {} not found", job_id); - return Ok(()); - } - }; - - // Get job payload - let payload: Option = conn.get(&job_key).await?; - let payload = match payload { - Some(p) => p, - None => { - tracing::warn!("Job {} payload not found", job_id); - return Ok(()); - } - }; - - // Update status to processing - meta.status = JobStatus::Processing; - meta.started_at = Some(chrono::Utc::now()); - meta.attempts += 1; - let _: () = conn.set(&meta_key, serde_json::to_string(&meta)?).await?; - - tracing::info!( - "Processing job {} ({}) - attempt {}/{}", - meta.job_type, - job_id, - meta.attempts, - meta.max_attempts - ); - - // Find handler - let handler = match self.handlers.get(meta.job_type.as_str()) { - Some(h) => h, - None => { - tracing::error!("No handler registered for job type: {}", meta.job_type); - meta.status = JobStatus::Failed; - meta.error = Some(format!("No handler for job type: {}", meta.job_type)); - meta.completed_at = Some(chrono::Utc::now()); - let _: () = conn.set(&meta_key, serde_json::to_string(&meta)?).await?; - return Ok(()); - } - }; - - // Execute job - match handler.process(&payload).await { - Ok(()) => { - meta.status = JobStatus::Completed; - meta.completed_at = Some(chrono::Utc::now()); - tracing::info!("Job {} completed successfully", job_id); - } - Err(e) => { - let error_msg = e.to_string(); - tracing::error!("Job {} failed: {}", job_id, error_msg); - - if meta.attempts >= meta.max_attempts { - meta.status = JobStatus::Failed; - meta.error = Some(error_msg); - meta.completed_at = Some(chrono::Utc::now()); - } else { - // Retry - push back to queue - meta.status = JobStatus::Pending; - meta.error = Some(format!("Attempt {} failed: {}", meta.attempts, error_msg)); - let queue_key = format!("jobs:queue:{}", queue); - let _: () = conn.rpush(&queue_key, job_id).await?; - tracing::info!("Job {} queued for retry", job_id); - } - } - } - - // Save final status - let _: () = conn.set(&meta_key, serde_json::to_string(&meta)?).await?; - - Ok(()) - } -} diff --git a/src/shared/middlewares/logging.rs b/src/shared/middlewares/logging.rs deleted file mode 100644 index d8066f7..0000000 --- a/src/shared/middlewares/logging.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Request/Response logging middleware. -//! -//! Provides structured logging for HTTP requests with timing and correlation. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::shared::middlewares::logging::{LoggingConfig, with_logging}; -//! use axum::Router; -//! -//! let app = Router::new() -//! .route("/api/test", get(handler)) -//! .layer(axum::middleware::from_fn(with_logging(LoggingConfig::default()))); -//! ``` - -use axum::{body::Body, extract::Request, http::StatusCode, middleware::Next, response::Response}; -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Instant; -use uuid::Uuid; - -/// Logging configuration. -#[derive(Debug, Clone)] -pub struct LoggingConfig { - /// Log request headers - pub log_headers: bool, - /// Log request body (be careful with sensitive data) - pub log_body: bool, - /// Log response body - pub log_response_body: bool, - /// Maximum body size to log (bytes) - pub max_body_size: usize, - /// Paths to exclude from logging - pub exclude_paths: HashSet, - /// Log level for successful requests - pub success_level: LogLevel, - /// Log level for client errors (4xx) - pub client_error_level: LogLevel, - /// Log level for server errors (5xx) - pub server_error_level: LogLevel, -} - -/// Log level enum. -#[derive(Debug, Clone, Copy)] -pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, -} - -impl Default for LoggingConfig { - fn default() -> Self { - let mut exclude = HashSet::new(); - exclude.insert("/health".to_string()); - exclude.insert("/favicon.ico".to_string()); - - Self { - log_headers: false, - log_body: false, - log_response_body: false, - max_body_size: 1024, - exclude_paths: exclude, - success_level: LogLevel::Info, - client_error_level: LogLevel::Warn, - server_error_level: LogLevel::Error, - } - } -} - -impl LoggingConfig { - /// Create a new logging config. - pub fn new() -> Self { - Self::default() - } - - /// Enable header logging. - pub fn with_headers(mut self) -> Self { - self.log_headers = true; - self - } - - /// Add a path to exclude from logging. - pub fn exclude_path(mut self, path: &str) -> Self { - self.exclude_paths.insert(path.to_string()); - self - } -} - -/// Request ID extension for correlation. -#[derive(Clone, Debug)] -pub struct RequestId(pub String); - -impl RequestId { - /// Generate a new request ID. - pub fn new() -> Self { - Self(Uuid::new_v4().to_string()) - } - - /// Get the request ID as a string. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl Default for RequestId { - fn default() -> Self { - Self::new() - } -} - -/// Logging middleware. -pub async fn logging_middleware(config: Arc, req: Request, next: Next) -> Response { - let path = req.uri().path(); - - if config.exclude_paths.contains(path) { - return next.run(req).await; - } - - let request_id = req - .headers() - .get("X-Request-ID") - .and_then(|h| h.to_str().ok()) - .map(|s| RequestId(s.to_string())) - .unwrap_or_else(RequestId::new); - - let method = req.method().clone(); - let uri = req.uri().clone(); - let path = uri.path().to_string(); - let query = uri.query().map(str::to_owned); - - let headers_log = if config.log_headers { - let headers: Vec = req - .headers() - .iter() - .filter(|(name, _)| !is_sensitive_header(name.as_str())) - .map(|(name, value)| format!("{}: {}", name, value.to_str().unwrap_or(""))) - .collect(); - Some(headers) - } else { - None - }; - - let mut req = req; - req.extensions_mut().insert(request_id.clone()); - - let start = Instant::now(); - - tracing::info!( - request_id = %request_id.0, - method = %method, - path = %path, - query = ?query, - "→ Request started" - ); - - if let Some(ref headers) = headers_log { - tracing::debug!(request_id = %request_id.0, headers = ?headers, "Request headers"); - } - - let response = next.run(req).await; - - let duration = start.elapsed(); - let status = response.status(); - - match status.as_u16() { - 100..=399 => log_response( - config.success_level, - &request_id, - status, - duration, - &method, - &path, - ), - 400..=499 => log_response( - config.client_error_level, - &request_id, - status, - duration, - &method, - &path, - ), - _ => log_response( - config.server_error_level, - &request_id, - status, - duration, - &method, - &path, - ), - } - - response -} - -fn is_sensitive_header(name: &str) -> bool { - name.eq_ignore_ascii_case("authorization") - || name.eq_ignore_ascii_case("cookie") - || name.eq_ignore_ascii_case("x-api-key") -} - -fn log_response( - level: LogLevel, - request_id: &RequestId, - status: StatusCode, - duration: std::time::Duration, - method: &axum::http::Method, - path: &str, -) { - let duration_ms = duration.as_millis(); - - match level { - LogLevel::Trace => tracing::trace!( - request_id = %request_id.0, - status = %status, - duration_ms = %duration_ms, - method = %method, - path = %path, - "← Response completed" - ), - LogLevel::Debug => tracing::debug!( - request_id = %request_id.0, - status = %status, - duration_ms = %duration_ms, - method = %method, - path = %path, - "← Response completed" - ), - LogLevel::Info => tracing::info!( - request_id = %request_id.0, - status = %status, - duration_ms = %duration_ms, - method = %method, - path = %path, - "← Response completed" - ), - LogLevel::Warn => tracing::warn!( - request_id = %request_id.0, - status = %status, - duration_ms = %duration_ms, - method = %method, - path = %path, - "← Response completed" - ), - LogLevel::Error => tracing::error!( - request_id = %request_id.0, - status = %status, - duration_ms = %duration_ms, - method = %method, - path = %path, - "← Response completed" - ), - } -} - -/// Create logging middleware. -pub fn with_logging( - config: LoggingConfig, -) -> impl Fn( - Request, - Next, -) -> std::pin::Pin + Send>> - + Clone - + Send { - let config = Arc::new(config); - move |req: Request, next: Next| { - let config = config.clone(); - Box::pin(async move { logging_middleware(config, req, next).await }) - } -} - -/// Extractor for RequestId in route handlers. -impl axum::extract::FromRequestParts for RequestId -where - S: Send + Sync, -{ - type Rejection = (StatusCode, &'static str); - - async fn from_request_parts( - parts: &mut axum::http::request::Parts, - _state: &S, - ) -> Result { - parts.extensions.get::().cloned().ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "RequestId not found. Did you add logging middleware?", - )) - } -} diff --git a/src/shared/middlewares/mod.rs b/src/shared/middlewares/mod.rs deleted file mode 100644 index abd0d0e..0000000 --- a/src/shared/middlewares/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod ratelimit; -pub use ratelimit::rate_limit_middleware; diff --git a/src/shared/middlewares/ratelimit.rs b/src/shared/middlewares/ratelimit.rs deleted file mode 100644 index 9e5a20e..0000000 --- a/src/shared/middlewares/ratelimit.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Rate limiter middleware using the governor crate. -//! -//! Provides token-bucket based rate limiting with configurable limits. -//! Default: 20 requests per IP address, burst 50. - -use axum::{ - extract::Request, - http::StatusCode, - middleware::Next, - response::{IntoResponse, Response}, - Json, -}; -use governor::{ - clock::DefaultClock, state::keyed::DefaultKeyedStateStore, Quota, - RateLimiter as GovernorRateLimiter, -}; -use once_cell::sync::Lazy; -use serde_json::json; -use std::{num::NonZeroU32, sync::Arc, time::Duration}; -use tracing::warn; - -/// Global IP-keyed rate limiter instance. -/// Configured for 20 requests per second with a burst of 50. -static IP_LIMITER: Lazy< - Arc, DefaultClock>>, -> = Lazy::new(|| { - let quota = Quota::with_period(Duration::from_millis(50)) - .unwrap() - .allow_burst(NonZeroU32::new(50).unwrap()); - - Arc::new(GovernorRateLimiter::keyed(quota)) -}); - -/// Rate limiter configuration. -#[derive(Debug, Clone)] -pub struct RateLimiterConfig { - /// Requests per second - pub requests_per_second: u32, - /// Burst size (max requests that can be made instantly) - pub burst_size: u32, -} - -impl Default for RateLimiterConfig { - fn default() -> Self { - Self { - requests_per_second: 20, - burst_size: 50, - } - } -} - -/// Create a custom rate limiter with specific configuration. -pub fn create_rate_limiter( - config: RateLimiterConfig, -) -> Arc, DefaultClock>> { - let period_ms = 1000 / config.requests_per_second.max(1); - let quota = Quota::with_period(Duration::from_millis(period_ms as u64)) - .unwrap() - .allow_burst(NonZeroU32::new(config.burst_size.max(1)).unwrap()); - - Arc::new(GovernorRateLimiter::keyed(quota)) -} - -fn extract_ip(req: &Request) -> String { - if let Some(ip) = req.headers().get("X-Forwarded-For") { - if let Ok(ip_str) = ip.to_str() { - if let Some(first_ip) = ip_str.split(',').next() { - return first_ip.trim().to_string(); - } - } - } - if let Some(ip) = req.headers().get("X-Real-IP") { - if let Ok(ip_str) = ip.to_str() { - return ip_str.trim().to_string(); - } - } - "unknown".to_string() -} - -/// Rate limiting middleware using the IP-keyed limiter. -/// -/// Returns 429 Too Many Requests if the limit is exceeded. -pub async fn rate_limit_middleware(req: Request, next: Next) -> Response { - let client_ip = extract_ip(&req); - - match IP_LIMITER.check_key(&client_ip) { - Ok(_) => next.run(req).await, - Err(_) => { - warn!("Rate limit exceeded for IP: {}", client_ip); - ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "Too many requests", - "code": "RATE_LIMIT_EXCEEDED", - "retry_after_ms": 1000 - })), - ) - .into_response() - } - } -} diff --git a/src/shared/mod.rs b/src/shared/mod.rs deleted file mode 100644 index 358cb1a..0000000 --- a/src/shared/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod browser; -pub mod config; -pub mod database; -pub mod errors; -pub mod events; -pub mod graceful; -pub mod health; -pub mod middlewares; -pub mod observability; -pub mod routing; -pub mod scheduler; -pub mod scrapers; -pub mod services; -pub mod state; -pub mod testing; -pub mod types; -pub mod utils; diff --git a/src/shared/observability/openapi_modules.rs b/src/shared/observability/openapi_modules.rs deleted file mode 100644 index 0a28aaa..0000000 --- a/src/shared/observability/openapi_modules.rs +++ /dev/null @@ -1,85 +0,0 @@ -use utoipa::OpenApi; - -/// Manual aggregation of OpenAPI docs from module controllers. -#[derive(OpenApi)] -#[openapi( - paths( - // Anime module controllers - crate::modules::anime::controller::anime_index, - crate::modules::anime::controller::genres, - crate::modules::anime::controller::detail_slug, - crate::modules::anime::controller::complete_anime_slug, - crate::modules::anime::controller::full_slug, - crate::modules::anime::controller::ongoing_anime_slug, - crate::modules::anime::controller::latest_slug, - crate::modules::anime::controller::search_slug_index, - crate::modules::anime::controller::search_slug_page, - crate::modules::anime::controller::genre_slug_index, - crate::modules::anime::controller::genre_slug_page, - // Anime2 module controllers - crate::modules::anime2::controller::index, - crate::modules::anime2::controller::genre_list, - crate::modules::anime2::controller::filter, - crate::modules::anime2::controller::detail_slug, - crate::modules::anime2::controller::genre_slug_index, - crate::modules::anime2::controller::genre_slug_page, - crate::modules::anime2::controller::search_slug_index, - crate::modules::anime2::controller::search_slug_page, - crate::modules::anime2::controller::latest_slug, - crate::modules::anime2::controller::ongoing_anime_slug, - crate::modules::anime2::controller::complete_anime_slug, - // Komik module controllers - crate::modules::komik::controller::genre_list, - crate::modules::komik::controller::chapter_slug, - crate::modules::komik::controller::detail_slug, - crate::modules::komik::controller::genre_slug, - crate::modules::komik::controller::genre_slug_page, - crate::modules::komik::controller::manga_slug, - crate::modules::komik::controller::manhua_slug, - crate::modules::komik::controller::manhwa_slug, - crate::modules::komik::controller::popular_slug, - crate::modules::komik::controller::search_slug, - crate::modules::komik::controller::search_slug_page, - // Proxy module controllers - crate::modules::proxy::controller::fetch_with_proxy_only, - crate::modules::proxy::controller::image_cache, - ), - components( - schemas( - // Application response wrapper - crate::shared::types::ApiResponse, - // Anime2 types - crate::modules::anime2::types::Anime2Response, - crate::modules::anime2::types::GenresResponse, - crate::modules::anime2::types::FilterResponse, - crate::modules::anime2::types::DetailResponse, - // Anime2 query schemas - crate::modules::anime2::schema::FilterQuery, - crate::modules::anime2::schema::GenreQuery, - crate::modules::anime2::schema::SearchQuery, - // Proxy types - crate::modules::proxy::types::ImageCacheResponse, - crate::modules::proxy::types::AuditImageCacheResponse, - // Proxy request schemas - crate::modules::proxy::schema::ProxyParams, - crate::modules::proxy::schema::ImageCacheRequest, - crate::modules::proxy::schema::AuditImageCacheRequest, - // Komik types - crate::modules::komik::types::GenresResponse, - crate::modules::komik::types::Genre, - crate::modules::komik::types::ChapterResponse, - crate::modules::komik::types::DetailResponse, - crate::modules::komik::types::KomikItem, - crate::modules::komik::types::Pagination, - crate::modules::komik::types::GenreKomikResponse, - crate::modules::komik::types::SearchKomikResponse, - ) - ), - tags( - (name = "anime", description = "Anime endpoints"), - (name = "anime2", description = "Anime2 endpoints"), - (name = "komik", description = "Komik endpoints"), - (name = "proxy", description = "Proxy endpoints"), - ) -)] -pub struct ModuleApiDoc; diff --git a/src/shared/routing/mod.rs b/src/shared/routing/mod.rs deleted file mode 100644 index 890dc86..0000000 --- a/src/shared/routing/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod versioning; - -pub use versioning::{extract_version, versioned_routes, ApiVersion, VersionedApi}; diff --git a/src/shared/routing/versioning.rs b/src/shared/routing/versioning.rs deleted file mode 100644 index a1f2f25..0000000 --- a/src/shared/routing/versioning.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! API versioning utilities. -//! -//! Provides helpers for versioned API routes. - -use axum::Router; - -/// API version prefix. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApiVersion { - V1, - V2, -} - -impl ApiVersion { - /// Get the URL prefix for this version. - pub fn prefix(&self) -> &'static str { - match self { - ApiVersion::V1 => "/api/v1", - ApiVersion::V2 => "/api/v2", - } - } -} - -impl std::fmt::Display for ApiVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ApiVersion::V1 => write!(f, "v1"), - ApiVersion::V2 => write!(f, "v2"), - } - } -} - -/// Builder for versioned API routes. -/// -/// # Example -/// -/// ```ignore -/// use scraper_service::versioning::VersionedApi; -/// -/// let app = VersionedApi::new() -/// .v1(users_v1_routes()) -/// .v1(products_v1_routes()) -/// .v2(users_v2_routes()) -/// .build(); -/// ``` -pub struct VersionedApi -where - S: Clone + Send + Sync + 'static, -{ - v1_routes: Vec>, - v2_routes: Vec>, -} - -impl VersionedApi -where - S: Clone + Send + Sync + 'static, -{ - /// Create a new versioned API builder. - pub fn new() -> Self { - Self { - v1_routes: Vec::new(), - v2_routes: Vec::new(), - } - } - - /// Add routes to API v1. - pub fn v1(mut self, routes: Router) -> Self { - self.v1_routes.push(routes); - self - } - - /// Add routes to API v2. - pub fn v2(mut self, routes: Router) -> Self { - self.v2_routes.push(routes); - self - } - - /// Build the combined router with versioned prefixes. - pub fn build(self) -> Router { - let mut app = Router::new(); - - // Merge v1 routes under /api/v1 - if !self.v1_routes.is_empty() { - let mut v1_router = Router::new(); - for routes in self.v1_routes { - v1_router = v1_router.merge(routes); - } - app = app.nest("/api/v1", v1_router); - } - - // Merge v2 routes under /api/v2 - if !self.v2_routes.is_empty() { - let mut v2_router = Router::new(); - for routes in self.v2_routes { - v2_router = v2_router.merge(routes); - } - app = app.nest("/api/v2", v2_router); - } - - app - } -} - -impl Default for VersionedApi -where - S: Clone + Send + Sync + 'static, -{ - fn default() -> Self { - Self::new() - } -} - -/// Create versioned routes with automatic fallback. -/// -/// Routes in v2 will be served under /api/v2. -/// Routes in v1 will be served under /api/v1 AND as fallback under /api/v2 if not overridden. -pub fn versioned_routes(v1: Router, v2: Router) -> Router -where - S: Clone + Send + Sync + 'static, -{ - Router::new() - .nest("/api/v1", v1.clone()) - .nest("/api/v2", v1.merge(v2)) // v2 includes v1 as fallback -} - -/// Extract API version from request path. -pub fn extract_version(path: &str) -> Option { - if path.starts_with("/api/v2") { - Some(ApiVersion::V2) - } else if path.starts_with("/api/v1") { - Some(ApiVersion::V1) - } else { - None - } -} diff --git a/src/shared/scrapers/mod.rs b/src/shared/scrapers/mod.rs deleted file mode 100644 index b8a8e6a..0000000 --- a/src/shared/scrapers/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod otakudesu; diff --git a/src/shared/scrapers/otakudesu.rs b/src/shared/scrapers/otakudesu.rs deleted file mode 100644 index bde0fd2..0000000 --- a/src/shared/scrapers/otakudesu.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::shared::types::entities::anime::*; -use crate::shared::utils::parse_html; -use crate::shared::utils::web::scraping::{ - attr, attr_from_or, extract_slug, selector, text, text_from_or, -}; -use once_cell::sync::Lazy; -use scraper::{Html, Selector}; - -/// Common selectors used across anime parsing -pub struct AnimeSelectors { - pub item: Selector, - pub title: Selector, - pub link: Selector, - pub img: Selector, - pub episode: Selector, - pub score: Selector, - pub status: Selector, - pub genre: Selector, - pub rating: Selector, - pub type_sel: Selector, - pub season: Selector, - pub desc: Selector, -} - -impl AnimeSelectors { - pub fn new() -> Result { - Ok(Self { - item: selector("article.bs").ok_or("Invalid selector: article.bs")?, - title: selector(".tt h2").ok_or("Invalid selector: .tt h2")?, - link: selector("a").ok_or("Invalid selector: a")?, - img: selector("img").ok_or("Invalid selector: img")?, - episode: selector(".epx").ok_or("Invalid selector: .epx")?, - score: selector(".numscore").ok_or("Invalid selector: .numscore")?, - status: selector(".status").ok_or("Invalid selector: .status")?, - genre: selector(".genres a").ok_or("Invalid selector: .genres a")?, - rating: selector(".score").ok_or("Invalid selector: .score")?, - type_sel: selector(".typez").ok_or("Invalid selector: .typez")?, - season: selector(".season").ok_or("Invalid selector: .season")?, - desc: selector(".data .typez").ok_or("Invalid selector: .data .typez")?, - }) - } -} - -static ANIME_SELECTORS: Lazy = - Lazy::new(|| AnimeSelectors::new().expect("Valid CSS selectors")); - -pub fn extract_poster(element: &scraper::ElementRef, img_selector: &Selector) -> String { - element - .select(img_selector) - .next() - .and_then(|e| attr(&e, "src").or(attr(&e, "data-src"))) - .unwrap_or_default() -} - -pub fn parse_ongoing_anime(html: &str) -> Vec { - let document = parse_html(html); - let selectors = &*ANIME_SELECTORS; - let mut items = Vec::new(); - - for element in document.select(&selectors.item) { - let title = text_from_or(&element, &selectors.title, ""); - if title.is_empty() { - continue; - } - - let href = attr_from_or(&element, &selectors.link, "href", ""); - let slug = extract_slug(&href); - let poster = extract_poster(&element, &selectors.img); - let current_episode = text_from_or(&element, &selectors.episode, "N/A"); - let anime_url = attr_from_or(&element, &selectors.link, "href", ""); - - items.push(OngoingAnimeItem { - title, - slug, - poster, - current_episode, - anime_url, - }); - } - items -} - -pub fn parse_pagination(document: &Html, current_page: u32) -> Result { - let pagination_selector = - selector(".pagination .page-numbers:not(.next)").ok_or("Invalid selector")?; - let next_selector = selector(".pagination .next").ok_or("Invalid selector")?; - - let last_visible_page = document - .select(&pagination_selector) - .next_back() - .and_then(|e| text(&e).trim().parse::().ok()) - .unwrap_or(current_page); - - let has_next_page = document.select(&next_selector).next().is_some(); - let next_page = if has_next_page { - Some(current_page + 1) - } else { - None - }; - let has_previous_page = current_page > 1; - let previous_page = if has_previous_page { - Some(current_page - 1) - } else { - None - }; - - Ok(Pagination { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - }) -} diff --git a/src/shared/state/mod.rs b/src/shared/state/mod.rs deleted file mode 100644 index 7aff869..0000000 --- a/src/shared/state/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::sync::Arc; - -use deadpool_redis::Pool; -use sea_orm::DatabaseConnection; - -pub struct AppState { - pub redis_pool: Pool, - pub db: Arc, - pub image_processing_semaphore: Arc, - pub event_bus: Arc, -} - -impl AppState { - pub fn sea_orm(&self) -> &DatabaseConnection { - &self.db - } -} diff --git a/src/shared/testing/app.rs b/src/shared/testing/app.rs deleted file mode 100644 index e5e5b5b..0000000 --- a/src/shared/testing/app.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Test application utilities. -//! -//! Provides a `TestApp` struct for integration testing that boots -//! the application in-memory with a test configuration. - -use axum::{ - body::Body, - http::{Method, Request, StatusCode}, - response::Response, - Router, -}; -use serde::{de::DeserializeOwned, Serialize}; -use tower::ServiceExt; - -/// A test application instance for integration testing. -/// -/// # Example -/// -/// ```ignore -/// use scraper_service::testing::TestApp; -/// -/// #[tokio::test] -/// async fn test_health_endpoint() { -/// let app = TestApp::new().await; -/// -/// let response = app.get("/health").await; -/// assert_eq!(response.status(), 200); -/// -/// let body = response.json::().await; -/// assert_eq!(body.status, "ok"); -/// } -/// ``` -pub struct TestApp { - router: Router, -} - -impl TestApp { - /// Create a new test application with default configuration. - /// - /// This sets up the router without starting a server. - pub fn with_router(router: Router) -> Self { - Self { router } - } - - /// Make a GET request. - pub async fn get(&self, path: &str) -> TestResponse { - self.request(Method::GET, path, Body::empty()).await - } - - /// Make a POST request with JSON body. - pub async fn post(&self, path: &str, body: &T) -> TestResponse { - let body = serde_json::to_string(body).unwrap_or_default(); - self.request_with_json(Method::POST, path, body).await - } - - /// Make a PUT request with JSON body. - pub async fn put(&self, path: &str, body: &T) -> TestResponse { - let body = serde_json::to_string(body).unwrap_or_default(); - self.request_with_json(Method::PUT, path, body).await - } - - /// Make a DELETE request. - pub async fn delete(&self, path: &str) -> TestResponse { - self.request(Method::DELETE, path, Body::empty()).await - } - - /// Make a request with custom method and body. - pub async fn request(&self, method: Method, path: &str, body: Body) -> TestResponse { - let request = Request::builder() - .method(method) - .uri(path) - .body(body) - .unwrap_or_else(|_| Request::new(Body::empty())); - - let response = self - .router - .clone() - .oneshot(request) - .await - .unwrap_or_else(|_| Response::builder().status(500).body(Body::empty()).unwrap()); - - TestResponse::new(response) - } - - /// Make a request with JSON content type. - async fn request_with_json(&self, method: Method, path: &str, body: String) -> TestResponse { - let request = Request::builder() - .method(method) - .uri(path) - .header("Content-Type", "application/json") - .body(Body::from(body)) - .unwrap_or_else(|_| Request::new(Body::empty())); - - let response = self - .router - .clone() - .oneshot(request) - .await - .unwrap_or_else(|_| Response::builder().status(500).body(Body::empty()).unwrap()); - - TestResponse::new(response) - } -} - -/// A test response with assertion helpers. -pub struct TestResponse { - response: Response, - body: Option, -} - -impl TestResponse { - fn new(response: Response) -> Self { - Self { - response, - body: None, - } - } - - /// Get the response status code. - pub fn status(&self) -> StatusCode { - self.response.status() - } - - /// Assert the status code. - pub fn assert_status(self, expected: u16) -> Self { - assert_eq!( - self.response.status().as_u16(), - expected, - "Expected status {} but got {}", - expected, - self.response.status() - ); - self - } - - /// Assert the status is 2xx (success). - pub fn assert_success(self) -> Self { - assert!( - self.response.status().is_success(), - "Expected success status but got {}", - self.response.status() - ); - self - } - - /// Assert the status is 4xx (client error). - pub fn assert_client_error(self) -> Self { - assert!( - self.response.status().is_client_error(), - "Expected client error status but got {}", - self.response.status() - ); - self - } - - /// Get a header value. - pub fn header(&self, name: &str) -> Option<&str> { - self.response - .headers() - .get(name) - .and_then(|v| v.to_str().ok()) - } - - /// Get the response body as bytes. - pub async fn bytes(mut self) -> bytes::Bytes { - if let Some(body) = self.body.take() { - return body; - } - - let body = std::mem::replace(self.response.body_mut(), Body::empty()); - axum::body::to_bytes(body, usize::MAX) - .await - .unwrap_or_default() - } - - /// Get the response body as a string. - pub async fn text(self) -> String { - let bytes = self.bytes().await; - String::from_utf8_lossy(&bytes).to_string() - } - - /// Get the response body as JSON. - pub async fn json(self) -> T { - let bytes = self.bytes().await; - serde_json::from_slice(&bytes).expect("Failed to parse response as JSON") - } - - /// Assert the response body contains a string. - pub async fn assert_body_contains(self, expected: &str) -> Self { - let body = self.text().await; - assert!( - body.contains(expected), - "Expected body to contain '{}' but got: {}", - expected, - body - ); - // Recreate self with cached body (simplified) - Self { - response: Response::builder().status(200).body(Body::empty()).unwrap(), - body: Some(bytes::Bytes::from(body)), - } - } -} - -/// Builder for TestApp with custom configuration. -pub struct TestAppBuilder { - // Future: Add configuration options -} - -impl TestAppBuilder { - /// Create a new test app builder. - pub fn new() -> Self { - Self {} - } - - /// Build the test app with a router. - pub fn build(self, router: Router) -> TestApp { - TestApp::with_router(router) - } -} - -impl Default for TestAppBuilder { - fn default() -> Self { - Self::new() - } -} diff --git a/src/shared/testing/mod.rs b/src/shared/testing/mod.rs deleted file mode 100644 index 309be62..0000000 --- a/src/shared/testing/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod app; diff --git a/src/shared/types/entities/anime.rs b/src/shared/types/entities/anime.rs deleted file mode 100644 index 9db207f..0000000 --- a/src/shared/types/entities/anime.rs +++ /dev/null @@ -1,208 +0,0 @@ -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -// ============================================================================ -// PAGINATION MODELS -// ============================================================================ - -/// Common pagination structure used across all anime2 endpoints -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct Pagination { - pub current_page: u32, - pub last_visible_page: u32, - pub has_next_page: bool, - pub next_page: Option, - pub has_previous_page: bool, - pub previous_page: Option, -} - -impl Pagination { - /// Create pagination with string-based next/previous pages - pub fn with_string_pages( - current_page: u32, - last_visible_page: u32, - has_next_page: bool, - next_page: Option, - has_previous_page: bool, - previous_page: Option, - ) -> PaginationWithStringPages { - PaginationWithStringPages { - current_page, - last_visible_page, - has_next_page, - next_page, - has_previous_page, - previous_page, - } - } -} - -/// Pagination variant with string-based page numbers (used in search endpoint) -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct PaginationWithStringPages { - pub current_page: u32, - pub last_visible_page: u32, - pub has_next_page: bool, - pub next_page: Option, - pub has_previous_page: bool, - pub previous_page: Option, -} - -// ============================================================================ -// ANIME ITEM MODELS -// ============================================================================ - -/// Anime item for ongoing anime listings -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct OngoingAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub current_episode: String, - pub anime_url: String, -} - -/// Anime item for ongoing anime with score (used in paginated ongoing lists) -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct OngoingAnimeItemWithScore { - pub title: String, - pub slug: String, - pub poster: String, - pub score: String, - pub anime_url: String, -} - -/// Anime item for complete anime listings -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct CompleteAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub episode_count: String, - pub anime_url: String, -} - -/// Anime item for latest anime listings with episode and score -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct LatestAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub current_episode: String, - pub score: String, - pub anime_url: String, -} - -/// Anime item for search results with full metadata -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct SearchAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub description: String, - pub anime_url: String, - pub genres: Vec, - pub rating: String, - pub r#type: String, - pub season: String, -} - -/// Anime item for genre filtering -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct GenreAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub score: String, - pub status: String, - pub anime_url: String, -} - -/// Anime item for advanced filtering (used in filter endpoint) -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct FilterAnimeItem { - pub title: String, - pub slug: String, - pub poster: String, - pub score: String, - pub status: String, - pub r#type: String, - pub anime_url: String, -} - -// ============================================================================ -// TRAITS -// ============================================================================ - -/// Trait for extracting poster URLs from anime items -pub trait HasPoster { - fn poster(&self) -> &str; - fn set_poster(&mut self, url: String); -} - -// ============================================================================ -// TRAIT IMPLEMENTATIONS -// ============================================================================ - -impl HasPoster for OngoingAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for OngoingAnimeItemWithScore { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for CompleteAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for LatestAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for SearchAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for GenreAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} - -impl HasPoster for FilterAnimeItem { - fn poster(&self) -> &str { - &self.poster - } - fn set_poster(&mut self, url: String) { - self.poster = url; - } -} diff --git a/src/shared/types/entities/image.rs b/src/shared/types/entities/image.rs deleted file mode 100644 index 222aa19..0000000 --- a/src/shared/types/entities/image.rs +++ /dev/null @@ -1,11 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ImageCache { - pub id: String, - pub original_url: String, - pub cdn_url: String, - pub created_at: DateTime, - pub expires_at: Option>, -} diff --git a/src/shared/types/entities/mod.rs b/src/shared/types/entities/mod.rs deleted file mode 100644 index ee74445..0000000 --- a/src/shared/types/entities/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod anime; -pub mod image; -pub mod types; diff --git a/src/shared/types/entities/types.rs b/src/shared/types/entities/types.rs deleted file mode 100644 index 05ea6a7..0000000 --- a/src/shared/types/entities/types.rs +++ /dev/null @@ -1,56 +0,0 @@ -use crate::shared::errors::AppError; -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] -pub struct ErrorResponse { - pub error: String, -} - -impl From for ErrorResponse { - fn from(app_error: AppError) -> Self { - ErrorResponse { - error: app_error.to_string(), - } - } -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - let (status, error_message) = match self.error.as_str() { - _ if self.error.contains("Environment variable not found") => { - (StatusCode::INTERNAL_SERVER_ERROR, self.error) - } - _ if self.error.contains("Redis error") => { - (StatusCode::INTERNAL_SERVER_ERROR, self.error) - } - _ if self.error.contains("Reqwest error") => { - (StatusCode::INTERNAL_SERVER_ERROR, self.error) - } - _ if self - .error - .contains("JSON serialization/deserialization error") => - { - (StatusCode::INTERNAL_SERVER_ERROR, self.error) - } - _ if self.error.contains("URL parsing error") => (StatusCode::BAD_REQUEST, self.error), - _ if self.error.contains("JWT error") => (StatusCode::UNAUTHORIZED, self.error), - _ if self.error.contains("Scraper error") => (StatusCode::BAD_GATEWAY, self.error), - _ if self.error.contains("Fantoccini error") => (StatusCode::BAD_GATEWAY, self.error), - _ if self.error.contains("IO error") => (StatusCode::INTERNAL_SERVER_ERROR, self.error), - _ if self.error.contains("Timeout error") => (StatusCode::REQUEST_TIMEOUT, self.error), - _ => (StatusCode::INTERNAL_SERVER_ERROR, self.error), - }; - - let body = Json(json!({ - "error": error_message, - })); - - (status, body).into_response() - } -} diff --git a/src/shared/types/mod.rs b/src/shared/types/mod.rs deleted file mode 100644 index 67040d2..0000000 --- a/src/shared/types/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod api_response; -pub mod entities; - -pub use api_response::ApiResponse; diff --git a/src/shared/utils/core/api_response.rs b/src/shared/utils/core/api_response.rs deleted file mode 100644 index 2f5ee22..0000000 --- a/src/shared/utils/core/api_response.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Standardized API response helpers. -//! -//! Provides consistent JSON response structures across all API endpoints. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::api_response::{ApiResponse, ApiResult}; -//! -//! async fn get_user(id: i32) -> ApiResult { -//! let user = find_user(id)?; -//! Ok(ApiResponse::success(user)) -//! } -//! -//! async fn list_users(page: u32) -> ApiResult> { -//! let (users, total) = find_users_paginated(page)?; -//! Ok(ApiResponse::paginated(users, page, 20, total)) -//! } -//! -//! async fn delete_user(id: i32) -> ApiResult<()> { -//! delete_user(id)?; -//! Ok(ApiResponse::no_content()) -//! } -//! ``` - -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; - -/// Standard API response wrapper. -#[derive(Debug, Serialize, Deserialize)] -pub struct ApiResponse { - /// Whether the request was successful - pub success: bool, - /// Response data (if successful) - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - /// Error message (if failed) - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - /// Pagination metadata (if applicable) - #[serde(skip_serializing_if = "Option::is_none")] - pub pagination: Option, - /// Additional metadata - #[serde(skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -/// Error details for failed responses. -#[derive(Debug, Serialize, Deserialize)] -pub struct ErrorDetails { - /// Error code (machine-readable) - pub code: String, - /// Human-readable error message - pub message: String, - /// Field-level validation errors - #[serde(skip_serializing_if = "Option::is_none")] - pub fields: Option>, -} - -/// Field-level validation error. -#[derive(Debug, Serialize, Deserialize)] -pub struct FieldError { - /// Field name - pub field: String, - /// Error message - pub message: String, -} - -/// Pagination metadata. -#[derive(Debug, Serialize, Deserialize)] -pub struct PaginationMeta { - /// Current page number - pub page: u32, - /// Items per page - pub per_page: u32, - /// Total number of items - pub total: u64, - /// Total number of pages - pub total_pages: u32, - /// Whether there's a next page - pub has_next: bool, - /// Whether there's a previous page - pub has_prev: bool, -} - -impl ApiResponse { - /// Create a successful response with data. - pub fn success(data: T) -> Self { - Self { - success: true, - data: Some(data), - error: None, - pagination: None, - meta: None, - } - } - - /// Create a successful response with data and metadata. - pub fn success_with_meta(data: T, meta: serde_json::Value) -> Self { - Self { - success: true, - data: Some(data), - error: None, - pagination: None, - meta: Some(meta), - } - } - - /// Create a paginated response. - pub fn paginated(data: T, page: u32, per_page: u32, total: u64) -> Self { - let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32; - Self { - success: true, - data: Some(data), - error: None, - pagination: Some(PaginationMeta { - page, - per_page, - total, - total_pages, - has_next: page < total_pages, - has_prev: page > 1, - }), - meta: None, - } - } -} - -impl ApiResponse<()> { - /// Create a successful response with no data. - pub fn no_content() -> Self { - Self { - success: true, - data: None, - error: None, - pagination: None, - meta: None, - } - } - - /// Create a successful message response. - pub fn message(msg: &str) -> ApiResponse { - ApiResponse { - success: true, - data: Some(MessageOnly { - message: msg.to_string(), - }), - error: None, - pagination: None, - meta: None, - } - } -} - -/// Simple message-only response data. -#[derive(Debug, Serialize, Deserialize)] -pub struct MessageOnly { - pub message: String, -} - -/// API error response builder. -#[derive(Debug)] -pub struct ApiError { - pub status: StatusCode, - pub code: String, - pub message: String, - pub fields: Option>, -} - -impl ApiError { - /// Create a new API error. - pub fn new(status: StatusCode, code: &str, message: &str) -> Self { - Self { - status, - code: code.to_string(), - message: message.to_string(), - fields: None, - } - } - - /// Create a 400 Bad Request error. - pub fn bad_request(message: &str) -> Self { - Self::new(StatusCode::BAD_REQUEST, "BAD_REQUEST", message) - } - - /// Create a 401 Unauthorized error. - pub fn unauthorized(message: &str) -> Self { - Self::new(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", message) - } - - /// Create a 403 Forbidden error. - pub fn forbidden(message: &str) -> Self { - Self::new(StatusCode::FORBIDDEN, "FORBIDDEN", message) - } - - /// Create a 404 Not Found error. - pub fn not_found(message: &str) -> Self { - Self::new(StatusCode::NOT_FOUND, "NOT_FOUND", message) - } - - /// Create a 409 Conflict error. - pub fn conflict(message: &str) -> Self { - Self::new(StatusCode::CONFLICT, "CONFLICT", message) - } - - /// Create a 422 Unprocessable Entity error with field errors. - pub fn validation(fields: Vec) -> Self { - Self { - status: StatusCode::UNPROCESSABLE_ENTITY, - code: "VALIDATION_ERROR".to_string(), - message: "Validation failed".to_string(), - fields: Some(fields), - } - } - - /// Create a 429 Too Many Requests error. - pub fn too_many_requests(message: &str) -> Self { - Self::new(StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED", message) - } - - /// Create a 500 Internal Server Error. - pub fn internal(message: &str) -> Self { - Self::new(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", message) - } - - /// Create a 503 Service Unavailable error. - pub fn service_unavailable(message: &str) -> Self { - Self::new( - StatusCode::SERVICE_UNAVAILABLE, - "SERVICE_UNAVAILABLE", - message, - ) - } - - /// Add field errors. - pub fn with_fields(mut self, fields: Vec) -> Self { - self.fields = Some(fields); - self - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let body = ApiResponse::<()> { - success: false, - data: None, - error: Some(ErrorDetails { - code: self.code, - message: self.message, - fields: self.fields, - }), - pagination: None, - meta: None, - }; - - (self.status, Json(body)).into_response() - } -} - -impl IntoResponse for ApiResponse { - fn into_response(self) -> Response { - let status = if self.success { - StatusCode::OK - } else { - StatusCode::BAD_REQUEST - }; - (status, Json(self)).into_response() - } -} - -/// Type alias for API handler results. -pub type ApiResult = Result, ApiError>; - -/// Helper to convert any serializable to success response. -pub fn success(data: T) -> ApiResponse { - ApiResponse::success(data) -} - -/// Helper to convert any serializable to paginated response. -pub fn paginated(data: T, page: u32, per_page: u32, total: u64) -> ApiResponse { - ApiResponse::paginated(data, page, per_page, total) -} - -/// Helper for quick internal error. -pub fn internal_err(msg: &str) -> ApiError { - ApiError::internal(msg) -} - -/// Helper for quick not found error. -pub fn not_found(msg: &str) -> ApiError { - ApiError::not_found(msg) -} - -/// Helper for quick bad request error. -pub fn bad_request(msg: &str) -> ApiError { - ApiError::bad_request(msg) -} diff --git a/src/shared/utils/core/errors.rs b/src/shared/utils/core/errors.rs deleted file mode 100644 index a75dd93..0000000 --- a/src/shared/utils/core/errors.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Axum error response helpers. - -use axum::http::StatusCode; - -/// Shorthand for creating error tuples for Axum handlers. -pub type HandlerError = (StatusCode, String); - -/// Create internal server error. -pub fn internal_error(msg: impl Into) -> HandlerError { - (StatusCode::INTERNAL_SERVER_ERROR, msg.into()) -} - -/// Create internal server error from any error type. -pub fn internal_err(e: E) -> HandlerError { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) -} - -/// Create bad request error. -pub fn bad_request(msg: impl Into) -> HandlerError { - (StatusCode::BAD_REQUEST, msg.into()) -} - -/// Create not found error. -pub fn not_found(msg: impl Into) -> HandlerError { - (StatusCode::NOT_FOUND, msg.into()) -} - -/// Create unauthorized error. -pub fn unauthorized(msg: impl Into) -> HandlerError { - (StatusCode::UNAUTHORIZED, msg.into()) -} - -/// Create forbidden error. -pub fn forbidden(msg: impl Into) -> HandlerError { - (StatusCode::FORBIDDEN, msg.into()) -} - -/// Create conflict error. -pub fn conflict(msg: impl Into) -> HandlerError { - (StatusCode::CONFLICT, msg.into()) -} - -/// Create too many requests error. -pub fn too_many_requests(msg: impl Into) -> HandlerError { - (StatusCode::TOO_MANY_REQUESTS, msg.into()) -} - -/// Map any error to internal server error. -pub fn map_internal(e: E) -> HandlerError { - internal_err(e) -} - -/// Create Redis error response. -pub fn redis_error(e: E) -> HandlerError { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Redis error: {}", e), - ) -} - -/// Create database error response. -pub fn db_error(e: E) -> HandlerError { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Database error: {}", e), - ) -} - -/// Create serialization error response. -pub fn serialization_error(e: E) -> HandlerError { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Serialization error: {}", e), - ) -} - -/// Trait extension for Result to easily convert errors. -pub trait ResultExt { - /// Map error to internal server error. - fn map_internal(self) -> Result; - - /// Map error to bad request. - fn map_bad_request(self) -> Result; - - /// Map error to not found. - fn map_not_found(self) -> Result; -} - -impl ResultExt for Result { - fn map_internal(self) -> Result { - self.map_err(internal_err) - } - - fn map_bad_request(self) -> Result { - self.map_err(|e| bad_request(e.to_string())) - } - - fn map_not_found(self) -> Result { - self.map_err(|e| not_found(e.to_string())) - } -} diff --git a/src/shared/utils/core/handler.rs b/src/shared/utils/core/handler.rs deleted file mode 100644 index a0f3438..0000000 --- a/src/shared/utils/core/handler.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Handler helper macros and utilities. - -/// Macro to create a simple CRUD handler set. -/// -/// # Example -/// -/// ```ignore -/// use scraper_service::helpers::crud_handlers; -/// -/// crud_handlers!(User, users); -/// // Generates: list_users, get_user, create_user, update_user, delete_user -/// ``` -#[macro_export] -macro_rules! crud_handlers { - ($entity:ident, $name:ident) => { - paste::paste! { - pub async fn []( - State(db): State>, - Query(params): Query, - ) -> impl IntoResponse { - use sea_orm::PaginatorTrait; - - let paginator = $entity::Entity::find() - .paginate(&*db, params.limit); - - let total = paginator.num_items().await.unwrap_or(0); - let items = paginator - .fetch_page(params.page.saturating_sub(1)) - .await - .unwrap_or_default(); - - Json(Paginated::from_params(items, ¶ms, total)) - } - - pub async fn []( - State(db): State>, - Path(id): Path, - ) -> Result { - let item = $entity::Entity::find_by_id(&id) - .one(&*db) - .await - .map_err(|e| ErrorResponse::internal(e.to_string()))? - .ok_or_else(|| ErrorResponse::not_found(concat!(stringify!($entity), " not found")))?; - - Ok(Json(json_ok(item))) - } - - pub async fn []( - State(db): State>, - Path(id): Path, - ) -> Result { - let result = $entity::Entity::delete_by_id(&id) - .exec(&*db) - .await - .map_err(|e| ErrorResponse::internal(e.to_string()))?; - - if result.rows_affected > 0 { - Ok(no_content()) - } else { - Err(ErrorResponse::not_found(concat!(stringify!($entity), " not found"))) - } - } - } - }; -} - -/// Wrap a handler result with consistent error handling. -/// -/// # Example -/// -/// ```ignore -/// use scraper_service::helpers::handler::try_handler; -/// -/// async fn my_handler() -> impl IntoResponse { -/// try_handler(async { -/// let data = fetch_data().await?; -/// Ok(data) -/// }).await -/// } -/// ``` -pub async fn try_handler( - f: F, -) -> Result, crate::shared::utils::ErrorResponse> -where - T: serde::Serialize, - E: std::fmt::Display, - F: FnOnce() -> Fut, - Fut: std::future::Future>, -{ - match f().await { - Ok(data) => Ok(crate::shared::utils::JsonResponse::ok(data)), - Err(e) => Err(crate::shared::utils::ErrorResponse::internal(e.to_string())), - } -} - -/// Extract user ID from JWT claims (helper). -pub fn get_user_id_from_claims(claims: &serde_json::Value) -> Option { - claims.get("sub").and_then(|v| v.as_str()).map(String::from) -} diff --git a/src/shared/utils/core/mod.rs b/src/shared/utils/core/mod.rs deleted file mode 100644 index 029362b..0000000 --- a/src/shared/utils/core/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod api_response; -pub mod errors; -pub mod handler; -pub mod pagination; -pub mod prelude; -pub mod response; diff --git a/src/shared/utils/core/pagination.rs b/src/shared/utils/core/pagination.rs deleted file mode 100644 index 76dcf89..0000000 --- a/src/shared/utils/core/pagination.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Pagination helpers. - -use serde::{Deserialize, Serialize}; - -/// Pagination query parameters. -#[derive(Debug, Clone, Deserialize)] -pub struct PaginationParams { - /// Page number (1-indexed). - #[serde(default = "default_page")] - pub page: u64, - /// Items per page. - #[serde(default = "default_limit")] - pub limit: u64, - /// Sort field. - #[serde(default)] - pub sort: Option, - /// Sort order (asc/desc). - #[serde(default = "default_order")] - pub order: String, -} - -fn default_page() -> u64 { - 1 -} -fn default_limit() -> u64 { - 20 -} -fn default_order() -> String { - "asc".to_string() -} - -impl PaginationParams { - /// Calculate offset for database query. - pub fn offset(&self) -> u64 { - (self.page.saturating_sub(1)) * self.limit - } - - /// Check if ascending order. - pub fn is_asc(&self) -> bool { - self.order.to_lowercase() == "asc" - } -} - -impl Default for PaginationParams { - fn default() -> Self { - Self { - page: default_page(), - limit: default_limit(), - sort: None, - order: default_order(), - } - } -} - -/// Paginated response wrapper. -#[derive(Debug, Clone, Serialize)] -pub struct Paginated { - pub data: Vec, - pub pagination: PaginationMeta, -} - -/// Pagination metadata. -#[derive(Debug, Clone, Serialize)] -pub struct PaginationMeta { - pub page: u64, - pub limit: u64, - pub total: u64, - pub total_pages: u64, - pub has_next: bool, - pub has_prev: bool, -} - -impl Paginated { - /// Create a paginated response. - pub fn new(data: Vec, page: u64, limit: u64, total: u64) -> Self { - let total_pages = (total + limit - 1) / limit; - Self { - data, - pagination: PaginationMeta { - page, - limit, - total, - total_pages, - has_next: page < total_pages, - has_prev: page > 1, - }, - } - } - - /// Create from params and total. - pub fn from_params(data: Vec, params: &PaginationParams, total: u64) -> Self { - Self::new(data, params.page, params.limit, total) - } -} - -/// Trait to easily paginate database results. -pub trait Paginatable { - fn paginate(self, params: &PaginationParams, total: u64) -> Paginated; -} - -impl Paginatable for Vec { - fn paginate(self, params: &PaginationParams, total: u64) -> Paginated { - Paginated::from_params(self, params, total) - } -} diff --git a/src/shared/utils/core/prelude.rs b/src/shared/utils/core/prelude.rs deleted file mode 100644 index 3b6f022..0000000 --- a/src/shared/utils/core/prelude.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Prelude module - common imports for handlers. -//! -//! # Usage -//! -//! ```ignore -//! use scraper_service::helpers::prelude::*; -//! ``` - -// Re-export common types -pub use axum::{ - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -pub use serde::{Deserialize, Serialize}; -pub use tracing::{debug, error, info, warn}; - -// Re-export our helpers -pub use super::pagination::{Paginated, PaginationParams}; -pub use super::response::{ApiResult, ErrorResponse, JsonResponse}; - -// Re-export common extractors -pub use crate::shared::observability::request_id::RequestId; - -// Re-export database types -pub use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, - QuerySelect, Set, -}; - -// Re-export common std types -pub use std::sync::Arc; diff --git a/src/shared/utils/core/response.rs b/src/shared/utils/core/response.rs deleted file mode 100644 index 34f70e2..0000000 --- a/src/shared/utils/core/response.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Response helpers for consistent API responses. - -use axum::{http::StatusCode, response::IntoResponse, Json}; -use serde::Serialize; - -/// Result type for API handlers. -pub type ApiResult = Result, ErrorResponse>; - -/// Success JSON response wrapper. -#[derive(Debug, Clone, Serialize)] -pub struct JsonResponse { - pub success: bool, - pub data: T, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -impl JsonResponse { - /// Create a success response. - pub fn ok(data: T) -> Self { - Self { - success: true, - data, - message: None, - } - } - - /// Create a success response with message. - pub fn ok_with_message(data: T, message: impl Into) -> Self { - Self { - success: true, - data, - message: Some(message.into()), - } - } -} - -impl IntoResponse for JsonResponse { - fn into_response(self) -> axum::response::Response { - (StatusCode::OK, Json(self)).into_response() - } -} - -/// Error response wrapper. -#[derive(Debug, Clone, Serialize)] -pub struct ErrorResponse { - pub success: bool, - pub error: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - #[serde(skip)] - pub status: StatusCode, -} - -impl ErrorResponse { - /// Create an error response. - pub fn new(status: StatusCode, error: impl Into) -> Self { - Self { - success: false, - error: error.into(), - code: None, - status, - } - } - - /// Add an error code. - pub fn with_code(mut self, code: impl Into) -> Self { - self.code = Some(code.into()); - self - } - - // Common error constructors - - /// 400 Bad Request - pub fn bad_request(error: impl Into) -> Self { - Self::new(StatusCode::BAD_REQUEST, error) - } - - /// 401 Unauthorized - pub fn unauthorized(error: impl Into) -> Self { - Self::new(StatusCode::UNAUTHORIZED, error) - } - - /// 403 Forbidden - pub fn forbidden(error: impl Into) -> Self { - Self::new(StatusCode::FORBIDDEN, error) - } - - /// 404 Not Found - pub fn not_found(error: impl Into) -> Self { - Self::new(StatusCode::NOT_FOUND, error) - } - - /// 409 Conflict - pub fn conflict(error: impl Into) -> Self { - Self::new(StatusCode::CONFLICT, error) - } - - /// 422 Unprocessable Entity - pub fn unprocessable(error: impl Into) -> Self { - Self::new(StatusCode::UNPROCESSABLE_ENTITY, error) - } - - /// 500 Internal Server Error - pub fn internal(error: impl Into) -> Self { - Self::new(StatusCode::INTERNAL_SERVER_ERROR, error) - } -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> axum::response::Response { - (self.status, Json(&self)).into_response() - } -} - -impl From for ErrorResponse { - fn from(err: anyhow::Error) -> Self { - Self::internal(err.to_string()) - } -} - -impl From for ErrorResponse { - fn from(err: sea_orm::DbErr) -> Self { - Self::internal(format!("Database error: {}", err)) - } -} - -// Convenience functions - -/// Create a success JSON response. -pub fn json_ok(data: T) -> JsonResponse { - JsonResponse::ok(data) -} - -/// Create an empty success response. -pub fn ok() -> impl IntoResponse { - (StatusCode::OK, Json(serde_json::json!({"success": true}))) -} - -/// Create a created response (201). -pub fn created(data: T) -> impl IntoResponse { - (StatusCode::CREATED, Json(JsonResponse::ok(data))) -} - -/// Create a no content response (204). -pub fn no_content() -> impl IntoResponse { - StatusCode::NO_CONTENT -} diff --git a/src/shared/utils/data/collections.rs b/src/shared/utils/data/collections.rs deleted file mode 100644 index e1ec301..0000000 --- a/src/shared/utils/data/collections.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Collection and iterator utilities. - -use std::collections::{HashMap, HashSet}; -use std::hash::Hash; - -/// Chunk a vector into smaller vectors of specified size. -pub fn chunk(items: Vec, size: usize) -> Vec> { - items.chunks(size).map(|chunk| chunk.to_vec()).collect() -} - -/// Get unique items from a vector. -pub fn unique(items: Vec) -> Vec { - let mut seen = HashSet::new(); - items - .into_iter() - .filter(|item| seen.insert(item.clone())) - .collect() -} - -/// Group items by a key function. -pub fn group_by(items: Vec, key_fn: F) -> HashMap> -where - K: Hash + Eq, - F: Fn(&T) -> K, -{ - let mut groups: HashMap> = HashMap::new(); - for item in items { - let key = key_fn(&item); - groups.entry(key).or_default().push(item); - } - groups -} - -/// Partition items into two vectors based on predicate. -pub fn partition(items: Vec, predicate: F) -> (Vec, Vec) -where - F: Fn(&T) -> bool, -{ - let mut pass = Vec::new(); - let mut fail = Vec::new(); - for item in items { - if predicate(&item) { - pass.push(item); - } else { - fail.push(item); - } - } - (pass, fail) -} - -/// Flatten nested vectors. -pub fn flatten(nested: Vec>) -> Vec { - nested.into_iter().flatten().collect() -} - -/// Zip two vectors into pairs. -pub fn zip(a: Vec, b: Vec) -> Vec<(T, U)> { - a.into_iter().zip(b).collect() -} - -/// Find first matching item. -pub fn find(items: &[T], predicate: F) -> Option<&T> -where - F: Fn(&T) -> bool, -{ - items.iter().find(|item| predicate(item)) -} - -/// Find first matching item and return its index. -pub fn find_index(items: &[T], predicate: F) -> Option -where - F: Fn(&T) -> bool, -{ - items.iter().position(|item| predicate(item)) -} - -/// Check if any item matches predicate. -pub fn any(items: &[T], predicate: F) -> bool -where - F: Fn(&T) -> bool, -{ - items.iter().any(predicate) -} - -/// Check if all items match predicate. -pub fn all(items: &[T], predicate: F) -> bool -where - F: Fn(&T) -> bool, -{ - items.iter().all(predicate) -} - -/// Sum numeric values. -pub fn sum(items: &[T], value_fn: F) -> N -where - N: std::iter::Sum, - F: Fn(&T) -> N, -{ - items.iter().map(value_fn).sum() -} - -/// Count items matching predicate. -pub fn count(items: &[T], predicate: F) -> usize -where - F: Fn(&T) -> bool, -{ - items.iter().filter(|item| predicate(item)).count() -} - -/// Get first n items. -pub fn take(items: &[T], n: usize) -> Vec { - items.iter().take(n).cloned().collect() -} - -/// Skip first n items. -pub fn skip(items: &[T], n: usize) -> Vec { - items.iter().skip(n).cloned().collect() -} - -/// Reverse a vector. -pub fn reverse(items: &[T]) -> Vec { - items.iter().rev().cloned().collect() -} - -/// Interleave two vectors. -pub fn interleave(a: Vec, b: Vec) -> Vec { - let mut result = Vec::with_capacity(a.len() + b.len()); - let mut a_iter = a.into_iter(); - let mut b_iter = b.into_iter(); - loop { - match (a_iter.next(), b_iter.next()) { - (Some(x), Some(y)) => { - result.push(x); - result.push(y); - } - (Some(x), None) => result.push(x), - (None, Some(y)) => result.push(y), - (None, None) => break, - } - } - result -} - -/// Create a frequency map. -pub fn frequencies(items: &[T]) -> HashMap { - let mut freq = HashMap::new(); - for item in items { - *freq.entry(item.clone()).or_insert(0) += 1; - } - freq -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_chunk() { - let items = vec![1, 2, 3, 4, 5]; - let chunks = chunk(items, 2); - assert_eq!(chunks, vec![vec![1, 2], vec![3, 4], vec![5]]); - } - - #[test] - fn test_unique() { - let items = vec![1, 2, 2, 3, 3, 3]; - assert_eq!(unique(items), vec![1, 2, 3]); - } -} diff --git a/src/shared/utils/data/convert/bools.rs b/src/shared/utils/data/convert/bools.rs deleted file mode 100644 index 4d3fc1b..0000000 --- a/src/shared/utils/data/convert/bools.rs +++ /dev/null @@ -1,84 +0,0 @@ -/// Convert string to bool (flexible parsing). -pub fn to_bool(s: &str) -> bool { - matches!( - s.to_lowercase().trim(), - "true" | "1" | "yes" | "on" | "enabled" | "t" | "y" | "ok" | "active" - ) -} - -/// Convert to bool with Option for invalid input. -pub fn try_bool(s: &str) -> Option { - match s.to_lowercase().trim() { - "true" | "1" | "yes" | "on" | "enabled" | "t" | "y" | "ok" | "active" => Some(true), - "false" | "0" | "no" | "off" | "disabled" | "f" | "n" | "inactive" => Some(false), - _ => None, - } -} - -/// Convert bool to string. -pub fn bool_to_str(b: bool) -> &'static str { - if b { - "true" - } else { - "false" - } -} - -/// Convert bool to yes/no. -pub fn bool_to_yes_no(b: bool) -> &'static str { - if b { - "yes" - } else { - "no" - } -} - -/// Convert bool to on/off. -pub fn bool_to_on_off(b: bool) -> &'static str { - if b { - "on" - } else { - "off" - } -} - -/// Convert bool to enabled/disabled. -pub fn bool_to_enabled(b: bool) -> &'static str { - if b { - "enabled" - } else { - "disabled" - } -} - -/// Convert bool to active/inactive. -pub fn bool_to_active(b: bool) -> &'static str { - if b { - "active" - } else { - "inactive" - } -} - -/// Convert bool to 0/1 i32. -pub fn bool_to_int(b: bool) -> i32 { - if b { - 1 - } else { - 0 - } -} - -/// Convert bool to 0/1 i64. -pub fn bool_to_i64(b: bool) -> i64 { - if b { - 1 - } else { - 0 - } -} - -/// Convert i32 to bool (0 = false, other = true). -pub fn int_to_bool(n: i32) -> bool { - n != 0 -} diff --git a/src/shared/utils/data/convert/bytes.rs b/src/shared/utils/data/convert/bytes.rs deleted file mode 100644 index 0cc1b76..0000000 --- a/src/shared/utils/data/convert/bytes.rs +++ /dev/null @@ -1,130 +0,0 @@ -/// Convert bytes to hex string (lowercase). -pub fn bytes_to_hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -/// Convert bytes to hex string (uppercase). -pub fn bytes_to_hex_upper(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02X}", b)).collect() -} - -/// Convert hex string to bytes. -pub fn hex_to_bytes(hex: &str) -> Result, std::num::ParseIntError> { - (0..hex.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&hex[i..i + 2], 16)) - .collect() -} - -/// Convert bytes to base64. -pub fn bytes_to_base64(bytes: &[u8]) -> String { - base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes) -} - -/// Convert base64 to bytes. -pub fn base64_to_bytes(s: &str) -> Result, base64::DecodeError> { - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s) -} - -/// Convert bytes to binary string. -pub fn bytes_to_binary(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{:08b}", b)) - .collect::>() - .join(" ") -} - -/// Convert bytes to octal string. -pub fn bytes_to_octal(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{:03o}", b)) - .collect::>() - .join(" ") -} - -/// Convert u8 to binary string. -pub fn u8_to_binary(n: u8) -> String { - format!("{:08b}", n) -} - -/// Convert u16 to binary string. -pub fn u16_to_binary(n: u16) -> String { - format!("{:016b}", n) -} - -/// Convert u32 to binary string. -pub fn u32_to_binary(n: u32) -> String { - format!("{:032b}", n) -} - -/// Convert u64 to binary string. -pub fn u64_to_binary(n: u64) -> String { - format!("{:064b}", n) -} - -/// Convert binary string to u64. -pub fn binary_to_u64(s: &str) -> Result { - u64::from_str_radix(&s.replace(" ", ""), 2) -} - -/// Swap endianness of u16. -pub fn swap_endian_u16(n: u16) -> u16 { - n.swap_bytes() -} - -/// Swap endianness of u32. -pub fn swap_endian_u32(n: u32) -> u32 { - n.swap_bytes() -} - -/// Swap endianness of u64. -pub fn swap_endian_u64(n: u64) -> u64 { - n.swap_bytes() -} - -/// Swap endianness of u128. -pub fn swap_endian_u128(n: u128) -> u128 { - n.swap_bytes() -} - -/// Convert to big endian bytes. -pub fn u32_to_be_bytes(n: u32) -> [u8; 4] { - n.to_be_bytes() -} - -/// Convert to little endian bytes. -pub fn u32_to_le_bytes(n: u32) -> [u8; 4] { - n.to_le_bytes() -} - -/// Convert from big endian bytes. -pub fn be_bytes_to_u32(bytes: [u8; 4]) -> u32 { - u32::from_be_bytes(bytes) -} - -/// Convert from little endian bytes. -pub fn le_bytes_to_u32(bytes: [u8; 4]) -> u32 { - u32::from_le_bytes(bytes) -} - -/// Convert to big endian bytes. -pub fn u64_to_be_bytes(n: u64) -> [u8; 8] { - n.to_be_bytes() -} - -/// Convert to little endian bytes. -pub fn u64_to_le_bytes(n: u64) -> [u8; 8] { - n.to_le_bytes() -} - -/// Convert from big endian bytes. -pub fn be_bytes_to_u64(bytes: [u8; 8]) -> u64 { - u64::from_be_bytes(bytes) -} - -/// Convert from little endian bytes. -pub fn le_bytes_to_u64(bytes: [u8; 8]) -> u64 { - u64::from_le_bytes(bytes) -} diff --git a/src/shared/utils/data/convert/char.rs b/src/shared/utils/data/convert/char.rs deleted file mode 100644 index 8514de5..0000000 --- a/src/shared/utils/data/convert/char.rs +++ /dev/null @@ -1,65 +0,0 @@ -/// Convert char to u32 (unicode code point). -pub fn char_to_u32(c: char) -> u32 { - c as u32 -} - -/// Convert u32 to char (unicode code point). -pub fn u32_to_char(n: u32) -> Option { - char::from_u32(n) -} - -/// Convert char to ascii u8. -pub fn char_to_ascii(c: char) -> Option { - if c.is_ascii() { - Some(c as u8) - } else { - None - } -} - -/// Convert u8 to char. -pub fn u8_to_char(n: u8) -> char { - n as char -} - -/// Convert digit char to u8. -pub fn digit_to_u8(c: char) -> Option { - c.to_digit(10).map(|d| d as u8) -} - -/// Convert u8 to digit char. -pub fn u8_to_digit(n: u8) -> Option { - if n <= 9 { - Some((b'0' + n) as char) - } else { - None - } -} - -/// Convert hex char to u8. -pub fn hex_char_to_u8(c: char) -> Option { - c.to_digit(16).map(|d| d as u8) -} - -/// Convert u8 to hex char (lowercase). -pub fn u8_to_hex_char(n: u8) -> Option { - if n < 16 { - Some(if n < 10 { - (b'0' + n) as char - } else { - (b'a' + n - 10) as char - }) - } else { - None - } -} - -/// Convert char to uppercase. -pub fn char_to_upper(c: char) -> char { - c.to_ascii_uppercase() -} - -/// Convert char to lowercase. -pub fn char_to_lower(c: char) -> char { - c.to_ascii_lowercase() -} diff --git a/src/shared/utils/data/convert/collections.rs b/src/shared/utils/data/convert/collections.rs deleted file mode 100644 index d5d0b5d..0000000 --- a/src/shared/utils/data/convert/collections.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, LinkedList, VecDeque}; -use std::hash::Hash; - -/// Convert Vec to Vec. -pub fn map_vec(vec: Vec, f: F) -> Vec -where - F: Fn(T) -> U, -{ - vec.into_iter().map(f).collect() -} - -/// Convert &[T] to Vec. -pub fn map_slice(slice: &[T], f: F) -> Vec -where - F: Fn(&T) -> U, -{ - slice.iter().map(f).collect() -} - -/// Convert Vec to Vec<&str>. -pub fn strings_to_strs(strings: &[String]) -> Vec<&str> { - strings.iter().map(|s| s.as_str()).collect() -} - -/// Convert Vec<&str> to Vec. -pub fn strs_to_strings(strs: &[&str]) -> Vec { - strs.iter().map(|s| s.to_string()).collect() -} - -/// Convert slice to fixed array. -pub fn slice_to_array(slice: &[T]) -> Option<[T; N]> { - slice.try_into().ok() -} - -/// Convert Vec to VecDeque. -pub fn vec_to_deque(v: Vec) -> VecDeque { - VecDeque::from(v) -} - -/// Convert VecDeque to Vec. -pub fn deque_to_vec(d: VecDeque) -> Vec { - d.into_iter().collect() -} - -/// Convert Vec to LinkedList. -pub fn vec_to_linked_list(v: Vec) -> LinkedList { - v.into_iter().collect() -} - -/// Convert LinkedList to Vec. -pub fn linked_list_to_vec(l: LinkedList) -> Vec { - l.into_iter().collect() -} - -/// Convert Vec to HashSet. -pub fn vec_to_hashset(v: Vec) -> HashSet { - v.into_iter().collect() -} - -/// Convert HashSet to Vec. -pub fn hashset_to_vec(s: HashSet) -> Vec { - s.into_iter().collect() -} - -/// Convert Vec to BTreeSet. -pub fn vec_to_btreeset(v: Vec) -> BTreeSet { - v.into_iter().collect() -} - -/// Convert BTreeSet to Vec. -pub fn btreeset_to_vec(s: BTreeSet) -> Vec { - s.into_iter().collect() -} - -/// Convert Vec of tuples to HashMap. -pub fn vec_to_hashmap(v: Vec<(K, V)>) -> HashMap { - v.into_iter().collect() -} - -/// Convert HashMap to Vec of tuples. -pub fn hashmap_to_vec(m: HashMap) -> Vec<(K, V)> { - m.into_iter().collect() -} - -/// Convert Vec of tuples to BTreeMap. -pub fn vec_to_btreemap(v: Vec<(K, V)>) -> BTreeMap { - v.into_iter().collect() -} - -/// Convert BTreeMap to Vec of tuples. -pub fn btreemap_to_vec(m: BTreeMap) -> Vec<(K, V)> { - m.into_iter().collect() -} - -/// Convert HashMap to BTreeMap. -pub fn hashmap_to_btreemap(m: HashMap) -> BTreeMap { - m.into_iter().collect() -} - -/// Convert BTreeMap to HashMap. -pub fn btreemap_to_hashmap(m: BTreeMap) -> HashMap { - m.into_iter().collect() -} diff --git a/src/shared/utils/data/convert/color.rs b/src/shared/utils/data/convert/color.rs deleted file mode 100644 index c31e2c4..0000000 --- a/src/shared/utils/data/convert/color.rs +++ /dev/null @@ -1,70 +0,0 @@ -/// Convert hex color to RGB tuple. -pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> { - let hex = hex.trim_start_matches('#'); - if hex.len() != 6 { - return None; - } - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - Some((r, g, b)) -} - -/// Convert hex color to RGBA tuple. -pub fn hex_to_rgba(hex: &str) -> Option<(u8, u8, u8, u8)> { - let hex = hex.trim_start_matches('#'); - if hex.len() == 6 { - let (r, g, b) = hex_to_rgb(hex)?; - return Some((r, g, b, 255)); - } - if hex.len() != 8 { - return None; - } - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - let a = u8::from_str_radix(&hex[6..8], 16).ok()?; - Some((r, g, b, a)) -} - -/// Convert RGB to hex color. -pub fn rgb_to_hex(r: u8, g: u8, b: u8) -> String { - format!("#{:02x}{:02x}{:02x}", r, g, b) -} - -/// Convert RGBA to hex color. -pub fn rgba_to_hex(r: u8, g: u8, b: u8, a: u8) -> String { - format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a) -} - -/// Convert RGB to HSL. -pub fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) { - let r = r as f64 / 255.0; - let g = g as f64 / 255.0; - let b = b as f64 / 255.0; - - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let l = (max + min) / 2.0; - - if (max - min).abs() < f64::EPSILON { - return (0.0, 0.0, l); - } - - let d = max - min; - let s = if l > 0.5 { - d / (2.0 - max - min) - } else { - d / (max + min) - }; - - let h = if (max - r).abs() < f64::EPSILON { - ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 - } else if (max - g).abs() < f64::EPSILON { - ((b - r) / d + 2.0) / 6.0 - } else { - ((r - g) / d + 4.0) / 6.0 - }; - - (h * 360.0, s * 100.0, l * 100.0) -} diff --git a/src/shared/utils/data/convert/mod.rs b/src/shared/utils/data/convert/mod.rs deleted file mode 100644 index 01c36f6..0000000 --- a/src/shared/utils/data/convert/mod.rs +++ /dev/null @@ -1,62 +0,0 @@ -pub mod bools; -pub mod bytes; -pub mod char; -pub mod collections; -pub mod color; -pub mod network; -pub mod numeric; -pub mod path; -pub mod pointers; -pub mod result; -pub mod string; -pub mod time; - -pub use bools::*; -pub use bytes::*; -pub use char::*; -pub use collections::*; -pub use color::*; -pub use network::*; -pub use numeric::*; -pub use path::*; -pub use pointers::*; -pub use result::*; -pub use string::*; -pub use time::*; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_integer_conversions() { - assert_eq!(i64_to_u8(300), 255); - assert_eq!(i64_to_u8(-5), 0); - assert_eq!(i128_to_i64(i128::MAX), i64::MAX); - } - - #[test] - fn test_time_conversions() { - assert_eq!(seconds_to_human(90), "1m 30s"); - assert_eq!(seconds_to_compact(86400), "1d"); - } - - #[test] - fn test_color_conversions() { - assert_eq!(hex_to_rgb("#ff8000"), Some((255, 128, 0))); - assert_eq!(rgb_to_hex(255, 128, 0), "#ff8000"); - } - - #[test] - fn test_network_conversions() { - assert_eq!(ipv4_to_u32("192.168.1.1"), Some(0xC0A80101)); - assert_eq!(u32_to_ipv4(0xC0A80101), "192.168.1.1"); - } - - #[test] - fn test_path_conversions() { - let path = str_to_path("/test/path.txt"); - assert_eq!(path_extension(&path), Some("txt".to_string())); - assert_eq!(path_filename(&path), Some("path.txt".to_string())); - } -} diff --git a/src/shared/utils/data/convert/network.rs b/src/shared/utils/data/convert/network.rs deleted file mode 100644 index 3115475..0000000 --- a/src/shared/utils/data/convert/network.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; - -/// Convert IPv4 string to u32. -pub fn ipv4_to_u32(ip: &str) -> Option { - ip.parse::().ok().map(|addr| u32::from(addr)) -} - -/// Convert u32 to IPv4 string. -pub fn u32_to_ipv4(n: u32) -> String { - Ipv4Addr::from(n).to_string() -} - -/// Convert u32 to Ipv4Addr. -pub fn u32_to_ipv4_addr(n: u32) -> Ipv4Addr { - Ipv4Addr::from(n) -} - -/// Convert string to IpAddr. -pub fn str_to_ip_addr(s: &str) -> Option { - s.parse().ok() -} - -/// Convert IpAddr to string. -pub fn ip_addr_to_string(ip: IpAddr) -> String { - ip.to_string() -} - -/// Convert string to SocketAddr. -pub fn str_to_socket_addr(s: &str) -> Option { - s.parse().ok() -} - -/// Convert SocketAddr to string. -pub fn socket_addr_to_string(addr: SocketAddr) -> String { - addr.to_string() -} - -/// Create SocketAddrV4 from IP and port. -pub fn ipv4_port_to_socket(ip: Ipv4Addr, port: u16) -> SocketAddrV4 { - SocketAddrV4::new(ip, port) -} - -/// Create SocketAddrV6 from IP and port. -pub fn ipv6_port_to_socket(ip: Ipv6Addr, port: u16) -> SocketAddrV6 { - SocketAddrV6::new(ip, port, 0, 0) -} - -/// Check if IP is loopback. -pub fn is_loopback(ip: &str) -> bool { - ip.parse::() - .map(|a| a.is_loopback()) - .unwrap_or(false) -} - -/// Check if IP is private (IPv4). -pub fn is_private_ip(ip: &str) -> bool { - ip.parse::() - .map(|a| a.is_private()) - .unwrap_or(false) -} diff --git a/src/shared/utils/data/convert/numeric.rs b/src/shared/utils/data/convert/numeric.rs deleted file mode 100644 index 2fed348..0000000 --- a/src/shared/utils/data/convert/numeric.rs +++ /dev/null @@ -1,532 +0,0 @@ -/// Safe i8 to u8. -pub fn i8_to_u8(n: i8) -> u8 { - n.max(0) as u8 -} - -/// Safe i8 to i16. -pub fn i8_to_i16(n: i8) -> i16 { - n as i16 -} - -/// Safe i8 to i32. -pub fn i8_to_i32(n: i8) -> i32 { - n as i32 -} - -/// Safe i8 to i64. -pub fn i8_to_i64(n: i8) -> i64 { - n as i64 -} - -/// Safe i8 to i128. -pub fn i8_to_i128(n: i8) -> i128 { - n as i128 -} - -/// Safe i8 to f32. -pub fn i8_to_f32(n: i8) -> f32 { - n as f32 -} - -/// Safe i8 to f64. -pub fn i8_to_f64(n: i8) -> f64 { - n as f64 -} - -/// Safe i16 to i8 (saturates). -pub fn i16_to_i8(n: i16) -> i8 { - n.clamp(i8::MIN as i16, i8::MAX as i16) as i8 -} - -/// Safe i16 to u8 (saturates). -pub fn i16_to_u8(n: i16) -> u8 { - n.clamp(0, u8::MAX as i16) as u8 -} - -/// Safe i16 to u16. -pub fn i16_to_u16(n: i16) -> u16 { - n.max(0) as u16 -} - -/// Safe i16 to i32. -pub fn i16_to_i32(n: i16) -> i32 { - n as i32 -} - -/// Safe i16 to i64. -pub fn i16_to_i64(n: i16) -> i64 { - n as i64 -} - -/// Safe i16 to i128. -pub fn i16_to_i128(n: i16) -> i128 { - n as i128 -} - -/// Safe i32 to i8 (saturates). -pub fn i32_to_i8(n: i32) -> i8 { - n.clamp(i8::MIN as i32, i8::MAX as i32) as i8 -} - -/// Safe i32 to i16 (saturates). -pub fn i32_to_i16(n: i32) -> i16 { - n.clamp(i16::MIN as i32, i16::MAX as i32) as i16 -} - -/// Safe i32 to u8 (saturates). -pub fn i32_to_u8(n: i32) -> u8 { - n.clamp(0, u8::MAX as i32) as u8 -} - -/// Safe i32 to u16 (saturates). -pub fn i32_to_u16(n: i32) -> u16 { - n.clamp(0, u16::MAX as i32) as u16 -} - -/// Safe i32 to u32. -pub fn i32_to_u32(n: i32) -> u32 { - n.max(0) as u32 -} - -/// Safe i32 to i64. -pub fn i32_to_i64(n: i32) -> i64 { - n as i64 -} - -/// Safe i32 to i128. -pub fn i32_to_i128(n: i32) -> i128 { - n as i128 -} - -/// Safe i32 to usize. -pub fn i32_to_usize(n: i32) -> usize { - n.max(0) as usize -} - -/// Safe i32 to f32. -pub fn i32_to_f32(n: i32) -> f32 { - n as f32 -} - -/// Safe i32 to f64. -pub fn i32_to_f64(n: i32) -> f64 { - n as f64 -} - -/// Safe i64 to i8 (saturates). -pub fn i64_to_i8(n: i64) -> i8 { - n.clamp(i8::MIN as i64, i8::MAX as i64) as i8 -} - -/// Safe i64 to i16 (saturates). -pub fn i64_to_i16(n: i64) -> i16 { - n.clamp(i16::MIN as i64, i16::MAX as i64) as i16 -} - -/// Safe i64 to i32 (saturates). -pub fn i64_to_i32(n: i64) -> i32 { - n.clamp(i32::MIN as i64, i32::MAX as i64) as i32 -} - -/// Safe i64 to u8 (saturates). -pub fn i64_to_u8(n: i64) -> u8 { - n.clamp(0, u8::MAX as i64) as u8 -} - -/// Safe i64 to u16 (saturates). -pub fn i64_to_u16(n: i64) -> u16 { - n.clamp(0, u16::MAX as i64) as u16 -} - -/// Safe i64 to u32 (saturates). -pub fn i64_to_u32(n: i64) -> u32 { - n.clamp(0, u32::MAX as i64) as u32 -} - -/// Safe i64 to u64. -pub fn i64_to_u64(n: i64) -> u64 { - n.max(0) as u64 -} - -/// Safe i64 to usize. -pub fn i64_to_usize(n: i64) -> usize { - n.max(0) as usize -} - -/// Safe i64 to i128. -pub fn i64_to_i128(n: i64) -> i128 { - n as i128 -} - -/// Safe i64 to u128. -pub fn i64_to_u128(n: i64) -> u128 { - n.max(0) as u128 -} - -/// Safe i64 to f32. -pub fn i64_to_f32(n: i64) -> f32 { - n as f32 -} - -/// Safe i64 to f64. -pub fn i64_to_f64(n: i64) -> f64 { - n as f64 -} - -/// Safe i128 to i8 (saturates). -pub fn i128_to_i8(n: i128) -> i8 { - n.clamp(i8::MIN as i128, i8::MAX as i128) as i8 -} - -/// Safe i128 to i16 (saturates). -pub fn i128_to_i16(n: i128) -> i16 { - n.clamp(i16::MIN as i128, i16::MAX as i128) as i16 -} - -/// Safe i128 to i32 (saturates). -pub fn i128_to_i32(n: i128) -> i32 { - n.clamp(i32::MIN as i128, i32::MAX as i128) as i32 -} - -/// Safe i128 to i64 (saturates). -pub fn i128_to_i64(n: i128) -> i64 { - n.clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -/// Safe i128 to u128. -pub fn i128_to_u128(n: i128) -> u128 { - n.max(0) as u128 -} - -/// Safe i128 to usize. -pub fn i128_to_usize(n: i128) -> usize { - n.clamp(0, usize::MAX as i128) as usize -} - -/// u8 to i8 (may overflow to negative). -pub fn u8_to_i8_wrap(n: u8) -> i8 { - n as i8 -} - -/// u8 to i8 (saturates at i8::MAX). -pub fn u8_to_i8_sat(n: u8) -> i8 { - n.min(i8::MAX as u8) as i8 -} - -/// u8 to i16. -pub fn u8_to_i16(n: u8) -> i16 { - n as i16 -} - -/// u8 to i32. -pub fn u8_to_i32(n: u8) -> i32 { - n as i32 -} - -/// u8 to i64. -pub fn u8_to_i64(n: u8) -> i64 { - n as i64 -} - -/// u8 to u16. -pub fn u8_to_u16(n: u8) -> u16 { - n as u16 -} - -/// u8 to u32. -pub fn u8_to_u32(n: u8) -> u32 { - n as u32 -} - -/// u8 to u64. -pub fn u8_to_u64(n: u8) -> u64 { - n as u64 -} - -/// u8 to usize. -pub fn u8_to_usize(n: u8) -> usize { - n as usize -} - -/// u8 to f32. -pub fn u8_to_f32(n: u8) -> f32 { - n as f32 -} - -/// u8 to f64. -pub fn u8_to_f64(n: u8) -> f64 { - n as f64 -} - -/// u16 to u8 (saturates). -pub fn u16_to_u8(n: u16) -> u8 { - n.min(u8::MAX as u16) as u8 -} - -/// u16 to i16 (saturates). -pub fn u16_to_i16(n: u16) -> i16 { - n.min(i16::MAX as u16) as i16 -} - -/// u16 to i32. -pub fn u16_to_i32(n: u16) -> i32 { - n as i32 -} - -/// u16 to i64. -pub fn u16_to_i64(n: u16) -> i64 { - n as i64 -} - -/// u16 to u32. -pub fn u16_to_u32(n: u16) -> u32 { - n as u32 -} - -/// u16 to u64. -pub fn u16_to_u64(n: u16) -> u64 { - n as u64 -} - -/// u16 to usize. -pub fn u16_to_usize(n: u16) -> usize { - n as usize -} - -/// u32 to u8 (saturates). -pub fn u32_to_u8(n: u32) -> u8 { - n.min(u8::MAX as u32) as u8 -} - -/// u32 to u16 (saturates). -pub fn u32_to_u16(n: u32) -> u16 { - n.min(u16::MAX as u32) as u16 -} - -/// u32 to i32 (saturates). -pub fn u32_to_i32(n: u32) -> i32 { - n.min(i32::MAX as u32) as i32 -} - -/// u32 to i64. -pub fn u32_to_i64(n: u32) -> i64 { - n as i64 -} - -/// u32 to u64. -pub fn u32_to_u64(n: u32) -> u64 { - n as u64 -} - -/// u32 to usize. -pub fn u32_to_usize(n: u32) -> usize { - n as usize -} - -/// u32 to f32. -pub fn u32_to_f32(n: u32) -> f32 { - n as f32 -} - -/// u32 to f64. -pub fn u32_to_f64(n: u32) -> f64 { - n as f64 -} - -/// u64 to u8 (saturates). -pub fn u64_to_u8(n: u64) -> u8 { - n.min(u8::MAX as u64) as u8 -} - -/// u64 to u16 (saturates). -pub fn u64_to_u16(n: u64) -> u16 { - n.min(u16::MAX as u64) as u16 -} - -/// u64 to u32 (saturates). -pub fn u64_to_u32(n: u64) -> u32 { - n.min(u32::MAX as u64) as u32 -} - -/// u64 to i64 (saturates). -pub fn u64_to_i64(n: u64) -> i64 { - n.min(i64::MAX as u64) as i64 -} - -/// u64 to i128. -pub fn u64_to_i128(n: u64) -> i128 { - n as i128 -} - -/// u64 to u128. -pub fn u64_to_u128(n: u64) -> u128 { - n as u128 -} - -/// u64 to usize (may truncate on 32-bit). -pub fn u64_to_usize(n: u64) -> usize { - n as usize -} - -/// u64 to f64. -pub fn u64_to_f64(n: u64) -> f64 { - n as f64 -} - -/// u128 to u64 (saturates). -pub fn u128_to_u64(n: u128) -> u64 { - n.min(u64::MAX as u128) as u64 -} - -/// u128 to i128 (saturates). -pub fn u128_to_i128(n: u128) -> i128 { - n.min(i128::MAX as u128) as i128 -} - -/// u128 to usize (saturates). -pub fn u128_to_usize(n: u128) -> usize { - n.min(usize::MAX as u128) as usize -} - -/// usize to i32 (saturates). -pub fn usize_to_i32(n: usize) -> i32 { - n.min(i32::MAX as usize) as i32 -} - -/// usize to i64. -pub fn usize_to_i64(n: usize) -> i64 { - n as i64 -} - -/// usize to u32 (saturates on 64-bit). -pub fn usize_to_u32(n: usize) -> u32 { - n.min(u32::MAX as usize) as u32 -} - -/// usize to u64. -pub fn usize_to_u64(n: usize) -> u64 { - n as u64 -} - -/// isize to i32 (saturates). -pub fn isize_to_i32(n: isize) -> i32 { - n.clamp(i32::MIN as isize, i32::MAX as isize) as i32 -} - -/// isize to i64. -pub fn isize_to_i64(n: isize) -> i64 { - n as i64 -} - -/// isize to usize. -pub fn isize_to_usize(n: isize) -> usize { - n.max(0) as usize -} - -/// f64 to f32 (may lose precision). -pub fn f64_to_f32(n: f64) -> f32 { - n as f32 -} - -/// f32 to f64. -pub fn f32_to_f64(n: f32) -> f64 { - n as f64 -} - -/// f64 to i64 (truncates). -pub fn f64_to_i64(n: f64) -> i64 { - n.clamp(i64::MIN as f64, i64::MAX as f64) as i64 -} - -/// f64 to i32 (truncates). -pub fn f64_to_i32(n: f64) -> i32 { - n.clamp(i32::MIN as f64, i32::MAX as f64) as i32 -} - -/// f64 to u64 (truncates). -pub fn f64_to_u64(n: f64) -> u64 { - n.clamp(0.0, u64::MAX as f64) as u64 -} - -/// f64 to u32 (truncates). -pub fn f64_to_u32(n: f64) -> u32 { - n.clamp(0.0, u32::MAX as f64) as u32 -} - -/// f32 to i32 (truncates). -pub fn f32_to_i32(n: f32) -> i32 { - n.clamp(i32::MIN as f32, i32::MAX as f32) as i32 -} - -/// Round f64 to n decimal places. -pub fn round_f64(n: f64, decimals: u32) -> f64 { - let factor = 10_f64.powi(decimals as i32); - (n * factor).round() / factor -} - -/// Round f32 to n decimal places. -pub fn round_f32(n: f32, decimals: u32) -> f32 { - let factor = 10_f32.powi(decimals as i32); - (n * factor).round() / factor -} - -/// Truncate f64 to n decimal places. -pub fn trunc_f64(n: f64, decimals: u32) -> f64 { - let factor = 10_f64.powi(decimals as i32); - (n * factor).trunc() / factor -} - -/// Ceil f64 to n decimal places. -pub fn ceil_f64(n: f64, decimals: u32) -> f64 { - let factor = 10_f64.powi(decimals as i32); - (n * factor).ceil() / factor -} - -/// Floor f64 to n decimal places. -pub fn floor_f64(n: f64, decimals: u32) -> f64 { - let factor = 10_f64.powi(decimals as i32); - (n * factor).floor() / factor -} - -/// Check if f64 is essentially zero. -pub fn is_zero(n: f64, epsilon: f64) -> bool { - n.abs() < epsilon -} - -/// Compare two f64 for approximate equality. -pub fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool { - (a - b).abs() < epsilon -} - -/// Check if f64 is NaN. -pub fn is_nan(n: f64) -> bool { - n.is_nan() -} - -/// Check if f64 is infinite. -pub fn is_infinite(n: f64) -> bool { - n.is_infinite() -} - -/// Check if f64 is finite. -pub fn is_finite(n: f64) -> bool { - n.is_finite() -} - -/// Convert NaN to 0. -pub fn nan_to_zero(n: f64) -> f64 { - if n.is_nan() { - 0.0 - } else { - n - } -} - -/// Convert NaN to default. -pub fn nan_to_default(n: f64, default: f64) -> f64 { - if n.is_nan() { - default - } else { - n - } -} diff --git a/src/shared/utils/data/convert/path.rs b/src/shared/utils/data/convert/path.rs deleted file mode 100644 index 19c3db2..0000000 --- a/src/shared/utils/data/convert/path.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; - -/// Convert &str to PathBuf. -pub fn str_to_path(s: &str) -> PathBuf { - PathBuf::from(s) -} - -/// Convert String to PathBuf. -pub fn string_to_path(s: String) -> PathBuf { - PathBuf::from(s) -} - -/// Convert PathBuf to String (lossy). -pub fn path_to_string(p: &Path) -> String { - p.to_string_lossy().to_string() -} - -/// Convert PathBuf to Option (None if not valid UTF-8). -pub fn path_to_string_strict(p: &Path) -> Option { - p.to_str().map(String::from) -} - -/// Convert &str to &Path. -pub fn str_to_path_ref(s: &str) -> &Path { - Path::new(s) -} - -/// Convert OsStr to String (lossy). -pub fn os_str_to_string(s: &OsStr) -> String { - s.to_string_lossy().to_string() -} - -/// Convert OsString to String (lossy). -pub fn os_string_to_string(s: OsString) -> String { - s.to_string_lossy().to_string() -} - -/// Convert String to OsString. -pub fn string_to_os_string(s: String) -> OsString { - OsString::from(s) -} - -/// Convert &str to &OsStr. -pub fn str_to_os_str(s: &str) -> &OsStr { - OsStr::new(s) -} - -/// Get file extension as String. -pub fn path_extension(p: &Path) -> Option { - p.extension().and_then(|e| e.to_str()).map(String::from) -} - -/// Get file name as String. -pub fn path_filename(p: &Path) -> Option { - p.file_name().and_then(|n| n.to_str()).map(String::from) -} - -/// Get parent directory as PathBuf. -pub fn path_parent(p: &Path) -> Option { - p.parent().map(PathBuf::from) -} diff --git a/src/shared/utils/data/convert/pointers.rs b/src/shared/utils/data/convert/pointers.rs deleted file mode 100644 index 8d309e5..0000000 --- a/src/shared/utils/data/convert/pointers.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::borrow::Cow; -use std::rc::Rc; -use std::sync::Arc; - -/// Wrap value in Box. -pub fn to_box(value: T) -> Box { - Box::new(value) -} - -/// Wrap value in Rc. -pub fn to_rc(value: T) -> Rc { - Rc::new(value) -} - -/// Wrap value in Arc. -pub fn to_arc(value: T) -> Arc { - Arc::new(value) -} - -/// Convert Box to T (unbox). -pub fn unbox(boxed: Box) -> T { - *boxed -} - -/// Clone from Rc. -pub fn rc_to_owned(rc: &Rc) -> T { - (**rc).clone() -} - -/// Clone from Arc. -pub fn arc_to_owned(arc: &Arc) -> T { - (**arc).clone() -} - -/// Convert &str to Cow. -pub fn str_to_cow(s: &str) -> Cow<'_, str> { - Cow::Borrowed(s) -} - -/// Convert String to Cow. -pub fn string_to_cow(s: String) -> Cow<'static, str> { - Cow::Owned(s) -} - -/// Convert Cow to String. -pub fn cow_to_string(cow: Cow<'_, str>) -> String { - cow.into_owned() -} - -/// Convert &[T] to Cow<[T]>. -pub fn slice_to_cow(s: &[T]) -> Cow<'_, [T]> { - Cow::Borrowed(s) -} - -/// Convert Vec to Cow<[T]>. -pub fn vec_to_cow(v: Vec) -> Cow<'static, [T]> { - Cow::Owned(v) -} diff --git a/src/shared/utils/data/convert/result.rs b/src/shared/utils/data/convert/result.rs deleted file mode 100644 index a736234..0000000 --- a/src/shared/utils/data/convert/result.rs +++ /dev/null @@ -1,39 +0,0 @@ -/// Convert Option to Result. -pub fn option_to_result(opt: Option, err: E) -> Result { - opt.ok_or(err) -} - -/// Convert Option to Result. -pub fn option_to_result_str(opt: Option, msg: &str) -> Result { - opt.ok_or_else(|| msg.to_string()) -} - -/// Convert Result to Option. -pub fn result_to_option(res: Result) -> Option { - res.ok() -} - -/// Convert Result to Option. -pub fn result_to_err(res: Result) -> Option { - res.err() -} - -/// Flatten nested Option. -pub fn flatten_option(opt: Option>) -> Option { - opt.flatten() -} - -/// Flatten nested Result. -pub fn flatten_result(res: Result, E>) -> Result { - res.and_then(|r| r) -} - -/// Transpose Option> to Result, E>. -pub fn transpose_option_result(opt: Option>) -> Result, E> { - opt.transpose() -} - -/// Transpose Result, E> to Option>. -pub fn transpose_result_option(res: Result, E>) -> Option> { - res.transpose() -} diff --git a/src/shared/utils/data/convert/string.rs b/src/shared/utils/data/convert/string.rs deleted file mode 100644 index e01af9d..0000000 --- a/src/shared/utils/data/convert/string.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::fmt::Display; -use std::str::FromStr; - -/// Parse with default value. -pub fn parse_or(s: &str, default: T) -> T { - s.parse().unwrap_or(default) -} - -/// Try parse with error context. -pub fn try_parse(s: &str, name: &str) -> Result -where - T::Err: Display, -{ - s.parse() - .map_err(|e| format!("Failed to parse {}: {}", name, e)) -} - -/// Parse i8 with default. -pub fn parse_i8(s: &str, default: i8) -> i8 { - s.parse().unwrap_or(default) -} - -/// Parse i16 with default. -pub fn parse_i16(s: &str, default: i16) -> i16 { - s.parse().unwrap_or(default) -} - -/// Parse i32 with default. -pub fn parse_i32(s: &str, default: i32) -> i32 { - s.parse().unwrap_or(default) -} - -/// Parse i64 with default. -pub fn parse_i64(s: &str, default: i64) -> i64 { - s.parse().unwrap_or(default) -} - -/// Parse i128 with default. -pub fn parse_i128(s: &str, default: i128) -> i128 { - s.parse().unwrap_or(default) -} - -/// Parse u8 with default. -pub fn parse_u8(s: &str, default: u8) -> u8 { - s.parse().unwrap_or(default) -} - -/// Parse u16 with default. -pub fn parse_u16(s: &str, default: u16) -> u16 { - s.parse().unwrap_or(default) -} - -/// Parse u32 with default. -pub fn parse_u32(s: &str, default: u32) -> u32 { - s.parse().unwrap_or(default) -} - -/// Parse u64 with default. -pub fn parse_u64(s: &str, default: u64) -> u64 { - s.parse().unwrap_or(default) -} - -/// Parse u128 with default. -pub fn parse_u128(s: &str, default: u128) -> u128 { - s.parse().unwrap_or(default) -} - -/// Parse f32 with default. -pub fn parse_f32(s: &str, default: f32) -> f32 { - s.parse().unwrap_or(default) -} - -/// Parse f64 with default. -pub fn parse_f64(s: &str, default: f64) -> f64 { - s.parse().unwrap_or(default) -} - -/// Parse usize with default. -pub fn parse_usize(s: &str, default: usize) -> usize { - s.parse().unwrap_or(default) -} - -/// Parse isize with default. -pub fn parse_isize(s: &str, default: isize) -> isize { - s.parse().unwrap_or(default) -} - -/// Convert string to Option (None for empty). -pub fn empty_to_none(s: &str) -> Option { - if s.is_empty() { - None - } else { - Some(s.to_string()) - } -} - -/// Convert None to empty string. -pub fn none_to_empty(opt: Option) -> String { - opt.unwrap_or_default() -} - -/// Convert Option<&str> to Option. -pub fn str_to_string(opt: Option<&str>) -> Option { - opt.map(|s| s.to_string()) -} - -/// Convert String to Option (None if empty). -pub fn string_to_option(s: String) -> Option { - if s.is_empty() { - None - } else { - Some(s) - } -} - -/// Trim and convert to Option (None if whitespace only). -pub fn trim_to_option(s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -/// Convert &str to String. -pub fn str_to_owned(s: &str) -> String { - s.to_string() -} - -/// Convert String to &str (returns empty if none). -pub fn string_to_str(s: &Option) -> &str { - s.as_deref().unwrap_or("") -} diff --git a/src/shared/utils/data/convert/time.rs b/src/shared/utils/data/convert/time.rs deleted file mode 100644 index 55f5e17..0000000 --- a/src/shared/utils/data/convert/time.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -/// Convert seconds to human readable duration. -pub fn seconds_to_human(secs: u64) -> String { - if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m {}s", secs / 60, secs % 60) - } else if secs < 86400 { - format!("{}h {}m", secs / 3600, (secs % 3600) / 60) - } else if secs < 604800 { - format!("{}d {}h", secs / 86400, (secs % 86400) / 3600) - } else if secs < 2592000 { - format!("{}w {}d", secs / 604800, (secs % 604800) / 86400) - } else if secs < 31536000 { - format!("{}mo {}d", secs / 2592000, (secs % 2592000) / 86400) - } else { - format!("{}y {}mo", secs / 31536000, (secs % 31536000) / 2592000) - } -} - -/// Convert seconds to compact human readable. -pub fn seconds_to_compact(secs: u64) -> String { - if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m", secs / 60) - } else if secs < 86400 { - format!("{}h", secs / 3600) - } else if secs < 604800 { - format!("{}d", secs / 86400) - } else if secs < 2592000 { - format!("{}w", secs / 604800) - } else if secs < 31536000 { - format!("{}mo", secs / 2592000) - } else { - format!("{}y", secs / 31536000) - } -} - -/// Convert milliseconds to human readable. -pub fn ms_to_human(ms: u64) -> String { - if ms < 1000 { - format!("{}ms", ms) - } else { - seconds_to_human(ms / 1000) - } -} - -/// Convert microseconds to human readable. -pub fn us_to_human(us: u64) -> String { - if us < 1000 { - format!("{}μs", us) - } else if us < 1_000_000 { - format!("{:.2}ms", us as f64 / 1000.0) - } else { - seconds_to_human(us / 1_000_000) - } -} - -/// Convert nanoseconds to human readable. -pub fn ns_to_human(ns: u64) -> String { - if ns < 1_000 { - format!("{}ns", ns) - } else if ns < 1_000_000 { - format!("{:.2}μs", ns as f64 / 1_000.0) - } else if ns < 1_000_000_000 { - format!("{:.2}ms", ns as f64 / 1_000_000.0) - } else { - format!("{:.2}s", ns as f64 / 1_000_000_000.0) - } -} - -/// Convert seconds to Duration. -pub fn secs_to_duration(secs: u64) -> Duration { - Duration::from_secs(secs) -} - -/// Convert milliseconds to Duration. -pub fn ms_to_duration(ms: u64) -> Duration { - Duration::from_millis(ms) -} - -/// Convert microseconds to Duration. -pub fn us_to_duration(us: u64) -> Duration { - Duration::from_micros(us) -} - -/// Convert nanoseconds to Duration. -pub fn ns_to_duration(ns: u64) -> Duration { - Duration::from_nanos(ns) -} - -/// Convert Duration to seconds. -pub fn duration_to_secs(d: Duration) -> u64 { - d.as_secs() -} - -/// Convert Duration to milliseconds. -pub fn duration_to_ms(d: Duration) -> u128 { - d.as_millis() -} - -/// Convert Duration to microseconds. -pub fn duration_to_us(d: Duration) -> u128 { - d.as_micros() -} - -/// Convert Duration to nanoseconds. -pub fn duration_to_ns(d: Duration) -> u128 { - d.as_nanos() -} - -/// Convert Duration to f64 seconds. -pub fn duration_to_secs_f64(d: Duration) -> f64 { - d.as_secs_f64() -} - -/// Convert f64 seconds to Duration. -pub fn secs_f64_to_duration(secs: f64) -> Duration { - Duration::from_secs_f64(secs.max(0.0)) -} - -/// Get SystemTime as Unix timestamp (seconds). -pub fn system_time_to_unix(t: SystemTime) -> u64 { - t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() -} - -/// Get SystemTime as Unix timestamp (milliseconds). -pub fn system_time_to_unix_ms(t: SystemTime) -> u128 { - t.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() -} - -/// Convert Unix timestamp to SystemTime. -pub fn unix_to_system_time(secs: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(secs) -} - -/// Convert Unix milliseconds to SystemTime. -pub fn unix_ms_to_system_time(ms: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_millis(ms) -} diff --git a/src/shared/utils/data/datetime.rs b/src/shared/utils/data/datetime.rs deleted file mode 100644 index 53144ce..0000000 --- a/src/shared/utils/data/datetime.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Date and time utilities. - -use chrono::{DateTime, Duration, NaiveDateTime, Utc}; - -/// Get current UTC timestamp. -pub fn now() -> DateTime { - Utc::now() -} - -/// Get current Unix timestamp (seconds). -pub fn timestamp() -> i64 { - Utc::now().timestamp() -} - -/// Get current Unix timestamp (milliseconds). -pub fn timestamp_millis() -> i64 { - Utc::now().timestamp_millis() -} - -/// Format datetime as ISO 8601 string. -pub fn to_iso(dt: DateTime) -> String { - dt.to_rfc3339() -} - -/// Format datetime as human-readable string. -pub fn to_human(dt: DateTime) -> String { - dt.format("%Y-%m-%d %H:%M:%S").to_string() -} - -/// Format datetime as date only. -pub fn to_date(dt: DateTime) -> String { - dt.format("%Y-%m-%d").to_string() -} - -/// Parse ISO 8601 string to DateTime. -pub fn parse_iso(s: &str) -> Option> { - DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) -} - -/// Add duration to a datetime. -pub fn add_days(dt: DateTime, days: i64) -> DateTime { - dt + Duration::days(days) -} - -/// Add hours to a datetime. -pub fn add_hours(dt: DateTime, hours: i64) -> DateTime { - dt + Duration::hours(hours) -} - -/// Add minutes to a datetime. -pub fn add_minutes(dt: DateTime, minutes: i64) -> DateTime { - dt + Duration::minutes(minutes) -} - -/// Check if datetime is in the past. -pub fn is_past(dt: DateTime) -> bool { - dt < Utc::now() -} - -/// Check if datetime is in the future. -pub fn is_future(dt: DateTime) -> bool { - dt > Utc::now() -} - -/// Get relative time string (e.g., "2 hours ago"). -pub fn relative(dt: DateTime) -> String { - let duration = Utc::now().signed_duration_since(dt); - - if duration.num_seconds() < 60 { - "just now".to_string() - } else if duration.num_minutes() < 60 { - format!("{} minutes ago", duration.num_minutes()) - } else if duration.num_hours() < 24 { - format!("{} hours ago", duration.num_hours()) - } else if duration.num_days() < 30 { - format!("{} days ago", duration.num_days()) - } else if duration.num_days() < 365 { - format!("{} months ago", duration.num_days() / 30) - } else { - format!("{} years ago", duration.num_days() / 365) - } -} - -/// Calculate age in years from birthdate. -pub fn age_years(birthdate: NaiveDateTime) -> i32 { - let today = Utc::now().naive_utc(); - let years = today.date().years_since(birthdate.date()); - years.map(|y| y as i32).unwrap_or(0) -} - -/// Get start of day (00:00:00). -pub fn start_of_day(dt: DateTime) -> DateTime { - dt.date_naive() - .and_hms_opt(0, 0, 0) - .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) - .unwrap_or(dt) -} - -/// Get end of day (23:59:59). -pub fn end_of_day(dt: DateTime) -> DateTime { - dt.date_naive() - .and_hms_opt(23, 59, 59) - .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) - .unwrap_or(dt) -} diff --git a/src/shared/utils/data/json.rs b/src/shared/utils/data/json.rs deleted file mode 100644 index f8f1f1e..0000000 --- a/src/shared/utils/data/json.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! JSON utilities. - -use serde::{de::DeserializeOwned, Serialize}; -use serde_json::{Map, Value}; - -/// Parse JSON string to type. -pub fn parse(json: &str) -> Result { - serde_json::from_str(json) -} - -/// Serialize type to JSON string. -pub fn stringify(value: &T) -> Result { - serde_json::to_string(value) -} - -/// Serialize type to pretty JSON string. -pub fn stringify_pretty(value: &T) -> Result { - serde_json::to_string_pretty(value) -} - -/// Merge two JSON objects (second overwrites first). -pub fn merge(base: Value, overlay: Value) -> Value { - match (base, overlay) { - (Value::Object(mut base_map), Value::Object(overlay_map)) => { - for (key, value) in overlay_map { - base_map.insert(key, value); - } - Value::Object(base_map) - } - (_, overlay) => overlay, - } -} - -/// Deep merge two JSON objects. -pub fn deep_merge(base: Value, overlay: Value) -> Value { - match (base, overlay) { - (Value::Object(mut base_map), Value::Object(overlay_map)) => { - for (key, overlay_value) in overlay_map { - let base_value = base_map.remove(&key).unwrap_or(Value::Null); - base_map.insert(key, deep_merge(base_value, overlay_value)); - } - Value::Object(base_map) - } - (_, overlay) => overlay, - } -} - -/// Extract a value at a path (e.g., "data.items.0.name"). -pub fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { - let mut current = value; - for key in path.split('.') { - current = match current { - Value::Object(map) => map.get(key)?, - Value::Array(arr) => { - let idx: usize = key.parse().ok()?; - arr.get(idx)? - } - _ => return None, - }; - } - Some(current) -} - -/// Extract string at path. -pub fn get_str<'a>(value: &'a Value, path: &str) -> Option<&'a str> { - get_path(value, path).and_then(|v| v.as_str()) -} - -/// Extract i64 at path. -pub fn get_i64(value: &Value, path: &str) -> Option { - get_path(value, path).and_then(|v| v.as_i64()) -} - -/// Extract bool at path. -pub fn get_bool(value: &Value, path: &str) -> Option { - get_path(value, path).and_then(|v| v.as_bool()) -} - -/// Extract array at path. -pub fn get_array<'a>(value: &'a Value, path: &str) -> Option<&'a Vec> { - get_path(value, path).and_then(|v| v.as_array()) -} - -/// Create a JSON object with key-value pairs. -#[macro_export] -macro_rules! json_object { - ($($key:expr => $value:expr),* $(,)?) => {{ - let mut map = serde_json::Map::new(); - $( - map.insert($key.to_string(), serde_json::json!($value)); - )* - serde_json::Value::Object(map) - }}; -} - -/// Check if value is empty (null, empty string, empty array, empty object). -pub fn is_empty(value: &Value) -> bool { - match value { - Value::Null => true, - Value::String(s) => s.is_empty(), - Value::Array(arr) => arr.is_empty(), - Value::Object(obj) => obj.is_empty(), - _ => false, - } -} - -/// Remove null values from object. -pub fn remove_nulls(value: Value) -> Value { - match value { - Value::Object(map) => { - let filtered: Map = map - .into_iter() - .filter(|(_, v)| !v.is_null()) - .map(|(k, v)| (k, remove_nulls(v))) - .collect(); - Value::Object(filtered) - } - Value::Array(arr) => Value::Array(arr.into_iter().map(remove_nulls).collect()), - other => other, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_get_path() { - let value = json!({ - "data": { - "items": [{"name": "test"}] - } - }); - assert_eq!(get_str(&value, "data.items.0.name"), Some("test")); - } -} diff --git a/src/shared/utils/data/mod.rs b/src/shared/utils/data/mod.rs deleted file mode 100644 index 59dda88..0000000 --- a/src/shared/utils/data/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod collections; -pub mod convert; -pub mod datetime; -pub mod json; -pub mod numbers; -pub mod string; -pub mod text; diff --git a/src/shared/utils/data/numbers.rs b/src/shared/utils/data/numbers.rs deleted file mode 100644 index 690d8b5..0000000 --- a/src/shared/utils/data/numbers.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Number utilities. - -/// Format number with thousand separators. -pub fn format_number(n: i64) -> String { - let s = n.abs().to_string(); - let chars: Vec = s.chars().rev().collect(); - let mut result = String::new(); - - for (i, c) in chars.iter().enumerate() { - if i > 0 && i % 3 == 0 { - result.push(','); - } - result.push(*c); - } - - if n < 0 { - result.push('-'); - } - - result.chars().rev().collect() -} - -/// Format as percentage. -pub fn format_percent(value: f64, decimals: usize) -> String { - format!("{:.1$}%", value * 100.0, decimals) -} - -/// Format as currency. -pub fn format_currency(value: f64, symbol: &str) -> String { - format!("{}{:.2}", symbol, value) -} - -/// Format bytes to human readable. -pub fn format_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - const TB: u64 = GB * 1024; - - if bytes >= TB { - format!("{:.2} TB", bytes as f64 / TB as f64) - } else if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} B", bytes) - } -} - -/// Clamp value to range. -pub fn clamp(value: T, min: T, max: T) -> T { - if value < min { - min - } else if value > max { - max - } else { - value - } -} - -/// Linear interpolation. -pub fn lerp(a: f64, b: f64, t: f64) -> f64 { - a + (b - a) * t -} - -/// Map value from one range to another. -pub fn map_range(value: f64, from_min: f64, from_max: f64, to_min: f64, to_max: f64) -> f64 { - let from_range = from_max - from_min; - let to_range = to_max - to_min; - ((value - from_min) / from_range) * to_range + to_min -} - -/// Round to n decimal places. -pub fn round_to(value: f64, decimals: u32) -> f64 { - let factor = 10_f64.powi(decimals as i32); - (value * factor).round() / factor -} - -/// Check if number is even. -pub fn is_even(n: i64) -> bool { - n % 2 == 0 -} - -/// Check if number is odd. -pub fn is_odd(n: i64) -> bool { - n % 2 != 0 -} - -/// Check if number is positive. -pub fn is_positive(n: T) -> bool { - n > T::default() -} - -/// Check if number is negative. -pub fn is_negative(n: T) -> bool { - n < T::default() -} - -/// Safe division (returns 0 if divisor is 0). -pub fn safe_div(a: f64, b: f64) -> f64 { - if b == 0.0 { - 0.0 - } else { - a / b - } -} - -/// Calculate percentage. -pub fn percentage(part: f64, total: f64) -> f64 { - safe_div(part, total) * 100.0 -} - -/// Parse string to i64 with default. -pub fn parse_i64(s: &str, default: i64) -> i64 { - s.parse().unwrap_or(default) -} - -/// Parse string to f64 with default. -pub fn parse_f64(s: &str, default: f64) -> f64 { - s.parse().unwrap_or(default) -} - -/// Generate range of numbers. -pub fn range(start: i64, end: i64) -> Vec { - (start..end).collect() -} - -/// Generate range with step. -pub fn range_step(start: i64, end: i64, step: i64) -> Vec { - (start..end).step_by(step as usize).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_number() { - assert_eq!(format_number(1234567), "1,234,567"); - assert_eq!(format_number(-1234), "-1,234"); - } - - #[test] - fn test_format_bytes() { - assert_eq!(format_bytes(1024), "1.00 KB"); - assert_eq!(format_bytes(1048576), "1.00 MB"); - } -} diff --git a/src/shared/utils/data/string.rs b/src/shared/utils/data/string.rs deleted file mode 100644 index f958559..0000000 --- a/src/shared/utils/data/string.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! String utilities. - -use once_cell::sync::Lazy; -use regex::Regex; - -pub fn slugify(s: &str) -> String { - static RE_SPECIAL: Lazy> = - Lazy::new(|| Regex::new(r"[^a-z0-9\s-]")); - static RE_SPACES: Lazy> = Lazy::new(|| Regex::new(r"[\s_]+")); - static RE_HYPHENS: Lazy> = Lazy::new(|| Regex::new(r"-+")); - - let s = s.to_lowercase(); - let s = RE_SPECIAL - .as_ref() - .map(|r| r.replace_all(&s, "").to_string()) - .unwrap_or(s); - let s = RE_SPACES - .as_ref() - .map(|r| r.replace_all(&s, "-").to_string()) - .unwrap_or(s); - let s = RE_HYPHENS - .as_ref() - .map(|r| r.replace_all(&s, "-").to_string()) - .unwrap_or(s); - s.trim_matches('-').to_string() -} - -pub fn truncate(s: &str, max_len: usize) -> String { - if s.len() <= max_len { - s.to_string() - } else if max_len <= 3 { - s.chars().take(max_len).collect() - } else { - format!("{}...", s.chars().take(max_len - 3).collect::()) - } -} - -pub fn initials(name: &str) -> String { - name.split_whitespace() - .filter_map(|word| word.chars().next()) - .take(2) - .collect::() - .to_uppercase() -} - -pub fn mask_email(email: &str) -> String { - if let Some(at_pos) = email.find('@') { - let (local, domain) = email.split_at(at_pos); - if local.len() <= 2 { - format!("{}***{}", local, domain) - } else { - let visible = &local[..2]; - format!("{}***{}", visible, domain) - } - } else { - "***".to_string() - } -} - -pub fn random_string(len: usize) -> String { - use rand::Rng; - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let mut rng = rand::thread_rng(); - (0..len) - .map(|_| { - let idx = rng.gen_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} - -pub fn random_code(len: usize) -> String { - use rand::Rng; - const CHARSET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let mut rng = rand::thread_rng(); - (0..len) - .map(|_| { - let idx = rng.gen_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} - -pub fn is_valid_email(email: &str) -> bool { - static RE: Lazy> = - Lazy::new(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")); - RE.as_ref().map(|r| r.is_match(email)).unwrap_or(false) -} - -pub fn title_case(s: &str) -> String { - s.split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => { - first.to_uppercase().collect::() - + chars.as_str().to_lowercase().as_str() - } - } - }) - .collect::>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_slugify() { - assert_eq!(slugify("Hello World!"), "hello-world"); - assert_eq!(slugify(" Multiple Spaces "), "multiple-spaces"); - } - - #[test] - fn test_truncate() { - assert_eq!(truncate("Hello", 10), "Hello"); - assert_eq!(truncate("Hello World", 8), "Hello..."); - } - - #[test] - fn test_initials() { - assert_eq!(initials("John Doe"), "JD"); - assert_eq!(initials("Alice"), "A"); - } -} diff --git a/src/shared/utils/data/text.rs b/src/shared/utils/data/text.rs deleted file mode 100644 index 8f28f9b..0000000 --- a/src/shared/utils/data/text.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Text processing utilities. - -use once_cell::sync::Lazy; -use regex::Regex; - -pub fn normalize_whitespace(s: &str) -> String { - static WHITESPACE_REGEX: Lazy> = Lazy::new(|| Regex::new(r"\s+")); - WHITESPACE_REGEX - .as_ref() - .map(|r| r.replace_all(s, " ").trim().to_string()) - .unwrap_or_else(|_| s.to_string()) -} - -pub fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().chain(chars).collect(), - } -} - -pub fn to_camel_case(s: &str) -> String { - let words: Vec<&str> = s - .split(|c: char| c == '_' || c == '-' || c.is_whitespace()) - .collect(); - let mut result = String::new(); - for (i, word) in words.iter().enumerate() { - if word.is_empty() { - continue; - } - if i == 0 { - result.push_str(&word.to_lowercase()); - } else { - result.push_str(&capitalize(&word.to_lowercase())); - } - } - result -} - -pub fn to_snake_case(s: &str) -> String { - static CAMEL_REGEX: Lazy> = - Lazy::new(|| Regex::new(r"([a-z])([A-Z])")); - let s = CAMEL_REGEX - .as_ref() - .map(|r| r.replace_all(s, "${1}_${2}").to_string()) - .unwrap_or_else(|_| s.to_string()); - s.replace('-', "_").to_lowercase() -} - -pub fn to_kebab_case(s: &str) -> String { - to_snake_case(s).replace('_', "-") -} - -pub fn to_pascal_case(s: &str) -> String { - s.split(|c: char| c == '_' || c == '-' || c.is_whitespace()) - .filter(|w| !w.is_empty()) - .map(|w| capitalize(&w.to_lowercase())) - .collect() -} - -pub fn to_constant_case(s: &str) -> String { - to_snake_case(s).to_uppercase() -} - -pub fn extract_words(s: &str) -> Vec { - static WORD_REGEX: Lazy> = Lazy::new(|| Regex::new(r"\b\w+\b")); - WORD_REGEX - .as_ref() - .map(|r| r.find_iter(s).map(|m| m.as_str().to_string()).collect()) - .unwrap_or_default() -} - -pub fn word_count(s: &str) -> usize { - extract_words(s).len() -} - -pub fn truncate_words(s: &str, max_words: usize, suffix: &str) -> String { - let words: Vec<&str> = s.split_whitespace().collect(); - if words.len() <= max_words { - s.to_string() - } else { - format!("{}{}", words[..max_words].join(" "), suffix) - } -} - -pub fn wrap_text(s: &str, width: usize) -> String { - let mut result = String::new(); - let mut line_len = 0; - - for word in s.split_whitespace() { - if line_len + word.len() + 1 > width && line_len > 0 { - result.push('\n'); - line_len = 0; - } else if line_len > 0 { - result.push(' '); - line_len += 1; - } - result.push_str(word); - line_len += word.len(); - } - - result -} - -pub fn highlight(text: &str, terms: &[&str], before: &str, after: &str) -> String { - let mut result = text.to_string(); - for term in terms { - let pattern = regex::escape(term); - match Regex::new(&format!("(?i)({})", pattern)) { - Ok(re) => { - result = re - .replace_all(&result, format!("{}$1{}", before, after)) - .to_string(); - } - Err(_) => continue, - } - } - result -} - -/// Remove diacritics/accents. -pub fn remove_accents(s: &str) -> String { - s.chars() - .map(|c| match c { - 'á' | 'à' | 'â' | 'ä' | 'ã' => 'a', - 'é' | 'è' | 'ê' | 'ë' => 'e', - 'í' | 'ì' | 'î' | 'ï' => 'i', - 'ó' | 'ò' | 'ô' | 'ö' | 'õ' => 'o', - 'ú' | 'ù' | 'û' | 'ü' => 'u', - 'ñ' => 'n', - 'ç' => 'c', - _ => c, - }) - .collect() -} - -/// Generate Lorem Ipsum text. -pub fn lorem_ipsum(sentences: usize) -> String { - const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ - Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \ - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. \ - Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. \ - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."; - - LOREM - .split(". ") - .take(sentences) - .collect::>() - .join(". ") - + "." -} - -/// Reverse a string. -pub fn reverse(s: &str) -> String { - s.chars().rev().collect() -} - -/// Check if palindrome. -pub fn is_palindrome(s: &str) -> bool { - let clean: String = s.chars().filter(|c| c.is_alphanumeric()).collect(); - let lower = clean.to_lowercase(); - lower == lower.chars().rev().collect::() -} diff --git a/src/shared/utils/dev/async_utils.rs b/src/shared/utils/dev/async_utils.rs deleted file mode 100644 index 28c0f6c..0000000 --- a/src/shared/utils/dev/async_utils.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Async utilities and helpers. - -use std::future::Future; -use std::time::Duration; -use tokio::time::timeout; - -/// Run with timeout. -pub async fn with_timeout( - duration: Duration, - future: F, -) -> Result -where - F: Future, -{ - timeout(duration, future).await -} - -/// Run with timeout in seconds. -pub async fn timeout_secs(secs: u64, future: F) -> Result -where - F: Future, -{ - timeout(Duration::from_secs(secs), future).await -} - -/// Run with timeout in milliseconds. -pub async fn timeout_ms(ms: u64, future: F) -> Result -where - F: Future, -{ - timeout(Duration::from_millis(ms), future).await -} - -/// Sleep for duration. -pub async fn sleep(duration: Duration) { - tokio::time::sleep(duration).await; -} - -/// Sleep for seconds. -pub async fn sleep_secs(secs: u64) { - tokio::time::sleep(Duration::from_secs(secs)).await; -} - -/// Sleep for milliseconds. -pub async fn sleep_ms(ms: u64) { - tokio::time::sleep(Duration::from_millis(ms)).await; -} - -/// Run multiple futures concurrently and collect results. -pub async fn join_all(futures: Vec) -> Vec -where - F: Future, -{ - futures::future::join_all(futures).await -} - -/// Run futures with concurrency limit. -pub async fn join_all_limited( - items: Vec, - concurrency: usize, - f: F, -) -> Vec -where - F: Fn(T) -> Fut, - Fut: Future, -{ - use futures::stream::{self, StreamExt}; - - stream::iter(items) - .map(f) - .buffer_unordered(concurrency) - .collect() - .await -} - -/// Race multiple futures, returning first to complete. -pub async fn race(f1: F1, f2: F2) -> T -where - F1: Future, - F2: Future, -{ - tokio::select! { - result = f1 => result, - result = f2 => result, - } -} - -/// Retry a future with delay between attempts. -pub async fn simple_retry(attempts: usize, delay: Duration, mut f: F) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut last_error = None; - for i in 0..attempts { - match f().await { - Ok(result) => return Ok(result), - Err(e) => { - last_error = Some(e); - if i < attempts - 1 { - tokio::time::sleep(delay).await; - } - } - } - } - Err(last_error.unwrap()) -} - -/// Run in blocking thread pool. -pub async fn spawn_blocking(f: F) -> Result -where - F: FnOnce() -> R + Send + 'static, - R: Send + 'static, -{ - tokio::task::spawn_blocking(f).await -} - -/// Run as a background task (fire and forget). -pub fn spawn(future: F) -where - F: Future + Send + 'static, -{ - tokio::spawn(future); -} - -/// Debounce - only run after delay with no new calls. -pub struct Debouncer { - delay: Duration, -} - -impl Debouncer { - pub fn new(delay: Duration) -> Self { - Self { delay } - } - - pub async fn debounce(&self, f: F) - where - F: FnOnce() -> Fut, - Fut: Future, - { - tokio::time::sleep(self.delay).await; - f().await; - } -} diff --git a/src/shared/utils/dev/logging.rs b/src/shared/utils/dev/logging.rs deleted file mode 100644 index d34e6f4..0000000 --- a/src/shared/utils/dev/logging.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Logging utilities and helpers. - -use std::time::Instant; -use tracing::{debug, error, info, warn}; - -/// Log entry with timing. -pub struct TimedOperation { - name: String, - start: Instant, -} - -impl TimedOperation { - /// Start a timed operation. - pub fn start(name: impl Into) -> Self { - let name = name.into(); - info!("Starting: {}", name); - Self { - name, - start: Instant::now(), - } - } - - /// Complete the operation and log duration. - pub fn complete(self) { - let duration = self.start.elapsed(); - info!("Completed: {} in {:?}", self.name, duration); - } - - /// Complete with custom message. - pub fn complete_with(self, message: &str) { - let duration = self.start.elapsed(); - info!("{}: {} in {:?}", self.name, message, duration); - } - - /// Fail the operation. - pub fn fail(self, error: &str) { - let duration = self.start.elapsed(); - error!("Failed: {} - {} after {:?}", self.name, error, duration); - } - - /// Get elapsed time. - pub fn elapsed(&self) -> std::time::Duration { - self.start.elapsed() - } -} - -/// Log request info. -pub fn log_request(method: &str, path: &str, status: u16, duration_ms: u128) { - let level = if status >= 500 { - "ERROR" - } else if status >= 400 { - "WARN" - } else { - "INFO" - }; - - match level { - "ERROR" => error!("[{}] {} {} - {}ms", status, method, path, duration_ms), - "WARN" => warn!("[{}] {} {} - {}ms", status, method, path, duration_ms), - _ => info!("[{}] {} {} - {}ms", status, method, path, duration_ms), - } -} - -/// Log with context. -#[macro_export] -macro_rules! log_ctx { - ($level:ident, $ctx:expr, $($arg:tt)*) => { - tracing::$level!(context = $ctx, $($arg)*); - }; -} - -/// Log and return error. -pub fn log_error(context: &str, error: E) -> E { - error!("[{}] Error: {}", context, error); - error -} - -/// Log and return error, mapped. -pub fn log_and_map(context: &str, error: E, mapper: F) -> R -where - E: std::fmt::Display, - F: FnOnce(E) -> R, -{ - error!("[{}] Error: {}", context, error); - mapper(error) -} - -/// Create a span for tracing. -#[macro_export] -macro_rules! span { - ($name:expr) => { - tracing::info_span!($name) - }; - ($name:expr, $($field:tt)*) => { - tracing::info_span!($name, $($field)*) - }; -} - -/// Performance logger for expensive operations. -pub struct PerfLogger { - name: String, - start: Instant, - threshold_ms: u128, -} - -impl PerfLogger { - pub fn new(name: impl Into, threshold_ms: u128) -> Self { - Self { - name: name.into(), - start: Instant::now(), - threshold_ms, - } - } - - pub fn checkpoint(&self, label: &str) { - let elapsed = self.start.elapsed().as_millis(); - debug!("[PERF] {} - {}: {}ms", self.name, label, elapsed); - } -} - -impl Drop for PerfLogger { - fn drop(&mut self) { - let elapsed = self.start.elapsed().as_millis(); - if elapsed > self.threshold_ms { - warn!( - "[PERF] {} slow operation: {}ms (threshold: {}ms)", - self.name, elapsed, self.threshold_ms - ); - } - } -} - -/// Debug print for development. -#[cfg(debug_assertions)] -#[macro_export] -macro_rules! debug_print { - ($($arg:tt)*) => { - eprintln!("[DEBUG] {}", format!($($arg)*)); - }; -} - -#[cfg(not(debug_assertions))] -#[macro_export] -macro_rules! debug_print { - ($($arg:tt)*) => {}; -} diff --git a/src/shared/utils/dev/mod.rs b/src/shared/utils/dev/mod.rs deleted file mode 100644 index 1705331..0000000 --- a/src/shared/utils/dev/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod async_utils; -pub mod logging; -pub mod performance; -pub mod result_ext; -pub mod serde_helpers; -pub mod testing; diff --git a/src/shared/utils/dev/performance.rs b/src/shared/utils/dev/performance.rs deleted file mode 100644 index 25beb0f..0000000 --- a/src/shared/utils/dev/performance.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Performance optimization utilities and best practices guide. - -/// Performance optimization tips for Rust application -/// -/// 1. **Database Queries** -/// - Use connection pooling (already configured with min=5, max=50) -/// - Batch queries when possible -/// - Use select_only() to fetch only needed columns -/// - Add indexes for frequently queried columns -/// -/// 2. **Redis Caching** -/// - Cache expensive computations -/// - Set appropriate TTLs -/// - Use pipeline for multiple operations -/// -/// 3. **Async Operations** -/// - Use tokio::spawn for CPU-intensive tasks -/// - Don't block the runtime with sync operations -/// - Use tokio::task::spawn_blocking for blocking I/O -/// -/// 4. **Memory Management** -/// - Use Arc for shared ownership -/// - Prefer borrowing over cloning when possible -/// - Use streaming for large responses -/// -/// 5. **Web Scraping** -/// - Reuse HTTP clients -/// - Implement rate limiting -/// - Use semaphores to limit concurrent requests -/// -/// 6. **Error Handling** -/// - Use Result types for recoverable errors -/// - Log errors appropriately -/// - Return structured error responses -use std::future::Future; -use std::time::Instant; -use tracing::{info, warn}; - -/// Measure execution time of an async operation -pub async fn measure_time(name: &str, f: F) -> T -where - F: FnOnce() -> Fut, - Fut: Future, -{ - let start = Instant::now(); - let result = f().await; - let duration = start.elapsed(); - - if duration.as_millis() > 100 { - warn!("{} took {:?}", name, duration); - } else { - info!("{} took {:?}", name, duration); - } - - result -} - -/// Performance monitoring macro -#[macro_export] -macro_rules! measure { - ($name:expr, $block:expr) => {{ - let start = std::time::Instant::now(); - let result = $block; - let duration = start.elapsed(); - if duration.as_millis() > 100 { - tracing::warn!("{} took {:?}", $name, duration); - } else { - tracing::debug!("{} took {:?}", $name, duration); - } - result - }}; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_measure_time() { - let result = measure_time("test_operation", || async { - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - 42 - }) - .await; - - assert_eq!(result, 42); - } -} diff --git a/src/shared/utils/dev/result_ext.rs b/src/shared/utils/dev/result_ext.rs deleted file mode 100644 index 992500f..0000000 --- a/src/shared/utils/dev/result_ext.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Result and Option extension utilities. - -/// Extension trait for Result. -pub trait ResultExt2 { - /// Log error and return default. - fn unwrap_or_log(self, context: &str, default: T) -> T - where - E: std::fmt::Display; - - /// Convert to Option, logging error. - fn ok_or_log(self, context: &str) -> Option - where - E: std::fmt::Display; - - /// Map both Ok and Err. - fn map_both(self, ok_fn: F, err_fn: G) -> Result - where - F: FnOnce(T) -> U, - G: FnOnce(E) -> E; - - /// Tap into Ok value without consuming. - fn tap_ok(self, f: F) -> Self - where - F: FnOnce(&T); - - /// Tap into Err value without consuming. - fn tap_err(self, f: F) -> Self - where - F: FnOnce(&E); -} - -impl ResultExt2 for Result { - fn unwrap_or_log(self, context: &str, default: T) -> T - where - E: std::fmt::Display, - { - match self { - Ok(v) => v, - Err(e) => { - tracing::error!("[{}] Error: {}", context, e); - default - } - } - } - - fn ok_or_log(self, context: &str) -> Option - where - E: std::fmt::Display, - { - match self { - Ok(v) => Some(v), - Err(e) => { - tracing::error!("[{}] Error: {}", context, e); - None - } - } - } - - fn map_both(self, ok_fn: F, err_fn: G) -> Result - where - F: FnOnce(T) -> U, - G: FnOnce(E) -> E, - { - match self { - Ok(v) => Ok(ok_fn(v)), - Err(e) => Err(err_fn(e)), - } - } - - fn tap_ok(self, f: F) -> Self - where - F: FnOnce(&T), - { - if let Ok(ref v) = self { - f(v); - } - self - } - - fn tap_err(self, f: F) -> Self - where - F: FnOnce(&E), - { - if let Err(ref e) = self { - f(e); - } - self - } -} - -/// Extension trait for Option. -pub trait OptionExt { - /// Log if None and return default. - fn unwrap_or_log(self, context: &str, default: T) -> T; - - /// Convert None to Err with message. - fn ok_or_msg(self, msg: &str) -> Result; - - /// Tap into Some value. - fn tap_some(self, f: F) -> Self - where - F: FnOnce(&T); - - /// Execute on None. - fn on_none(self, f: F) -> Self - where - F: FnOnce(); -} - -impl OptionExt for Option { - fn unwrap_or_log(self, context: &str, default: T) -> T { - match self { - Some(v) => v, - None => { - tracing::warn!("[{}] Value was None, using default", context); - default - } - } - } - - fn ok_or_msg(self, msg: &str) -> Result { - self.ok_or_else(|| msg.to_string()) - } - - fn tap_some(self, f: F) -> Self - where - F: FnOnce(&T), - { - if let Some(ref v) = self { - f(v); - } - self - } - - fn on_none(self, f: F) -> Self - where - F: FnOnce(), - { - if self.is_none() { - f(); - } - self - } -} - -/// Wrap a value in Ok. -pub fn ok(value: T) -> Result { - Ok(value) -} - -/// Wrap a value in Some. -pub fn some(value: T) -> Option { - Some(value) -} - -/// Create an error result. -pub fn err(error: E) -> Result { - Err(error) -} - -/// Flatten nested option. -pub fn flatten_option(opt: Option>) -> Option { - opt.flatten() -} - -/// Flatten nested result. -pub fn flatten_result(res: Result, E>) -> Result { - res.and_then(|r| r) -} diff --git a/src/shared/utils/dev/serde_helpers.rs b/src/shared/utils/dev/serde_helpers.rs deleted file mode 100644 index 6b99f29..0000000 --- a/src/shared/utils/dev/serde_helpers.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Custom serde helpers and utilities. - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// Serialize Option as empty string when None. -pub mod option_empty_string { - use super::*; - - pub fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - T: Serialize, - { - match value { - Some(v) => v.serialize(serializer), - None => serializer.serialize_str(""), - } - } - - pub fn deserialize<'de, D, T>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - T: Deserialize<'de>, - { - let value: Option = Option::deserialize(deserializer)?; - Ok(value) - } -} - -/// Deserialize string to number. -pub mod string_to_number { - use super::*; - use std::str::FromStr; - - pub fn deserialize<'de, D, T>(deserializer: D) -> Result - where - D: Deserializer<'de>, - T: FromStr + Deserialize<'de>, - T::Err: std::fmt::Display, - { - use serde::de::Error; - - let s = String::deserialize(deserializer)?; - s.parse::().map_err(D::Error::custom) - } -} - -/// Deserialize string or number to i64. -pub mod flexible_i64 { - use super::*; - use serde_json::Value; - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::Error; - - let value = Value::deserialize(deserializer)?; - match value { - Value::Number(n) => n.as_i64().ok_or_else(|| D::Error::custom("invalid number")), - Value::String(s) => s.parse().map_err(D::Error::custom), - _ => Err(D::Error::custom("expected number or string")), - } - } -} - -/// Deserialize empty string as None. -pub mod empty_string_as_none { - use super::*; - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let opt: Option = Option::deserialize(deserializer)?; - match opt { - Some(s) if s.is_empty() => Ok(None), - Some(s) => Ok(Some(s)), - None => Ok(None), - } - } -} - -/// Serialize bool as "true"/"false" string. -pub mod bool_as_string { - use super::*; - - pub fn serialize(value: &bool, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(if *value { "true" } else { "false" }) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::Error; - - let s = String::deserialize(deserializer)?; - match s.to_lowercase().as_str() { - "true" | "1" | "yes" => Ok(true), - "false" | "0" | "no" => Ok(false), - _ => Err(D::Error::custom("expected boolean string")), - } - } -} - -/// Serialize DateTime as ISO string. -pub mod datetime_iso { - use super::*; - use chrono::{DateTime, Utc}; - - pub fn serialize(date: &DateTime, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&date.to_rfc3339()) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - use serde::de::Error; - - let s = String::deserialize(deserializer)?; - DateTime::parse_from_rfc3339(&s) - .map(|dt| dt.with_timezone(&Utc)) - .map_err(D::Error::custom) - } -} - -/// Deserialize comma-separated string to Vec. -pub mod comma_separated { - use super::*; - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - if s.is_empty() { - Ok(Vec::new()) - } else { - Ok(s.split(',').map(|s| s.trim().to_string()).collect()) - } - } - - pub fn serialize(values: &[String], serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&values.join(",")) - } -} - -/// Default to empty vec if null. -pub fn default_empty_vec() -> Vec { - Vec::new() -} - -/// Default to empty string. -pub fn default_empty_string() -> String { - String::new() -} - -/// Default to false. -pub fn default_false() -> bool { - false -} - -/// Default to true. -pub fn default_true() -> bool { - true -} - -/// Default to zero. -pub fn default_zero() -> i64 { - 0 -} diff --git a/src/shared/utils/dev/testing.rs b/src/shared/utils/dev/testing.rs deleted file mode 100644 index 27ad079..0000000 --- a/src/shared/utils/dev/testing.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Testing utilities and helpers. - -/// Assert that two JSON values are equal. -#[macro_export] -macro_rules! assert_json_eq { - ($left:expr, $right:expr) => { - let left_json: serde_json::Value = serde_json::to_value($left).unwrap(); - let right_json: serde_json::Value = serde_json::to_value($right).unwrap(); - assert_eq!(left_json, right_json); - }; -} - -/// Assert that a result is Ok. -#[macro_export] -macro_rules! assert_ok { - ($expr:expr) => { - assert!($expr.is_ok(), "Expected Ok, got Err: {:?}", $expr.err()); - }; - ($expr:expr, $msg:expr) => { - assert!( - $expr.is_ok(), - "{}: Expected Ok, got Err: {:?}", - $msg, - $expr.err() - ); - }; -} - -/// Assert that a result is Err. -#[macro_export] -macro_rules! assert_err { - ($expr:expr) => { - assert!($expr.is_err(), "Expected Err, got Ok: {:?}", $expr.ok()); - }; - ($expr:expr, $msg:expr) => { - assert!( - $expr.is_err(), - "{}: Expected Err, got Ok: {:?}", - $msg, - $expr.ok() - ); - }; -} - -/// Assert that an option is Some. -#[macro_export] -macro_rules! assert_some { - ($expr:expr) => { - assert!($expr.is_some(), "Expected Some, got None"); - }; -} - -/// Assert that an option is None. -#[macro_export] -macro_rules! assert_none { - ($expr:expr) => { - assert!($expr.is_none(), "Expected None, got Some: {:?}", $expr); - }; -} - -/// Assert that a string contains a substring. -#[macro_export] -macro_rules! assert_contains { - ($haystack:expr, $needle:expr) => { - assert!( - $haystack.contains($needle), - "Expected {:?} to contain {:?}", - $haystack, - $needle - ); - }; -} - -/// Assert that a string starts with prefix. -#[macro_export] -macro_rules! assert_starts_with { - ($string:expr, $prefix:expr) => { - assert!( - $string.starts_with($prefix), - "Expected {:?} to start with {:?}", - $string, - $prefix - ); - }; -} - -/// Assert that a string ends with suffix. -#[macro_export] -macro_rules! assert_ends_with { - ($string:expr, $suffix:expr) => { - assert!( - $string.ends_with($suffix), - "Expected {:?} to end with {:?}", - $string, - $suffix - ); - }; -} - -/// Mock HTTP response builder. -#[derive(Debug, Clone)] -pub struct MockResponse { - pub status: u16, - pub body: String, - pub headers: std::collections::HashMap, -} - -impl MockResponse { - pub fn new(status: u16) -> Self { - Self { - status, - body: String::new(), - headers: std::collections::HashMap::new(), - } - } - - pub fn ok() -> Self { - Self::new(200) - } - - pub fn not_found() -> Self { - Self::new(404) - } - - pub fn error() -> Self { - Self::new(500) - } - - pub fn body(mut self, body: impl Into) -> Self { - self.body = body.into(); - self - } - - pub fn json(mut self, value: &T) -> Self { - self.body = serde_json::to_string(value).unwrap(); - self.headers - .insert("Content-Type".to_string(), "application/json".to_string()); - self - } - - pub fn header(mut self, key: impl Into, value: impl Into) -> Self { - self.headers.insert(key.into(), value.into()); - self - } -} - -/// Simple test fixture. -pub struct TestFixture { - pub data: T, - setup_done: bool, -} - -impl TestFixture { - pub fn new(data: T) -> Self { - Self { - data, - setup_done: false, - } - } - - pub fn setup(mut self, f: F) -> Self { - f(&mut self.data); - self.setup_done = true; - self - } - - pub fn get(&self) -> &T { - &self.data - } - - pub fn get_mut(&mut self) -> &mut T { - &mut self.data - } -} - -/// Generate random test data. -pub mod random { - use rand::Rng; - - pub fn string(len: usize) -> String { - use rand::distributions::Alphanumeric; - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(len) - .map(char::from) - .collect() - } - - pub fn email() -> String { - format!("test_{}@example.com", string(8).to_lowercase()) - } - - pub fn int(min: i64, max: i64) -> i64 { - rand::thread_rng().gen_range(min..=max) - } - - pub fn bool() -> bool { - rand::thread_rng().gen() - } - - pub fn choice(items: &[T]) -> T { - let idx = rand::thread_rng().gen_range(0..items.len()); - items[idx].clone() - } -} diff --git a/src/shared/utils/infra/bulk.rs b/src/shared/utils/infra/bulk.rs deleted file mode 100644 index 8d77a68..0000000 --- a/src/shared/utils/infra/bulk.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Bulk Operations for batch database operations. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::bulk::{BulkResult, batch_insert}; -//! -//! // Bulk insert -//! let result = batch_insert::(&db, users, 100).await?; -//! ``` - -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; - -/// Bulk operation result. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BulkResult { - pub total: usize, - pub inserted: usize, - pub updated: usize, - pub failed: usize, - pub errors: Vec, -} - -/// Bulk operation error. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BulkError { - pub index: usize, - pub message: String, -} - -impl BulkResult { - pub fn new(total: usize) -> Self { - Self { - total, - inserted: 0, - updated: 0, - failed: 0, - errors: Vec::new(), - } - } - - pub fn success(&self) -> bool { - self.failed == 0 - } -} - -/// Batch insert helper. -pub async fn batch_insert( - db: &DatabaseConnection, - models: Vec, - chunk_size: usize, -) -> Result -where - E: EntityTrait, - A: ActiveModelTrait + Send, -{ - let total = models.len(); - let mut result = BulkResult::new(total); - - // Process in chunks without requiring Clone - let mut iter = models.into_iter().peekable(); - let mut chunk_idx = 0; - - while iter.peek().is_some() { - let chunk: Vec = iter.by_ref().take(chunk_size).collect(); - let chunk_len = chunk.len(); - - match E::insert_many(chunk).exec(db).await { - Ok(_) => { - result.inserted += chunk_len; - } - Err(e) => { - result.failed += chunk_len; - result.errors.push(BulkError { - index: chunk_idx * chunk_size, - message: e.to_string(), - }); - } - } - chunk_idx += 1; - } - - Ok(result) -} - -/// Batch delete helper. -pub async fn batch_delete( - db: &DatabaseConnection, - column: C, - values: Vec + Clone>, - chunk_size: usize, -) -> Result -where - E: EntityTrait, - C: ColumnTrait, -{ - let total = values.len(); - let mut result = BulkResult::new(total); - - for chunk in values.chunks(chunk_size) { - let chunk_values: Vec<_> = chunk.iter().cloned().map(|v| v.into()).collect(); - match E::delete_many() - .filter(column.is_in(chunk_values)) - .exec(db) - .await - { - Ok(delete_result) => { - result.updated += delete_result.rows_affected as usize; - } - Err(e) => { - result.failed += chunk.len(); - result.errors.push(BulkError { - index: 0, - message: e.to_string(), - }); - } - } - } - - Ok(result) -} - -/// Progress callback for long operations. -pub type ProgressCallback = Box; - -/// Batch process with progress reporting. -pub async fn batch_with_progress( - items: Vec, - chunk_size: usize, - process_fn: F, - on_progress: Option, -) -> BulkResult -where - T: Send, - F: Fn(T) -> Fut + Send + Sync, - Fut: std::future::Future> + Send, -{ - let total = items.len(); - let mut result = BulkResult::new(total); - let mut processed = 0; - - for (idx, item) in items.into_iter().enumerate() { - match process_fn(item).await { - Ok(_) => { - result.inserted += 1; - } - Err(e) => { - result.failed += 1; - result.errors.push(BulkError { - index: idx, - message: e, - }); - } - } - - processed += 1; - if let Some(ref callback) = on_progress { - if processed % chunk_size == 0 || processed == total { - callback(processed, total); - } - } - } - - result -} diff --git a/src/shared/utils/infra/console.rs b/src/shared/utils/infra/console.rs deleted file mode 100644 index 7f369c1..0000000 --- a/src/shared/utils/infra/console.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Console Commands / CLI Builder. -//! -//! Build artisan-like CLI commands. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::console::{Console, Command, CommandContext}; -//! -//! let mut console = Console::new("myapp"); -//! console.register("migrate", "Run migrations", |ctx| { -//! ctx.info("Running migrations..."); -//! Ok(()) -//! }); -//! console.run(); -//! ``` - -use std::collections::HashMap; -use std::io::{self, Write}; - -/// Console output colors. -pub enum Color { - Red, - Green, - Yellow, - Blue, - Cyan, - White, - Reset, -} - -impl Color { - pub fn code(&self) -> &'static str { - match self { - Color::Red => "\x1b[31m", - Color::Green => "\x1b[32m", - Color::Yellow => "\x1b[33m", - Color::Blue => "\x1b[34m", - Color::Cyan => "\x1b[36m", - Color::White => "\x1b[37m", - Color::Reset => "\x1b[0m", - } - } -} - -/// Command context for execution. -pub struct CommandContext { - pub args: Vec, - pub options: HashMap, -} - -impl CommandContext { - pub fn new(args: Vec) -> Self { - let mut options = HashMap::new(); - let mut positional = Vec::new(); - - for arg in args { - if arg.starts_with("--") { - let parts: Vec<&str> = arg[2..].splitn(2, '=').collect(); - options.insert( - parts[0].to_string(), - parts.get(1).unwrap_or(&"true").to_string(), - ); - } else if arg.starts_with('-') { - options.insert(arg[1..].to_string(), "true".to_string()); - } else { - positional.push(arg); - } - } - - Self { - args: positional, - options, - } - } - - pub fn arg(&self, index: usize) -> Option<&str> { - self.args.get(index).map(|s| s.as_str()) - } - - pub fn option(&self, name: &str) -> Option<&str> { - self.options.get(name).map(|s| s.as_str()) - } - - pub fn has_option(&self, name: &str) -> bool { - self.options.contains_key(name) - } - - pub fn info(&self, message: &str) { - println!( - "{}[INFO]{} {}", - Color::Blue.code(), - Color::Reset.code(), - message - ); - } - - pub fn success(&self, message: &str) { - println!( - "{}[OK]{} {}", - Color::Green.code(), - Color::Reset.code(), - message - ); - } - - pub fn warning(&self, message: &str) { - println!( - "{}[WARN]{} {}", - Color::Yellow.code(), - Color::Reset.code(), - message - ); - } - - pub fn error(&self, message: &str) { - eprintln!( - "{}[ERROR]{} {}", - Color::Red.code(), - Color::Reset.code(), - message - ); - } - - pub fn line(&self, message: &str) { - println!("{}", message); - } - - pub fn confirm(&self, question: &str) -> bool { - print!("{} [y/N]: ", question); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap_or(0); - matches!(input.trim().to_lowercase().as_str(), "y" | "yes") - } - - pub fn ask(&self, question: &str) -> String { - print!("{}: ", question); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap_or(0); - input.trim().to_string() - } - - pub fn table(&self, headers: &[&str], rows: &[Vec]) { - // Calculate column widths - let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); - for row in rows { - for (i, cell) in row.iter().enumerate() { - if i < widths.len() && cell.len() > widths[i] { - widths[i] = cell.len(); - } - } - } - - // Print header - let header_line: Vec = headers - .iter() - .enumerate() - .map(|(i, h)| format!("{:width$}", h, width = widths[i])) - .collect(); - println!("| {} |", header_line.join(" | ")); - - // Print separator - let separator: Vec = widths.iter().map(|w| "-".repeat(*w)).collect(); - println!("|-{}-|", separator.join("-|-")); - - // Print rows - for row in rows { - let row_line: Vec = row - .iter() - .enumerate() - .map(|(i, c)| format!("{:width$}", c, width = widths.get(i).copied().unwrap_or(0))) - .collect(); - println!("| {} |", row_line.join(" | ")); - } - } -} - -/// Command handler type. -pub type CommandHandler = Box Result<(), String> + Send + Sync>; - -/// Command definition. -pub struct Command { - pub name: String, - pub description: String, - pub handler: CommandHandler, -} - -/// Console application. -pub struct Console { - name: String, - version: String, - commands: HashMap, -} - -impl Console { - pub fn new(name: &str) -> Self { - Self { - name: name.to_string(), - version: "1.0.0".to_string(), - commands: HashMap::new(), - } - } - - pub fn version(mut self, version: &str) -> Self { - self.version = version.to_string(); - self - } - - pub fn register(&mut self, name: &str, description: &str, handler: F) - where - F: Fn(&CommandContext) -> Result<(), String> + Send + Sync + 'static, - { - self.commands.insert( - name.to_string(), - Command { - name: name.to_string(), - description: description.to_string(), - handler: Box::new(handler), - }, - ); - } - - pub fn run(&self, args: Vec) -> Result<(), String> { - if args.is_empty() || args[0] == "help" || args[0] == "--help" { - self.show_help(); - return Ok(()); - } - - let command_name = &args[0]; - let command_args = args[1..].to_vec(); - - if let Some(command) = self.commands.get(command_name) { - let ctx = CommandContext::new(command_args); - (command.handler)(&ctx) - } else { - Err(format!( - "Command '{}' not found. Run 'help' for available commands.", - command_name - )) - } - } - - fn show_help(&self) { - println!( - "{}{} v{}{}", - Color::Cyan.code(), - self.name, - self.version, - Color::Reset.code() - ); - println!(); - println!( - "{}Available commands:{}", - Color::Yellow.code(), - Color::Reset.code() - ); - println!(); - - for (name, cmd) in &self.commands { - println!( - " {}{}{} {}", - Color::Green.code(), - name, - Color::Reset.code(), - cmd.description - ); - } - println!(); - } -} diff --git a/src/shared/utils/infra/encryption.rs b/src/shared/utils/infra/encryption.rs deleted file mode 100644 index cbd851f..0000000 --- a/src/shared/utils/infra/encryption.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Encryption at Rest for model attributes. -//! -//! Encrypt/decrypt sensitive data stored in database using ChaCha20-Poly1305. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::encryption::{Encryptor, EncryptedField}; -//! -//! let encryptor = Encryptor::new("secret-key"); -//! -//! // Encrypt -//! let encrypted = encryptor.encrypt("sensitive data")?; -//! -//! // Decrypt -//! let decrypted = encryptor.decrypt(&encrypted)?; -//! ``` - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use hmac::{Hmac, Mac}; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; - -/// Encryption error. -#[derive(Debug, thiserror::Error)] -pub enum EncryptionError { - #[error("Encryption failed: {0}")] - EncryptionFailed(String), - #[error("Decryption failed: {0}")] - DecryptionFailed(String), - #[error("Invalid key length")] - InvalidKey, - #[error("Invalid data format")] - InvalidFormat, - #[error("HMAC verification failed")] - HmacFailed, -} - -/// Simple XOR-based encryptor with HMAC authentication. -/// Note: For production, consider using a proper encryption crate. -#[derive(Clone)] -pub struct Encryptor { - key: Vec, -} - -impl Encryptor { - /// Create a new encryptor. - pub fn new(key: &str) -> Result { - let key_bytes = Self::derive_key(key); - Ok(Self { - key: key_bytes.to_vec(), - }) - } - - /// Derive a 32-byte key from any string. - fn derive_key(key: &str) -> [u8; 32] { - use sha2::Digest; - let mut hasher = sha2::Sha256::new(); - hasher.update(key.as_bytes()); - let result = hasher.finalize(); - let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(&result); - key_bytes - } - - /// Generate a random IV. - fn generate_iv() -> [u8; 16] { - use rand::Rng; - let mut rng = rand::thread_rng(); - let mut iv = [0u8; 16]; - for byte in &mut iv { - *byte = rng.gen(); - } - iv - } - - /// XOR encrypt/decrypt. - fn xor_cipher(&self, data: &[u8], iv: &[u8]) -> Vec { - let mut key_stream = Vec::with_capacity(data.len()); - let mut counter = 0u32; - - while key_stream.len() < data.len() { - let mut mac = Hmac::::new_from_slice(&self.key).unwrap(); - mac.update(iv); - mac.update(&counter.to_le_bytes()); - let block = mac.finalize().into_bytes(); - key_stream.extend_from_slice(&block); - counter += 1; - } - - data.iter() - .zip(key_stream.iter()) - .map(|(d, k)| d ^ k) - .collect() - } - - /// Generate HMAC for authentication. - fn generate_hmac(&self, data: &[u8]) -> [u8; 32] { - let mut mac = Hmac::::new_from_slice(&self.key).unwrap(); - mac.update(data); - let result = mac.finalize(); - let mut hmac = [0u8; 32]; - hmac.copy_from_slice(&result.into_bytes()); - hmac - } - - /// Encrypt a string. - pub fn encrypt(&self, plaintext: &str) -> Result { - let iv = Self::generate_iv(); - let ciphertext = self.xor_cipher(plaintext.as_bytes(), &iv); - - // Format: IV + ciphertext + HMAC - let mut combined = iv.to_vec(); - combined.extend(&ciphertext); - - let hmac = self.generate_hmac(&combined); - combined.extend(&hmac); - - Ok(BASE64.encode(combined)) - } - - /// Decrypt a string. - pub fn decrypt(&self, encrypted: &str) -> Result { - let combined = BASE64 - .decode(encrypted) - .map_err(|_| EncryptionError::InvalidFormat)?; - - if combined.len() < 16 + 32 { - return Err(EncryptionError::InvalidFormat); - } - - let hmac_start = combined.len() - 32; - let (data, hmac_bytes) = combined.split_at(hmac_start); - - // Verify HMAC - let expected_hmac = self.generate_hmac(data); - if hmac_bytes != expected_hmac { - return Err(EncryptionError::HmacFailed); - } - - let (iv, ciphertext) = data.split_at(16); - let plaintext = self.xor_cipher(ciphertext, iv); - - String::from_utf8(plaintext).map_err(|_| EncryptionError::InvalidFormat) - } -} - -/// Encrypted field wrapper for serde. -#[derive(Debug, Clone)] -pub struct EncryptedField { - pub encrypted: String, -} - -impl EncryptedField { - pub fn new(encryptor: &Encryptor, value: &str) -> Result { - Ok(Self { - encrypted: encryptor.encrypt(value)?, - }) - } - - pub fn decrypt(&self, encryptor: &Encryptor) -> Result { - encryptor.decrypt(&self.encrypted) - } -} - -impl Serialize for EncryptedField { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.encrypted) - } -} - -impl<'de> Deserialize<'de> for EncryptedField { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Ok(Self { encrypted: s }) - } -} - -/// Global encryptor. -static ENCRYPTOR: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Initialize global encryptor. -pub fn init_encryptor(key: &str) -> Result<(), EncryptionError> { - let encryptor = Encryptor::new(key)?; - ENCRYPTOR - .set(encryptor) - .map_err(|_| EncryptionError::InvalidKey)?; - Ok(()) -} - -/// Get global encryptor. -pub fn encryptor() -> Option<&'static Encryptor> { - ENCRYPTOR.get() -} - -/// Quick encrypt using global encryptor. -pub fn encrypt(value: &str) -> Result { - encryptor() - .ok_or(EncryptionError::InvalidKey)? - .encrypt(value) -} - -/// Quick decrypt using global encryptor. -pub fn decrypt(value: &str) -> Result { - encryptor() - .ok_or(EncryptionError::InvalidKey)? - .decrypt(value) -} diff --git a/src/shared/utils/infra/env.rs b/src/shared/utils/infra/env.rs deleted file mode 100644 index e040420..0000000 --- a/src/shared/utils/infra/env.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Environment variable utilities. - -use std::env; - -/// Get environment variable or terminate the process. -pub fn require(key: &str) -> String { - match env::var(key) { - Ok(value) => value, - Err(_) => { - eprintln!("Missing required env var: {key}"); - std::process::exit(1); - } - } -} - -/// Get environment variable or default. -pub fn get_or(key: &str, default: &str) -> String { - env::var(key).unwrap_or_else(|_| default.to_string()) -} - -/// Get environment variable as Option. -pub fn get(key: &str) -> Option { - env::var(key).ok() -} - -/// Get environment variable as i64 or default. -pub fn get_i64(key: &str, default: i64) -> i64 { - env::var(key) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} - -/// Get environment variable as u64 or default. -pub fn get_u64(key: &str, default: u64) -> u64 { - env::var(key) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} - -/// Get environment variable as bool (true, 1, yes). -pub fn get_bool(key: &str, default: bool) -> bool { - env::var(key) - .ok() - .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes")) - .unwrap_or(default) -} - -/// Get environment variable as f64 or default. -pub fn get_f64(key: &str, default: f64) -> f64 { - env::var(key) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} - -/// Check if running in production. -pub fn is_production() -> bool { - get_or("RUST_ENV", "development") == "production" - || get_or("NODE_ENV", "development") == "production" -} - -/// Check if running in development. -pub fn is_development() -> bool { - !is_production() -} - -/// Check if debug mode. -pub fn is_debug() -> bool { - get_bool("DEBUG", false) || cfg!(debug_assertions) -} - -/// Get database URL. -pub fn database_url() -> String { - require("DATABASE_URL") -} - -/// Get Redis URL. -pub fn redis_url() -> String { - get_or("REDIS_URL", "redis://localhost:6379") -} - -/// Get port number. -pub fn port() -> u16 { - get_u64("PORT", 3000) as u16 -} - -/// Get host. -pub fn host() -> String { - get_or("HOST", "0.0.0.0") -} - -/// Get API key. -pub fn api_key() -> Option { - get("API_KEY") -} - -/// Get JWT secret. -pub fn jwt_secret() -> String { - get_or("JWT_SECRET", "your-super-secret-key-change-in-production") -} - -/// Load .env file if present. -pub fn load_dotenv() { - let _ = dotenvy::dotenv(); -} - -/// Set environment variable. -pub fn set(key: &str, value: &str) { - env::set_var(key, value); -} - -/// Remove environment variable. -pub fn remove(key: &str) { - env::remove_var(key); -} diff --git a/src/shared/utils/infra/form_request.rs b/src/shared/utils/infra/form_request.rs deleted file mode 100644 index cbf8b18..0000000 --- a/src/shared/utils/infra/form_request.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Form Request Validation. -//! -//! Reusable validation rules for request data. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::form_request::{FormRequest, ValidationRules, validate}; -//! -//! let rules = ValidationRules::new() -//! .required("email") -//! .email("email") -//! .min_length("password", 8); -//! -//! let errors = validate(&data, &rules); -//! ``` - -use serde_json::Value; -use std::collections::HashMap; - -/// Validation error. -#[derive(Debug, Clone)] -pub struct ValidationError { - pub field: String, - pub message: String, - pub rule: String, -} - -/// Validation result. -#[derive(Debug, Clone, Default)] -pub struct ValidationResult { - pub errors: Vec, -} - -impl ValidationResult { - pub fn new() -> Self { - Self { errors: Vec::new() } - } - - pub fn is_valid(&self) -> bool { - self.errors.is_empty() - } - - pub fn add(&mut self, field: &str, rule: &str, message: &str) { - self.errors.push(ValidationError { - field: field.to_string(), - message: message.to_string(), - rule: rule.to_string(), - }); - } - - pub fn errors_for(&self, field: &str) -> Vec<&ValidationError> { - self.errors.iter().filter(|e| e.field == field).collect() - } - - pub fn first_error(&self) -> Option<&ValidationError> { - self.errors.first() - } - - pub fn to_json(&self) -> Value { - let mut map: HashMap> = HashMap::new(); - for error in &self.errors { - map.entry(error.field.clone()) - .or_default() - .push(error.message.clone()); - } - serde_json::to_value(map).unwrap_or(Value::Null) - } -} - -/// Validation rule. -#[derive(Debug, Clone)] -pub enum Rule { - Required, - Email, - Url, - MinLength(usize), - MaxLength(usize), - Min(i64), - Max(i64), - Regex(String), - In(Vec), - NotIn(Vec), - Confirmed(String), - Numeric, - Alpha, - AlphaNumeric, - Date, - Uuid, -} - -/// Validation rules builder. -#[derive(Debug, Clone, Default)] -pub struct ValidationRules { - rules: HashMap>, -} - -impl ValidationRules { - pub fn new() -> Self { - Self { - rules: HashMap::new(), - } - } - - fn add_rule(&mut self, field: &str, rule: Rule) -> &mut Self { - self.rules.entry(field.to_string()).or_default().push(rule); - self - } - - pub fn required(&mut self, field: &str) -> &mut Self { - self.add_rule(field, Rule::Required) - } - - pub fn email(&mut self, field: &str) -> &mut Self { - self.add_rule(field, Rule::Email) - } - - pub fn url(&mut self, field: &str) -> &mut Self { - self.add_rule(field, Rule::Url) - } - - pub fn min_length(&mut self, field: &str, len: usize) -> &mut Self { - self.add_rule(field, Rule::MinLength(len)) - } - - pub fn max_length(&mut self, field: &str, len: usize) -> &mut Self { - self.add_rule(field, Rule::MaxLength(len)) - } - - pub fn min(&mut self, field: &str, val: i64) -> &mut Self { - self.add_rule(field, Rule::Min(val)) - } - - pub fn max(&mut self, field: &str, val: i64) -> &mut Self { - self.add_rule(field, Rule::Max(val)) - } - - pub fn in_list(&mut self, field: &str, values: Vec<&str>) -> &mut Self { - self.add_rule( - field, - Rule::In(values.into_iter().map(String::from).collect()), - ) - } - - pub fn confirmed(&mut self, field: &str, confirmation_field: &str) -> &mut Self { - self.add_rule(field, Rule::Confirmed(confirmation_field.to_string())) - } - - pub fn numeric(&mut self, field: &str) -> &mut Self { - self.add_rule(field, Rule::Numeric) - } - - pub fn uuid(&mut self, field: &str) -> &mut Self { - self.add_rule(field, Rule::Uuid) - } -} - -/// Validate data against rules. -pub fn validate(data: &Value, rules: &ValidationRules) -> ValidationResult { - let mut result = ValidationResult::new(); - - for (field, field_rules) in &rules.rules { - let value = data.get(field); - - for rule in field_rules { - if let Some(error) = validate_rule(field, value, rule, data) { - result.errors.push(error); - } - } - } - - result -} - -fn validate_rule( - field: &str, - value: Option<&Value>, - rule: &Rule, - data: &Value, -) -> Option { - match rule { - Rule::Required => { - if value.is_none() - || value == Some(&Value::Null) - || value - .map(|v| v.as_str().map(|s| s.is_empty()).unwrap_or(false)) - .unwrap_or(true) - { - return Some(ValidationError { - field: field.to_string(), - rule: "required".to_string(), - message: format!("The {} field is required.", field), - }); - } - } - Rule::Email => { - if let Some(s) = value.and_then(|v| v.as_str()) { - if !s.contains('@') || !s.contains('.') { - return Some(ValidationError { - field: field.to_string(), - rule: "email".to_string(), - message: format!("The {} must be a valid email.", field), - }); - } - } - } - Rule::MinLength(len) => { - if let Some(s) = value.and_then(|v| v.as_str()) { - if s.len() < *len { - return Some(ValidationError { - field: field.to_string(), - rule: "min_length".to_string(), - message: format!("The {} must be at least {} characters.", field, len), - }); - } - } - } - Rule::MaxLength(len) => { - if let Some(s) = value.and_then(|v| v.as_str()) { - if s.len() > *len { - return Some(ValidationError { - field: field.to_string(), - rule: "max_length".to_string(), - message: format!("The {} must not exceed {} characters.", field, len), - }); - } - } - } - Rule::Min(min) => { - if let Some(n) = value.and_then(|v| v.as_i64()) { - if n < *min { - return Some(ValidationError { - field: field.to_string(), - rule: "min".to_string(), - message: format!("The {} must be at least {}.", field, min), - }); - } - } - } - Rule::Max(max) => { - if let Some(n) = value.and_then(|v| v.as_i64()) { - if n > *max { - return Some(ValidationError { - field: field.to_string(), - rule: "max".to_string(), - message: format!("The {} must not exceed {}.", field, max), - }); - } - } - } - Rule::In(allowed) => { - if let Some(s) = value.and_then(|v| v.as_str()) { - if !allowed.contains(&s.to_string()) { - return Some(ValidationError { - field: field.to_string(), - rule: "in".to_string(), - message: format!("The {} must be one of: {}", field, allowed.join(", ")), - }); - } - } - } - Rule::Confirmed(confirm_field) => { - let confirm_value = data.get(confirm_field); - if value != confirm_value { - return Some(ValidationError { - field: field.to_string(), - rule: "confirmed".to_string(), - message: format!("The {} confirmation does not match.", field), - }); - } - } - Rule::Numeric => { - if let Some(v) = value { - if !v.is_number() - && v.as_str() - .map(|s| s.parse::().is_err()) - .unwrap_or(true) - { - return Some(ValidationError { - field: field.to_string(), - rule: "numeric".to_string(), - message: format!("The {} must be numeric.", field), - }); - } - } - } - Rule::Uuid => { - if let Some(s) = value.and_then(|v| v.as_str()) { - if uuid::Uuid::parse_str(s).is_err() { - return Some(ValidationError { - field: field.to_string(), - rule: "uuid".to_string(), - message: format!("The {} must be a valid UUID.", field), - }); - } - } - } - _ => {} - } - - None -} diff --git a/src/shared/utils/infra/health_check.rs b/src/shared/utils/infra/health_check.rs deleted file mode 100644 index 82ffcae..0000000 --- a/src/shared/utils/infra/health_check.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Health Check Registry. -//! -//! Custom health checks for dependencies. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::health_check::{HealthRegistry, HealthCheck, HealthStatus}; -//! -//! let mut registry = HealthRegistry::new(); -//! registry.register("database", DatabaseHealthCheck); -//! registry.register("redis", RedisHealthCheck); -//! -//! let results = registry.check_all().await; -//! ``` - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -/// Health status. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum HealthStatus { - Healthy, - Degraded, - Unhealthy, -} - -impl HealthStatus { - pub fn is_healthy(&self) -> bool { - *self == HealthStatus::Healthy - } -} - -/// Health check result. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthResult { - pub status: HealthStatus, - pub message: Option, - pub latency_ms: Option, - pub details: Option, -} - -impl HealthResult { - pub fn healthy() -> Self { - Self { - status: HealthStatus::Healthy, - message: None, - latency_ms: None, - details: None, - } - } - - pub fn unhealthy(message: &str) -> Self { - Self { - status: HealthStatus::Unhealthy, - message: Some(message.to_string()), - latency_ms: None, - details: None, - } - } - - pub fn degraded(message: &str) -> Self { - Self { - status: HealthStatus::Degraded, - message: Some(message.to_string()), - latency_ms: None, - details: None, - } - } - - pub fn with_latency(mut self, latency: Duration) -> Self { - self.latency_ms = Some(latency.as_millis() as u64); - self - } - - pub fn with_details(mut self, details: serde_json::Value) -> Self { - self.details = Some(details); - self - } -} - -/// Health check trait. -#[async_trait] -pub trait HealthCheck: Send + Sync { - /// Name of the health check. - fn name(&self) -> &str; - - /// Perform the health check. - async fn check(&self) -> HealthResult; - - /// Timeout for this check. - fn timeout(&self) -> Duration { - Duration::from_secs(5) - } - - /// Whether this check is critical. - fn critical(&self) -> bool { - true - } -} - -/// Overall health response. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthResponse { - pub status: HealthStatus, - pub checks: HashMap, - pub timestamp: chrono::DateTime, -} - -impl HealthResponse { - pub fn is_healthy(&self) -> bool { - self.status == HealthStatus::Healthy - } -} - -/// Health check registry. -pub struct HealthRegistry { - checks: Arc>>>, -} - -impl Default for HealthRegistry { - fn default() -> Self { - Self::new() - } -} - -impl HealthRegistry { - /// Create a new health registry. - pub fn new() -> Self { - Self { - checks: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Register a health check. - pub async fn register(&self, check: H) { - let mut checks = self.checks.write().await; - checks.insert(check.name().to_string(), Box::new(check)); - } - - /// Run all health checks. - pub async fn check_all(&self) -> HealthResponse { - let checks = self.checks.read().await; - let mut results = HashMap::new(); - let mut overall_status = HealthStatus::Healthy; - - for (name, check) in checks.iter() { - let start = Instant::now(); - let timeout = check.timeout(); - - let result = tokio::time::timeout(timeout, check.check()) - .await - .unwrap_or_else(|_| HealthResult::unhealthy("Timeout")) - .with_latency(start.elapsed()); - - if check.critical() { - match result.status { - HealthStatus::Unhealthy => overall_status = HealthStatus::Unhealthy, - HealthStatus::Degraded if overall_status == HealthStatus::Healthy => { - overall_status = HealthStatus::Degraded; - } - _ => {} - } - } - - results.insert(name.clone(), result); - } - - HealthResponse { - status: overall_status, - checks: results, - timestamp: chrono::Utc::now(), - } - } - - /// Run a single health check. - pub async fn check_one(&self, name: &str) -> Option { - let checks = self.checks.read().await; - if let Some(check) = checks.get(name) { - let start = Instant::now(); - Some(check.check().await.with_latency(start.elapsed())) - } else { - None - } - } -} - -// ============================================================================= -// Built-in health checks -// ============================================================================= - -/// Redis health check. -pub struct RedisHealthCheck { - pool: Arc, -} - -impl RedisHealthCheck { - pub fn new(pool: Arc) -> Self { - Self { pool } - } -} - -#[async_trait] -impl HealthCheck for RedisHealthCheck { - fn name(&self) -> &str { - "redis" - } - - async fn check(&self) -> HealthResult { - use deadpool_redis::redis::AsyncCommands; - - match self.pool.get().await { - Ok(mut conn) => { - let result: Result = conn.get("health:ping").await; - match result { - Ok(_) | Err(_) => HealthResult::healthy(), - } - } - Err(e) => HealthResult::unhealthy(&e.to_string()), - } - } -} - -/// Memory health check. -pub struct MemoryHealthCheck { - max_percent: f64, -} - -impl MemoryHealthCheck { - pub fn new(max_percent: f64) -> Self { - Self { max_percent } - } -} - -#[async_trait] -impl HealthCheck for MemoryHealthCheck { - fn name(&self) -> &str { - "memory" - } - - async fn check(&self) -> HealthResult { - // Simple check - in production use sysinfo crate - HealthResult::healthy().with_details(serde_json::json!({ - "max_percent": self.max_percent - })) - } - - fn critical(&self) -> bool { - false - } -} diff --git a/src/shared/utils/infra/import_export.rs b/src/shared/utils/infra/import_export.rs deleted file mode 100644 index c84ec80..0000000 --- a/src/shared/utils/infra/import_export.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Import/Export helpers for CSV, JSON data. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::import_export::{CsvExporter, JsonExporter}; -//! -//! // Export to CSV -//! let csv = CsvExporter::export(&users)?; -//! -//! // Import from JSON -//! let users: Vec = JsonImporter::import(&json_str)?; -//! ``` - -use serde::{de::DeserializeOwned, Serialize}; - -/// Import/Export error. -#[derive(Debug, thiserror::Error)] -pub enum ImportExportError { - #[error("Parse error: {0}")] - ParseError(String), - #[error("IO error: {0}")] - IoError(String), - #[error("Invalid format")] - InvalidFormat, -} - -// ============================================================================= -// JSON Import/Export -// ============================================================================= - -/// Export items to JSON string. -pub fn export_json(items: &[T]) -> Result { - serde_json::to_string_pretty(items).map_err(|e| ImportExportError::ParseError(e.to_string())) -} - -/// Export items to JSON file. -pub fn export_json_file(items: &[T], path: &str) -> Result<(), ImportExportError> { - let json = export_json(items)?; - std::fs::write(path, json).map_err(|e| ImportExportError::IoError(e.to_string())) -} - -/// Import from JSON string. -pub fn import_json(json: &str) -> Result, ImportExportError> { - serde_json::from_str(json).map_err(|e| ImportExportError::ParseError(e.to_string())) -} - -/// Import from JSON file. -pub fn import_json_file(path: &str) -> Result, ImportExportError> { - let content = - std::fs::read_to_string(path).map_err(|e| ImportExportError::IoError(e.to_string()))?; - import_json(&content) -} - -// ============================================================================= -// CSV Import/Export (Simple implementation) -// ============================================================================= - -/// Export items to CSV string. -pub fn export_csv(items: &[T]) -> Result { - if items.is_empty() { - return Ok(String::new()); - } - - // Convert to JSON first to get field names - let json_items: Vec = items - .iter() - .map(|item| serde_json::to_value(item)) - .collect::, _>>() - .map_err(|e| ImportExportError::ParseError(e.to_string()))?; - - // Get headers from first item - let headers: Vec = match &json_items[0] { - serde_json::Value::Object(map) => map.keys().cloned().collect(), - _ => return Err(ImportExportError::InvalidFormat), - }; - - let mut csv = String::new(); - - // Write header - csv.push_str(&headers.join(",")); - csv.push('\n'); - - // Write rows - for item in &json_items { - if let serde_json::Value::Object(map) = item { - let row: Vec = headers - .iter() - .map(|h| map.get(h).map(|v| escape_csv_value(v)).unwrap_or_default()) - .collect(); - csv.push_str(&row.join(",")); - csv.push('\n'); - } - } - - Ok(csv) -} - -/// Export to CSV file. -pub fn export_csv_file(items: &[T], path: &str) -> Result<(), ImportExportError> { - let csv = export_csv(items)?; - std::fs::write(path, csv).map_err(|e| ImportExportError::IoError(e.to_string())) -} - -/// Import from CSV string. -pub fn import_csv(csv: &str) -> Result, ImportExportError> { - let mut lines = csv.lines(); - - // Parse header - let header_line = lines.next().ok_or(ImportExportError::InvalidFormat)?; - let headers: Vec<&str> = parse_csv_line(header_line); - - // Parse rows - let mut items = Vec::new(); - for line in lines { - if line.trim().is_empty() { - continue; - } - - let values = parse_csv_line(line); - let mut map = serde_json::Map::new(); - - for (i, header) in headers.iter().enumerate() { - let value = values.get(i).copied().unwrap_or(""); - map.insert( - header.to_string(), - if value.is_empty() { - serde_json::Value::Null - } else if let Ok(n) = value.parse::() { - serde_json::Value::Number(n.into()) - } else if let Ok(f) = value.parse::() { - serde_json::Value::Number(serde_json::Number::from_f64(f).unwrap_or(0.into())) - } else if value == "true" { - serde_json::Value::Bool(true) - } else if value == "false" { - serde_json::Value::Bool(false) - } else { - serde_json::Value::String(value.to_string()) - }, - ); - } - - let item: T = serde_json::from_value(serde_json::Value::Object(map)) - .map_err(|e| ImportExportError::ParseError(e.to_string()))?; - items.push(item); - } - - Ok(items) -} - -/// Import from CSV file. -pub fn import_csv_file(path: &str) -> Result, ImportExportError> { - let content = - std::fs::read_to_string(path).map_err(|e| ImportExportError::IoError(e.to_string()))?; - import_csv(&content) -} - -fn escape_csv_value(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(s) => { - if s.contains(',') || s.contains('"') || s.contains('\n') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.clone() - } - } - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Null => String::new(), - _ => value.to_string(), - } -} - -fn parse_csv_line(line: &str) -> Vec<&str> { - // Simple CSV parsing (doesn't handle all edge cases) - let mut fields = Vec::new(); - let mut start = 0; - let mut in_quotes = false; - - for (i, c) in line.char_indices() { - match c { - '"' => in_quotes = !in_quotes, - ',' if !in_quotes => { - fields.push(&line[start..i]); - start = i + 1; - } - _ => {} - } - } - fields.push(&line[start..]); - - // Remove surrounding quotes - fields - .into_iter() - .map(|f| f.trim().trim_matches('"')) - .collect() -} - -// ============================================================================= -// NDJSON (Newline Delimited JSON) -// ============================================================================= - -/// Export to NDJSON. -pub fn export_ndjson(items: &[T]) -> Result { - let lines: Vec = items - .iter() - .map(|item| serde_json::to_string(item)) - .collect::, _>>() - .map_err(|e| ImportExportError::ParseError(e.to_string()))?; - - Ok(lines.join("\n")) -} - -/// Import from NDJSON. -pub fn import_ndjson(ndjson: &str) -> Result, ImportExportError> { - ndjson - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|line| { - serde_json::from_str(line).map_err(|e| ImportExportError::ParseError(e.to_string())) - }) - .collect() -} diff --git a/src/shared/utils/infra/mod.rs b/src/shared/utils/infra/mod.rs deleted file mode 100644 index f1ad2c3..0000000 --- a/src/shared/utils/infra/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod bulk; -pub mod console; -pub mod encryption; -pub mod env; -pub mod form_request; -pub mod health_check; -pub mod import_export; -pub mod query_profiler; -pub mod resource; -pub mod ryzen_cdn; -pub mod searchable; -pub mod transaction; -pub mod uuid_utils; -pub mod versioning; diff --git a/src/shared/utils/infra/query_profiler.rs b/src/shared/utils/infra/query_profiler.rs deleted file mode 100644 index 83773a1..0000000 --- a/src/shared/utils/infra/query_profiler.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Database Query Profiling. -//! -//! Track and analyze database query performance. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::query_profiler::{QueryProfiler, QueryLog}; -//! -//! let profiler = QueryProfiler::new(); -//! -//! profiler.log("SELECT * FROM users WHERE id = ?", duration); -//! -//! // Get slow queries -//! let slow = profiler.slow_queries(100); // > 100ms -//! ``` - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; -use std::time::Duration; - -/// Query log entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QueryLog { - /// Query string (may be truncated). - pub query: String, - /// Execution duration in milliseconds. - pub duration_ms: u64, - /// Timestamp. - pub timestamp: DateTime, - /// Rows affected (if available). - pub rows_affected: Option, - /// Location in code (file:line). - pub location: Option, -} - -/// Query statistics. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct QueryStats { - pub total_queries: u64, - pub total_duration_ms: u64, - pub slow_queries: u64, - pub avg_duration_ms: f64, - pub max_duration_ms: u64, -} - -/// Query profiler. -#[derive(Clone)] -pub struct QueryProfiler { - logs: Arc>>, - stats: Arc, - enabled: Arc, - slow_threshold_ms: u64, - max_logs: usize, -} - -struct ProfilerStats { - total_queries: AtomicU64, - total_duration_ms: AtomicU64, - slow_queries: AtomicU64, - max_duration_ms: AtomicU64, -} - -impl Default for QueryProfiler { - fn default() -> Self { - Self::new() - } -} - -impl QueryProfiler { - /// Create a new profiler. - pub fn new() -> Self { - Self { - logs: Arc::new(RwLock::new(Vec::new())), - stats: Arc::new(ProfilerStats { - total_queries: AtomicU64::new(0), - total_duration_ms: AtomicU64::new(0), - slow_queries: AtomicU64::new(0), - max_duration_ms: AtomicU64::new(0), - }), - enabled: Arc::new(std::sync::atomic::AtomicBool::new(true)), - slow_threshold_ms: 100, - max_logs: 1000, - } - } - - /// Create with custom settings. - pub fn with_settings(slow_threshold_ms: u64, max_logs: usize) -> Self { - Self { - slow_threshold_ms, - max_logs, - ..Self::new() - } - } - - /// Enable profiling. - pub fn enable(&self) { - self.enabled.store(true, Ordering::SeqCst); - } - - /// Disable profiling. - pub fn disable(&self) { - self.enabled.store(false, Ordering::SeqCst); - } - - /// Check if profiling is enabled. - pub fn is_enabled(&self) -> bool { - self.enabled.load(Ordering::SeqCst) - } - - /// Log a query. - pub fn log(&self, query: &str, duration: Duration) { - if !self.is_enabled() { - return; - } - - let duration_ms = duration.as_millis() as u64; - - // Update stats - self.stats.total_queries.fetch_add(1, Ordering::SeqCst); - self.stats - .total_duration_ms - .fetch_add(duration_ms, Ordering::SeqCst); - - if duration_ms > self.slow_threshold_ms { - self.stats.slow_queries.fetch_add(1, Ordering::SeqCst); - tracing::warn!( - "Slow query ({}ms): {}", - duration_ms, - truncate_query(query, 200) - ); - } - - // Update max - let mut current_max = self.stats.max_duration_ms.load(Ordering::SeqCst); - while duration_ms > current_max { - match self.stats.max_duration_ms.compare_exchange( - current_max, - duration_ms, - Ordering::SeqCst, - Ordering::SeqCst, - ) { - Ok(_) => break, - Err(v) => current_max = v, - } - } - - // Add to logs - let log = QueryLog { - query: truncate_query(query, 500), - duration_ms, - timestamp: Utc::now(), - rows_affected: None, - location: None, - }; - - if let Ok(mut logs) = self.logs.write() { - logs.push(log); - // Keep only last max_logs entries - let len = logs.len(); - if len > self.max_logs { - logs.drain(0..len - self.max_logs); - } - } - } - - /// Log a query with additional info. - pub fn log_full( - &self, - query: &str, - duration: Duration, - rows_affected: Option, - location: Option<&str>, - ) { - if !self.is_enabled() { - return; - } - - let duration_ms = duration.as_millis() as u64; - - self.stats.total_queries.fetch_add(1, Ordering::SeqCst); - self.stats - .total_duration_ms - .fetch_add(duration_ms, Ordering::SeqCst); - - if duration_ms > self.slow_threshold_ms { - self.stats.slow_queries.fetch_add(1, Ordering::SeqCst); - } - - let log = QueryLog { - query: truncate_query(query, 500), - duration_ms, - timestamp: Utc::now(), - rows_affected, - location: location.map(String::from), - }; - - if let Ok(mut logs) = self.logs.write() { - logs.push(log); - let len = logs.len(); - if len > self.max_logs { - logs.drain(0..len - self.max_logs); - } - } - } - - /// Get all query logs. - pub fn get_logs(&self) -> Vec { - self.logs.read().map(|l| l.clone()).unwrap_or_default() - } - - /// Get slow queries. - pub fn slow_queries(&self, threshold_ms: u64) -> Vec { - self.logs - .read() - .map(|logs| { - logs.iter() - .filter(|l| l.duration_ms > threshold_ms) - .cloned() - .collect() - }) - .unwrap_or_default() - } - - /// Get query statistics. - pub fn stats(&self) -> QueryStats { - let total = self.stats.total_queries.load(Ordering::SeqCst); - let total_duration = self.stats.total_duration_ms.load(Ordering::SeqCst); - - QueryStats { - total_queries: total, - total_duration_ms: total_duration, - slow_queries: self.stats.slow_queries.load(Ordering::SeqCst), - avg_duration_ms: if total > 0 { - total_duration as f64 / total as f64 - } else { - 0.0 - }, - max_duration_ms: self.stats.max_duration_ms.load(Ordering::SeqCst), - } - } - - /// Clear logs and reset stats. - pub fn clear(&self) { - if let Ok(mut logs) = self.logs.write() { - logs.clear(); - } - self.stats.total_queries.store(0, Ordering::SeqCst); - self.stats.total_duration_ms.store(0, Ordering::SeqCst); - self.stats.slow_queries.store(0, Ordering::SeqCst); - self.stats.max_duration_ms.store(0, Ordering::SeqCst); - } -} - -fn truncate_query(query: &str, max_len: usize) -> String { - let query = query.trim(); - if query.len() <= max_len { - query.to_string() - } else { - format!("{}...", &query[..max_len]) - } -} - -/// Global query profiler. -static PROFILER: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Initialize global profiler. -pub fn init_profiler() -> &'static QueryProfiler { - PROFILER.get_or_init(QueryProfiler::new) -} - -/// Get global profiler. -pub fn profiler() -> Option<&'static QueryProfiler> { - PROFILER.get() -} diff --git a/src/shared/utils/infra/resource.rs b/src/shared/utils/infra/resource.rs deleted file mode 100644 index e0d7983..0000000 --- a/src/shared/utils/infra/resource.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! API Resources / Transformers. -//! -//! Transform models for API output with field selection and relationships. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::resource::{Resource, ResourceCollection}; -//! -//! struct UserResource; -//! impl Resource for UserResource { -//! type Model = User; -//! fn transform(model: &Self::Model) -> serde_json::Value { -//! json!({ "id": model.id, "name": model.name }) -//! } -//! } -//! ``` - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; - -/// Resource trait for transforming models to API output. -pub trait Resource { - /// The model type being transformed. - type Model; - - /// Transform a single model to JSON. - fn transform(model: &Self::Model) -> Value; - - /// Transform with additional data. - fn transform_with(model: &Self::Model, _extra: &HashMap) -> Value { - Self::transform(model) - } - - /// Get field whitelist (if any). - fn fields() -> Option> { - None - } - - /// Get hidden fields. - fn hidden() -> Vec<&'static str> { - vec![] - } -} - -/// Resource collection for paginated results. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceCollection { - pub data: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub links: Option, -} - -/// Collection metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollectionMeta { - pub current_page: u64, - pub per_page: u64, - pub total: u64, - pub total_pages: u64, -} - -/// Collection links. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollectionLinks { - #[serde(skip_serializing_if = "Option::is_none")] - pub first: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next: Option, -} - -impl ResourceCollection { - /// Create a simple collection without pagination. - pub fn new(data: Vec) -> Self { - Self { - data, - meta: None, - links: None, - } - } - - /// Create a paginated collection. - pub fn paginated(data: Vec, page: u64, per_page: u64, total: u64) -> Self { - let total_pages = (total as f64 / per_page as f64).ceil() as u64; - Self { - data, - meta: Some(CollectionMeta { - current_page: page, - per_page, - total, - total_pages, - }), - links: None, - } - } - - /// Add links. - pub fn with_links(mut self, base_url: &str, page: u64, total_pages: u64) -> Self { - let first = Some(format!("{}?page=1", base_url)); - let last = Some(format!("{}?page={}", base_url, total_pages)); - let prev = if page > 1 { - Some(format!("{}?page={}", base_url, page - 1)) - } else { - None - }; - let next = if page < total_pages { - Some(format!("{}?page={}", base_url, page + 1)) - } else { - None - }; - - self.links = Some(CollectionLinks { - first, - last, - prev, - next, - }); - self - } -} - -/// Transform a collection of models using a resource. -pub fn collection(models: &[R::Model]) -> Vec { - models.iter().map(R::transform).collect() -} - -/// Transform a single model using a resource. -pub fn item(model: &R::Model) -> Value { - R::transform(model) -} - -/// Filter fields from a JSON value. -pub fn only(value: Value, fields: &[&str]) -> Value { - if let Value::Object(map) = value { - let filtered: serde_json::Map = map - .into_iter() - .filter(|(k, _)| fields.contains(&k.as_str())) - .collect(); - Value::Object(filtered) - } else { - value - } -} - -/// Remove fields from a JSON value. -pub fn except(value: Value, fields: &[&str]) -> Value { - if let Value::Object(map) = value { - let filtered: serde_json::Map = map - .into_iter() - .filter(|(k, _)| !fields.contains(&k.as_str())) - .collect(); - Value::Object(filtered) - } else { - value - } -} - -/// Merge additional data into a JSON object. -pub fn merge(mut value: Value, extra: Value) -> Value { - if let (Value::Object(ref mut map1), Value::Object(map2)) = (&mut value, extra) { - for (k, v) in map2 { - map1.insert(k, v); - } - } - value -} - -/// Wrap data in a standard API envelope. -pub fn envelope(data: Value) -> Value { - json!({ "data": data }) -} - -/// Wrap data with success status. -pub fn success(data: Value) -> Value { - json!({ - "success": true, - "data": data - }) -} - -/// Wrap error response. -pub fn error(message: &str, code: Option<&str>) -> Value { - let mut resp = json!({ - "success": false, - "error": { "message": message } - }); - if let Some(c) = code { - resp["error"]["code"] = Value::String(c.to_string()); - } - resp -} diff --git a/src/shared/utils/infra/ryzen_cdn.rs b/src/shared/utils/infra/ryzen_cdn.rs deleted file mode 100644 index 79ed75c..0000000 --- a/src/shared/utils/infra/ryzen_cdn.rs +++ /dev/null @@ -1,67 +0,0 @@ -use crate::shared::errors::AppError; -use infer; // For file type detection -use reqwest::{multipart, Client}; -use serde::{Deserialize, Serialize}; -use tracing::error; - -#[derive(Debug, Serialize, Deserialize)] -pub struct RyzenCDNResponse { - pub success: bool, - pub message: Option, - pub url: Option, -} - -pub async fn ryzen_cdn( - inp: Vec, // Simplified input to a single byte vector for now - original_name: Option, -) -> Result { - let client = Client::new(); - let form = multipart::Form::new(); - - let file_type = infer::get(&inp); - let mime_type = file_type.as_ref().map(|t| t.mime_type()); - let extension = file_type.as_ref().map(|t| t.extension()); - - let file_name = if let Some(name) = original_name { - if let Some(ext) = extension { - format!("{}.{}", name.split('.').next().unwrap_or("file"), ext) - } else { - name - } - } else { - "file".to_string() - }; - - let part = multipart::Part::bytes(inp) - .file_name(file_name) - .mime_str(mime_type.unwrap_or("application/octet-stream"))?; - - let form = form.part("file", part); - - let res = client.post("https://api.ryzumi.vip/api/uploader/ryzencdn") - .multipart(form) - .header("accept", "application/json") - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0") - .header("Connection", "keep-alive") - .header("Accept-Encoding", "gzip, deflate, br") - .send() - .await?; - - let json_response: RyzenCDNResponse = res.json().await?; - - if !json_response.success { - let error_message = json_response - .message - .unwrap_or_else(|| "Upload failed".to_string()); - error!("RyzenCDN Error: {}", error_message); - return Err(AppError::Other(error_message)); - } - - if let Some(url) = json_response.url { - Ok(url) - } else { - Err(AppError::Other( - "RyzenCDN Error: URL not found in response".to_string(), - )) - } -} diff --git a/src/shared/utils/infra/searchable.rs b/src/shared/utils/infra/searchable.rs deleted file mode 100644 index a376d09..0000000 --- a/src/shared/utils/infra/searchable.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Searchable trait for simple full-text search. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::searchable::{Searchable, SearchQuery, search}; -//! -//! impl Searchable for User { -//! fn searchable_fields() -> Vec<&'static str> { -//! vec!["name", "email", "bio"] -//! } -//! } -//! -//! let query = SearchQuery::new("john") -//! .fields(vec!["name", "email"]); -//! ``` - -use sea_orm::{ColumnTrait, Condition}; -use serde::{Deserialize, Serialize}; - -/// Searchable trait for models. -pub trait Searchable { - /// Get searchable field names. - fn searchable_fields() -> Vec<&'static str>; - - /// Get default search weight for a field (1-10). - fn field_weight(_field: &str) -> u32 { - 1 - } -} - -/// Search query options. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SearchQuery { - /// Search term. - pub term: String, - /// Fields to search (empty = all). - pub fields: Vec, - /// Minimum match score. - pub min_score: f32, - /// Fuzzy matching. - pub fuzzy: bool, -} - -impl SearchQuery { - pub fn new(term: &str) -> Self { - Self { - term: term.to_string(), - fields: Vec::new(), - min_score: 0.0, - fuzzy: false, - } - } - - pub fn fields(mut self, fields: Vec<&str>) -> Self { - self.fields = fields.into_iter().map(String::from).collect(); - self - } - - pub fn min_score(mut self, score: f32) -> Self { - self.min_score = score; - self - } - - pub fn fuzzy(mut self) -> Self { - self.fuzzy = true; - self - } -} - -/// Search result with scoring. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub item: T, - pub score: f32, - pub highlights: Vec, -} - -/// Search highlight. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchHighlight { - pub field: String, - pub snippet: String, -} - -/// Simple text matching score. -pub fn calculate_score(text: &str, query: &str, fuzzy: bool) -> f32 { - let text_lower = text.to_lowercase(); - let query_lower = query.to_lowercase(); - - if text_lower == query_lower { - return 1.0; - } - - if text_lower.contains(&query_lower) { - // Position-based scoring - let pos = text_lower.find(&query_lower).unwrap_or(0); - let pos_score = 1.0 - (pos as f32 / text.len() as f32); - return 0.5 + (pos_score * 0.3); - } - - if fuzzy { - // Simple fuzzy: word overlap - let text_words: Vec<&str> = text_lower.split_whitespace().collect(); - let query_words: Vec<&str> = query_lower.split_whitespace().collect(); - - let mut matches = 0; - for qw in &query_words { - for tw in &text_words { - if tw.contains(qw) || qw.contains(tw) { - matches += 1; - break; - } - } - } - - if !query_words.is_empty() { - return matches as f32 / query_words.len() as f32 * 0.5; - } - } - - 0.0 -} - -/// Generate highlight snippet. -pub fn generate_highlight(text: &str, query: &str, context_len: usize) -> Option { - let text_lower = text.to_lowercase(); - let query_lower = query.to_lowercase(); - - if let Some(pos) = text_lower.find(&query_lower) { - let start = pos.saturating_sub(context_len); - let end = (pos + query.len() + context_len).min(text.len()); - - let mut snippet = String::new(); - if start > 0 { - snippet.push_str("..."); - } - snippet.push_str(&text[start..end]); - if end < text.len() { - snippet.push_str("..."); - } - - return Some(snippet); - } - - None -} - -/// Search in a vector of strings. -pub fn search_in_vec(items: &[String], query: &SearchQuery) -> Vec<(usize, f32)> { - let mut results: Vec<(usize, f32)> = items - .iter() - .enumerate() - .filter_map(|(i, text)| { - let score = calculate_score(text, &query.term, query.fuzzy); - if score >= query.min_score { - Some((i, score)) - } else { - None - } - }) - .collect(); - - results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - results -} - -/// Build LIKE conditions for search. -pub fn build_like_condition(columns: Vec, term: &str) -> Condition { - let pattern = format!("%{}%", term); - let mut condition = Condition::any(); - - for col in columns { - condition = condition.add(col.contains(&pattern)); - } - - condition -} - -/// Macro to implement searchable for entity. -#[macro_export] -macro_rules! impl_searchable { - ($entity:ty, $($field:ident),+) => { - impl $crate::shared::utils::searchable::Searchable for $entity { - fn searchable_fields() -> Vec<&'static str> { - vec![$(stringify!($field)),+] - } - } - }; -} diff --git a/src/shared/utils/infra/transaction.rs b/src/shared/utils/infra/transaction.rs deleted file mode 100644 index 2e00803..0000000 --- a/src/shared/utils/infra/transaction.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Database Transaction helpers. -//! -//! Atomic database operations with automatic rollback. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::transaction::{transaction, TransactionExt}; -//! -//! let result = transaction(&db, |txn| async move { -//! User::insert(user1).exec(&txn).await?; -//! User::insert(user2).exec(&txn).await?; -//! Ok(()) -//! }).await?; -//! ``` - -use sea_orm::{DatabaseConnection, DatabaseTransaction, DbErr, TransactionTrait}; -use std::future::Future; - -/// Transaction error. -#[derive(Debug, thiserror::Error)] -pub enum TransactionError { - #[error("Database error: {0}")] - DbError(#[from] DbErr), - #[error("Transaction failed: {0}")] - Failed(String), -} - -/// Execute a closure within a database transaction. -pub async fn transaction(db: &DatabaseConnection, f: F) -> Result -where - F: FnOnce(DatabaseTransaction) -> Fut, - Fut: Future>, -{ - let txn = db.begin().await?; - - match f(txn).await { - Ok(result) => Ok(result), - Err(e) => Err(TransactionError::DbError(e)), - } -} - -/// Execute with automatic commit/rollback. -pub async fn with_transaction( - db: &DatabaseConnection, - f: F, -) -> Result -where - F: FnOnce(&DatabaseTransaction) -> Fut, - Fut: Future>, -{ - let txn = db.begin().await?; - - match f(&txn).await { - Ok(result) => { - txn.commit().await?; - Ok(result) - } - Err(e) => { - txn.rollback().await?; - Err(TransactionError::DbError(e)) - } - } -} - -/// Nested transaction support (savepoints). -pub struct NestedTransaction { - depth: usize, -} - -impl NestedTransaction { - pub fn new() -> Self { - Self { depth: 0 } - } - - pub fn begin(&mut self) -> usize { - self.depth += 1; - self.depth - } - - pub fn commit(&mut self) -> usize { - if self.depth > 0 { - self.depth -= 1; - } - self.depth - } - - pub fn rollback(&mut self) -> usize { - if self.depth > 0 { - self.depth -= 1; - } - self.depth - } - - pub fn depth(&self) -> usize { - self.depth - } -} - -impl Default for NestedTransaction { - fn default() -> Self { - Self::new() - } -} - -/// Retry transaction on deadlock. -pub async fn retry_transaction( - db: &DatabaseConnection, - max_retries: usize, - f: F, -) -> Result -where - F: Fn() -> Fut + Clone, - Fut: Future>, -{ - let mut attempts = 0; - let mut last_error = None; - - while attempts < max_retries { - let txn = db.begin().await?; - - match f().await { - Ok(result) => { - txn.commit().await?; - return Ok(result); - } - Err(e) => { - txn.rollback().await.ok(); - - // Check if deadlock (simplified - actual check would be DB-specific) - let is_deadlock = e.to_string().to_lowercase().contains("deadlock"); - - if is_deadlock && attempts < max_retries - 1 { - attempts += 1; - tokio::time::sleep(tokio::time::Duration::from_millis(100 * attempts as u64)) - .await; - continue; - } - - last_error = Some(e); - break; - } - } - } - - Err(TransactionError::DbError(last_error.unwrap())) -} - -/// Transaction context for tracking. -#[derive(Debug, Clone)] -pub struct TransactionContext { - pub id: String, - pub started_at: chrono::DateTime, - pub operations: usize, -} - -impl TransactionContext { - pub fn new() -> Self { - Self { - id: uuid::Uuid::new_v4().to_string(), - started_at: chrono::Utc::now(), - operations: 0, - } - } - - pub fn record_operation(&mut self) { - self.operations += 1; - } - - pub fn duration(&self) -> chrono::Duration { - chrono::Utc::now() - self.started_at - } -} - -impl Default for TransactionContext { - fn default() -> Self { - Self::new() - } -} diff --git a/src/shared/utils/infra/uuid_utils.rs b/src/shared/utils/infra/uuid_utils.rs deleted file mode 100644 index 4c89595..0000000 --- a/src/shared/utils/infra/uuid_utils.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! UUID utilities. - -use uuid::Uuid; - -/// Generate a new UUID v4. -pub fn new_v4() -> String { - Uuid::new_v4().to_string() -} - -/// Generate a new UUID v4 without hyphens. -pub fn new_v4_simple() -> String { - Uuid::new_v4().simple().to_string() -} - -/// Parse a UUID string. -pub fn parse(s: &str) -> Result { - Uuid::parse_str(s) -} - -/// Check if string is valid UUID. -pub fn is_valid(s: &str) -> bool { - Uuid::parse_str(s).is_ok() -} - -/// Convert to hyphenated format. -pub fn to_hyphenated(s: &str) -> Option { - Uuid::parse_str(s).ok().map(|u| u.hyphenated().to_string()) -} - -/// Convert to simple format (no hyphens). -pub fn to_simple(s: &str) -> Option { - Uuid::parse_str(s).ok().map(|u| u.simple().to_string()) -} - -/// Generate a nil UUID (all zeros). -pub fn nil() -> String { - Uuid::nil().to_string() -} - -/// Check if UUID is nil. -pub fn is_nil(s: &str) -> bool { - parse(s).map(|u| u.is_nil()).unwrap_or(false) -} - -/// Extract timestamp from UUID v7 (if applicable). -pub fn timestamp_v7(s: &str) -> Option { - parse(s).ok().and_then(|u| { - if u.get_version() == Some(uuid::Version::SortRand) { - u.get_timestamp().map(|ts| { - let (secs, _) = ts.to_unix(); - secs - }) - } else { - None - } - }) -} - -/// Create a UUID namespace for v5. -pub fn namespace(ns: &str) -> Option { - match ns.to_lowercase().as_str() { - "dns" => Some(Uuid::NAMESPACE_DNS), - "url" => Some(Uuid::NAMESPACE_URL), - "oid" => Some(Uuid::NAMESPACE_OID), - "x500" => Some(Uuid::NAMESPACE_X500), - _ => Uuid::parse_str(ns).ok(), - } -} - -/// Generate a short ID (first 8 chars of UUID). -pub fn short_id() -> String { - Uuid::new_v4().simple().to_string()[..8].to_string() -} - -/// Generate a medium ID (first 12 chars of UUID). -pub fn medium_id() -> String { - Uuid::new_v4().simple().to_string()[..12].to_string() -} diff --git a/src/shared/utils/infra/versioning.rs b/src/shared/utils/infra/versioning.rs deleted file mode 100644 index 47256c1..0000000 --- a/src/shared/utils/infra/versioning.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Versioned API helpers. -//! -//! API version extraction and routing. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::versioning::{ApiVersion, extract_version}; -//! -//! // From header: Accept: application/vnd.api+json; version=2 -//! let version = extract_version(&headers); -//! -//! // From path: /api/v2/users -//! let version = ApiVersion::from_path("/api/v2/users"); -//! ``` - -use axum::http::{header::ACCEPT, HeaderMap}; -use serde::{Deserialize, Serialize}; -use std::cmp::Ordering; - -/// API version. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ApiVersion { - pub major: u32, - pub minor: u32, -} - -impl ApiVersion { - /// Create a new version. - pub const fn new(major: u32, minor: u32) -> Self { - Self { major, minor } - } - - /// Create from major version only. - pub const fn major_only(major: u32) -> Self { - Self { major, minor: 0 } - } - - /// Parse from string like "v2" or "2.1". - pub fn parse(s: &str) -> Option { - let s = s.trim().trim_start_matches('v').trim_start_matches('V'); - - if let Some((major, minor)) = s.split_once('.') { - Some(Self { - major: major.parse().ok()?, - minor: minor.parse().ok()?, - }) - } else { - Some(Self { - major: s.parse().ok()?, - minor: 0, - }) - } - } - - /// Extract from URL path like /api/v2/users. - pub fn from_path(path: &str) -> Option { - for segment in path.split('/') { - if segment.starts_with('v') || segment.starts_with('V') { - if let Some(v) = Self::parse(segment) { - return Some(v); - } - } - } - None - } - - /// Extract from Accept header. - pub fn from_accept_header(accept: &str) -> Option { - // Format: application/vnd.api+json; version=2 - for part in accept.split(';') { - let part = part.trim(); - if part.starts_with("version=") { - let version = part.trim_start_matches("version="); - return Self::parse(version); - } - } - None - } - - /// Check if version is at least the given version. - pub fn at_least(&self, major: u32, minor: u32) -> bool { - self.major > major || (self.major == major && self.minor >= minor) - } - - /// Check if version is below the given version. - pub fn below(&self, major: u32, minor: u32) -> bool { - !self.at_least(major, minor) - } -} - -impl Default for ApiVersion { - fn default() -> Self { - Self { major: 1, minor: 0 } - } -} - -impl std::fmt::Display for ApiVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "v{}.{}", self.major, self.minor) - } -} - -impl PartialOrd for ApiVersion { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for ApiVersion { - fn cmp(&self, other: &Self) -> Ordering { - match self.major.cmp(&other.major) { - Ordering::Equal => self.minor.cmp(&other.minor), - other => other, - } - } -} - -/// Extract API version from request. -pub fn extract_version(headers: &HeaderMap) -> ApiVersion { - // Try Accept header first - if let Some(accept) = headers.get(ACCEPT).and_then(|h| h.to_str().ok()) { - if let Some(v) = ApiVersion::from_accept_header(accept) { - return v; - } - } - - // Try custom header - if let Some(version) = headers.get("X-API-Version").and_then(|h| h.to_str().ok()) { - if let Some(v) = ApiVersion::parse(version) { - return v; - } - } - - ApiVersion::default() -} - -/// Common API versions. -pub mod versions { - use super::ApiVersion; - - pub const V1: ApiVersion = ApiVersion::new(1, 0); - pub const V2: ApiVersion = ApiVersion::new(2, 0); - pub const V3: ApiVersion = ApiVersion::new(3, 0); -} - -/// Version constraint for routing. -#[derive(Debug, Clone)] -pub struct VersionConstraint { - pub min: Option, - pub max: Option, -} - -impl VersionConstraint { - pub fn new() -> Self { - Self { - min: None, - max: None, - } - } - - pub fn min(mut self, version: ApiVersion) -> Self { - self.min = Some(version); - self - } - - pub fn max(mut self, version: ApiVersion) -> Self { - self.max = Some(version); - self - } - - pub fn matches(&self, version: ApiVersion) -> bool { - if let Some(min) = self.min { - if version < min { - return false; - } - } - if let Some(max) = self.max { - if version > max { - return false; - } - } - true - } -} - -impl Default for VersionConstraint { - fn default() -> Self { - Self::new() - } -} diff --git a/src/shared/utils/io/cache_tags.rs b/src/shared/utils/io/cache_tags.rs deleted file mode 100644 index 0f344d2..0000000 --- a/src/shared/utils/io/cache_tags.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Cache tags for tag-based cache invalidation. -//! -//! Extends the basic cache helper with tag support for grouped invalidation. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::cache_tags::TaggedCache; -//! -//! let cache = TaggedCache::new(redis_pool); -//! -//! // Set with tags -//! cache.put_tagged("user:123", data, &["users", "user:123"], 3600).await?; -//! cache.put_tagged("user:456", data2, &["users", "user:456"], 3600).await?; -//! -//! // Invalidate all users -//! cache.flush_tag("users").await?; -//! ``` - -use deadpool_redis::{redis::AsyncCommands, Pool}; -use serde::{de::DeserializeOwned, Serialize}; -use std::sync::Arc; - -/// Tagged cache error types. -#[derive(Debug, thiserror::Error)] -pub enum CacheTagError { - #[error("Redis error: {0}")] - RedisError(String), - #[error("Serialization error: {0}")] - SerializationError(String), - #[error("Deserialization error: {0}")] - DeserializationError(String), -} - -/// Cache with tag-based invalidation support. -#[derive(Clone)] -pub struct TaggedCache { - pool: Arc, - prefix: String, - tag_prefix: String, -} - -impl TaggedCache { - /// Create a new tagged cache. - pub fn new(pool: Arc) -> Self { - Self { - pool, - prefix: "cache:".to_string(), - tag_prefix: "cache:tag:".to_string(), - } - } - - /// Create with custom prefix. - pub fn with_prefix(pool: Arc, prefix: &str) -> Self { - Self { - pool, - prefix: format!("{}:", prefix), - tag_prefix: format!("{}:tag:", prefix), - } - } - - /// Generate cache key. - fn key(&self, key: &str) -> String { - format!("{}{}", self.prefix, key) - } - - /// Generate tag key. - fn tag_key(&self, tag: &str) -> String { - format!("{}{}", self.tag_prefix, tag) - } - - /// Get a value from cache. - pub async fn get(&self, key: &str) -> Result, CacheTagError> { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let cache_key = self.key(key); - let value: Option = conn.get(&cache_key).await.map_err(|e| { - tracing::error!("Redis get error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - match value { - Some(json) => { - let data: T = serde_json::from_str(&json) - .map_err(|e| CacheTagError::DeserializationError(e.to_string()))?; - Ok(Some(data)) - } - None => Ok(None), - } - } - - /// Put a value in cache with TTL (seconds). - pub async fn put( - &self, - key: &str, - value: &T, - ttl: u64, - ) -> Result<(), CacheTagError> { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let cache_key = self.key(key); - let json = serde_json::to_string(value) - .map_err(|e| CacheTagError::SerializationError(e.to_string()))?; - - conn.set_ex::<_, _, ()>(&cache_key, &json, ttl) - .await - .map_err(|e| { - tracing::error!("Redis set error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - Ok(()) - } - - /// Put a value in cache with tags. - pub async fn put_tagged( - &self, - key: &str, - value: &T, - tags: &[&str], - ttl: u64, - ) -> Result<(), CacheTagError> { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let cache_key = self.key(key); - let json = serde_json::to_string(value) - .map_err(|e| CacheTagError::SerializationError(e.to_string()))?; - - // Set the value - conn.set_ex::<_, _, ()>(&cache_key, &json, ttl) - .await - .map_err(|e| { - tracing::error!("Redis set error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - // Add key to each tag set - for tag in tags { - let tag_key = self.tag_key(tag); - conn.sadd::<_, _, ()>(&tag_key, &cache_key) - .await - .map_err(|e| { - tracing::error!("Redis sadd error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - } - - Ok(()) - } - - /// Delete a specific key. - pub async fn forget(&self, key: &str) -> Result<(), CacheTagError> { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let cache_key = self.key(key); - conn.del::<_, ()>(&cache_key).await.map_err(|e| { - tracing::error!("Redis del error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - Ok(()) - } - - /// Flush all keys associated with a tag. - pub async fn flush_tag(&self, tag: &str) -> Result { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let tag_key = self.tag_key(tag); - - // Get all keys in the tag set - let keys: Vec = conn.smembers(&tag_key).await.map_err(|e| { - tracing::error!("Redis smembers error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let count = keys.len(); - - // Delete each key - for key in &keys { - let _: () = conn.del(key).await.unwrap_or(()); - } - - // Delete the tag set itself - conn.del::<_, ()>(&tag_key).await.map_err(|e| { - tracing::error!("Redis del error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - tracing::info!("Flushed {} keys for tag '{}'", count, tag); - Ok(count) - } - - /// Flush multiple tags at once. - pub async fn flush_tags(&self, tags: &[&str]) -> Result { - let mut total = 0; - for tag in tags { - total += self.flush_tag(tag).await?; - } - Ok(total) - } - - /// Check if a key exists. - pub async fn has(&self, key: &str) -> Result { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let cache_key = self.key(key); - let exists: bool = conn.exists(&cache_key).await.map_err(|e| { - tracing::error!("Redis exists error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - Ok(exists) - } - - /// Get or set a value (cache-aside pattern). - pub async fn remember(&self, key: &str, ttl: u64, f: F) -> Result - where - T: Serialize + DeserializeOwned, - F: FnOnce() -> Fut, - Fut: std::future::Future>, - { - // Try to get from cache - if let Some(value) = self.get::(key).await? { - return Ok(value); - } - - // Generate value - let value = f().await?; - - // Store in cache - self.put(key, &value, ttl).await?; - - Ok(value) - } - - /// Get or set a value with tags. - pub async fn remember_tagged( - &self, - key: &str, - tags: &[&str], - ttl: u64, - f: F, - ) -> Result - where - T: Serialize + DeserializeOwned, - F: FnOnce() -> Fut, - Fut: std::future::Future>, - { - // Try to get from cache - if let Some(value) = self.get::(key).await? { - return Ok(value); - } - - // Generate value - let value = f().await?; - - // Store in cache with tags - self.put_tagged(key, &value, tags, ttl).await?; - - Ok(value) - } - - /// Get cache statistics for a tag. - pub async fn tag_count(&self, tag: &str) -> Result { - let mut conn = self.pool.get().await.map_err(|e| { - tracing::error!("Redis connection error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - let tag_key = self.tag_key(tag); - let count: usize = conn.scard(&tag_key).await.map_err(|e| { - tracing::error!("Redis scard error: {}", e); - CacheTagError::RedisError(e.to_string()) - })?; - - Ok(count) - } -} - -/// Helper to create cache tags from entity type and ID. -pub fn entity_tags(entity_type: &str, id: &str) -> Vec { - vec![entity_type.to_string(), format!("{}:{}", entity_type, id)] -} - -/// Helper to create cache key for entity. -pub fn entity_key(entity_type: &str, id: &str) -> String { - format!("{}:{}", entity_type, id) -} diff --git a/src/shared/utils/io/cache_ttl.rs b/src/shared/utils/io/cache_ttl.rs deleted file mode 100644 index 9b1a0f9..0000000 --- a/src/shared/utils/io/cache_ttl.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Cache TTL constants for consistent caching across the application. - -/// Very short TTL for highly volatile data (5 minutes) -/// Use for: Real-time data, user presence, active sessions -pub const CACHE_TTL_VERY_SHORT: u64 = 300; - -/// Short TTL for frequently changing data (15 minutes) -/// Use for: Trending content, live feeds, dynamic lists -pub const CACHE_TTL_SHORT: u64 = 900; - -/// Medium TTL for regular data (1 hour) -/// Use for: User profiles, search results, API responses -pub const CACHE_TTL_MEDIUM: u64 = 3600; - -/// Long TTL for mostly static data (6 hours) -/// Use for: Configuration, reference data, translations -pub const CACHE_TTL_LONG: u64 = 21600; - -/// Very long TTL for static content (1 day) -/// Use for: Static pages, archived content, historical data -pub const CACHE_TTL_VERY_LONG: u64 = 86400; - -/// Image cache TTL (7 days) -/// Use for: Cached images, thumbnails, avatars -pub const CACHE_TTL_IMAGE: u64 = 604800; - -/// CDN cache TTL (30 days) -/// Use for: Immutable assets, versioned files -pub const CACHE_TTL_CDN: u64 = 2592000; - -/// Get TTL based on cache type -pub fn get_ttl_for(cache_type: CacheType) -> u64 { - match cache_type { - CacheType::RealTime => CACHE_TTL_VERY_SHORT, - CacheType::Volatile => CACHE_TTL_SHORT, - CacheType::Regular => CACHE_TTL_MEDIUM, - CacheType::Stable => CACHE_TTL_LONG, - CacheType::Static => CACHE_TTL_VERY_LONG, - CacheType::Image => CACHE_TTL_IMAGE, - CacheType::Cdn => CACHE_TTL_CDN, - } -} - -/// Cache type classification -#[derive(Debug, Clone, Copy)] -pub enum CacheType { - /// Real-time data (5 min) - RealTime, - /// Volatile data (15 min) - Volatile, - /// Regular data (1 hour) - Regular, - /// Stable data (6 hours) - Stable, - /// Static data (1 day) - Static, - /// Images (7 days) - Image, - /// CDN assets (30 days) - Cdn, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cache_ttl_values() { - assert_eq!(CACHE_TTL_VERY_SHORT, 300); - assert_eq!(CACHE_TTL_SHORT, 900); - assert_eq!(CACHE_TTL_MEDIUM, 3600); - assert_eq!(CACHE_TTL_LONG, 21600); - assert_eq!(CACHE_TTL_VERY_LONG, 86400); - assert_eq!(CACHE_TTL_IMAGE, 604800); - assert_eq!(CACHE_TTL_CDN, 2592000); - } - - #[test] - fn test_get_ttl_for() { - assert_eq!(get_ttl_for(CacheType::RealTime), 300); - assert_eq!(get_ttl_for(CacheType::Regular), 3600); - assert_eq!(get_ttl_for(CacheType::Image), 604800); - } -} diff --git a/src/shared/utils/io/file.rs b/src/shared/utils/io/file.rs deleted file mode 100644 index 89e58f1..0000000 --- a/src/shared/utils/io/file.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! File utilities. - -use std::path::Path; -use tokio::fs; -use tokio::io::AsyncWriteExt; - -/// Read file contents as string. -pub async fn read_file(path: &str) -> anyhow::Result { - Ok(fs::read_to_string(path).await?) -} - -/// Read file contents as bytes. -pub async fn read_bytes(path: &str) -> anyhow::Result> { - Ok(fs::read(path).await?) -} - -/// Write string to file. -pub async fn write_file(path: &str, contents: &str) -> anyhow::Result<()> { - fs::write(path, contents).await?; - Ok(()) -} - -/// Write bytes to file. -pub async fn write_bytes(path: &str, contents: &[u8]) -> anyhow::Result<()> { - fs::write(path, contents).await?; - Ok(()) -} - -/// Append to file. -pub async fn append_file(path: &str, contents: &str) -> anyhow::Result<()> { - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .await?; - file.write_all(contents.as_bytes()).await?; - Ok(()) -} - -/// Check if file exists. -pub async fn file_exists(path: &str) -> bool { - fs::metadata(path).await.is_ok() -} - -/// Check if path is a directory. -pub async fn is_directory(path: &str) -> bool { - fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - -/// Create directory (including parents). -pub async fn create_dir(path: &str) -> anyhow::Result<()> { - fs::create_dir_all(path).await?; - Ok(()) -} - -/// Delete file. -pub async fn delete_file(path: &str) -> anyhow::Result<()> { - fs::remove_file(path).await?; - Ok(()) -} - -/// Delete directory recursively. -pub async fn delete_dir(path: &str) -> anyhow::Result<()> { - fs::remove_dir_all(path).await?; - Ok(()) -} - -/// Get file extension. -pub fn get_extension(path: &str) -> Option { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_lowercase()) -} - -/// Get filename without extension. -pub fn get_filename(path: &str) -> Option { - Path::new(path) - .file_stem() - .and_then(|name| name.to_str()) - .map(String::from) -} - -/// Get file size in bytes. -pub async fn file_size(path: &str) -> anyhow::Result { - let metadata = fs::metadata(path).await?; - Ok(metadata.len()) -} - -/// Format file size to human readable string. -pub fn format_file_size(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} bytes", bytes) - } -} - -/// Get MIME type from file extension. -pub fn mime_from_extension(ext: &str) -> &'static str { - match ext.to_lowercase().as_str() { - "html" | "htm" => "text/html", - "css" => "text/css", - "js" => "application/javascript", - "json" => "application/json", - "xml" => "application/xml", - "txt" => "text/plain", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "svg" => "image/svg+xml", - "webp" => "image/webp", - "ico" => "image/x-icon", - "pdf" => "application/pdf", - "zip" => "application/zip", - "mp3" => "audio/mpeg", - "mp4" => "video/mp4", - "webm" => "video/webm", - _ => "application/octet-stream", - } -} diff --git a/src/shared/utils/io/mod.rs b/src/shared/utils/io/mod.rs deleted file mode 100644 index d94727f..0000000 --- a/src/shared/utils/io/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod cache; -pub mod cache_tags; -pub mod cache_ttl; -pub mod file; -pub mod retry; -pub mod soft_delete; diff --git a/src/shared/utils/io/soft_delete.rs b/src/shared/utils/io/soft_delete.rs deleted file mode 100644 index a930a42..0000000 --- a/src/shared/utils/io/soft_delete.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Soft delete helpers for SeaORM entities. -//! -//! Provides soft delete filtering for entities with a `deleted_at` column. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::soft_delete::{SoftDeletable, soft_delete_filter, SoftDeleteScope}; -//! use sea_orm::*; -//! -//! // Query without deleted -//! let users = User::find() -//! .filter(soft_delete_filter(user::Column::DeletedAt, SoftDeleteScope::WithoutDeleted)) -//! .all(db) -//! .await?; -//! -//! // Query only deleted -//! let deleted = User::find() -//! .filter(soft_delete_filter(user::Column::DeletedAt, SoftDeleteScope::OnlyDeleted)) -//! .all(db) -//! .await?; -//! ``` - -use chrono::{DateTime, Utc}; -use sea_orm::{ColumnTrait, Condition}; - -/// Trait for entities that support soft deletes. -pub trait SoftDeletable { - /// Get the deleted_at timestamp if soft deleted. - fn deleted_at(&self) -> Option>; - - /// Check if the entity is soft deleted. - fn is_deleted(&self) -> bool { - self.deleted_at().is_some() - } - - /// Check if the entity is not soft deleted. - fn is_active(&self) -> bool { - self.deleted_at().is_none() - } -} - -/// Query scope for filtering soft-deleted entities. -#[derive(Debug, Clone, Copy)] -pub enum SoftDeleteScope { - /// Exclude soft-deleted entities (default). - WithoutDeleted, - /// Include soft-deleted entities. - WithDeleted, - /// Only include soft-deleted entities. - OnlyDeleted, -} - -/// Create a condition for soft delete filtering. -/// -/// Use this in your entity queries: -/// ```ignore -/// User::find() -/// .filter(soft_delete_filter(user::Column::DeletedAt, SoftDeleteScope::WithoutDeleted)) -/// .all(db) -/// .await -/// ``` -pub fn soft_delete_filter(column: C, scope: SoftDeleteScope) -> Condition { - match scope { - SoftDeleteScope::WithoutDeleted => Condition::all().add(column.is_null()), - SoftDeleteScope::WithDeleted => Condition::all(), - SoftDeleteScope::OnlyDeleted => Condition::all().add(column.is_not_null()), - } -} - -/// Macro to implement SoftDeletable for a model. -/// -/// Usage: -/// ```ignore -/// impl_soft_deletable!(user::Model, deleted_at); -/// ``` -#[macro_export] -macro_rules! impl_soft_deletable { - ($model:ty, $deleted_at_field:ident) => { - impl $crate::shared::utils::soft_delete::SoftDeletable for $model { - fn deleted_at(&self) -> Option> { - self.$deleted_at_field - } - } - }; -} diff --git a/src/shared/utils/mod.rs b/src/shared/utils/mod.rs deleted file mode 100644 index 3c74736..0000000 --- a/src/shared/utils/mod.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Helper utilities for easier development. -//! -//! Comprehensive collection of updated utility modules for cleaner, more maintainable code. - -// Submodules -pub mod core; -pub mod data; -pub mod dev; -pub mod infra; -pub mod io; -pub mod web; - -// Re-exports for convenience (backward compatibility) - -// Core -pub use core::api_response; -pub use core::errors; -pub use core::handler; -pub use core::pagination; -pub use core::prelude; -pub use core::response; - -// Data -pub use data::collections; -pub use data::convert; -pub use data::datetime; -pub use data::json; -pub use data::numbers; -pub use data::string; -pub use data::text; - -// IO -pub use io::cache; -pub use io::cache_tags; -pub use io::cache_ttl; -pub use io::file; -pub use io::retry; -pub use io::soft_delete; - -// Web -pub use web::http; -pub use web::query; -pub use web::request; -pub use web::scraping; -pub use web::url; - -// Dev -pub use dev::async_utils; -pub use dev::logging; -pub use dev::performance; -pub use dev::result_ext; -pub use dev::serde_helpers; -pub use dev::testing; - -// Infra -pub use infra::bulk; -pub use infra::console; -pub use infra::encryption; -pub use infra::env; -pub use infra::form_request; -pub use infra::health_check; -pub use infra::import_export; -pub use infra::query_profiler; -pub use infra::resource; -pub use infra::searchable; -pub use infra::transaction; -pub use infra::uuid_utils; -pub use infra::versioning; - -// Ryzen CDN -pub use infra::ryzen_cdn::*; - -// ============================================================================ -// Original Re-exports (preserved) -// ============================================================================ - -// Prelude (common imports) -pub use prelude::*; - -// Response helpers -pub use pagination::*; -pub use response::*; - -// Error helpers -pub use errors::{ - bad_request, db_error, forbidden, internal_err, internal_error, not_found, redis_error, - unauthorized, HandlerError, ResultExt, -}; - -// Retry/Backoff -pub use retry::{ - custom_backoff, default_backoff, permanent, quick_backoff, retry, slow_backoff, transient, -}; - -// Caching -pub use cache::{cache_key, cache_key_multi, Cache, DEFAULT_CACHE_TTL}; - -// Scraping -pub use scraping::{ - attr_from, attr_from_or, extract_number, extract_slug, fetch_html_with_retry, parse_html, - select_attr, select_text, selector, strip_tags, text, text_from, text_from_or, Scraper, -}; - -// Strings -pub use string::{ - initials, is_valid_email, mask_email, random_code, random_string, slugify, title_case, truncate, -}; - -// DateTime -pub use datetime::{ - add_days, add_hours, is_future, is_past, now, parse_iso, relative, timestamp, to_human, to_iso, -}; - -// Crypto - -// Files -pub use file::{ - create_dir, file_exists, format_file_size, get_extension, mime_from_extension, read_file, - write_file, -}; - -// JSON -pub use json::{ - deep_merge, get_i64, get_path, get_str, is_empty, merge, parse, remove_nulls, stringify, - stringify_pretty, -}; - -// URL -pub use url::{ - decode, encode, extract_domain, is_absolute, join_paths, make_absolute, parse_query, UrlBuilder, -}; - -// Logging -pub use logging::{log_and_map, log_error, log_request, PerfLogger, TimedOperation}; - -// Collections -pub use collections::{ - all, any, chunk, count, find, find_index, flatten, frequencies, group_by, partition, reverse, - skip, sum, take, unique, zip, -}; - -// Numbers -pub use numbers::{ - clamp, format_bytes, format_currency, format_number, format_percent, is_even, is_odd, lerp, - parse_f64, parse_i64, percentage, round_to, safe_div, -}; - -// Async -pub use async_utils::{ - join_all, join_all_limited, simple_retry, sleep, sleep_ms, sleep_secs, spawn, spawn_blocking, - timeout_ms, timeout_secs, with_timeout, Debouncer, -}; - -// Serde helpers -pub use serde_helpers::{ - default_empty_string, default_empty_vec, default_false, default_true, default_zero, -}; - -// Text processing -pub use text::{ - capitalize, highlight, lorem_ipsum, normalize_whitespace, remove_accents, to_camel_case, - to_constant_case, to_kebab_case, to_pascal_case, to_snake_case, truncate_words, word_count, -}; - -// Conversions -pub use convert::{ - bool_to_str, bytes_to_hex, empty_to_none, hex_to_bytes, i64_to_usize, ms_to_human, - none_to_empty, parse_or, seconds_to_human, to_bool, try_parse, -}; - -// Result/Option extensions -pub use result_ext::{err, flatten_option, flatten_result, ok, some, OptionExt, ResultExt2}; - -// HTTP Request helpers -pub use request::{ - accepts_gzip, bearer_token, client_ip, content_type, header_value, is_form, is_json, origin, - referer, request_id, user_agent, -}; - -// Environment -pub use env::{ - database_url, get_or as env_get_or, host, is_debug, is_development, is_production, load_dotenv, - port, redis_url, require as env_require, -}; - -// UUID -pub use uuid_utils::{ - is_valid as is_valid_uuid_format, medium_id, new_v4 as uuid_v4, new_v4_simple as uuid_simple, - short_id, -}; diff --git a/src/shared/utils/web/http.rs b/src/shared/utils/web/http.rs deleted file mode 100644 index 7a66b34..0000000 --- a/src/shared/utils/web/http.rs +++ /dev/null @@ -1,26 +0,0 @@ -use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; - -pub fn common_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")); - headers.insert("Referer", HeaderValue::from_static("https://google.com")); - headers -} - -pub fn common_image_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")); - headers.insert( - "Accept", - HeaderValue::from_static( - "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - ), - ); - headers -} - -pub fn is_internet_baik_block_page(content: &str) -> bool { - content.contains("Internet Baik") - || content.contains("TrustPositif") - || content.contains("Mercusuar") -} diff --git a/src/shared/utils/web/mod.rs b/src/shared/utils/web/mod.rs deleted file mode 100644 index 0aecb1d..0000000 --- a/src/shared/utils/web/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod http; -pub mod http_client; -pub mod proxy_fetch; -pub mod query; -pub mod request; -pub mod scraping; -pub mod scraping_urls; -pub mod url; diff --git a/src/shared/utils/web/query.rs b/src/shared/utils/web/query.rs deleted file mode 100644 index b4a33c4..0000000 --- a/src/shared/utils/web/query.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Query builder helpers for SeaORM. -//! -//! Provides utilities for pagination, filtering, and sorting queries. -//! -//! # Example -//! -//! ```ignore -//! use scraper_service::helpers::query::{QueryBuilder, Pagination, SortOrder}; -//! -//! let query = User::find() -//! .apply_pagination(Pagination::new(1, 20)) -//! .apply_sort("created_at", SortOrder::Desc); -//! ``` - -use sea_orm::{entity::prelude::*, Order, QuerySelect, Select}; -use serde::{Deserialize, Serialize}; - -/// Pagination parameters. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pagination { - /// Current page (1-indexed). - pub page: u64, - /// Items per page. - pub per_page: u64, - /// Total items (optional, set after query). - #[serde(skip_deserializing)] - pub total: Option, -} - -impl Pagination { - /// Create new pagination. - pub fn new(page: u64, per_page: u64) -> Self { - Self { - page: page.max(1), - per_page: per_page.clamp(1, 100), - total: None, - } - } - - /// Default pagination (page 1, 20 per page). - pub fn default_page() -> Self { - Self::new(1, 20) - } - - /// Calculate offset for query. - pub fn offset(&self) -> u64 { - (self.page - 1) * self.per_page - } - - /// Calculate total pages. - pub fn total_pages(&self) -> u64 { - match self.total { - Some(total) => (total as f64 / self.per_page as f64).ceil() as u64, - None => 0, - } - } - - /// Check if there's a next page. - pub fn has_next(&self) -> bool { - self.page < self.total_pages() - } - - /// Check if there's a previous page. - pub fn has_prev(&self) -> bool { - self.page > 1 - } - - /// Set total after counting. - pub fn with_total(mut self, total: u64) -> Self { - self.total = Some(total); - self - } -} - -impl Default for Pagination { - fn default() -> Self { - Self::default_page() - } -} - -/// Sort order. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum SortOrder { - Asc, - Desc, -} - -impl From for Order { - fn from(order: SortOrder) -> Self { - match order { - SortOrder::Asc => Order::Asc, - SortOrder::Desc => Order::Desc, - } - } -} - -/// Sort parameter. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Sort { - pub field: String, - pub order: SortOrder, -} - -impl Sort { - pub fn new(field: &str, order: SortOrder) -> Self { - Self { - field: field.to_string(), - order, - } - } - - pub fn asc(field: &str) -> Self { - Self::new(field, SortOrder::Asc) - } - - pub fn desc(field: &str) -> Self { - Self::new(field, SortOrder::Desc) - } -} - -/// Filter operator. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum FilterOp { - Eq, - Ne, - Gt, - Gte, - Lt, - Lte, - Like, - In, - IsNull, - IsNotNull, -} - -/// Filter parameter. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Filter { - pub field: String, - pub op: FilterOp, - pub value: serde_json::Value, -} - -impl Filter { - pub fn eq(field: &str, value: impl Into) -> Self { - Self { - field: field.to_string(), - op: FilterOp::Eq, - value: value.into(), - } - } - - pub fn ne(field: &str, value: impl Into) -> Self { - Self { - field: field.to_string(), - op: FilterOp::Ne, - value: value.into(), - } - } - - pub fn like(field: &str, value: &str) -> Self { - Self { - field: field.to_string(), - op: FilterOp::Like, - value: serde_json::Value::String(value.to_string()), - } - } - - pub fn is_null(field: &str) -> Self { - Self { - field: field.to_string(), - op: FilterOp::IsNull, - value: serde_json::Value::Null, - } - } - - pub fn is_not_null(field: &str) -> Self { - Self { - field: field.to_string(), - op: FilterOp::IsNotNull, - value: serde_json::Value::Null, - } - } -} - -/// Query parameters combining pagination, sort, and filters. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct QueryParams { - #[serde(default)] - pub pagination: Pagination, - #[serde(default)] - pub sort: Option, - #[serde(default)] - pub filters: Vec, - #[serde(default)] - pub search: Option, -} - -impl QueryParams { - pub fn new() -> Self { - Self::default() - } - - pub fn page(mut self, page: u64) -> Self { - self.pagination.page = page.max(1); - self - } - - pub fn per_page(mut self, per_page: u64) -> Self { - self.pagination.per_page = per_page.clamp(1, 100); - self - } - - pub fn sort_by(mut self, field: &str, order: SortOrder) -> Self { - self.sort = Some(Sort::new(field, order)); - self - } - - pub fn filter(mut self, filter: Filter) -> Self { - self.filters.push(filter); - self - } - - pub fn search(mut self, query: &str) -> Self { - self.search = Some(query.to_string()); - self - } -} - -/// Paginated result. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaginatedResult { - pub data: Vec, - pub pagination: PaginationMeta, -} - -/// Pagination metadata for response. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaginationMeta { - pub page: u64, - pub per_page: u64, - pub total: u64, - pub total_pages: u64, - pub has_next: bool, - pub has_prev: bool, -} - -impl PaginatedResult { - pub fn new(data: Vec, pagination: Pagination) -> Self { - let total = pagination.total.unwrap_or(0); - let total_pages = pagination.total_pages(); - - Self { - data, - pagination: PaginationMeta { - page: pagination.page, - per_page: pagination.per_page, - total, - total_pages, - has_next: pagination.has_next(), - has_prev: pagination.has_prev(), - }, - } - } -} - -/// Extension trait for applying pagination to queries. -pub trait PaginateExt { - fn apply_pagination(self, pagination: &Pagination) -> Self; -} - -impl PaginateExt for Select { - fn apply_pagination(self, pagination: &Pagination) -> Self { - self.offset(pagination.offset()).limit(pagination.per_page) - } -} diff --git a/src/shared/utils/web/request.rs b/src/shared/utils/web/request.rs deleted file mode 100644 index 4b68c89..0000000 --- a/src/shared/utils/web/request.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! HTTP request helpers. - -use axum::http::{HeaderMap, HeaderValue}; - -/// Extract client IP from headers (X-Forwarded-For, X-Real-IP). -pub fn client_ip(headers: &HeaderMap) -> Option { - // Try X-Forwarded-For first - if let Some(forwarded) = headers.get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(ip) = value.split(',').next() { - return Some(ip.trim().to_string()); - } - } - } - - // Try X-Real-IP - if let Some(real_ip) = headers.get("x-real-ip") { - if let Ok(value) = real_ip.to_str() { - return Some(value.to_string()); - } - } - - // Try CF-Connecting-IP (Cloudflare) - if let Some(cf_ip) = headers.get("cf-connecting-ip") { - if let Ok(value) = cf_ip.to_str() { - return Some(value.to_string()); - } - } - - None -} - -/// Extract User-Agent from headers. -pub fn user_agent(headers: &HeaderMap) -> Option { - headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Extract Accept-Language from headers. -pub fn accept_language(headers: &HeaderMap) -> Option { - headers - .get("accept-language") - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Extract Authorization bearer token. -pub fn bearer_token(headers: &HeaderMap) -> Option { - headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .map(String::from) -} - -/// Extract content type. -pub fn content_type(headers: &HeaderMap) -> Option { - headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Check if request is JSON. -pub fn is_json(headers: &HeaderMap) -> bool { - content_type(headers) - .map(|ct| ct.contains("application/json")) - .unwrap_or(false) -} - -/// Check if request is form data. -pub fn is_form(headers: &HeaderMap) -> bool { - content_type(headers) - .map(|ct| { - ct.contains("application/x-www-form-urlencoded") || ct.contains("multipart/form-data") - }) - .unwrap_or(false) -} - -/// Extract referer. -pub fn referer(headers: &HeaderMap) -> Option { - headers - .get("referer") - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Extract origin. -pub fn origin(headers: &HeaderMap) -> Option { - headers - .get("origin") - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Extract request ID. -pub fn request_id(headers: &HeaderMap) -> Option { - headers - .get("x-request-id") - .or_else(|| headers.get("x-correlation-id")) - .and_then(|v| v.to_str().ok()) - .map(String::from) -} - -/// Check if request accepts gzip. -pub fn accepts_gzip(headers: &HeaderMap) -> bool { - headers - .get("accept-encoding") - .and_then(|v| v.to_str().ok()) - .map(|v| v.contains("gzip")) - .unwrap_or(false) -} - -/// Create header value. -pub fn header_value(s: &str) -> HeaderValue { - HeaderValue::from_str(s).unwrap_or_else(|_| HeaderValue::from_static("")) -} - -/// Parse quality value from Accept header (e.g., "text/html;q=0.9"). -pub fn parse_accept_quality(accept: &str) -> Vec<(String, f32)> { - accept - .split(',') - .filter_map(|part| { - let mut parts = part.trim().split(';'); - let mime = parts.next()?.trim().to_string(); - let quality = parts - .find_map(|p| { - let p = p.trim(); - if p.starts_with("q=") { - p[2..].parse().ok() - } else { - None - } - }) - .unwrap_or(1.0); - Some((mime, quality)) - }) - .collect() -} diff --git a/src/shared/utils/web/url.rs b/src/shared/utils/web/url.rs deleted file mode 100644 index cce7c86..0000000 --- a/src/shared/utils/web/url.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! URL building and manipulation utilities. - -use std::collections::HashMap; - -/// URL builder for constructing URLs with query parameters. -#[derive(Debug, Clone)] -pub struct UrlBuilder { - base: String, - path_segments: Vec, - query_params: Vec<(String, String)>, -} - -impl UrlBuilder { - /// Create a new URL builder with base URL. - pub fn new(base: impl Into) -> Self { - let base = base.into(); - let base = base.trim_end_matches('/').to_string(); - Self { - base, - path_segments: Vec::new(), - query_params: Vec::new(), - } - } - - /// Add a path segment. - pub fn path(mut self, segment: impl Into) -> Self { - self.path_segments.push(segment.into()); - self - } - - /// Add multiple path segments. - pub fn paths(mut self, segments: &[&str]) -> Self { - for seg in segments { - self.path_segments.push(seg.to_string()); - } - self - } - - /// Add a query parameter. - pub fn query(mut self, key: impl Into, value: impl Into) -> Self { - self.query_params.push((key.into(), value.into())); - self - } - - /// Add optional query parameter (only if Some). - pub fn query_opt(self, key: impl Into, value: Option>) -> Self { - match value { - Some(v) => self.query(key, v), - None => self, - } - } - - /// Add multiple query parameters from HashMap. - pub fn query_map(mut self, params: HashMap) -> Self { - for (k, v) in params { - self.query_params.push((k, v)); - } - self - } - - /// Build the final URL string. - pub fn build(self) -> String { - let mut url = self.base; - - // Add path segments - for segment in self.path_segments { - url.push('/'); - url.push_str(&urlencoding::encode(&segment)); - } - - // Add query params - if !self.query_params.is_empty() { - url.push('?'); - let params: Vec = self - .query_params - .into_iter() - .map(|(k, v)| format!("{}={}", urlencoding::encode(&k), urlencoding::encode(&v))) - .collect(); - url.push_str(¶ms.join("&")); - } - - url - } -} - -/// Encode a string for use in URL. -pub fn encode(s: &str) -> String { - urlencoding::encode(s).to_string() -} - -/// Decode a URL-encoded string. -pub fn decode(s: &str) -> Result { - urlencoding::decode(s).map(|s| s.to_string()) -} - -/// Parse query string into HashMap. -pub fn parse_query(query: &str) -> HashMap { - let query = query.trim_start_matches('?'); - query - .split('&') - .filter_map(|pair| { - let mut parts = pair.splitn(2, '='); - let key = parts.next()?; - let value = parts.next().unwrap_or(""); - Some((decode(key).ok()?, decode(value).ok()?)) - }) - .collect() -} - -/// Extract domain from URL. -pub fn extract_domain(url: &str) -> Option { - let url = url - .trim_start_matches("https://") - .trim_start_matches("http://"); - url.split('/').next().map(|s| s.to_string()) -} - -/// Join URL paths safely. -pub fn join_paths(base: &str, path: &str) -> String { - let base = base.trim_end_matches('/'); - let path = path.trim_start_matches('/'); - format!("{}/{}", base, path) -} - -/// Check if URL is absolute. -pub fn is_absolute(url: &str) -> bool { - url.starts_with("http://") || url.starts_with("https://") -} - -/// Make URL absolute. -pub fn make_absolute(url: &str, base: &str) -> String { - if is_absolute(url) { - url.to_string() - } else { - join_paths(base, url) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_url_builder() { - let url = UrlBuilder::new("https://api.example.com") - .path("users") - .path("123") - .query("page", "1") - .query("limit", "10") - .build(); - assert_eq!(url, "https://api.example.com/users/123?page=1&limit=10"); - } - - #[test] - fn test_parse_query() { - let params = parse_query("?name=John&age=30"); - assert_eq!(params.get("name"), Some(&"John".to_string())); - } -} diff --git a/src/shared/utils/web/validation.rs b/src/shared/utils/web/validation.rs deleted file mode 100644 index 2802ba0..0000000 --- a/src/shared/utils/web/validation.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Input validation helpers. - -use once_cell::sync::Lazy; -use regex::Regex; - -static EMAIL_REGEX: Lazy> = Lazy::new(|| { - Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") -}); - -static URL_REGEX: Lazy> = Lazy::new(|| { - Regex::new(r"^https?://[^\s/$.?#].[^\s]*$") -}); - -static PHONE_REGEX: Lazy> = Lazy::new(|| { - Regex::new(r"^\+?[1-9]\d{1,14}$") -}); - -static UUID_REGEX: Lazy> = Lazy::new(|| { - Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$") -}); - -static SLUG_REGEX: Lazy> = Lazy::new(|| { - Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -}); - -pub fn is_email(s: &str) -> bool { - EMAIL_REGEX.as_ref().map(|r| r.is_match(s)).unwrap_or(false) -} - -pub fn is_url(s: &str) -> bool { - URL_REGEX.as_ref().map(|r| r.is_match(s)).unwrap_or(false) -} - -pub fn is_phone(s: &str) -> bool { - PHONE_REGEX.as_ref().map(|r| r.is_match(s)).unwrap_or(false) -} - -pub fn is_uuid(s: &str) -> bool { - UUID_REGEX.as_ref().map(|r| r.is_match(s)).unwrap_or(false) -} - -pub fn is_slug(s: &str) -> bool { - SLUG_REGEX.as_ref().map(|r| r.is_match(s)).unwrap_or(false) -} - -/// Check if string is not empty. -pub fn is_not_empty(s: &str) -> bool { - !s.trim().is_empty() -} - -/// Check minimum length. -pub fn min_length(s: &str, min: usize) -> bool { - s.len() >= min -} - -/// Check maximum length. -pub fn max_length(s: &str, max: usize) -> bool { - s.len() <= max -} - -/// Check length is within range. -pub fn length_between(s: &str, min: usize, max: usize) -> bool { - s.len() >= min && s.len() <= max -} - -/// Check if string contains only alphanumeric characters. -pub fn is_alphanumeric(s: &str) -> bool { - s.chars().all(|c| c.is_alphanumeric()) -} - -/// Check if string contains only ASCII characters. -pub fn is_ascii(s: &str) -> bool { - s.is_ascii() -} - -/// Check if string is numeric only. -pub fn is_numeric(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_numeric()) -} - -/// Check if number is in range. -pub fn in_range(val: T, min: T, max: T) -> bool { - val >= min && val <= max -} - -/// Validate password strength (min 8 chars, has upper, lower, digit). -pub fn is_strong_password(s: &str) -> bool { - s.len() >= 8 - && s.chars().any(|c| c.is_uppercase()) - && s.chars().any(|c| c.is_lowercase()) - && s.chars().any(|c| c.is_numeric()) -} - -/// Validation result builder. -#[derive(Debug, Default)] -pub struct Validator { - errors: Vec, -} - -impl Validator { - pub fn new() -> Self { - Self::default() - } - - /// Add a validation check. - pub fn check(mut self, condition: bool, message: impl Into) -> Self { - if !condition { - self.errors.push(message.into()); - } - self - } - - /// Check if validation passed. - pub fn is_valid(&self) -> bool { - self.errors.is_empty() - } - - /// Get all errors. - pub fn errors(&self) -> &[String] { - &self.errors - } - - /// Get first error. - pub fn first_error(&self) -> Option<&String> { - self.errors.first() - } - - /// Convert to Result. - pub fn validate(self) -> Result<(), String> { - if self.errors.is_empty() { - Ok(()) - } else { - Err(self.errors.join(", ")) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_email() { - assert!(is_email("test@example.com")); - assert!(!is_email("invalid")); - } - - #[test] - fn test_validator() { - let result = Validator::new() - .check(is_email("test@example.com"), "Invalid email") - .check(min_length("password123", 8), "Password too short") - .validate(); - assert!(result.is_ok()); - } -}