use crate::{
collection::{
Authentication, ProfileId, RecipeId, UnknownRecipeError, ValueTemplate,
},
util::json::JsonTemplateError,
};
use bytes::Bytes;
use chrono::{DateTime, Duration, Utc};
use derive_more::FromStr;
use indexmap::IndexMap;
use itertools::Itertools;
use mime::Mime;
use reqwest::{
Body, Client, Request, StatusCode, Url,
header::{self, HeaderMap, InvalidHeaderName, InvalidHeaderValue},
};
use serde::{Deserialize, Serialize};
use slumber_template::{RenderError, Template};
use std::{
error::Error,
fmt::{Debug, Display},
io,
str::{FromStr, Utf8Error},
sync::Arc,
};
use strum::{EnumDiscriminants, EnumIter, IntoEnumIterator};
use thiserror::Error;
use tracing::error;
use uuid::Uuid;
#[derive(
Copy,
Clone,
Debug,
derive_more::Display,
Eq,
FromStr,
Hash,
Ord,
PartialEq,
PartialOrd,
Serialize,
Deserialize,
)]
pub struct RequestId(pub Uuid);
impl RequestId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for RequestId {
fn default() -> Self {
Self::new()
}
}
#[derive(Copy, Clone, Debug, Default, EnumIter, Serialize, Deserialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(into = "&str", try_from = "String")]
pub enum HttpVersion {
Http09,
Http10,
#[default]
Http11,
Http2,
Http3,
}
impl HttpVersion {
pub fn to_str(self) -> &'static str {
match self {
Self::Http09 => "HTTP/0.9",
Self::Http10 => "HTTP/1.0",
Self::Http11 => "HTTP/1.1",
Self::Http2 => "HTTP/2.0",
Self::Http3 => "HTTP/3.0",
}
}
}
impl Display for HttpVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.to_str())
}
}
impl From<reqwest::Version> for HttpVersion {
fn from(version: reqwest::Version) -> Self {
match version {
reqwest::Version::HTTP_09 => Self::Http09,
reqwest::Version::HTTP_10 => Self::Http10,
reqwest::Version::HTTP_11 => Self::Http11,
reqwest::Version::HTTP_2 => Self::Http2,
reqwest::Version::HTTP_3 => Self::Http3,
_ => panic!("Unrecognized HTTP version: {version:?}"),
}
}
}
impl FromStr for HttpVersion {
type Err = HttpVersionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"HTTP/0.9" => Ok(Self::Http09),
"HTTP/1.0" => Ok(Self::Http10),
"HTTP/1.1" => Ok(Self::Http11),
"HTTP/2.0" => Ok(Self::Http2),
"HTTP/3.0" => Ok(Self::Http3),
_ => Err(HttpVersionParseError {
input: s.to_owned(),
}),
}
}
}
impl From<HttpVersion> for &'static str {
fn from(version: HttpVersion) -> Self {
version.to_str()
}
}
impl TryFrom<String> for HttpVersion {
type Error = <Self as FromStr>::Err;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
#[derive(Debug, Error)]
#[error(
"Invalid HTTP version `{input}`. Must be one of: {}",
HttpVersion::iter().map(HttpVersion::to_str).format(", "),
)]
pub struct HttpVersionParseError {
input: String,
}
#[derive(Copy, Clone, Debug, EnumIter, Serialize, Deserialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(!try_from, rename_all = "UPPERCASE"), // Show as a string enum
)]
#[serde(into = "&str", try_from = "String")]
pub enum HttpMethod {
Connect,
Delete,
Get,
Head,
Options,
Patch,
Post,
Put,
Trace,
}
impl HttpMethod {
pub fn to_str(self) -> &'static str {
match self {
Self::Connect => "CONNECT",
Self::Delete => "DELETE",
Self::Get => "GET",
Self::Head => "HEAD",
Self::Options => "OPTIONS",
Self::Patch => "PATCH",
Self::Post => "POST",
Self::Put => "PUT",
Self::Trace => "TRACE",
}
}
}
impl Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.to_str())
}
}
impl FromStr for HttpMethod {
type Err = HttpMethodParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"CONNECT" => Ok(Self::Connect),
"DELETE" => Ok(Self::Delete),
"GET" => Ok(Self::Get),
"HEAD" => Ok(Self::Head),
"OPTIONS" => Ok(Self::Options),
"PATCH" => Ok(Self::Patch),
"POST" => Ok(Self::Post),
"PUT" => Ok(Self::Put),
"TRACE" => Ok(Self::Trace),
_ => Err(HttpMethodParseError {
input: s.to_owned(),
}),
}
}
}
impl From<&reqwest::Method> for HttpMethod {
fn from(method: &reqwest::Method) -> Self {
method.as_str().parse().unwrap()
}
}
impl From<HttpMethod> for &'static str {
fn from(method: HttpMethod) -> Self {
method.to_str()
}
}
impl TryFrom<String> for HttpMethod {
type Error = <Self as FromStr>::Err;
fn try_from(method: String) -> Result<Self, Self::Error> {
method.parse()
}
}
#[derive(Debug, Error)]
#[error(
"Invalid HTTP method `{input}`. Must be one of: {}",
HttpMethod::iter().map(HttpMethod::to_str).format(", "),
)]
pub struct HttpMethodParseError {
input: String,
}
pub struct RequestSeed {
pub id: RequestId,
pub recipe_id: RecipeId,
pub options: BuildOptions,
}
impl RequestSeed {
pub fn new(recipe_id: RecipeId, options: BuildOptions) -> Self {
Self {
id: RequestId::new(),
recipe_id,
options,
}
}
}
#[derive(Debug, Default)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub struct BuildOptions {
pub url: Option<Template>,
pub authentication: Option<Authentication>,
pub headers: IndexMap<String, BuildFieldOverride>,
pub query_parameters: IndexMap<(String, usize), BuildFieldOverride>,
pub form_fields: IndexMap<String, BuildFieldOverride>,
pub body: Option<BodyOverride>,
}
#[derive(Clone, Debug)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub enum BuildFieldOverride {
Omit,
Override(Template),
}
#[cfg(any(test, feature = "test"))]
impl From<&'static str> for BuildFieldOverride {
fn from(template: &'static str) -> Self {
Self::Override(template.into())
}
}
#[derive(Debug)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub enum BodyOverride {
Raw(Template),
Json(ValueTemplate),
}
#[cfg(any(test, feature = "test"))]
impl From<&'static str> for BodyOverride {
fn from(template: &'static str) -> Self {
Self::Raw(template.into())
}
}
#[cfg(any(test, feature = "test"))]
impl From<serde_json::Value> for BodyOverride {
fn from(json: serde_json::Value) -> Self {
Self::Json(json.try_into().unwrap())
}
}
#[derive(Debug)]
pub struct RequestTicket {
pub(super) record: Arc<RequestRecord>,
pub(super) client: Client,
pub(super) request: Request,
}
impl RequestTicket {
pub fn record(&self) -> &Arc<RequestRecord> {
&self.record
}
}
#[derive(Clone, Debug)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub struct Exchange {
pub id: RequestId,
pub request: Arc<RequestRecord>,
pub response: Arc<ResponseRecord>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
impl Exchange {
pub fn duration(&self) -> Duration {
self.end_time - self.start_time
}
pub fn summary(&self) -> ExchangeSummary {
ExchangeSummary {
id: self.id,
recipe_id: self.request.recipe_id.clone(),
profile_id: self.request.profile_id.clone(),
start_time: self.start_time,
end_time: self.end_time,
status: self.response.status,
}
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for Exchange {
fn factory((): ()) -> Self {
Self::factory((None, RecipeId::factory(())))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<RecipeId> for Exchange {
fn factory(params: RecipeId) -> Self {
Self::factory((None, params))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<(RequestId, Option<ProfileId>, RecipeId)>
for Exchange
{
fn factory(
(id, profile_id, recipe_id): (RequestId, Option<ProfileId>, RecipeId),
) -> Self {
Self::factory((
RequestRecord {
id,
..RequestRecord::factory((profile_id, recipe_id))
},
ResponseRecord::factory(id),
))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<(Option<ProfileId>, RecipeId)> for Exchange {
fn factory(params: (Option<ProfileId>, RecipeId)) -> Self {
let id = RequestId::new();
Self::factory((
RequestRecord {
id,
..RequestRecord::factory(params)
},
ResponseRecord::factory(id),
))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<RequestRecord> for Exchange {
fn factory(request: RequestRecord) -> Self {
let response = ResponseRecord::factory(request.id);
Self::factory((request, response))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<(RequestRecord, ResponseRecord)> for Exchange {
fn factory((request, response): (RequestRecord, ResponseRecord)) -> Self {
assert_eq!(
request.id, response.id,
"Request and response have different IDs"
);
Self {
id: request.id,
request: request.into(),
response: response.into(),
start_time: Utc::now(),
end_time: Utc::now(),
}
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<RequestId> for Exchange {
fn factory(id: RequestId) -> Self {
Self::factory((RequestRecord::factory(id), ResponseRecord::factory(id)))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExchangeSummary {
pub id: RequestId,
pub recipe_id: RecipeId,
pub profile_id: Option<ProfileId>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub status: StatusCode,
}
#[derive(Debug)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub struct RequestRecord {
pub id: RequestId,
pub profile_id: Option<ProfileId>,
pub recipe_id: RecipeId,
pub http_version: HttpVersion,
pub method: HttpMethod,
pub url: Url,
pub headers: HeaderMap,
pub body: RequestBody,
}
impl RequestRecord {
pub(super) fn new(
id: RequestId,
profile_id: Option<ProfileId>,
recipe_id: RecipeId,
request: &Request,
max_body_size: usize,
) -> Self {
let body = match request.body().map(Body::as_bytes) {
Some(Some(bytes)) if bytes.len() <= max_body_size => {
RequestBody::Some(bytes.to_owned().into())
}
Some(Some(_)) => RequestBody::TooLarge,
Some(None) => RequestBody::Stream, None => RequestBody::None, };
Self {
id,
profile_id,
recipe_id,
http_version: request.version().into(),
method: request.method().into(),
url: request.url().clone(),
headers: request.headers().clone(),
body,
}
}
pub fn mime(&self) -> Option<Mime> {
content_type_header(&self.headers)
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for RequestRecord {
fn factory((): ()) -> Self {
Self::factory((RequestId::new(), None, RecipeId::factory(())))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<RequestId> for RequestRecord {
fn factory(id: RequestId) -> Self {
Self::factory((id, None, RecipeId::factory(())))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<(Option<ProfileId>, RecipeId)> for RequestRecord {
fn factory((profile_id, recipe_id): (Option<ProfileId>, RecipeId)) -> Self {
Self::factory((RequestId::new(), profile_id, recipe_id))
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<(RequestId, Option<ProfileId>, RecipeId)>
for RequestRecord
{
fn factory(
(id, profile_id, recipe_id): (RequestId, Option<ProfileId>, RecipeId),
) -> Self {
use crate::test_util::header_map;
Self {
id,
profile_id,
recipe_id,
method: HttpMethod::Get,
http_version: HttpVersion::Http11,
url: "http://localhost/url".parse().unwrap(),
headers: header_map([
("Accept", "application/json"),
("Content-Type", "application/json"),
("User-Agent", "slumber"),
]),
body: RequestBody::None,
}
}
}
#[derive(Clone, Debug, EnumDiscriminants)]
#[strum_discriminants(name(RequestBodyKind))] #[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub enum RequestBody {
None,
Some(Bytes),
Stream,
TooLarge,
}
impl RequestBody {
pub fn bytes(&self) -> Option<&[u8]> {
match self {
Self::None | Self::Stream | Self::TooLarge => None,
Self::Some(bytes) => Some(bytes.as_ref()),
}
}
pub fn is_lost(&self) -> bool {
match self {
Self::None | Self::Some(_) => false,
Self::Stream | Self::TooLarge => true,
}
}
}
#[cfg(any(test, feature = "test"))]
impl From<&'static [u8]> for RequestBody {
fn from(bytes: &'static [u8]) -> Self {
Self::Some(bytes.into())
}
}
#[derive(Debug)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
pub struct ResponseRecord {
pub id: RequestId,
pub status: StatusCode,
pub headers: HeaderMap,
pub body: ResponseBody,
}
impl ResponseRecord {
pub fn mime(&self) -> Option<Mime> {
content_type_header(&self.headers)
}
pub fn file_name(&self) -> Option<String> {
self.headers
.get(header::CONTENT_DISPOSITION)
.and_then(|value| {
let value = value.to_str().ok()?;
value.split(';').find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
if key == "filename" {
Some(value.trim_matches('"').to_owned())
} else {
None
}
})
})
.or_else(|| {
let content_type = self.headers.get(header::CONTENT_TYPE)?;
let mime: Mime = content_type.to_str().ok()?.parse().ok()?;
Some(format!("data.{}", mime.subtype()))
})
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for ResponseRecord {
fn factory((): ()) -> Self {
Self::factory(RequestId::new())
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<RequestId> for ResponseRecord {
fn factory(id: RequestId) -> Self {
Self {
id,
status: StatusCode::OK,
headers: HeaderMap::new(),
body: ResponseBody::default(),
}
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<StatusCode> for ResponseRecord {
fn factory(status: StatusCode) -> Self {
Self {
id: RequestId::new(),
status,
headers: HeaderMap::new(),
body: ResponseBody::default(),
}
}
}
fn content_type_header(headers: &HeaderMap) -> Option<Mime> {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()?.parse().ok())
}
#[derive(Clone, Default)]
pub struct ResponseBody<T = Bytes> {
data: T,
}
impl<T: AsRef<[u8]>> ResponseBody<T> {
pub fn new(data: T) -> Self {
Self { data }
}
pub fn bytes(&self) -> &T {
&self.data
}
pub fn into_bytes(self) -> T {
self.data
}
pub fn text(&self) -> Option<&str> {
std::str::from_utf8(self.data.as_ref()).ok()
}
pub fn size(&self) -> usize {
self.data.as_ref().len()
}
}
impl Debug for ResponseBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Body")
.field(&format!("<{} bytes>", self.data.len()))
.finish()
}
}
impl<T: From<Bytes>> From<Bytes> for ResponseBody<T> {
fn from(data: Bytes) -> Self {
Self { data: data.into() }
}
}
#[cfg(any(test, feature = "test"))]
impl From<&str> for ResponseBody {
fn from(value: &str) -> Self {
Self::new(value.to_owned().into())
}
}
#[cfg(any(test, feature = "test"))]
impl From<&[u8]> for ResponseBody {
fn from(value: &[u8]) -> Self {
Self::new(value.to_owned().into())
}
}
#[cfg(any(test, feature = "test"))]
impl From<serde_json::Value> for ResponseBody {
fn from(value: serde_json::Value) -> Self {
Self::new(value.to_string().into())
}
}
#[cfg(any(test, feature = "test"))]
impl PartialEq for ResponseBody {
fn eq(&self, other: &Self) -> bool {
self.data == other.data
}
}
#[derive(Debug, Error)]
#[error("Error building request {id}")]
pub struct RequestBuildError {
#[source]
pub error: Box<RequestBuildErrorKind>,
pub profile_id: Option<ProfileId>,
pub recipe_id: RecipeId,
pub id: RequestId,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
impl RequestBuildError {
pub fn has_trigger_disabled_error(&self) -> bool {
let mut next: Option<&dyn Error> = Some(self);
while let Some(error) = next {
if matches!(
error.downcast_ref(),
Some(TriggeredRequestError::NotAllowed)
) {
return true;
}
next = error.source();
}
false
}
}
#[cfg(any(test, feature = "test"))]
impl PartialEq for RequestBuildError {
fn eq(&self, other: &Self) -> bool {
self.profile_id == other.profile_id
&& self.recipe_id == other.recipe_id
&& self.id == other.id
&& self.start_time == other.start_time
&& self.end_time == other.end_time
&& self.error.to_string() == other.error.to_string()
}
}
#[derive(Debug, Error)]
pub enum RequestBuildErrorKind {
#[error("Rendering password")]
AuthPasswordRender(#[source] RenderError),
#[error("Rendering bearer token")]
AuthTokenRender(#[source] RenderError),
#[error("Rendering username")]
AuthUsernameRender(#[source] RenderError),
#[error("Streaming request body")]
BodyFileStream(#[source] io::Error),
#[error("Rendering form field `{field}`")]
BodyFormFieldRender {
field: String,
#[source]
error: RenderError,
},
#[error(
"Cannot resend request {previous_request_id} because its body is not \
available; it was not saved because it was either streamed or too large"
)]
BodyMissing { previous_request_id: RequestId },
#[error("Rendering body")]
BodyRender(#[source] RenderError),
#[error("Streaming request body")]
BodyStream(#[source] RenderError),
#[error(transparent)]
Build(#[from] reqwest::Error),
#[error("Non-text value in curl output")]
CurlInvalidUtf8(#[source] Utf8Error),
#[error("Invalid header name `{header}`")]
HeaderInvalidName {
header: String,
#[source]
error: InvalidHeaderName,
},
#[error("Invalid header name `{header}`")]
HeaderInvalidValue {
header: String,
#[source]
error: InvalidHeaderValue,
},
#[error("Invalid value for header `{header}`")]
HeaderRender {
header: String,
#[source]
error: RenderError,
},
#[error("Invalid JSON override")]
Json(
#[from]
#[source]
JsonTemplateError,
),
#[error(
"Cannot override form body; override individual form fields instead"
)]
OverrideFormBody,
#[error("Rendering query parameter `{parameter}`")]
QueryRender {
parameter: String,
#[source]
error: RenderError,
},
#[error(transparent)]
RecipeUnknown(#[from] UnknownRecipeError),
#[error("Invalid URL")]
UrlInvalid {
url: String,
#[source]
error: url::ParseError,
},
#[error("Rendering URL")]
UrlRender(#[source] RenderError),
}
#[derive(Debug, Error)]
#[error(
"Error executing request for `{}` (request `{}`)",
.request.recipe_id,
.request.id,
)]
pub struct RequestError {
#[source]
pub error: reqwest::Error,
pub request: Arc<RequestRecord>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
#[cfg(any(test, feature = "test"))]
impl PartialEq for RequestError {
fn eq(&self, other: &Self) -> bool {
self.error.to_string() == other.error.to_string()
&& self.request == other.request
&& self.start_time == other.start_time
&& self.end_time == other.end_time
}
}
#[derive(Debug, Error)]
#[error(transparent)]
pub struct StoredRequestError(pub Box<dyn 'static + Error + Send + Sync>);
impl StoredRequestError {
pub fn new<E: 'static + Error + Send + Sync>(error: E) -> Self {
Self(Box::new(error))
}
}
#[derive(Clone, Debug, Error)]
#[cfg_attr(test, derive(PartialEq))]
pub enum TriggeredRequestError {
#[error("Triggered request execution not allowed in this context")]
NotAllowed,
#[error(transparent)]
Build(#[from] Arc<RequestBuildError>),
#[error(transparent)]
Send(#[from] Arc<RequestError>),
}
impl From<RequestBuildError> for TriggeredRequestError {
fn from(error: RequestBuildError) -> Self {
Self::Build(error.into())
}
}
impl From<RequestError> for TriggeredRequestError {
fn from(error: RequestError) -> Self {
Self::Send(error.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::header_map;
use indexmap::indexmap;
use rstest::rstest;
use slumber_util::Factory;
#[rstest]
#[case::content_disposition(
ResponseRecord {
headers: header_map(indexmap! {
"content-disposition" => "form-data;name=\"field\"; filename=\"fish.png\"",
"content-type" => "image/png",
}),
..ResponseRecord::factory(())
},
Some("fish.png")
)]
#[case::content_type_known(
ResponseRecord {
headers: header_map(indexmap! {
"content-disposition" => "form-data",
"content-type" => "application/json",
}),
..ResponseRecord::factory(())
},
Some("data.json")
)]
#[case::content_type_unknown(
ResponseRecord {
headers: header_map(indexmap! {
"content-disposition" => "form-data",
"content-type" => "image/jpeg",
}),
..ResponseRecord::factory(())
},
Some("data.jpeg")
)]
#[case::none(ResponseRecord::factory(()), None)]
fn test_file_name(
#[case] response: ResponseRecord,
#[case] expected: Option<&str>,
) {
assert_eq!(response.file_name().as_deref(), expected);
}
}