diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index a2c4864..0000000 --- a/src/app.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::{ - config::AppConfig, error::AppError, pokemon::PokeApiClient, rate_limit::RateLimiter, - service::PokedexService, telemetry::Telemetry, translation::TranslationClient, -}; -use axum::{ - Json, Router, - extract::{MatchedPath, Path, Request, State}, - http::{StatusCode, header}, - middleware, - response::{IntoResponse, Response}, - routing::get, -}; -use serde::Serialize; -use std::{sync::Arc, time::Instant}; -use tower::ServiceBuilder; -use tower_http::{ - ServiceBuilderExt, - request_id::MakeRequestUuid, - trace::{DefaultOnResponse, TraceLayer}, -}; - -#[derive(Clone)] -pub struct AppState { - service: Arc, - telemetry: Arc, - rate_limiter: Arc, -} - -impl AppState { - #[must_use] - pub fn new(service: PokedexService, telemetry: Telemetry, rate_limiter: RateLimiter) -> Self { - Self { - service: Arc::new(service), - telemetry: Arc::new(telemetry), - rate_limiter: Arc::new(rate_limiter), - } - } - - pub fn from_config(config: &AppConfig) -> Result { - let telemetry = Telemetry::new(&config.service_name); - let pokemon = PokeApiClient::new(&config.pokeapi_base_url, config.request_timeout)?; - let translations = - TranslationClient::new(&config.translations_base_url, config.request_timeout)?; - let service = PokedexService::new(pokemon, translations, telemetry.metrics()); - Ok(Self::new( - service, - telemetry, - RateLimiter::new(config.rate_limit_per_second, config.rate_limit_burst) - .map_err(|_| AppError::Internal)?, - )) - } -} - -pub fn create_app(state: AppState) -> Router { - let tracing_layers = ServiceBuilder::new() - .set_x_request_id(MakeRequestUuid) - .layer( - TraceLayer::new_for_http() - .make_span_with(|request: &Request| { - let request_id = request - .headers() - .get("x-request-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or("unknown"); - tracing::info_span!( - "http.request", - method = %request.method(), - uri = %request.uri(), - request_id = %request_id, - ) - }) - .on_response(DefaultOnResponse::new()), - ) - .propagate_x_request_id(); - - Router::new() - .route("/health", get(health)) - .route("/metrics", get(metrics)) - .route("/pokemon/{name}", get(get_pokemon)) - .route("/pokemon/translated/{name}", get(get_translated_pokemon)) - .with_state(state.clone()) - .layer(middleware::from_fn_with_state( - state.clone(), - enforce_rate_limit, - )) - .layer(middleware::from_fn_with_state(state, record_http_metrics)) - .layer(tracing_layers) -} - -async fn health() -> Json { - Json(HealthResponse { status: "ok" }) -} - -async fn metrics(State(state): State) -> Result { - let body = state.telemetry.render_prometheus()?; - Ok(([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body)) -} - -async fn enforce_rate_limit( - State(state): State, - req: Request, - next: middleware::Next, -) -> Response { - if state.rate_limiter.try_acquire() { - next.run(req).await - } else { - ( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ "error": "rate limit exceeded" })), - ) - .into_response() - } -} - -async fn record_http_metrics( - State(state): State, - req: Request, - next: middleware::Next, -) -> Response { - let start = Instant::now(); - let method = req.method().clone(); - let path = req.extensions().get::().map_or_else( - || req.uri().path().to_owned(), - |path| path.as_str().to_owned(), - ); - - let response = next.run(req).await; - let status = response.status(); - state.telemetry.metrics().record_http_request( - method.as_str(), - &path, - status.as_u16(), - start.elapsed(), - ); - - response -} - -async fn get_pokemon( - State(state): State, - Path(name): Path, -) -> Result, AppError> { - state.service.get_basic(&name).await.map(Json) -} - -async fn get_translated_pokemon( - State(state): State, - Path(name): Path, -) -> Result, AppError> { - state.service.get_translated(&name).await.map(Json) -} - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, -} diff --git a/src/bin/pokedex-api.rs b/src/bin/pokedex-api.rs index 861c8fa..5f56900 100644 --- a/src/bin/pokedex-api.rs +++ b/src/bin/pokedex-api.rs @@ -2,16 +2,16 @@ #![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] -use pokedex_api::{app, config::AppConfig, telemetry}; +use pokedex_api::{config::AppConfig, http, telemetry}; #[tokio::main] async fn main() -> Result<(), Box> { telemetry::init_tracing(); let config = AppConfig::from_env()?; let listener = tokio::net::TcpListener::bind(config.bind_addr).await?; - let state = app::AppState::from_config(&config)?; + let state = http::AppState::from_config(&config)?; - axum::serve(listener, app::create_app(state)) + axum::serve(listener, http::create_app(state)) .with_graceful_shutdown(shutdown_signal()) .await?; diff --git a/src/http/handlers.rs b/src/http/handlers.rs new file mode 100644 index 0000000..4607f0e --- /dev/null +++ b/src/http/handlers.rs @@ -0,0 +1,37 @@ +use super::AppState; +use crate::{domain::PokemonInfo, error::AppError}; +use axum::{ + Json, + extract::{Path, State}, + http::header, + response::IntoResponse, +}; +use serde::Serialize; + +pub(super) async fn health() -> Json { + Json(HealthResponse { status: "ok" }) +} + +pub(super) async fn metrics(State(state): State) -> Result { + let body = state.telemetry.render_prometheus()?; + Ok(([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body)) +} + +pub(super) async fn get_pokemon( + State(state): State, + Path(name): Path, +) -> Result, AppError> { + state.service.get_basic(&name).await.map(Json) +} + +pub(super) async fn get_translated_pokemon( + State(state): State, + Path(name): Path, +) -> Result, AppError> { + state.service.get_translated(&name).await.map(Json) +} + +#[derive(Serialize)] +pub(super) struct HealthResponse { + status: &'static str, +} diff --git a/src/http/middleware.rs b/src/http/middleware.rs new file mode 100644 index 0000000..2cf1908 --- /dev/null +++ b/src/http/middleware.rs @@ -0,0 +1,49 @@ +use super::AppState; +use axum::{ + Json, + extract::{MatchedPath, Request, State}, + http::StatusCode, + middleware, + response::{IntoResponse, Response}, +}; +use std::time::Instant; + +pub(super) async fn enforce_rate_limit( + State(state): State, + req: Request, + next: middleware::Next, +) -> Response { + if state.rate_limiter.try_acquire() { + next.run(req).await + } else { + ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": "rate limit exceeded" })), + ) + .into_response() + } +} + +pub(super) async fn record_http_metrics( + State(state): State, + req: Request, + next: middleware::Next, +) -> Response { + let start = Instant::now(); + let method = req.method().clone(); + let path = req.extensions().get::().map_or_else( + || req.uri().path().to_owned(), + |path| path.as_str().to_owned(), + ); + + let response = next.run(req).await; + let status = response.status(); + state.telemetry.metrics().record_http_request( + method.as_str(), + &path, + status.as_u16(), + start.elapsed(), + ); + + response +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..2ef8ba3 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,89 @@ +mod handlers; +mod middleware; + +use crate::{ + config::AppConfig, error::AppError, pokemon::PokeApiClient, rate_limit::RateLimiter, + service::PokedexService, telemetry::Telemetry, translation::TranslationClient, +}; +use axum::{Router, extract::Request, middleware as axum_middleware, routing::get}; +use std::sync::Arc; +use tower::ServiceBuilder; +use tower_http::{ + ServiceBuilderExt, + request_id::MakeRequestUuid, + trace::{DefaultOnResponse, TraceLayer}, +}; + +#[derive(Clone)] +pub struct AppState { + pub(super) service: Arc, + pub(super) telemetry: Arc, + pub(super) rate_limiter: Arc, +} + +impl AppState { + #[must_use] + pub fn new(service: PokedexService, telemetry: Telemetry, rate_limiter: RateLimiter) -> Self { + Self { + service: Arc::new(service), + telemetry: Arc::new(telemetry), + rate_limiter: Arc::new(rate_limiter), + } + } + + pub fn from_config(config: &AppConfig) -> Result { + let telemetry = Telemetry::new(&config.service_name); + let pokemon = PokeApiClient::new(&config.pokeapi_base_url, config.request_timeout)?; + let translations = + TranslationClient::new(&config.translations_base_url, config.request_timeout)?; + let service = PokedexService::new(pokemon, translations, telemetry.metrics()); + Ok(Self::new( + service, + telemetry, + RateLimiter::new(config.rate_limit_per_second, config.rate_limit_burst) + .map_err(|_| AppError::Internal)?, + )) + } +} + +pub fn create_app(state: AppState) -> Router { + let tracing_layers = ServiceBuilder::new() + .set_x_request_id(MakeRequestUuid) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + let request_id = request + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + tracing::info_span!( + "http.request", + method = %request.method(), + uri = %request.uri(), + request_id = %request_id, + ) + }) + .on_response(DefaultOnResponse::new()), + ) + .propagate_x_request_id(); + + Router::new() + .route("/health", get(handlers::health)) + .route("/metrics", get(handlers::metrics)) + .route("/pokemon/{name}", get(handlers::get_pokemon)) + .route( + "/pokemon/translated/{name}", + get(handlers::get_translated_pokemon), + ) + .with_state(state.clone()) + .layer(axum_middleware::from_fn_with_state( + state.clone(), + middleware::enforce_rate_limit, + )) + .layer(axum_middleware::from_fn_with_state( + state, + middleware::record_http_metrics, + )) + .layer(tracing_layers) +} diff --git a/src/lib.rs b/src/lib.rs index 5b9b9c2..1c65dff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,10 +2,10 @@ #![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] -pub mod app; pub mod config; pub mod domain; pub mod error; +pub mod http; pub mod pokemon; pub mod rate_limit; pub mod service; diff --git a/tests/app.rs b/tests/app.rs index 23e9cad..91dbdcb 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -3,7 +3,7 @@ use axum::{ http::{Request, StatusCode, header::HeaderName}, }; use pokedex_api::{ - app::{AppState, create_app}, + http::{AppState, create_app}, pokemon::PokeApiClient, rate_limit::RateLimiter, service::PokedexService,