use std::collections::BTreeMap;
use reqwest::header::{HeaderMap, CONTENT_LENGTH, CONTENT_TYPE, LINK};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
use url::Url;
pub(crate) const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const ERROR_BODY_PREVIEW_BYTES: usize = 1024;
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub(crate) struct CallResult {
pub(crate) status: u16,
pub(crate) body: Value,
pub(crate) next: Option<Value>,
pub(crate) headers: BTreeMap<String, String>,
}
impl CallResult {
pub(crate) fn succeeded(&self) -> bool {
(200..300).contains(&self.status)
}
}
#[derive(Debug)]
pub(crate) enum BodyError {
TooLarge { size: u64, limit: usize },
Transport(reqwest::Error),
}
pub(crate) async fn read_body(
mut response: reqwest::Response,
limit: usize,
) -> Result<Vec<u8>, BodyError> {
let declared = response
.headers()
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()?.parse::<u64>().ok());
if let Some(size) = declared.filter(|size| *size > limit as u64) {
return Err(BodyError::TooLarge { size, limit });
}
let mut body = Vec::new();
let mut size = 0u64;
while let Some(chunk) = response.chunk().await.map_err(BodyError::Transport)? {
size += chunk.len() as u64;
if size <= limit as u64 {
body.extend_from_slice(&chunk);
} else {
body = Vec::new();
}
}
if size > limit as u64 {
return Err(BodyError::TooLarge { size, limit });
}
Ok(body)
}
pub(crate) fn decode_body(headers: &HeaderMap, bytes: &[u8]) -> Value {
if bytes.is_empty() {
return Value::Null;
}
let essence = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.map(|value| value.trim().to_ascii_lowercase());
let maybe_json = match essence.as_deref() {
None => true,
Some(essence) => essence == "application/json" || essence.ends_with("+json"),
};
if maybe_json {
if let Ok(value) = serde_json::from_slice(bytes) {
return value;
}
}
Value::String(String::from_utf8_lossy(bytes).into_owned())
}
pub(crate) fn kept_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
const KEPT: &[&str] = &[
"retry-after",
"x-total-count",
"x-total",
"ratelimit",
"ratelimit-policy",
"ratelimit-limit",
"ratelimit-remaining",
"ratelimit-reset",
];
headers
.iter()
.filter(|(name, _)| {
let name = name.as_str();
KEPT.contains(&name) || name.starts_with("x-ratelimit-")
})
.filter_map(|(name, value)| {
Some((name.as_str().to_owned(), value.to_str().ok()?.to_owned()))
})
.collect()
}
pub(crate) fn next_link(headers: &HeaderMap, base: &Url) -> Option<Url> {
headers
.get_all(LINK)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(next_target)
.and_then(|target| base.join(target).ok())
}
fn next_target(value: &str) -> Option<&str> {
let mut rest = value;
while let Some(open) = rest.find('<') {
let after = &rest[open + 1..];
let close = after.find('>')?;
let target = &after[..close];
let mut params = Vec::new();
let mut start = close + 1;
let mut end = after.len();
let mut quoted = false;
let mut escaped = false;
for (index, character) in after
.char_indices()
.skip_while(|(index, _)| *index <= close)
{
if escaped {
escaped = false;
continue;
}
match character {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
';' if !quoted => {
params.push(&after[start..index]);
start = index + 1;
}
',' if !quoted => {
end = index;
break;
}
_ => {}
}
}
params.push(&after[start..end]);
let is_next = params.iter().any(|param| {
param.split_once('=').is_some_and(|(key, value)| {
key.trim().eq_ignore_ascii_case("rel")
&& value
.trim()
.trim_matches('"')
.split_ascii_whitespace()
.any(|rel| rel.eq_ignore_ascii_case("next"))
})
});
if is_next {
return Some(target);
}
rest = &after[end..];
}
None
}
pub(crate) fn model_text(route: &str, result: &CallResult, max_bytes: usize) -> String {
let reason = reqwest::StatusCode::from_u16(result.status)
.ok()
.and_then(|status| status.canonical_reason())
.unwrap_or("");
let status = format!("{} {reason}", result.status);
let body = match &result.body {
Value::Null => String::new(),
Value::String(text) => text.clone(),
body => body.to_string(),
};
let text = if result.succeeded() {
let mut text = format!("HTTP {}", status.trim_end());
if !body.is_empty() {
text.push('\n');
text.push_str(&body);
}
if let Some(next) = &result.next {
text.push_str(&format!("\nnext page arguments: {next}"));
}
text
} else {
let preview = rho_tools::tool::truncate(
body.split_whitespace().collect::<Vec<_>>().join(" "),
ERROR_BODY_PREVIEW_BYTES,
);
format!("{route} returned {}: {preview}", status.trim_end())
.trim_end_matches([' ', ':'])
.to_owned()
};
rho_tools::tool::truncate(
text,
max_bytes.saturating_sub(rho_tools::tool::TRUNCATION_MARKER.len()),
)
}
#[cfg(test)]
#[path = "response_tests.rs"]
mod tests;