use crate::{clients::body, domain::TranslationKind, error::AppError}; use serde::Deserialize; use std::time::Duration; use tracing::instrument; #[derive(Clone)] pub struct TranslationClient { base_url: String, http: reqwest::Client, } 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/{}", self.base_url, endpoint_for(kind)); let response = self .http .post(url) .json(&TranslationRequest { 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}" ))); } let payload = body::read_json::(response, "FunTranslations").await?; if payload.contents.translated.trim().is_empty() { Err(AppError::InvalidUpstreamData( "FunTranslations returned an empty translation".to_owned(), )) } else { Ok(payload.contents.translated) } } } #[derive(serde::Serialize)] struct TranslationRequest<'a> { text: &'a str, } #[derive(Debug, Deserialize)] struct TranslationResponse { contents: TranslationContents, } #[derive(Debug, Deserialize)] struct TranslationContents { translated: String, } const fn endpoint_for(kind: TranslationKind) -> &'static str { match kind { TranslationKind::Shakespeare => "shakespeare", TranslationKind::Yoda => "yoda", } } 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, endpoint_for}; use crate::domain::TranslationKind; use std::time::Duration; use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; #[test] fn translation_kind_resolves_funtranslations_endpoint() { assert_eq!(endpoint_for(TranslationKind::Yoda), "yoda"); assert_eq!(endpoint_for(TranslationKind::Shakespeare), "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")) .and(matchers::body_string_contains("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 rejects_empty_translations_so_callers_can_fallback() { let server = MockServer::start().await; Mock::given(matchers::method("POST")) .and(matchers::path("/translate/yoda")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "contents": { "translated": " " } }))) .mount(&server) .await; let client = client_for(&server); let error = client .translate(TranslationKind::Yoda, "hello") .await .expect_err("empty translation should fail"); assert!(error.to_string().contains("empty translation")); } #[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")) .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") } }