mod curl;
mod models;
#[cfg(test)]
mod tests;
pub use models::*;
use crate::{
collection::{Authentication, Recipe, RecipeBody},
database::CollectionDatabase,
http::curl::CurlBuilder,
render::TemplateContext,
util::json::value_to_json,
};
use bytes::{Bytes, BytesMut};
use chrono::Utc;
use futures::{
Future, StreamExt, TryFutureExt, TryStreamExt,
future::{self, OptionFuture, try_join_all},
stream::BoxStream,
try_join,
};
use indexmap::IndexMap;
use reqwest::{
Body, Client, Request, RequestBuilder, Response, Url,
header::{HeaderMap, HeaderName, HeaderValue},
multipart::{Form, Part},
redirect,
};
use slumber_config::HttpEngineConfig;
use slumber_template::{RenderError, StreamSource, Template};
use slumber_util::ResultTraced;
use std::{collections::HashSet, error::Error, hash::Hash};
use tracing::{Instrument, error, info, info_span};
const USER_AGENT: &str = concat!("slumber/", env!("CARGO_PKG_VERSION"));
#[derive(Clone, Debug)]
pub struct HttpEngine {
client: Client,
danger_client: Option<(Client, HashSet<String>)>,
large_body_size: usize,
}
impl HttpEngine {
pub fn new(config: &HttpEngineConfig) -> Self {
let make_builder = || {
let redirect_policy = if config.follow_redirects {
redirect::Policy::default()
} else {
redirect::Policy::none()
};
Client::builder()
.user_agent(USER_AGENT)
.redirect(redirect_policy)
};
let client = make_builder()
.build()
.expect("Error building reqwest client");
let danger_client = if config.ignore_certificate_hosts.is_empty() {
None
} else {
Some((
make_builder()
.danger_accept_invalid_certs(true)
.build()
.expect("Error building reqwest client"),
config.ignore_certificate_hosts.iter().cloned().collect(),
))
};
Self {
client,
danger_client,
large_body_size: config.large_body_size,
}
}
pub async fn build(
&self,
seed: RequestSeed,
context: &TemplateContext,
) -> Result<RequestTicket, RequestBuildError> {
let RequestSeed {
id,
recipe_id,
options,
} = &seed;
let future = async {
let recipe =
context.collection.recipes.try_get_recipe(recipe_id)?;
let (url, query, headers, authentication, body) = try_join!(
recipe.render_url(options, context),
recipe.render_query(options, context),
recipe.render_headers(options, context),
recipe.render_authentication(options, context),
recipe.render_body(options, context),
)?;
let client = self.get_client(&url);
let mut builder =
client.request(recipe.method.into(), url).query(&query);
if let Some(body) = body {
builder = body.apply(builder).await?;
}
builder = builder.headers(headers);
if let Some(authentication) = authentication {
builder = authentication.apply(builder);
}
let request = builder.build()?;
Ok((client, request))
}
.instrument(
info_span!("Build request", request_id = %id, ?recipe_id, ?options),
);
let (client, request) = seed.run_future(future, context).await?;
Ok(RequestTicket {
record: RequestRecord::new(
seed.id,
context.selected_profile.clone(),
seed.recipe_id,
&request,
self.large_body_size,
)
.into(),
client: client.clone(),
request,
})
}
pub fn rebuild(
&self,
previous_request: &RequestRecord,
) -> Result<RequestTicket, RequestBuildError> {
fn build_request(
client: &Client,
previous_request: &RequestRecord,
) -> Result<Request, RequestBuildErrorKind> {
let url = previous_request.url.clone();
let builder = client
.request(previous_request.method.into(), url)
.headers(previous_request.headers.clone());
let builder = match &previous_request.body {
RequestBody::None => builder,
RequestBody::Some(bytes) => builder.body(bytes.clone()),
RequestBody::Stream | RequestBody::TooLarge => {
return Err(RequestBuildErrorKind::BodyMissing {
previous_request_id: previous_request.id,
});
}
};
let request = builder.build()?;
Ok(request)
}
let id = RequestId::new();
let profile_id = previous_request.profile_id.clone();
let recipe_id = previous_request.recipe_id.clone();
info!(old = %previous_request.id, new = %id, "Rebuilding request");
let start_time = Utc::now();
let client = self.get_client(&previous_request.url);
let request =
build_request(client, previous_request).map_err(|error| {
RequestBuildError {
profile_id: profile_id.clone(),
recipe_id: recipe_id.clone(),
id,
start_time,
end_time: Utc::now(),
error: Box::new(error),
}
})?;
let record = RequestRecord::new(
id,
previous_request.profile_id.clone(),
previous_request.recipe_id.clone(),
&request,
self.large_body_size,
);
Ok(RequestTicket {
record: record.into(),
client: client.clone(),
request,
})
}
pub async fn build_url(
&self,
seed: RequestSeed,
context: &TemplateContext,
) -> Result<Url, RequestBuildError> {
let RequestSeed {
id,
recipe_id,
options,
} = &seed;
let future = async {
let recipe =
context.collection.recipes.try_get_recipe(recipe_id)?;
let (url, query) = try_join!(
recipe.render_url(options, context),
recipe.render_query(options, context),
)?;
let client = self.get_client(&url);
let request = client
.request(recipe.method.into(), url)
.query(&query)
.build()?;
Ok(request)
}
.instrument(info_span!("Build request URL", request_id = %id, ?recipe_id, ?options));
let request = seed.run_future(future, context).await?;
Ok(request.url().clone())
}
pub async fn build_body(
&self,
seed: RequestSeed,
context: &TemplateContext,
) -> Result<Option<Bytes>, RequestBuildError> {
let RequestSeed {
id,
recipe_id,
options,
} = &seed;
let future = async {
let recipe =
context.collection.recipes.try_get_recipe(recipe_id)?;
let Some(body) = recipe.render_body(options, context).await? else {
return Ok(None);
};
match body {
RenderedBody::Raw(bytes) => Ok(Some(bytes)),
RenderedBody::Stream(stream) => {
let bytes = stream
.stream
.try_collect::<BytesMut>()
.await
.map_err(RequestBuildErrorKind::BodyStream)?;
Ok(Some(bytes.into()))
}
RenderedBody::Json(_)
| RenderedBody::FormUrlencoded(_)
| RenderedBody::FormMultipart(_) => {
let url = Url::parse("http://localhost").unwrap();
let client = self.get_client(&url);
let mut builder = client.request(reqwest::Method::GET, url);
builder = body.apply(builder).await?;
let request = builder.build()?;
let bytes = request
.body()
.expect("Body should be present")
.as_bytes()
.expect("Body should be raw bytes")
.to_owned()
.into();
Ok(Some(bytes))
}
}
}
.instrument(info_span!(
"Build request body",
request_id = %id, ?recipe_id, ?options
));
seed.run_future(future, context).await
}
pub async fn build_curl(
&self,
seed: RequestSeed,
context: &TemplateContext,
) -> Result<String, RequestBuildError> {
let RequestSeed {
id,
recipe_id,
options,
} = &seed;
let future = async {
let recipe =
context.collection.recipes.try_get_recipe(recipe_id)?;
let (url, query, headers, authentication, body) = try_join!(
recipe.render_url(options, context),
recipe.render_query(options, context),
recipe.render_headers(options, context),
recipe.render_authentication(options, context),
recipe.render_body(options, context),
)?;
let mut builder = CurlBuilder::new(recipe.method)
.url(url, &query)
.headers(&headers)?;
if let Some(authentication) = authentication {
builder = builder.authentication(&authentication);
}
if let Some(body) = body {
builder = builder.body(body).await?;
}
Ok(builder.build())
}
.instrument(info_span!(
"Build request cURL",
request_id = %id, ?recipe_id, ?options
));
seed.run_future(future, context).await
}
fn get_client(&self, url: &Url) -> &Client {
let host = url.host_str().unwrap_or_default();
match &self.danger_client {
Some((client, hostnames)) if hostnames.contains(host) => client,
_ => &self.client,
}
}
}
impl Default for HttpEngine {
fn default() -> Self {
Self::new(&HttpEngineConfig::default())
}
}
impl RequestSeed {
async fn run_future<T>(
&self,
future: impl Future<Output = Result<T, RequestBuildErrorKind>>,
context: &TemplateContext,
) -> Result<T, RequestBuildError> {
let start_time = Utc::now();
future
.await
.map_err(|error| RequestBuildError {
profile_id: context.selected_profile.clone(),
recipe_id: self.recipe_id.clone(),
id: self.id,
start_time,
end_time: Utc::now(),
error: Box::new(error),
})
.traced()
}
}
impl RequestTicket {
pub async fn send(
self,
persist_to: Option<CollectionDatabase>,
) -> Result<Exchange, RequestError> {
let id = self.record.id;
let span = info_span!(
"HTTP request",
recipe = %self.record.recipe_id,
request_id = %id,
);
let start_time = Utc::now();
let result = async {
info!("Sending request");
let response = self.client.execute(self.request).await?;
info!(status = response.status().as_u16(), "Response");
ResponseRecord::from_response(id, response).await
}
.instrument(span)
.await;
let end_time = Utc::now();
match result {
Ok(response) => {
let exchange = Exchange {
id,
request: self.record,
response: response.into(),
start_time,
end_time,
};
if let Some(database) = persist_to {
let _ = database.insert_exchange(&exchange).traced();
}
Ok(exchange)
}
Err(error) => Err(RequestError {
request: self.record,
start_time,
end_time,
error,
})
.inspect_err(|err| error!(error = err as &dyn Error)),
}
}
}
impl ResponseRecord {
async fn from_response(
id: RequestId,
response: Response,
) -> reqwest::Result<ResponseRecord> {
let status = response.status();
let headers = response.headers().clone();
let body = response.bytes().await?.into();
Ok(ResponseRecord {
id,
status,
headers,
body,
})
}
}
impl Recipe {
async fn render_url(
&self,
options: &BuildOptions,
context: &TemplateContext,
) -> Result<Url, RequestBuildErrorKind> {
let template = options.url.as_ref().unwrap_or(&self.url);
let url = template
.render_string(context)
.await
.map_err(RequestBuildErrorKind::UrlRender)?;
url.parse::<Url>()
.map_err(|error| RequestBuildErrorKind::UrlInvalid { url, error })
}
async fn render_query(
&self,
options: &BuildOptions,
context: &TemplateContext,
) -> Result<Vec<(String, String)>, RequestBuildErrorKind> {
let merged = apply_overrides(
self.query_iter().map(|(k, i, v)| ((k, i), v)),
options
.query_parameters
.iter()
.map(|((k, i), v)| ((k.as_str(), *i), v)),
);
let iter = merged.into_iter().map(async |((param, _), template)| {
let value =
template.render_string(context).await.map_err(|error| {
RequestBuildErrorKind::QueryRender {
parameter: param.to_owned(),
error,
}
})?;
Ok((param.to_owned(), value))
});
future::try_join_all(iter).await
}
async fn render_headers(
&self,
options: &BuildOptions,
context: &TemplateContext,
) -> Result<HeaderMap, RequestBuildErrorKind> {
let mut headers = HeaderMap::new();
let merged = apply_overrides(&self.headers, &options.headers);
let iter = merged.into_iter().map(|(header, template)| {
self.render_header(context, header, template)
});
let rendered = future::try_join_all(iter).await?;
headers.reserve(rendered.len());
for (header, value) in rendered {
headers.insert(header, value);
}
Ok(headers)
}
async fn render_header(
&self,
context: &TemplateContext,
header: &str,
value_template: &Template,
) -> Result<(HeaderName, HeaderValue), RequestBuildErrorKind> {
let mut value: Vec<u8> = value_template
.render_bytes(context)
.await
.map_err(|error| RequestBuildErrorKind::HeaderRender {
header: header.to_owned(),
error,
})?
.into();
trim_bytes(&mut value, |c| c == b'\n' || c == b'\r');
let name: HeaderName = header.try_into().map_err(|error| {
RequestBuildErrorKind::HeaderInvalidName {
header: header.to_owned(),
error,
}
})?;
let value: HeaderValue = value.try_into().map_err(|error| {
RequestBuildErrorKind::HeaderInvalidValue {
header: header.to_owned(),
error,
}
})?;
Ok((name, value))
}
async fn render_authentication(
&self,
options: &BuildOptions,
context: &TemplateContext,
) -> Result<Option<Authentication<String>>, RequestBuildErrorKind> {
let authentication = options
.authentication
.as_ref()
.or(self.authentication.as_ref());
match authentication {
Some(Authentication::Basic { username, password }) => {
let (username, password) =
try_join!(
username
.render_string(context)
.map_err(RequestBuildErrorKind::AuthUsernameRender),
async {
OptionFuture::from(password.as_ref().map(
|password| password.render_string(context),
))
.await
.transpose()
.map_err(RequestBuildErrorKind::AuthPasswordRender)
},
)?;
Ok(Some(Authentication::Basic { username, password }))
}
Some(Authentication::Bearer { token }) => {
let token = token
.render_string(context)
.await
.map_err(RequestBuildErrorKind::AuthTokenRender)?;
Ok(Some(Authentication::Bearer { token }))
}
None => Ok(None),
}
}
async fn render_body(
&self,
options: &BuildOptions,
context: &TemplateContext,
) -> Result<Option<RenderedBody>, RequestBuildErrorKind> {
match (&self.body, &options.body) {
(None, None) => Ok(None), (
Some(
RecipeBody::FormUrlencoded(_)
| RecipeBody::FormMultipart(_),
),
Some(_),
) => Err(RequestBuildErrorKind::OverrideFormBody),
(Some(RecipeBody::Raw(template)), None)
| (
None | Some(RecipeBody::Raw(_) | RecipeBody::Json(_)),
Some(BodyOverride::Raw(template)),
) => {
let rendered = template
.render_bytes(context)
.await
.map_err(RequestBuildErrorKind::BodyRender)?;
Ok(Some(RenderedBody::Raw(rendered)))
}
(Some(RecipeBody::Stream(template)), None)
| (
Some(RecipeBody::Stream(_)),
Some(BodyOverride::Raw(template)),
) => {
let chunks = template.render_chunks_stream(context).await;
let source = chunks.stream_source().cloned();
let stream = chunks
.try_into_stream()
.map_err(RequestBuildErrorKind::BodyRender)?
.boxed();
Ok(Some(RenderedBody::Stream(BodyStream { stream, source })))
}
#[expect(clippy::unnested_or_patterns)] (Some(RecipeBody::Json(json)), None)
| (None, Some(BodyOverride::Json(json)))
| (Some(RecipeBody::Raw(_)), Some(BodyOverride::Json(json)))
| (Some(RecipeBody::Stream(_)), Some(BodyOverride::Json(json)))
| (Some(RecipeBody::Json(_)), Some(BodyOverride::Json(json))) => {
let rendered_value = json
.render_value(context)
.await
.try_into_value()
.map_err(RequestBuildErrorKind::BodyRender)?;
let json = value_to_json(rendered_value);
Ok(Some(RenderedBody::Json(json)))
}
(Some(RecipeBody::FormUrlencoded(fields)), None) => {
let merged = apply_overrides(fields, &options.form_fields);
let iter = merged.into_iter().map(async |(field, template)| {
let value = template.render_string(context).await.map_err(
|error| RequestBuildErrorKind::BodyFormFieldRender {
field: field.clone(),
error,
},
)?;
Ok::<_, RequestBuildErrorKind>((field.clone(), value))
});
let rendered = try_join_all(iter).await?;
Ok(Some(RenderedBody::FormUrlencoded(rendered)))
}
(Some(RecipeBody::FormMultipart(fields)), None) => {
let merged = apply_overrides(fields, &options.form_fields);
let iter = merged.into_iter().map(async |(field, template)| {
let chunks = template.render_chunks_stream(context).await;
let source = chunks.stream_source().cloned();
let stream = chunks
.try_into_stream()
.map_err(|error| {
RequestBuildErrorKind::BodyFormFieldRender {
field: field.clone(),
error,
}
})?
.boxed();
Ok::<_, RequestBuildErrorKind>((
field.clone(),
BodyStream { stream, source },
))
});
let rendered = try_join_all(iter).await?;
Ok(Some(RenderedBody::FormMultipart(rendered)))
}
}
}
}
impl Authentication<String> {
fn apply(self, builder: RequestBuilder) -> RequestBuilder {
match self {
Authentication::Basic { username, password } => {
builder.basic_auth(username, password)
}
Authentication::Bearer { token } => builder.bearer_auth(token),
}
}
}
enum RenderedBody {
Raw(Bytes),
Stream(BodyStream),
Json(serde_json::Value),
FormUrlencoded(Vec<(String, String)>),
FormMultipart(Vec<(String, BodyStream)>),
}
impl RenderedBody {
async fn apply(
self,
builder: RequestBuilder,
) -> Result<RequestBuilder, RequestBuildErrorKind> {
match self {
RenderedBody::Raw(bytes) => Ok(builder.body(bytes)),
RenderedBody::Stream(stream) => {
let body = Body::wrap_stream(stream.stream);
Ok(builder.body(body))
}
RenderedBody::Json(json) => Ok(builder.json(&json)),
RenderedBody::FormUrlencoded(fields) => Ok(builder.form(&fields)),
RenderedBody::FormMultipart(fields) => {
let mut form = Form::new();
#[cfg(test)]
{
tests::MULTIPART_BOUNDARY.set(form.boundary().to_owned());
}
for (field, stream) in fields {
let part = match stream.source {
Some(StreamSource::File { path }) => Part::file(path)
.await
.map_err(RequestBuildErrorKind::BodyFileStream)?,
_ => Part::stream(Body::wrap_stream(stream.stream)),
};
form = form.part(field, part);
}
Ok(builder.multipart(form))
}
}
}
}
struct BodyStream {
stream: BoxStream<'static, Result<Bytes, RenderError>>,
source: Option<StreamSource>,
}
impl From<HttpMethod> for reqwest::Method {
fn from(method: HttpMethod) -> Self {
match method {
HttpMethod::Connect => reqwest::Method::CONNECT,
HttpMethod::Delete => reqwest::Method::DELETE,
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Head => reqwest::Method::HEAD,
HttpMethod::Options => reqwest::Method::OPTIONS,
HttpMethod::Patch => reqwest::Method::PATCH,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Trace => reqwest::Method::TRACE,
}
}
}
fn apply_overrides<'a, K>(
defaults: impl IntoIterator<Item = (K, &'a Template)>,
overrides: impl IntoIterator<Item = (K, &'a BuildFieldOverride)>,
) -> IndexMap<K, &'a Template>
where
K: Eq + Hash + PartialEq,
{
let mut map: IndexMap<K, &Template> = defaults.into_iter().collect();
for (k, ovr) in overrides {
let ovr: &'a BuildFieldOverride = ovr; match ovr {
BuildFieldOverride::Omit => {
map.shift_remove(&k);
}
BuildFieldOverride::Override(template) => {
map.entry(k).insert_entry(template);
}
}
}
map
}
fn trim_bytes(bytes: &mut Vec<u8>, f: impl Fn(u8) -> bool) {
for i in 0..bytes.len() {
if !f(bytes[i]) {
bytes.drain(0..i);
break;
}
}
for i in (0..bytes.len()).rev() {
if !f(bytes[i]) {
bytes.drain((i + 1)..bytes.len());
break;
}
}
}