fix: cover api edge cases
This commit is contained in:
51
src/clients/body.rs
Normal file
51
src/clients/body.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::error::AppError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const MAX_UPSTREAM_BODY_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub async fn read_json<T>(
|
||||
mut response: reqwest::Response,
|
||||
service: &'static str,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_UPSTREAM_BODY_BYTES as u64)
|
||||
{
|
||||
return Err(AppError::Upstream(format!(
|
||||
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(&error))?
|
||||
{
|
||||
let next_len = body
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.ok_or(AppError::Internal)?;
|
||||
if next_len > MAX_UPSTREAM_BODY_BYTES {
|
||||
return Err(AppError::Upstream(format!(
|
||||
"{service} response body exceeded {MAX_UPSTREAM_BODY_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
serde_json::from_slice(&body).map_err(|error| {
|
||||
AppError::InvalidUpstreamData(format!("{service} returned invalid JSON: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn map_reqwest_error(error: &reqwest::Error) -> AppError {
|
||||
if error.is_timeout() {
|
||||
AppError::Timeout
|
||||
} else {
|
||||
AppError::Upstream(error.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{domain::TranslationKind, error::AppError};
|
||||
use crate::{clients::body, domain::TranslationKind, error::AppError};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -40,11 +40,14 @@ impl TranslationClient {
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<TranslationResponse>()
|
||||
.await
|
||||
.map(|payload| payload.contents.translated)
|
||||
.map_err(|error| map_reqwest_error(&error))
|
||||
let payload = body::read_json::<TranslationResponse>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +115,26 @@ mod tests {
|
||||
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.json"))
|
||||
.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;
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
mod body;
|
||||
|
||||
pub mod funtranslations;
|
||||
pub mod pokeapi;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{domain::PokemonInfo, error::AppError};
|
||||
use crate::{clients::body, domain::PokemonInfo, error::AppError};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use tracing::instrument;
|
||||
@@ -44,12 +44,9 @@ impl PokeApiClient {
|
||||
)));
|
||||
}
|
||||
|
||||
let species = response
|
||||
.json::<PokemonSpeciesResponse>()
|
||||
body::read_json::<PokemonSpeciesResponse>(response, "PokéAPI")
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(&error))?;
|
||||
|
||||
species.try_into()
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +270,24 @@ mod tests {
|
||||
assert!(matches!(error, AppError::InvalidUpstreamData(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_oversized_upstream_bodies() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(matchers::method("GET"))
|
||||
.and(matchers::path("/pokemon-species/snorlax"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(1025 * 1024)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = client_for(&server);
|
||||
|
||||
let error = client
|
||||
.get_pokemon_info("snorlax")
|
||||
.await
|
||||
.expect_err("oversized body should fail");
|
||||
|
||||
assert!(error.to_string().contains("exceeded"));
|
||||
}
|
||||
|
||||
fn client_for(server: &MockServer) -> PokeApiClient {
|
||||
PokeApiClient::new(server.uri(), Duration::from_secs(1)).expect("client should build")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user