use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::future::BoxFuture;
#[derive(Clone, PartialEq, Eq)]
pub struct HttpRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl std::fmt::Debug for HttpRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let headers: Vec<(&str, &str)> = self
.headers
.iter()
.map(|(k, v)| {
let v = if k == "authorization" {
"<redacted>"
} else {
v.as_str()
};
(k.as_str(), v)
})
.collect();
f.debug_struct("HttpRequest")
.field("url", &self.url)
.field("headers", &headers)
.field("body_len", &self.body.len())
.finish()
}
}
impl HttpRequest {
#[must_use]
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
headers: Vec::new(),
body: Vec::new(),
}
}
#[must_use]
pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
self.headers.push((name.to_ascii_lowercase(), value.into()));
self
}
#[must_use]
pub fn body(mut self, body: Vec<u8>) -> Self {
self.body = body;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpReply {
pub status: u16,
pub retry_after_secs: Option<u64>,
pub body: Vec<u8>,
}
impl HttpReply {
#[must_use]
pub fn json(status: u16, body: &serde_json::Value) -> Self {
Self {
status,
retry_after_secs: None,
body: body.to_string().into_bytes(),
}
}
#[must_use]
pub fn text(status: u16, body: &str) -> Self {
Self {
status,
retry_after_secs: None,
body: body.as_bytes().to_vec(),
}
}
#[must_use]
pub const fn retry_after(mut self, secs: u64) -> Self {
self.retry_after_secs = Some(secs);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct TransportError(pub String);
pub trait HttpTransport: Send + Sync + 'static {
fn post(&self, req: HttpRequest) -> BoxFuture<'_, Result<HttpReply, TransportError>>;
}
#[derive(Debug, Clone)]
pub struct ReqwestTransport {
client: reqwest::Client,
}
impl ReqwestTransport {
pub fn new(timeout: Duration) -> Result<Self, TransportError> {
let client = reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("autumn-plugin-slack/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| TransportError(format!("client setup: {e}")))?;
Ok(Self { client })
}
}
impl HttpTransport for ReqwestTransport {
fn post(&self, req: HttpRequest) -> BoxFuture<'_, Result<HttpReply, TransportError>> {
Box::pin(async move {
let mut builder = self.client.post(&req.url).body(req.body);
for (k, v) in &req.headers {
builder = builder.header(k, v);
}
let res = builder
.send()
.await
.map_err(|e| TransportError(e.without_url().to_string()))?;
let status = res.status().as_u16();
let retry_after_secs = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse().ok());
let body = res
.bytes()
.await
.map_err(|e| TransportError(e.without_url().to_string()))?
.to_vec();
Ok(HttpReply {
status,
retry_after_secs,
body,
})
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordedRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl RecordedRequest {
#[must_use]
pub fn header(&self, name: &str) -> Option<String> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.clone())
}
#[must_use]
pub fn form(&self) -> BTreeMap<String, String> {
serde_urlencoded::from_bytes(&self.body).unwrap_or_default()
}
#[must_use]
pub fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
}
}
#[derive(Default)]
struct MemoryInner {
requests: Vec<RecordedRequest>,
replies: HashMap<String, VecDeque<HttpReply>>,
failures: HashMap<String, u32>,
}
#[derive(Clone, Default)]
pub struct MemoryTransport {
inner: Arc<Mutex<MemoryInner>>,
}
impl std::fmt::Debug for MemoryTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemoryTransport").finish_non_exhaustive()
}
}
impl MemoryTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn respond(&self, pattern: &str, reply: HttpReply) {
self.respond_seq(pattern, vec![reply]);
}
pub fn respond_seq(&self, pattern: &str, replies: Vec<HttpReply>) {
self.lock()
.replies
.insert(pattern.to_owned(), replies.into());
}
pub fn fail_next(&self, pattern: &str, count: u32) {
self.lock().failures.insert(pattern.to_owned(), count);
}
#[must_use]
pub fn requests(&self) -> Vec<RecordedRequest> {
self.lock().requests.clone()
}
#[must_use]
pub fn requests_to(&self, pattern: &str) -> Vec<RecordedRequest> {
self.lock()
.requests
.iter()
.filter(|r| matches(&r.url, pattern))
.cloned()
.collect()
}
fn lock(&self) -> std::sync::MutexGuard<'_, MemoryInner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn reply_for(&self, url: &str) -> Result<HttpReply, TransportError> {
let mut inner = self.lock();
if let Some(n) = inner
.failures
.iter_mut()
.find(|(p, n)| **n > 0 && matches(url, p))
.map(|(_, n)| n)
{
*n -= 1;
drop(inner);
return Err(TransportError("memory transport: set failure".to_owned()));
}
let reply = inner
.replies
.iter_mut()
.find(|(p, _)| matches(url, p))
.and_then(|(_, q)| {
if q.len() > 1 {
q.pop_front()
} else {
q.front().cloned()
}
});
drop(inner);
Ok(reply.unwrap_or_else(|| HttpReply::json(200, &serde_json::json!({"ok": true}))))
}
}
fn matches(url: &str, pattern: &str) -> bool {
url == pattern || url.rsplit('/').next() == Some(pattern)
}
impl HttpTransport for MemoryTransport {
fn post(&self, req: HttpRequest) -> BoxFuture<'_, Result<HttpReply, TransportError>> {
Box::pin(async move {
let reply = self.reply_for(&req.url);
self.lock().requests.push(RecordedRequest {
url: req.url,
headers: req.headers,
body: req.body,
});
reply
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn memory_default_seq_and_failures() {
let t = MemoryTransport::new();
let url = "https://slack.com/api/chat.postMessage";
let r = t.post(HttpRequest::new(url)).await.unwrap();
assert_eq!(r.status, 200);
assert_eq!(r.body, br#"{"ok":true}"#);
t.respond_seq(
"chat.postMessage",
vec![HttpReply::text(500, "a"), HttpReply::text(201, "b")],
);
assert_eq!(t.post(HttpRequest::new(url)).await.unwrap().status, 500);
assert_eq!(t.post(HttpRequest::new(url)).await.unwrap().status, 201);
assert_eq!(t.post(HttpRequest::new(url)).await.unwrap().status, 201);
t.fail_next(url, 1);
assert!(t.post(HttpRequest::new(url)).await.is_err());
assert_eq!(t.post(HttpRequest::new(url)).await.unwrap().status, 201);
assert_eq!(t.requests_to("chat.postMessage").len(), 6);
assert!(
t.requests_to("postMessage").is_empty(),
"pattern is a whole method"
);
assert!(t.requests_to("chat.update").is_empty());
}
#[test]
fn recorded_decoders() {
let r = RecordedRequest {
url: "u".into(),
headers: vec![("x-a".into(), "1".into())],
body: b"a=1&b=two+words".to_vec(),
};
assert_eq!(r.header("x-a").as_deref(), Some("1"));
assert_eq!(r.header("X-A").as_deref(), Some("1"));
assert_eq!(r.form()["b"], "two words");
assert!(r.json().is_null());
}
#[test]
fn debug_redacts_authorization() {
let r = HttpRequest::new("u").header("Authorization", "Bearer xoxb-1");
assert!(!format!("{r:?}").contains("xoxb-1"));
assert!(format!("{:?}", MemoryTransport::new()).contains("MemoryTransport"));
}
}