use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{CONTENT_TYPE, HeaderMap, RETRY_AFTER};
use reqwest::{Client as HttpClient, Method, Url};
use serde::Serialize;
use crate::auth::{API_KEY_HEADER, SIGNATURE_HEADER, Signer, TIMESTAMP_HEADER, signing_message};
use crate::error::{Error, Result};
const HEX: &[u8; 16] = b"0123456789ABCDEF";
pub 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,
}
}
#[cfg(feature = "agent")]
pub(crate) const fn from_parts(
method: Method,
path: String,
query: Vec<(String, String)>,
body: Option<Vec<u8>>,
requires_auth: bool,
) -> Self {
Self {
method,
path,
query,
body,
requires_auth,
}
}
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) const fn public(mut self) -> Self {
self.requires_auth = false;
self
}
pub const fn method(&self) -> &Method {
&self.method
}
pub fn path(&self) -> &str {
&self.path
}
pub fn query(&self) -> &[(String, String)] {
&self.query
}
pub fn body(&self) -> Option<&[u8]> {
self.body.as_deref()
}
pub const fn requires_auth(&self) -> bool {
self.requires_auth
}
}
#[derive(Debug, Clone)]
pub struct RawResponse {
pub status: u16,
pub retry_after: Option<Duration>,
pub body: Vec<u8>,
}
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait RequestExecutor: Send + Sync {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>>;
fn base_url(&self) -> &str;
fn is_authenticated(&self) -> bool;
}
#[derive(Clone)]
pub struct LocalExecutor {
http: HttpClient,
base_url: Url,
base_path: String,
signer: Option<Arc<dyn Signer>>,
}
impl LocalExecutor {
pub fn new(base_url: &str, http: HttpClient, signer: Option<Arc<dyn Signer>>) -> 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,
signer,
})
}
async fn send(&self, spec: RequestSpec) -> Result<RawResponse> {
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) });
if url.path() != full_path {
return Err(Error::invalid_request(format!(
"request path '{full_path}' is not URL-safe (it normalizes to '{}'); \
check the symbol or order id for '.'/'..' path segments",
url.path()
)));
}
if url.query().unwrap_or("") != query {
return Err(Error::invalid_request(format!(
"request query '{query}' is not URL-safe (it normalizes to '{}')",
url.query().unwrap_or("")
)));
}
let mut request = self.http.request(spec.method.clone(), url);
if spec.requires_auth {
let signer = self.signer.as_ref().ok_or(Error::MissingCredentials)?;
let timestamp = now_unix_millis();
let message = signing_message(timestamp, method_token, &full_path, &query, body);
let auth = signer.authenticate(&message)?;
request = request
.header(API_KEY_HEADER, auth.api_key.as_str())
.header(TIMESTAMP_HEADER, timestamp.to_string())
.header(SIGNATURE_HEADER, auth.signature);
}
if let Some(body) = &spec.body {
request = request
.header(CONTENT_TYPE, "application/json")
.body(body.clone());
}
let response = request.send().await.map_err(Error::Transport)?;
let status = response.status().as_u16();
let retry_after = parse_retry_after(response.headers());
let bytes = response.bytes().await.map_err(Error::Transport)?;
Ok(RawResponse {
status,
retry_after,
body: bytes.to_vec(),
})
}
}
impl RequestExecutor for LocalExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
Box::pin(async move { self.send(request).await })
}
fn base_url(&self) -> &str {
self.base_url.as_str()
}
fn is_authenticated(&self) -> bool {
self.signer.is_some()
}
}
impl std::fmt::Debug for LocalExecutor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalExecutor")
.field("base_url", &self.base_url.as_str())
.field("authenticated", &self.signer.is_some())
.finish_non_exhaustive()
}
}
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'~' | 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
}
pub(crate) fn now_unix_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
headers
.get(RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(Duration::from_millis)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
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");
assert_eq!(encode_component("BTC-USD,ETH-USD"), "BTC-USD,ETH-USD");
}
#[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_and_reports_unauthenticated() {
let executor =
LocalExecutor::new("https://revx.revolut.com/api/1.0/", HttpClient::new(), None)
.unwrap();
assert_eq!(executor.base_path, "/api/1.0");
assert!(!executor.is_authenticated());
assert_eq!(executor.base_url(), "https://revx.revolut.com/api/1.0/");
}
#[test]
fn rejects_invalid_base_url() {
assert!(LocalExecutor::new("not a url", HttpClient::new(), None).is_err());
}
}