use std::time::Duration;
use reqwest::header::{CONTENT_TYPE, HeaderMap, RETRY_AFTER};
use reqwest::{Client as HttpClient, Method, Url};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::auth::{
API_KEY_HEADER, Credentials, SIGNATURE_HEADER, TIMESTAMP_HEADER, signing_message,
};
use crate::error::{Error, Result, classify_error_response};
const HEX: &[u8; 16] = b"0123456789ABCDEF";
const MAX_BODY_PREVIEW: usize = 2048;
pub(crate) struct RequestSpec {
method: Method,
path: String,
query: Vec<(String, String)>,
body: Option<Vec<u8>>,
requires_auth: bool,
}
impl RequestSpec {
pub(crate) fn get(path: impl Into<String>) -> Self {
Self::new(Method::GET, path)
}
pub(crate) fn delete(path: impl Into<String>) -> Self {
Self::new(Method::DELETE, path)
}
pub(crate) fn post_json<T: Serialize>(path: impl Into<String>, body: &T) -> Result<Self> {
Self::new(Method::POST, path).with_json_body(body)
}
pub(crate) fn put_json<T: Serialize>(path: impl Into<String>, body: &T) -> Result<Self> {
Self::new(Method::PUT, path).with_json_body(body)
}
fn new(method: Method, path: impl Into<String>) -> Self {
Self {
method,
path: path.into(),
query: Vec::new(),
body: None,
requires_auth: true,
}
}
fn with_json_body<T: Serialize>(mut self, body: &T) -> Result<Self> {
self.body = Some(serde_json::to_vec(body).map_err(Error::Serialize)?);
Ok(self)
}
pub(crate) fn with_query(mut self, query: Vec<(String, String)>) -> Self {
self.query = query;
self
}
pub(crate) fn public(mut self) -> Self {
self.requires_auth = false;
self
}
}
#[derive(Debug, Clone)]
pub(crate) struct Transport {
http: HttpClient,
base_url: Url,
base_path: String,
credentials: Option<Credentials>,
}
impl Transport {
pub(crate) fn new(
base_url: &str,
http: HttpClient,
credentials: Option<Credentials>,
) -> Result<Self> {
let url = Url::parse(base_url)
.map_err(|e| Error::configuration(format!("invalid base URL '{base_url}': {e}")))?;
if url.cannot_be_a_base() {
return Err(Error::configuration(format!(
"base URL '{base_url}' cannot be a base URL"
)));
}
let base_path = url.path().trim_end_matches('/').to_owned();
Ok(Self {
http,
base_url: url,
base_path,
credentials,
})
}
pub(crate) fn base_url(&self) -> &str {
self.base_url.as_str()
}
pub(crate) fn has_credentials(&self) -> bool {
self.credentials.is_some()
}
pub(crate) async fn send_json<T: DeserializeOwned>(&self, spec: RequestSpec) -> Result<T> {
let method = spec.method.as_str().to_owned();
let full_path = format!("{}{}", self.base_path, spec.path);
let response = self.send(&spec).await?;
let status = response.status();
let retry_after = parse_retry_after(response.headers());
let bytes = response.bytes().await.map_err(Error::Transport)?;
if status.is_success() {
serde_json::from_slice::<T>(bytes.as_ref()).map_err(|source| Error::Deserialize {
method,
path: full_path,
source,
body: preview(bytes.as_ref()),
})
} else {
Err(classify_error_response(
status.as_u16(),
retry_after,
bytes.as_ref(),
))
}
}
pub(crate) async fn send_no_content(&self, spec: RequestSpec) -> Result<()> {
let response = self.send(&spec).await?;
let status = response.status();
let retry_after = parse_retry_after(response.headers());
if status.is_success() {
return Ok(());
}
let bytes = response.bytes().await.map_err(Error::Transport)?;
Err(classify_error_response(
status.as_u16(),
retry_after,
bytes.as_ref(),
))
}
async fn send(&self, spec: &RequestSpec) -> Result<reqwest::Response> {
let method_token = spec.method.as_str();
let full_path = format!("{}{}", self.base_path, spec.path);
let query = build_query(&spec.query);
let body: &[u8] = spec.body.as_deref().unwrap_or(&[]);
let mut url = self.base_url.clone();
url.set_path(&full_path);
url.set_query(if query.is_empty() { None } else { Some(&query) });
debug_assert_eq!(url.path(), full_path, "path changed during URL assembly");
debug_assert_eq!(
url.query().unwrap_or(""),
query,
"query changed during URL assembly"
);
let mut request = self.http.request(spec.method.clone(), url);
if spec.requires_auth {
let credentials = self.credentials.as_ref().ok_or(Error::MissingCredentials)?;
let timestamp = now_unix_millis();
let message = signing_message(timestamp, method_token, &full_path, &query, body);
let signature = credentials.sign(&message);
request = request
.header(API_KEY_HEADER, credentials.api_key())
.header(TIMESTAMP_HEADER, timestamp.to_string())
.header(SIGNATURE_HEADER, signature);
}
if let Some(body) = &spec.body {
request = request
.header(CONTENT_TYPE, "application/json")
.body(body.clone());
}
request.send().await.map_err(Error::Transport)
}
}
pub(crate) fn encode_component(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &b in input.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
}
}
out
}
fn build_query(params: &[(String, String)]) -> String {
let mut out = String::new();
for (key, value) in params {
if !out.is_empty() {
out.push('&');
}
out.push_str(&encode_component(key));
out.push('=');
out.push_str(&encode_component(value));
}
out
}
fn now_unix_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
headers
.get(RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(Duration::from_millis)
}
fn preview(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
if text.len() <= MAX_BODY_PREVIEW {
return text.into_owned();
}
let mut end = MAX_BODY_PREVIEW;
while !text.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes total)", &text[..end], bytes.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_reserved_query_characters() {
assert_eq!(encode_component("a+b/c=d"), "a%2Bb%2Fc%3Dd");
assert_eq!(encode_component("BTC-USD"), "BTC-USD");
assert_eq!(encode_component("BTC/USD"), "BTC%2FUSD");
}
#[test]
fn builds_query_with_repeated_keys_in_order() {
let q = build_query(&[
("symbols".into(), "BTC-USD".into()),
("symbols".into(), "ETH-USD".into()),
("limit".into(), "100".into()),
]);
assert_eq!(q, "symbols=BTC-USD&symbols=ETH-USD&limit=100");
}
#[test]
fn empty_query_is_blank() {
assert_eq!(build_query(&[]), "");
}
#[test]
fn base_path_strips_trailing_slash() {
let t =
Transport::new("https://revx.revolut.com/api/1.0/", HttpClient::new(), None).unwrap();
assert_eq!(t.base_path, "/api/1.0");
assert!(!t.has_credentials());
}
#[test]
fn rejects_invalid_base_url() {
assert!(Transport::new("not a url", HttpClient::new(), None).is_err());
}
}