diff --git a/src/lib.rs b/src/lib.rs index e58bb76..c14a94a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ pub mod config; pub mod domain; pub mod error; pub mod pokemon; +pub mod service; +pub mod translation; #[must_use] pub const fn crate_name() -> &'static str { diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..4078c1d --- /dev/null +++ b/src/service.rs @@ -0,0 +1,193 @@ +use crate::{ + domain::PokemonInfo, + error::AppError, + pokemon::PokeApiClient, + translation::{TranslationClient, TranslationKind}, +}; +use tracing::{debug, instrument}; + +#[derive(Clone)] +pub struct PokedexService { + pokemon: PokeApiClient, + translations: TranslationClient, +} + +impl PokedexService { + #[must_use] + pub const fn new(pokemon: PokeApiClient, translations: TranslationClient) -> Self { + Self { + pokemon, + translations, + } + } + + #[instrument(skip(self), fields(pokemon.name = %name))] + pub async fn get_basic(&self, name: &str) -> Result { + self.pokemon.get_pokemon_info(name).await + } + + #[instrument(skip(self), fields(pokemon.name = %name))] + pub async fn get_translated(&self, name: &str) -> Result { + let mut pokemon = self.pokemon.get_pokemon_info(name).await?; + let translation_kind = translation_kind_for(&pokemon); + + match self + .translations + .translate(translation_kind, &pokemon.description) + .await + { + Ok(translated) => pokemon.description = translated, + Err(error) => debug!(%error, "using original description after translation failure"), + } + + Ok(pokemon) + } +} + +#[must_use] +pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind { + if pokemon.needs_yoda_translation() { + TranslationKind::Yoda + } else { + TranslationKind::Shakespeare + } +} + +#[cfg(test)] +mod tests { + use super::{PokedexService, translation_kind_for}; + use crate::{ + domain::PokemonInfo, pokemon::PokeApiClient, translation::TranslationClient, + translation::TranslationKind, + }; + use std::time::Duration; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + #[test] + fn chooses_yoda_for_legendary_pokemon() { + let pokemon = pokemon_info("rare", true, "Created by science."); + + assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda); + } + + #[test] + fn chooses_yoda_for_cave_pokemon() { + let pokemon = pokemon_info("cave", false, "Sleeps upside down."); + + assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda); + } + + #[test] + fn chooses_shakespeare_for_regular_pokemon() { + let pokemon = pokemon_info("forest", false, "Electric cheeks."); + + assert_eq!(translation_kind_for(&pokemon), TranslationKind::Shakespeare); + } + + #[tokio::test] + async fn translates_legendary_pokemon_with_yoda() { + let pokeapi = MockServer::start().await; + let translations = MockServer::start().await; + mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await; + Mock::given(matchers::method("POST")) + .and(matchers::path("/translate/yoda.json")) + .and(matchers::body_string_contains("Created+by+science")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "contents": { "translated": "Created by science, it was." } + }))) + .expect(1) + .mount(&translations) + .await; + let service = service_for(&pokeapi, &translations); + + let pokemon = service + .get_translated("mewtwo") + .await + .expect("translated pokemon should be returned"); + + assert_eq!(pokemon.description, "Created by science, it was."); + } + + #[tokio::test] + async fn translates_regular_pokemon_with_shakespeare() { + 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, good sir." } + }))) + .expect(1) + .mount(&translations) + .await; + let service = service_for(&pokeapi, &translations); + + let pokemon = service + .get_translated("pikachu") + .await + .expect("translated pokemon should be returned"); + + assert_eq!(pokemon.description, "Electric cheeks, good sir."); + } + + #[tokio::test] + async fn falls_back_to_original_description_when_translation_fails() { + let pokeapi = MockServer::start().await; + let translations = MockServer::start().await; + mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await; + Mock::given(matchers::method("POST")) + .and(matchers::path("/translate/yoda.json")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&translations) + .await; + let service = service_for(&pokeapi, &translations); + + let pokemon = service + .get_translated("zubat") + .await + .expect("translation failure should not fail the endpoint"); + + assert_eq!(pokemon.description, "It has no eyes."); + } + + 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" } } + ] + }))) + .expect(1) + .mount(server) + .await; + } + + fn pokemon_info(habitat: &str, is_legendary: bool, description: &str) -> PokemonInfo { + PokemonInfo { + name: "testmon".to_owned(), + description: description.to_owned(), + habitat: habitat.to_owned(), + is_legendary, + } + } + + fn service_for(pokeapi: &MockServer, translations: &MockServer) -> PokedexService { + 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"), + ) + } +} diff --git a/src/translation.rs b/src/translation.rs new file mode 100644 index 0000000..691744a --- /dev/null +++ b/src/translation.rs @@ -0,0 +1,144 @@ +use crate::error::AppError; +use serde::Deserialize; +use std::time::Duration; +use tracing::instrument; + +#[derive(Clone)] +pub struct TranslationClient { + base_url: String, + http: reqwest::Client, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TranslationKind { + Shakespeare, + Yoda, +} + +impl TranslationKind { + #[must_use] + pub const fn endpoint(self) -> &'static str { + match self { + Self::Shakespeare => "shakespeare", + Self::Yoda => "yoda", + } + } +} + +impl TranslationClient { + 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, text), fields(translation.kind = ?kind))] + pub async fn translate(&self, kind: TranslationKind, text: &str) -> Result { + let url = format!("{}/translate/{}.json", self.base_url, kind.endpoint()); + let response = self + .http + .post(url) + .form(&[("text", text)]) + .send() + .await + .map_err(|error| map_reqwest_error(&error))?; + let status = response.status(); + + if !status.is_success() { + return Err(AppError::Upstream(format!( + "FunTranslations returned status {status}" + ))); + } + + response + .json::() + .await + .map(|payload| payload.contents.translated) + .map_err(|error| map_reqwest_error(&error)) + } +} + +#[derive(Debug, Deserialize)] +struct TranslationResponse { + contents: TranslationContents, +} + +#[derive(Debug, Deserialize)] +struct TranslationContents { + translated: String, +} + +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::{TranslationClient, TranslationKind}; + use std::time::Duration; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + #[test] + fn translation_kind_resolves_funtranslations_endpoint() { + assert_eq!(TranslationKind::Yoda.endpoint(), "yoda"); + assert_eq!(TranslationKind::Shakespeare.endpoint(), "shakespeare"); + } + + #[tokio::test] + async fn posts_text_to_the_requested_translation_endpoint() { + let server = MockServer::start().await; + Mock::given(matchers::method("POST")) + .and(matchers::path("/translate/yoda.json")) + .and(matchers::body_string_contains("text=Created")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "contents": { "translated": "Created, it was." } + }))) + .expect(1) + .mount(&server) + .await; + let client = client_for(&server); + + let translated = client + .translate(TranslationKind::Yoda, "Created") + .await + .expect("translation should succeed"); + + assert_eq!(translated, "Created, it was."); + } + + #[tokio::test] + async fn maps_translation_http_failures_to_upstream_errors() { + let server = MockServer::start().await; + Mock::given(matchers::method("POST")) + .and(matchers::path("/translate/shakespeare.json")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + let client = client_for(&server); + + let error = client + .translate(TranslationKind::Shakespeare, "hello") + .await + .expect_err("translation should fail"); + + assert!(error.to_string().contains("FunTranslations")); + } + + fn client_for(server: &MockServer) -> TranslationClient { + TranslationClient::new(server.uri(), Duration::from_secs(1)).expect("client should build") + } +}