diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..9413331 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,17 @@ +use serde::Serialize; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct PokemonInfo { + pub name: String, + pub description: String, + pub habitat: String, + #[serde(rename = "isLegendary")] + pub is_legendary: bool, +} + +impl PokemonInfo { + #[must_use] + pub fn needs_yoda_translation(&self) -> bool { + self.is_legendary || self.habitat.eq_ignore_ascii_case("cave") + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0bc8a30 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,44 @@ +use axum::{Json, http::StatusCode, response::IntoResponse}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("pokemon not found")] + NotFound, + #[error("upstream service returned invalid data: {0}")] + InvalidUpstreamData(String), + #[error("upstream service failed: {0}")] + Upstream(String), + #[error("request timed out")] + Timeout, + #[error("internal server error")] + Internal, +} + +impl AppError { + #[must_use] + pub const fn status_code(&self) -> StatusCode { + match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::InvalidUpstreamData(_) | Self::Upstream(_) | Self::Timeout => { + StatusCode::BAD_GATEWAY + } + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let status = self.status_code(); + let body = ErrorBody { + error: self.to_string(), + }; + (status, Json(body)).into_response() + } +} + +#[derive(Serialize)] +struct ErrorBody { + error: String, +} diff --git a/src/lib.rs b/src/lib.rs index 0edcb66..e58bb76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,9 @@ #![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)] pub mod config; +pub mod domain; +pub mod error; +pub mod pokemon; #[must_use] pub const fn crate_name() -> &'static str { diff --git a/src/pokemon.rs b/src/pokemon.rs new file mode 100644 index 0000000..98a3ddb --- /dev/null +++ b/src/pokemon.rs @@ -0,0 +1,230 @@ +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 { + 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() + .find(|entry| entry.language.name == "en") + .map(|entry| normalize_description(&entry.flavor_text)) + .filter(|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, + }) + } +} + +#[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}; + 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"); + } + + #[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 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") + } +}