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::{DefaultMakeSpan, 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(DefaultMakeSpan::new().include_headers(true)) .on_response(DefaultOnResponse::new().include_headers(true)), ) .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, } #[cfg(test)] mod tests { use super::{AppState, create_app}; use crate::{ pokemon::PokeApiClient, rate_limit::RateLimiter, service::PokedexService, telemetry::Telemetry, translation::TranslationClient, }; use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header::HeaderName}, }; use serde_json::Value; use std::time::Duration; use tower::ServiceExt; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; #[tokio::test] async fn health_endpoint_reports_ok() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/health")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "status": "ok" }) ); } #[tokio::test] async fn pokemon_endpoint_returns_basic_pokemon_information() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await, serde_json::json!({ "name": "mewtwo", "description": "Created by science.", "habitat": "rare", "isLegendary": true }) ); } #[tokio::test] async fn translated_endpoint_returns_translated_description() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/shakespeare.json")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "contents": { "translated": "Electric cheeks, prithee." } }))) .mount(&translations) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/translated/pikachu")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( json_body(response).await["description"], "Electric cheeks, prithee." ); } #[tokio::test] async fn every_response_has_a_request_id() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/health")) .await .expect("request should be handled"); assert!(response.headers().contains_key("x-request-id")); } #[tokio::test] async fn keeps_incoming_request_id_when_present() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let app = create_app(state_for(&pokeapi, &translations)); let request = Request::builder() .uri("/health") .header(HeaderName::from_static("x-request-id"), "external-id") .body(Body::empty()) .expect("request should build"); let response = app .oneshot(request) .await .expect("request should be handled"); assert_eq!(response.headers()["x-request-id"], "external-id"); } #[tokio::test] async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .clone() .oneshot(request("/pokemon/mewtwo")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::OK); let response = app .oneshot(request("/metrics")) .await .expect("request should be handled"); let body = text_body(response).await; assert!(body.contains("http_server_requests")); assert!(body.contains("upstream_client_requests")); } #[tokio::test] async fn rate_limit_rejects_requests_over_the_configured_burst() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; let telemetry = Telemetry::new("test-service"); let app = create_app(AppState::new( PokedexService::new( PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)) .expect("client should build"), TranslationClient::new(translations.uri(), Duration::from_secs(1)) .expect("client should build"), telemetry.metrics(), ), telemetry, RateLimiter::new(0.01, 1).expect("rate limiter should build"), )); let first = app .clone() .oneshot(request("/health")) .await .expect("request should be handled"); let second = app .oneshot(request("/health")) .await .expect("request should be handled"); assert_eq!(first.status(), StatusCode::OK); assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(json_body(second).await["error"], "rate limit exceeded"); } #[tokio::test] async fn pokemon_endpoint_maps_missing_pokemon_to_404() { let pokeapi = MockServer::start().await; let translations = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/nope")) .respond_with(ResponseTemplate::new(404)) .mount(&pokeapi) .await; let app = create_app(state_for(&pokeapi, &translations)); let response = app .oneshot(request("/pokemon/nope")) .await .expect("request should be handled"); assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(json_body(response).await["error"], "pokemon not found"); } async fn mount_species( server: &MockServer, name: &str, is_legendary: bool, habitat: &str, description: &str, ) { Mock::given(matchers::method("GET")) .and(matchers::path(format!("/pokemon-species/{name}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": name, "is_legendary": is_legendary, "habitat": { "name": habitat }, "flavor_text_entries": [ { "flavor_text": description, "language": { "name": "en" } } ] }))) .mount(server) .await; } fn state_for(pokeapi: &MockServer, translations: &MockServer) -> AppState { let telemetry = Telemetry::new("test-service"); AppState::new( PokedexService::new( PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)) .expect("client should build"), TranslationClient::new(translations.uri(), Duration::from_secs(1)) .expect("client should build"), telemetry.metrics(), ), telemetry, RateLimiter::new(100.0, 100).expect("rate limiter should build"), ) } fn request(path: &str) -> Request { Request::builder() .uri(path) .body(Body::empty()) .expect("request should build") } async fn json_body(response: axum::response::Response) -> Value { let bytes = body_bytes(response).await; serde_json::from_slice(&bytes).expect("body should be valid JSON") } async fn text_body(response: axum::response::Response) -> String { String::from_utf8(body_bytes(response).await.to_vec()).expect("body should be valid UTF-8") } async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes { to_bytes(response.into_body(), 1024 * 1024) .await .expect("body should be readable") } }