use std::future::Future;
pub trait Http {
fn send(&self, request: Request) -> impl Future<Output = Result<Response, TransportError>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub method: Method,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Request {
#[must_use]
pub fn get(url: impl Into<String>) -> Self {
Request { method: Method::Get, url: url.into(), headers: Vec::new(), body: Vec::new() }
}
#[must_use]
pub fn post_json(url: impl Into<String>, body: String) -> Self {
Request {
method: Method::Post,
url: url.into(),
headers: vec![("Content-Type".to_owned(), "application/json".to_owned())],
body: body.into_bytes(),
}
}
#[must_use]
pub fn post_form(url: impl Into<String>, fields: &[(&str, String)]) -> Self {
Request {
method: Method::Post,
url: url.into(),
headers: vec![("Content-Type".to_owned(), "application/x-www-form-urlencoded".to_owned())],
body: form(fields).into_bytes(),
}
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
find_header(&self.headers, name)
}
#[must_use]
pub fn is_signed(&self) -> bool {
const RAW: &[u8] = b"eth_sendRawTransaction";
let path = self.url.split(['?', '#']).next().unwrap_or_default().trim_end_matches('/');
self.method == Method::Post
&& (SIGNED_PATHS.iter().any(|signed| path.ends_with(signed))
|| self.body.windows(RAW.len()).any(|window| window == RAW))
}
pub fn refuse_if_offline(&self) -> Result<(), TransportError> {
match OFFLINE && self.is_signed() {
true => Err(TransportError::Offline(self.url.clone())),
false => Ok(()),
}
}
}
const SIGNED_PATHS: [&str; 4] = ["/api/v1/sendTx", "/api/v1/sendTxBatch", "/api/v1/fastwithdraw", "/api/v1/litLease"];
#[must_use]
pub fn form(fields: &[(&str, String)]) -> String {
let mut text = String::new();
for (at, (key, value)) in fields.iter().enumerate() {
if at > 0 {
text.push('&');
}
escape(key, &mut text);
text.push('=');
escape(value, &mut text);
}
text
}
fn escape(value: &str, out: &mut String) {
for byte in value.bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => out.push(char::from(byte)),
b' ' => out.push('+'),
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[usize::from(byte >> 4)]));
out.push(char::from(b"0123456789ABCDEF"[usize::from(byte & 0x0f)]));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Response {
#[must_use]
pub const fn is_success(&self) -> bool {
self.status >= 200 && self.status < 300
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
find_header(&self.headers, name)
}
#[must_use]
pub fn retry_after_ms(&self) -> Option<u64> {
let seconds = self.header("retry-after").and_then(|seconds| seconds.trim().parse::<u64>().ok())?;
Some(seconds.saturating_mul(1_000))
}
#[must_use]
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.as_str())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TransportError {
#[error("no answer from the venue: {0}")]
Unreachable(String),
#[error("the venue answered HTTP {status}: {body}")]
Status { status: u16, body: String },
#[error("rate limited; try again in {} ms", retry_after_ms.unwrap_or(0))]
RateLimited { retry_after_ms: Option<u64> },
#[error("an offline build sends nothing signed: {0}")]
Offline(String),
#[error("{0} needs an auth token: give the client an API key or a read-only token")]
NoToken(String),
}
pub const OFFLINE: bool = cfg!(feature = "offline");
#[cfg(test)]
#[path = "tests/wire.rs"]
mod tests;