1use crate::{
7 collection::{
8 Authentication, ProfileId, RecipeId, UnknownRecipeError, ValueTemplate,
9 },
10 util::json::JsonTemplateError,
11};
12use bytes::Bytes;
13use chrono::{DateTime, Duration, Utc};
14use derive_more::FromStr;
15use indexmap::IndexMap;
16use itertools::Itertools;
17use mime::Mime;
18use reqwest::{
19 Body, Client, Request, StatusCode, Url,
20 header::{self, HeaderMap, InvalidHeaderName, InvalidHeaderValue},
21};
22use serde::{Deserialize, Serialize};
23use slumber_template::{RenderError, Template};
24use std::{
25 error::Error,
26 fmt::{Debug, Display},
27 io,
28 str::{FromStr, Utf8Error},
29 sync::Arc,
30};
31use strum::{EnumDiscriminants, EnumIter, IntoEnumIterator};
32use thiserror::Error;
33use uuid::Uuid;
34
35#[derive(
38 Copy,
39 Clone,
40 Debug,
41 derive_more::Display,
42 Eq,
43 FromStr,
44 Hash,
45 Ord,
46 PartialEq,
47 PartialOrd,
48 Serialize,
49 Deserialize,
50)]
51pub struct RequestId(pub Uuid);
52
53impl RequestId {
54 pub fn new() -> Self {
55 Self(Uuid::new_v4())
56 }
57}
58
59impl Default for RequestId {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65#[derive(Copy, Clone, Debug, Default, EnumIter, Serialize, Deserialize)]
69#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
70#[serde(into = "&str", try_from = "String")]
71pub enum HttpVersion {
72 Http09,
73 Http10,
74 #[default]
75 Http11,
76 Http2,
77 Http3,
78}
79
80impl HttpVersion {
81 pub fn to_str(self) -> &'static str {
82 match self {
83 Self::Http09 => "HTTP/0.9",
84 Self::Http10 => "HTTP/1.0",
85 Self::Http11 => "HTTP/1.1",
86 Self::Http2 => "HTTP/2.0",
87 Self::Http3 => "HTTP/3.0",
88 }
89 }
90}
91
92impl Display for HttpVersion {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str(self.to_str())
95 }
96}
97
98impl From<reqwest::Version> for HttpVersion {
99 fn from(version: reqwest::Version) -> Self {
100 match version {
101 reqwest::Version::HTTP_09 => Self::Http09,
102 reqwest::Version::HTTP_10 => Self::Http10,
103 reqwest::Version::HTTP_11 => Self::Http11,
104 reqwest::Version::HTTP_2 => Self::Http2,
105 reqwest::Version::HTTP_3 => Self::Http3,
106 _ => panic!("Unrecognized HTTP version: {version:?}"),
107 }
108 }
109}
110
111impl FromStr for HttpVersion {
112 type Err = HttpVersionParseError;
113
114 fn from_str(s: &str) -> Result<Self, Self::Err> {
115 match s.to_uppercase().as_str() {
116 "HTTP/0.9" => Ok(Self::Http09),
117 "HTTP/1.0" => Ok(Self::Http10),
118 "HTTP/1.1" => Ok(Self::Http11),
119 "HTTP/2.0" => Ok(Self::Http2),
120 "HTTP/3.0" => Ok(Self::Http3),
121 _ => Err(HttpVersionParseError {
122 input: s.to_owned(),
123 }),
124 }
125 }
126}
127
128impl From<HttpVersion> for &'static str {
130 fn from(version: HttpVersion) -> Self {
131 version.to_str()
132 }
133}
134
135impl TryFrom<String> for HttpVersion {
137 type Error = <Self as FromStr>::Err;
138
139 fn try_from(value: String) -> Result<Self, Self::Error> {
140 value.parse()
141 }
142}
143
144#[derive(Debug, Error)]
145#[error(
146 "Invalid HTTP version `{input}`. Must be one of: {}",
147 HttpVersion::iter().map(HttpVersion::to_str).format(", "),
148)]
149pub struct HttpVersionParseError {
150 input: String,
151}
152
153#[derive(Copy, Clone, Debug, EnumIter, Serialize, Deserialize)]
158#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
159#[cfg_attr(
160 feature = "schema",
161 derive(schemars::JsonSchema),
162 schemars(!try_from, rename_all = "UPPERCASE"), )]
164#[serde(into = "&str", try_from = "String")]
166pub enum HttpMethod {
167 Connect,
168 Delete,
169 Get,
170 Head,
171 Options,
172 Patch,
173 Post,
174 Put,
175 Trace,
176}
177
178impl HttpMethod {
179 pub fn to_str(self) -> &'static str {
180 match self {
181 Self::Connect => "CONNECT",
182 Self::Delete => "DELETE",
183 Self::Get => "GET",
184 Self::Head => "HEAD",
185 Self::Options => "OPTIONS",
186 Self::Patch => "PATCH",
187 Self::Post => "POST",
188 Self::Put => "PUT",
189 Self::Trace => "TRACE",
190 }
191 }
192}
193
194impl Display for HttpMethod {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 f.write_str(self.to_str())
197 }
198}
199
200impl FromStr for HttpMethod {
201 type Err = HttpMethodParseError;
202
203 fn from_str(s: &str) -> Result<Self, Self::Err> {
204 match s.to_ascii_uppercase().as_str() {
205 "CONNECT" => Ok(Self::Connect),
206 "DELETE" => Ok(Self::Delete),
207 "GET" => Ok(Self::Get),
208 "HEAD" => Ok(Self::Head),
209 "OPTIONS" => Ok(Self::Options),
210 "PATCH" => Ok(Self::Patch),
211 "POST" => Ok(Self::Post),
212 "PUT" => Ok(Self::Put),
213 "TRACE" => Ok(Self::Trace),
214 _ => Err(HttpMethodParseError {
215 input: s.to_owned(),
216 }),
217 }
218 }
219}
220
221impl From<&reqwest::Method> for HttpMethod {
222 fn from(method: &reqwest::Method) -> Self {
223 method.as_str().parse().unwrap()
226 }
227}
228
229impl From<HttpMethod> for &'static str {
231 fn from(method: HttpMethod) -> Self {
232 method.to_str()
233 }
234}
235
236impl TryFrom<String> for HttpMethod {
238 type Error = <Self as FromStr>::Err;
239
240 fn try_from(method: String) -> Result<Self, Self::Error> {
241 method.parse()
242 }
243}
244
245#[derive(Debug, Error)]
246#[error(
247 "Invalid HTTP method `{input}`. Must be one of: {}",
248 HttpMethod::iter().map(HttpMethod::to_str).format(", "),
249)]
250pub struct HttpMethodParseError {
251 input: String,
252}
253
254pub struct RequestSeed {
259 pub id: RequestId,
261 pub recipe_id: RecipeId,
263 pub options: BuildOptions,
265}
266
267impl RequestSeed {
268 pub fn new(recipe_id: RecipeId, options: BuildOptions) -> Self {
269 Self {
270 id: RequestId::new(),
271 recipe_id,
272 options,
273 }
274 }
275}
276
277#[derive(Debug, Default)]
296#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
297pub struct BuildOptions {
298 pub url: Option<Template>,
300 pub authentication: Option<Authentication>,
303 pub headers: IndexMap<String, BuildFieldOverride>,
305 pub query_parameters: IndexMap<(String, usize), BuildFieldOverride>,
309 pub form_fields: IndexMap<String, BuildFieldOverride>,
311 pub body: Option<BodyOverride>,
314}
315
316#[derive(Clone, Debug)]
319#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
320pub enum BuildFieldOverride {
321 Omit,
323 Override(Template),
325}
326
327#[cfg(any(test, feature = "test"))]
329impl From<&'static str> for BuildFieldOverride {
330 fn from(template: &'static str) -> Self {
331 Self::Override(template.into())
332 }
333}
334
335#[derive(Debug)]
341#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
342pub enum BodyOverride {
343 Raw(Template),
346 Json(ValueTemplate),
348}
349
350#[cfg(any(test, feature = "test"))]
351impl From<&'static str> for BodyOverride {
352 fn from(template: &'static str) -> Self {
353 Self::Raw(template.into())
354 }
355}
356
357#[cfg(any(test, feature = "test"))]
358impl From<serde_json::Value> for BodyOverride {
359 fn from(json: serde_json::Value) -> Self {
360 Self::Json(json.try_into().unwrap())
361 }
362}
363
364#[derive(Debug)]
369pub struct RequestTicket {
370 pub(super) record: Arc<RequestRecord>,
372 pub(super) client: Client,
374 pub(super) request: Request,
376}
377
378impl RequestTicket {
379 pub fn record(&self) -> &Arc<RequestRecord> {
380 &self.record
381 }
382}
383
384#[derive(Clone, Debug)]
389#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
390pub struct Exchange {
391 pub id: RequestId,
393 pub request: Arc<RequestRecord>,
395 pub response: Arc<ResponseRecord>,
397 pub start_time: DateTime<Utc>,
399 pub end_time: DateTime<Utc>,
401}
402
403impl Exchange {
404 pub fn duration(&self) -> Duration {
406 self.end_time - self.start_time
407 }
408
409 pub fn summary(&self) -> ExchangeSummary {
410 ExchangeSummary {
411 id: self.id,
412 recipe_id: self.request.recipe_id.clone(),
413 profile_id: self.request.profile_id.clone(),
414 start_time: self.start_time,
415 end_time: self.end_time,
416 status: self.response.status,
417 }
418 }
419}
420
421#[cfg(any(test, feature = "test"))]
422impl slumber_util::Factory for Exchange {
423 fn factory((): ()) -> Self {
424 Self::factory((None, RecipeId::factory(())))
425 }
426}
427
428#[cfg(any(test, feature = "test"))]
430impl slumber_util::Factory<RecipeId> for Exchange {
431 fn factory(params: RecipeId) -> Self {
432 Self::factory((None, params))
433 }
434}
435
436#[cfg(any(test, feature = "test"))]
438impl slumber_util::Factory<(RequestId, Option<ProfileId>, RecipeId)>
439 for Exchange
440{
441 fn factory(
442 (id, profile_id, recipe_id): (RequestId, Option<ProfileId>, RecipeId),
443 ) -> Self {
444 Self::factory((
445 RequestRecord {
446 id,
447 ..RequestRecord::factory((profile_id, recipe_id))
448 },
449 ResponseRecord::factory(id),
450 ))
451 }
452}
453
454#[cfg(any(test, feature = "test"))]
456impl slumber_util::Factory<(Option<ProfileId>, RecipeId)> for Exchange {
457 fn factory(params: (Option<ProfileId>, RecipeId)) -> Self {
458 let id = RequestId::new();
459 Self::factory((
460 RequestRecord {
461 id,
462 ..RequestRecord::factory(params)
463 },
464 ResponseRecord::factory(id),
465 ))
466 }
467}
468
469#[cfg(any(test, feature = "test"))]
471impl slumber_util::Factory<RequestRecord> for Exchange {
472 fn factory(request: RequestRecord) -> Self {
473 let response = ResponseRecord::factory(request.id);
474 Self::factory((request, response))
475 }
476}
477
478#[cfg(any(test, feature = "test"))]
480impl slumber_util::Factory<(RequestRecord, ResponseRecord)> for Exchange {
481 fn factory((request, response): (RequestRecord, ResponseRecord)) -> Self {
482 assert_eq!(
485 request.id, response.id,
486 "Request and response have different IDs"
487 );
488 Self {
489 id: request.id,
490 request: request.into(),
491 response: response.into(),
492 start_time: Utc::now(),
493 end_time: Utc::now(),
494 }
495 }
496}
497
498#[cfg(any(test, feature = "test"))]
499impl slumber_util::Factory<RequestId> for Exchange {
500 fn factory(id: RequestId) -> Self {
501 Self::factory((RequestRecord::factory(id), ResponseRecord::factory(id)))
502 }
503}
504
505#[derive(Clone, Debug, PartialEq)]
508pub struct ExchangeSummary {
509 pub id: RequestId,
510 pub recipe_id: RecipeId,
511 pub profile_id: Option<ProfileId>,
512 pub start_time: DateTime<Utc>,
513 pub end_time: DateTime<Utc>,
514 pub status: StatusCode,
515}
516
517#[derive(Debug)]
528#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
529pub struct RequestRecord {
530 pub id: RequestId,
532 pub profile_id: Option<ProfileId>,
534 pub recipe_id: RecipeId,
536
537 pub http_version: HttpVersion,
540 pub method: HttpMethod,
542 pub url: Url,
544 pub headers: HeaderMap,
545 pub body: RequestBody,
547}
548
549impl RequestRecord {
550 pub(super) fn new(
560 id: RequestId,
561 profile_id: Option<ProfileId>,
562 recipe_id: RecipeId,
563 request: &Request,
564 max_body_size: usize,
565 ) -> Self {
566 let body = match request.body().map(Body::as_bytes) {
567 Some(Some(bytes)) if bytes.len() <= max_body_size => {
568 RequestBody::Some(bytes.to_owned().into())
570 }
571 Some(Some(_)) => RequestBody::TooLarge,
572 Some(None) => RequestBody::Stream, None => RequestBody::None, };
575 Self {
576 id,
577 profile_id,
578 recipe_id,
579
580 http_version: request.version().into(),
581 method: request.method().into(),
582 url: request.url().clone(),
583 headers: request.headers().clone(),
584 body,
585 }
586 }
587
588 pub fn mime(&self) -> Option<Mime> {
590 content_type_header(&self.headers)
591 }
592}
593
594#[cfg(any(test, feature = "test"))]
595impl slumber_util::Factory for RequestRecord {
596 fn factory((): ()) -> Self {
597 Self::factory((RequestId::new(), None, RecipeId::factory(())))
598 }
599}
600
601#[cfg(any(test, feature = "test"))]
602impl slumber_util::Factory<RequestId> for RequestRecord {
603 fn factory(id: RequestId) -> Self {
604 Self::factory((id, None, RecipeId::factory(())))
605 }
606}
607
608#[cfg(any(test, feature = "test"))]
610impl slumber_util::Factory<(Option<ProfileId>, RecipeId)> for RequestRecord {
611 fn factory((profile_id, recipe_id): (Option<ProfileId>, RecipeId)) -> Self {
612 Self::factory((RequestId::new(), profile_id, recipe_id))
613 }
614}
615
616#[cfg(any(test, feature = "test"))]
618impl slumber_util::Factory<(RequestId, Option<ProfileId>, RecipeId)>
619 for RequestRecord
620{
621 fn factory(
622 (id, profile_id, recipe_id): (RequestId, Option<ProfileId>, RecipeId),
623 ) -> Self {
624 use crate::test_util::header_map;
625 Self {
626 id,
627 profile_id,
628 recipe_id,
629 method: HttpMethod::Get,
630 http_version: HttpVersion::Http11,
631 url: "http://localhost/url".parse().unwrap(),
632 headers: header_map([
633 ("Accept", "application/json"),
634 ("Content-Type", "application/json"),
635 ("User-Agent", "slumber"),
636 ]),
637 body: RequestBody::None,
638 }
639 }
640}
641
642#[derive(Clone, Debug, EnumDiscriminants)]
644#[strum_discriminants(name(RequestBodyKind))] #[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
646pub enum RequestBody {
647 None,
649 Some(Bytes),
651 Stream,
653 TooLarge,
656}
657
658impl RequestBody {
659 pub fn bytes(&self) -> Option<&[u8]> {
663 match self {
664 Self::None | Self::Stream | Self::TooLarge => None,
665 Self::Some(bytes) => Some(bytes.as_ref()),
666 }
667 }
668
669 pub fn is_lost(&self) -> bool {
671 match self {
672 Self::None | Self::Some(_) => false,
673 Self::Stream | Self::TooLarge => true,
674 }
675 }
676}
677
678#[cfg(any(test, feature = "test"))]
679impl From<&'static [u8]> for RequestBody {
680 fn from(bytes: &'static [u8]) -> Self {
681 Self::Some(bytes.into())
682 }
683}
684
685#[derive(Debug)]
693#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
694pub struct ResponseRecord {
695 pub id: RequestId,
696 pub status: StatusCode,
697 pub headers: HeaderMap,
698 pub body: ResponseBody,
699}
700
701impl ResponseRecord {
702 pub fn mime(&self) -> Option<Mime> {
704 content_type_header(&self.headers)
705 }
706
707 pub fn file_name(&self) -> Option<String> {
712 self.headers
713 .get(header::CONTENT_DISPOSITION)
714 .and_then(|value| {
715 let value = value.to_str().ok()?;
718 value.split(';').find_map(|part| {
719 let (key, value) = part.trim().split_once('=')?;
720 if key == "filename" {
721 Some(value.trim_matches('"').to_owned())
722 } else {
723 None
724 }
725 })
726 })
727 .or_else(|| {
728 let content_type = self.headers.get(header::CONTENT_TYPE)?;
731 let mime: Mime = content_type.to_str().ok()?.parse().ok()?;
732 Some(format!("data.{}", mime.subtype()))
733 })
734 }
735}
736
737#[cfg(any(test, feature = "test"))]
738impl slumber_util::Factory for ResponseRecord {
739 fn factory((): ()) -> Self {
740 Self::factory(RequestId::new())
741 }
742}
743
744#[cfg(any(test, feature = "test"))]
745impl slumber_util::Factory<RequestId> for ResponseRecord {
746 fn factory(id: RequestId) -> Self {
747 Self {
748 id,
749 status: StatusCode::OK,
750 headers: HeaderMap::new(),
751 body: ResponseBody::default(),
752 }
753 }
754}
755
756#[cfg(any(test, feature = "test"))]
757impl slumber_util::Factory<StatusCode> for ResponseRecord {
758 fn factory(status: StatusCode) -> Self {
759 Self {
760 id: RequestId::new(),
761 status,
762 headers: HeaderMap::new(),
763 body: ResponseBody::default(),
764 }
765 }
766}
767
768fn content_type_header(headers: &HeaderMap) -> Option<Mime> {
771 headers
772 .get(header::CONTENT_TYPE)
773 .and_then(|value| value.to_str().ok()?.parse().ok())
774}
775
776#[derive(Clone, Default)]
782pub struct ResponseBody<T = Bytes> {
783 data: T,
785}
786
787impl<T: AsRef<[u8]>> ResponseBody<T> {
788 pub fn new(data: T) -> Self {
789 Self { data }
790 }
791
792 pub fn bytes(&self) -> &T {
794 &self.data
795 }
796
797 pub fn into_bytes(self) -> T {
799 self.data
800 }
801
802 pub fn text(&self) -> Option<&str> {
804 std::str::from_utf8(self.data.as_ref()).ok()
805 }
806
807 pub fn size(&self) -> usize {
809 self.data.as_ref().len()
810 }
811}
812
813impl Debug for ResponseBody {
814 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815 f.debug_tuple("Body")
817 .field(&format!("<{} bytes>", self.data.len()))
818 .finish()
819 }
820}
821
822impl<T: From<Bytes>> From<Bytes> for ResponseBody<T> {
823 fn from(data: Bytes) -> Self {
824 Self { data: data.into() }
825 }
826}
827
828#[cfg(any(test, feature = "test"))]
829impl From<&str> for ResponseBody {
830 fn from(value: &str) -> Self {
831 Self::new(value.to_owned().into())
832 }
833}
834
835#[cfg(any(test, feature = "test"))]
836impl From<&[u8]> for ResponseBody {
837 fn from(value: &[u8]) -> Self {
838 Self::new(value.to_owned().into())
839 }
840}
841
842#[cfg(any(test, feature = "test"))]
843impl From<serde_json::Value> for ResponseBody {
844 fn from(value: serde_json::Value) -> Self {
845 Self::new(value.to_string().into())
846 }
847}
848
849#[cfg(any(test, feature = "test"))]
850impl PartialEq for ResponseBody {
851 fn eq(&self, other: &Self) -> bool {
852 self.data == other.data
854 }
855}
856
857#[derive(Debug, Error)]
859#[error("Error building request {id}")]
860pub struct RequestBuildError {
861 #[source]
863 pub error: Box<RequestBuildErrorKind>,
864
865 pub profile_id: Option<ProfileId>,
867 pub recipe_id: RecipeId,
869 pub id: RequestId,
871 pub start_time: DateTime<Utc>,
873 pub end_time: DateTime<Utc>,
875}
876
877impl RequestBuildError {
878 pub fn has_trigger_disabled_error(&self) -> bool {
882 let mut next: Option<&dyn Error> = Some(self);
886 while let Some(error) = next {
887 if matches!(
888 error.downcast_ref(),
889 Some(TriggeredRequestError::NotAllowed)
890 ) {
891 return true;
892 }
893 next = error.source();
894 }
895 false
896 }
897}
898
899#[cfg(any(test, feature = "test"))]
900impl PartialEq for RequestBuildError {
901 fn eq(&self, other: &Self) -> bool {
902 self.profile_id == other.profile_id
903 && self.recipe_id == other.recipe_id
904 && self.id == other.id
905 && self.start_time == other.start_time
906 && self.end_time == other.end_time
907 && self.error.to_string() == other.error.to_string()
908 }
909}
910
911#[derive(Debug, Error)]
914pub enum RequestBuildErrorKind {
915 #[error("Rendering password")]
917 AuthPasswordRender(#[source] RenderError),
918 #[error("Rendering bearer token")]
920 AuthTokenRender(#[source] RenderError),
921 #[error("Rendering username")]
923 AuthUsernameRender(#[source] RenderError),
924
925 #[error("Streaming request body")]
927 BodyFileStream(#[source] io::Error),
928 #[error("Rendering form field `{field}`")]
930 BodyFormFieldRender {
931 field: String,
932 #[source]
933 error: RenderError,
934 },
935 #[error(
942 "Cannot resend request {previous_request_id} because its body is not \
943 available; it was not saved because it was either streamed or too large"
944 )]
945 BodyMissing { previous_request_id: RequestId },
946 #[error("Rendering body")]
948 BodyRender(#[source] RenderError),
949 #[error("Streaming request body")]
951 BodyStream(#[source] RenderError),
952
953 #[error(transparent)]
955 Build(#[from] reqwest::Error),
956
957 #[error("Non-text value in curl output")]
960 CurlInvalidUtf8(#[source] Utf8Error),
961
962 #[error("Invalid header name `{header}`")]
964 HeaderInvalidName {
965 header: String,
966 #[source]
967 error: InvalidHeaderName,
968 },
969 #[error("Invalid header name `{header}`")]
971 HeaderInvalidValue {
972 header: String,
973 #[source]
974 error: InvalidHeaderValue,
975 },
976 #[error("Invalid value for header `{header}`")]
978 HeaderRender {
979 header: String,
980 #[source]
981 error: RenderError,
982 },
983
984 #[error("Invalid JSON override")]
986 Json(
987 #[from]
988 #[source]
989 JsonTemplateError,
990 ),
991
992 #[error(
995 "Cannot override form body; override individual form fields instead"
996 )]
997 OverrideFormBody,
998
999 #[error("Rendering query parameter `{parameter}`")]
1001 QueryRender {
1002 parameter: String,
1003 #[source]
1004 error: RenderError,
1005 },
1006
1007 #[error(transparent)]
1009 RecipeUnknown(#[from] UnknownRecipeError),
1010
1011 #[error("Invalid URL")]
1013 UrlInvalid {
1014 url: String,
1015 #[source]
1016 error: url::ParseError,
1017 },
1018 #[error("Rendering URL")]
1020 UrlRender(#[source] RenderError),
1021}
1022
1023#[derive(Debug, Error)]
1026#[error(
1027 "Error executing request for `{}` (request `{}`)",
1028 .request.recipe_id,
1029 .request.id,
1030)]
1031pub struct RequestError {
1032 #[source]
1034 pub error: reqwest::Error,
1035
1036 pub request: Arc<RequestRecord>,
1038 pub start_time: DateTime<Utc>,
1040 pub end_time: DateTime<Utc>,
1042}
1043
1044#[cfg(any(test, feature = "test"))]
1045impl PartialEq for RequestError {
1046 fn eq(&self, other: &Self) -> bool {
1047 self.error.to_string() == other.error.to_string()
1048 && self.request == other.request
1049 && self.start_time == other.start_time
1050 && self.end_time == other.end_time
1051 }
1052}
1053
1054#[derive(Debug, Error)]
1056#[error(transparent)]
1057pub struct StoredRequestError(pub Box<dyn 'static + Error + Send + Sync>);
1058
1059impl StoredRequestError {
1060 pub fn new<E: 'static + Error + Send + Sync>(error: E) -> Self {
1061 Self(Box::new(error))
1062 }
1063}
1064
1065#[derive(Clone, Debug, Error)]
1070#[cfg_attr(test, derive(PartialEq))]
1071pub enum TriggeredRequestError {
1072 #[error("Triggered request execution not allowed in this context")]
1076 NotAllowed,
1077
1078 #[error(transparent)]
1080 Build(#[from] Arc<RequestBuildError>),
1081
1082 #[error(transparent)]
1084 Send(#[from] Arc<RequestError>),
1085}
1086
1087impl From<RequestBuildError> for TriggeredRequestError {
1088 fn from(error: RequestBuildError) -> Self {
1089 Self::Build(error.into())
1090 }
1091}
1092
1093impl From<RequestError> for TriggeredRequestError {
1094 fn from(error: RequestError) -> Self {
1095 Self::Send(error.into())
1096 }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102 use crate::test_util::header_map;
1103 use indexmap::indexmap;
1104 use rstest::rstest;
1105 use slumber_util::Factory;
1106
1107 #[rstest]
1108 #[case::content_disposition(
1109 ResponseRecord {
1110 headers: header_map(indexmap! {
1111 "content-disposition" => "form-data;name=\"field\"; filename=\"fish.png\"",
1112 "content-type" => "image/png",
1113 }),
1114 ..ResponseRecord::factory(())
1115 },
1116 Some("fish.png")
1117 )]
1118 #[case::content_type_known(
1119 ResponseRecord {
1120 headers: header_map(indexmap! {
1121 "content-disposition" => "form-data",
1122 "content-type" => "application/json",
1123 }),
1124 ..ResponseRecord::factory(())
1125 },
1126 Some("data.json")
1127 )]
1128 #[case::content_type_unknown(
1129 ResponseRecord {
1130 headers: header_map(indexmap! {
1131 "content-disposition" => "form-data",
1132 "content-type" => "image/jpeg",
1133 }),
1134 ..ResponseRecord::factory(())
1135 },
1136 Some("data.jpeg")
1137 )]
1138 #[case::none(ResponseRecord::factory(()), None)]
1139 fn test_file_name(
1140 #[case] response: ResponseRecord,
1141 #[case] expected: Option<&str>,
1142 ) {
1143 assert_eq!(response.file_name().as_deref(), expected);
1144 }
1145}