use crate::{domain::PokemonInfo, error::AppError}; use serde::Deserialize; use std::time::Duration; use tracing::instrument; #[derive(Clone)] pub struct PokeApiClient { base_url: String, http: reqwest::Client, } impl PokeApiClient { pub fn new(base_url: impl Into, timeout: Duration) -> Result { let http = reqwest::Client::builder() .timeout(timeout) .build() .map_err(|error| AppError::Upstream(error.to_string()))?; Ok(Self { base_url: trim_trailing_slash(&base_url.into()), http, }) } #[instrument(skip(self), fields(pokemon.name = %name))] pub async fn get_pokemon_info(&self, name: &str) -> Result { validate_pokemon_name(name)?; let url = format!("{}/pokemon-species/{}", self.base_url, name); let response = self .http .get(url) .send() .await .map_err(|error| map_reqwest_error(&error))?; let status = response.status(); if status == reqwest::StatusCode::NOT_FOUND { return Err(AppError::NotFound); } if !status.is_success() { return Err(AppError::Upstream(format!( "PokéAPI returned status {status}" ))); } let species = response .json::() .await .map_err(|error| map_reqwest_error(&error))?; species.try_into() } } #[derive(Debug, Deserialize)] struct PokemonSpeciesResponse { name: String, is_legendary: bool, habitat: Option, flavor_text_entries: Vec, } #[derive(Debug, Deserialize)] struct NamedResource { name: String, } #[derive(Debug, Deserialize)] struct FlavorTextEntry { flavor_text: String, language: NamedResource, } impl TryFrom for PokemonInfo { type Error = AppError; fn try_from(species: PokemonSpeciesResponse) -> Result { let description = species .flavor_text_entries .iter() .filter(|entry| entry.language.name == "en") .map(|entry| normalize_description(&entry.flavor_text)) .find(|description| !description.is_empty()) .ok_or_else(|| { AppError::InvalidUpstreamData("missing English flavor text".to_owned()) })?; Ok(Self { name: species.name, description, habitat: species .habitat .map_or_else(|| "unknown".to_owned(), |habitat| habitat.name), is_legendary: species.is_legendary, }) } } pub fn validate_pokemon_name(name: &str) -> Result<(), AppError> { let is_valid = !name.is_empty() && name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if is_valid { Ok(()) } else { Err(AppError::BadRequest( "pokemon name must contain only ASCII letters, digits, or hyphens".to_owned(), )) } } #[must_use] pub fn normalize_description(description: &str) -> String { description.split_whitespace().collect::>().join(" ") } fn trim_trailing_slash(value: &str) -> String { value.trim_end_matches('/').to_owned() } fn map_reqwest_error(error: &reqwest::Error) -> AppError { if error.is_timeout() { AppError::Timeout } else { AppError::Upstream(error.to_string()) } } #[cfg(test)] mod tests { use super::{PokeApiClient, normalize_description, validate_pokemon_name}; use crate::error::AppError; use std::time::Duration; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; #[test] fn normalizes_pokemon_descriptions_for_api_output() { let normalized = normalize_description("Line one\nline two\u{000c} line three"); assert_eq!(normalized, "Line one line two line three"); } #[test] fn rejects_names_that_could_change_the_upstream_url_shape() { let error = validate_pokemon_name("foo?bar=baz").expect_err("name should be rejected"); assert!(matches!(error, AppError::BadRequest(_))); } #[tokio::test] async fn fetches_basic_pokemon_information_from_species_endpoint() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/mewtwo")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": "mewtwo", "is_legendary": true, "habitat": { "name": "rare" }, "flavor_text_entries": [ { "flavor_text": "Versione italiana", "language": { "name": "it" } }, { "flavor_text": "It was created\nby science.", "language": { "name": "en" } } ] }))) .mount(&server) .await; let client = client_for(&server); let pokemon = client .get_pokemon_info("mewtwo") .await .expect("pokemon should be parsed"); assert_eq!(pokemon.name, "mewtwo"); assert_eq!(pokemon.description, "It was created by science."); assert_eq!(pokemon.habitat, "rare"); assert!(pokemon.is_legendary); } #[tokio::test] async fn uses_unknown_habitat_when_pokeapi_has_no_habitat() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/missingno")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": "missingno", "is_legendary": false, "habitat": null, "flavor_text_entries": [ { "flavor_text": "A glitch Pokémon.", "language": { "name": "en" } } ] }))) .mount(&server) .await; let client = client_for(&server); let pokemon = client .get_pokemon_info("missingno") .await .expect("pokemon should be parsed"); assert_eq!(pokemon.habitat, "unknown"); } #[tokio::test] async fn maps_missing_pokemon_to_not_found() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/nope")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let client = client_for(&server); let error = client .get_pokemon_info("nope") .await .expect_err("missing pokemon should fail"); assert!(matches!(error, AppError::NotFound)); } #[tokio::test] async fn skips_empty_english_descriptions_until_it_finds_a_non_empty_one() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/eevee")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": "eevee", "is_legendary": false, "habitat": { "name": "urban" }, "flavor_text_entries": [ { "flavor_text": " \n \t", "language": { "name": "en" } }, { "flavor_text": "Can evolve in many ways.", "language": { "name": "en" } } ] }))) .mount(&server) .await; let client = client_for(&server); let pokemon = client .get_pokemon_info("eevee") .await .expect("second English description should be used"); assert_eq!(pokemon.description, "Can evolve in many ways."); } #[tokio::test] async fn rejects_species_without_english_description() { let server = MockServer::start().await; Mock::given(matchers::method("GET")) .and(matchers::path("/pokemon-species/pikachu")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "name": "pikachu", "is_legendary": false, "habitat": { "name": "forest" }, "flavor_text_entries": [ { "flavor_text": "Souris électrique.", "language": { "name": "fr" } } ] }))) .mount(&server) .await; let client = client_for(&server); let error = client .get_pokemon_info("pikachu") .await .expect_err("missing English entry should fail"); assert!(matches!(error, AppError::InvalidUpstreamData(_))); } fn client_for(server: &MockServer) -> PokeApiClient { PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build") } }