use crate::cassette::{
Body, CapturedRequest, CapturedResponse, Cassette, Exchange, retain_request_headers,
retain_response_headers,
};
use axum::Router;
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use std::fmt;
use std::io;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
#[derive(Debug)]
pub enum RecordError {
Bind { addr: SocketAddr, source: io::Error },
Serve(io::Error),
Upstream { url: String, source: reqwest::Error },
}
impl fmt::Display for RecordError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bind { addr, source } => write!(f, "cannot bind {addr}: {source}"),
Self::Serve(source) => write!(f, "capture proxy stopped: {source}"),
Self::Upstream { url, source } => {
write!(f, "unusable upstream base URL {url}: {source}")
}
}
}
}
impl std::error::Error for RecordError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Bind { source, .. } | Self::Serve(source) => Some(source),
Self::Upstream { source, .. } => Some(source),
}
}
}
type Tape = Arc<Mutex<Vec<Exchange>>>;
struct ProxyState {
upstream: String,
client: reqwest::Client,
tape: Tape,
}
pub struct CaptureProxy {
addr: SocketAddr,
tape: Tape,
id: String,
}
impl CaptureProxy {
pub async fn bind(id: impl Into<String>, upstream: &str) -> Result<Self, RecordError> {
let client =
reqwest::Client::builder()
.build()
.map_err(|source| RecordError::Upstream {
url: upstream.to_string(),
source,
})?;
let tape: Tape = Arc::new(Mutex::new(Vec::new()));
let state = Arc::new(ProxyState {
upstream: upstream.trim_end_matches('/').to_string(),
client,
tape: Arc::clone(&tape),
});
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
let listener = TcpListener::bind(addr)
.await
.map_err(|source| RecordError::Bind { addr, source })?;
let addr = listener.local_addr().map_err(RecordError::Serve)?;
let app = Router::new().fallback(capture).with_state(state);
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(Self {
addr,
tape,
id: id.into(),
})
}
#[must_use]
pub fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
#[must_use]
pub fn addr(&self) -> SocketAddr {
self.addr
}
#[must_use]
pub fn cassette(&self) -> Cassette {
let exchanges = self
.tape
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Cassette {
exchanges,
..Cassette::new(self.id.clone())
}
}
}
async fn capture(State(state): State<Arc<ProxyState>>, req: Request) -> Response {
let method = req.method().clone();
let path = req
.uri()
.path_and_query()
.map_or_else(|| req.uri().path().to_string(), ToString::to_string);
let (parts, body) = req.into_parts();
let body = match axum::body::to_bytes(body, usize::MAX).await {
Ok(bytes) => bytes,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
format!("cannot read request body: {err}"),
)
.into_response();
}
};
let url = format!("{}{}", state.upstream, path);
let mut outbound = state.client.request(method.clone(), &url);
for (name, value) in &parts.headers {
if name.as_str().eq_ignore_ascii_case("host") {
continue;
}
outbound = outbound.header(name, value);
}
let upstream_response = match outbound.body(body.clone()).send().await {
Ok(response) => response,
Err(err) => {
return (
StatusCode::BAD_GATEWAY,
format!("upstream request failed: {err}"),
)
.into_response();
}
};
let status = upstream_response.status();
let response_headers = upstream_response.headers().clone();
let response_body = match upstream_response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
return (
StatusCode::BAD_GATEWAY,
format!("upstream response failed: {err}"),
)
.into_response();
}
};
push(
&state.tape,
CapturedRequest {
method: method.to_string(),
path,
headers: retain_request_headers(
parts
.headers
.iter()
.map(|(n, v)| (n.as_str(), v.as_bytes())),
),
body: Body::from_bytes(&body),
},
CapturedResponse {
status: status.as_u16(),
headers: retain_response_headers(
response_headers
.iter()
.map(|(n, v)| (n.as_str(), v.as_bytes())),
),
body: Body::from_bytes(&response_body),
},
);
let mut relayed = Response::builder().status(status);
for (name, value) in &response_headers {
if is_hop_by_hop(name.as_str()) {
continue;
}
relayed = relayed.header(name, value);
}
relayed
.body(axum::body::Body::from(response_body))
.map_or_else(
|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot relay response: {err}"),
)
.into_response()
},
IntoResponse::into_response,
)
}
fn is_hop_by_hop(name: &str) -> bool {
const HOP_BY_HOP: &[&str] = &[
"connection",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
}
fn push(tape: &Tape, request: CapturedRequest, response: CapturedResponse) {
let mut tape = tape
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let idx = tape.len();
tape.push(Exchange {
idx,
request,
response,
});
}