diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..273f925 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,208 @@ +use crate::{ + config::AppConfig, error::AppError, pokemon::PokeApiClient, service::PokedexService, + translation::TranslationClient, +}; +use axum::{ + Json, Router, + extract::{Path, State}, + routing::get, +}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + service: Arc, +} + +impl AppState { + #[must_use] + pub fn new(service: PokedexService) -> Self { + Self { + service: Arc::new(service), + } + } + + pub fn from_config(config: &AppConfig) -> Result { + let pokemon = PokeApiClient::new(&config.pokeapi_base_url, config.request_timeout)?; + let translations = + TranslationClient::new(&config.translations_base_url, config.request_timeout)?; + Ok(Self::new(PokedexService::new(pokemon, translations))) + } +} + +pub fn create_app(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/pokemon/{name}", get(get_pokemon)) + .route("/pokemon/translated/{name}", get(get_translated_pokemon)) + .with_state(state) +} + +async fn health() -> Json { + Json(HealthResponse { status: "ok" }) +} + +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, service::PokedexService, translation::TranslationClient}; + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + 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 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 { + 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"), + )) + } + + 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 = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("body should be readable"); + serde_json::from_slice(&bytes).expect("body should be valid JSON") + } +} diff --git a/src/lib.rs b/src/lib.rs index c14a94a..96d546a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ #![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; diff --git a/src/main.rs b/src/main.rs index af8b78e..db6ae50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,41 @@ #![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] -use pokedex_api::{config::AppConfig, crate_name}; +use pokedex_api::{app, config::AppConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = AppConfig::from_env()?; + let listener = tokio::net::TcpListener::bind(config.bind_addr).await?; + let state = app::AppState::from_config(&config)?; + + axum::serve(listener, app::create_app(state)) + .with_graceful_shutdown(shutdown_signal()) + .await?; -fn main() -> Result<(), Box> { - let _config = AppConfig::from_env()?; - println!("{name} bootstrap complete", name = crate_name()); Ok(()) } + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } +}