Skip to main content

slumber_core/http/
models.rs

1//! HTTP-related data types. The primary term here to know is "exchange". An
2//! exchange is a single HTTP request-response pair. It can be in various
3//! stages, meaning the request or response may not actually be present, if the
4//! exchange is incomplete or failed.
5
6use 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/// Unique ID for a single request. Can also be used to refer to the
36/// corresponding [Exchange] or [ResponseRecord].
37#[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/// HTTP protocl version. This is duplicated from [reqwest::Version] because
66/// that type doesn't provide any way to construct it. It only allows you to use
67/// the existing constants.
68#[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
128/// For serialization
129impl From<HttpVersion> for &'static str {
130    fn from(version: HttpVersion) -> Self {
131        version.to_str()
132    }
133}
134
135/// For deserialization
136impl 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/// [HTTP request method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods)
154// This is duplicated from [reqwest::Method] so we can enforce
155// the method is valid during deserialization. This is also generally more
156// ergonomic at the cost of some flexibility.
157#[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"), // Show as a string enum
163)]
164// Use FromStr to enable case-insensitivity
165#[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        // reqwest supports custom methods, but we don't provide any
224        // mechanism for users to use them, so we should never panic
225        method.as_str().parse().unwrap()
226    }
227}
228
229/// For serialization
230impl From<HttpMethod> for &'static str {
231    fn from(method: HttpMethod) -> Self {
232        method.to_str()
233    }
234}
235
236/// For deserialization
237impl 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
254/// The first stage in building a request. This contains the initialization data
255/// needed to build a request. This holds owned data because we need to be able
256/// to move it between tasks as part of the build process, which requires it
257/// to be `'static`.
258pub struct RequestSeed {
259    /// Unique ID for this request
260    pub id: RequestId,
261    /// Recipe from which the request should be rendered
262    pub recipe_id: RecipeId,
263    /// Configuration for the build
264    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/// Options for modifying a recipe during a build, corresponding to changes the
278/// user can make in the TUI (as opposed to the collection file). This is
279/// helpful for applying temporary modifications made by the user. By providing
280/// this in a separate struct, we prevent the need to clone, modify, and pass
281/// recipes everywhere. Recipes could be very large so cloning may be expensive,
282/// and this options layer makes the available modifications clear and
283/// restricted.
284///
285/// The distinction between this and
286/// [TemplateContext::overrides](super::TemplateContext::overrides) is that
287/// this struct stores *recipe* overrides whereas that field stores *profile
288/// field* overrides. This is important because profile overrides apply to
289/// triggered requests as well, but recipe overrides do not. That's why we
290/// can't put recipe overrides into the template context.
291///
292/// These store *indexes* rather than keys because keys may not be necessarily
293/// unique (e.g. in the case of query params). Technically some could use keys
294/// and some could use indexes, but I chose consistency.
295#[derive(Debug, Default)]
296#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
297pub struct BuildOptions {
298    /// URL can be overridden but not disabled
299    pub url: Option<Template>,
300    /// Authentication can be overridden, but not disabled. For simplicity,
301    /// the override is wholesale rather than by field.
302    pub authentication: Option<Authentication>,
303    /// Override individual headers
304    pub headers: IndexMap<String, BuildFieldOverride>,
305    /// Override individual query parameters. The index is the instance of that
306    /// parameter unique to the key. E.g. the keys of `a=1&a=2&b=1` would be
307    /// `[(a, 0), (a, 1), (b, 0)]`
308    pub query_parameters: IndexMap<(String, usize), BuildFieldOverride>,
309    /// Override individual fields in a URL-encoded or multipart form
310    pub form_fields: IndexMap<String, BuildFieldOverride>,
311    /// Override body. This should *not* be used for form bodies, since those
312    /// can be overridden on a field-by-field basis.
313    pub body: Option<BodyOverride>,
314}
315
316/// Modifications made to a single field (query param, header, etc.) in a
317/// recipe
318#[derive(Clone, Debug)]
319#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
320pub enum BuildFieldOverride {
321    /// Do not include this field in the recipe
322    Omit,
323    /// Replace the value for this field with a different template
324    Override(Template),
325}
326
327/// Build a [BuildFieldOverride::Override] from a template literal
328#[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/// Override definition for a request body
336///
337/// This allows the HTTP engine to accept different override values based on the
338/// body type (raw vs structured). This is used for all bodies **except** forms,
339/// because those use a per-field override.
340#[derive(Debug)]
341#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
342pub enum BodyOverride {
343    /// Override with plain old bytes, or a stream if the recipe body is a
344    /// stream
345    Raw(Template),
346    /// Override with a JSON value
347    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/// A request ready to be launched into through the stratosphere. This is
365/// basically a two-part ticket: the request is the part we'll hand to the HTTP
366/// engine to be launched, and the record is the ticket stub we'll keep for
367/// ourselves (to display to the user)
368#[derive(Debug)]
369pub struct RequestTicket {
370    /// A record of the request that we can hang onto and persist
371    pub(super) record: Arc<RequestRecord>,
372    /// reqwest client that should be used to launch the request
373    pub(super) client: Client,
374    /// Our brave little astronaut, ready to be launched...
375    pub(super) request: Request,
376}
377
378impl RequestTicket {
379    pub fn record(&self) -> &Arc<RequestRecord> {
380        &self.record
381    }
382}
383
384/// A complete request+response pairing. This is generated by
385/// [RequestTicket::send] when a response is received successfully for a sent
386/// request. This is cheaply cloneable because the request and response are
387/// both wrapped in `Arc`.
388#[derive(Clone, Debug)]
389#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
390pub struct Exchange {
391    /// ID to uniquely refer to this exchange
392    pub id: RequestId,
393    /// What we said. Use an Arc so the view can hang onto it.
394    pub request: Arc<RequestRecord>,
395    /// What we heard
396    pub response: Arc<ResponseRecord>,
397    /// When was the request sent to the server?
398    pub start_time: DateTime<Utc>,
399    /// When did we finish receiving the *entire* response?
400    pub end_time: DateTime<Utc>,
401}
402
403impl Exchange {
404    /// Get the elapsed time for this request
405    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/// Customize recipe ID
429#[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/// Customize request, profile, and recipe ID
437#[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/// Customize profile and recipe ID
455#[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/// Custom request, generated response
470#[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/// Custom request and response
479#[cfg(any(test, feature = "test"))]
480impl slumber_util::Factory<(RequestRecord, ResponseRecord)> for Exchange {
481    fn factory((request, response): (RequestRecord, ResponseRecord)) -> Self {
482        // Request and response should've been generated from the same ID,
483        // otherwise we're going to see some shitty bugs
484        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/// Metadata about an exchange. Useful in lists where request/response content
506/// isn't needed.
507#[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/// Data for an HTTP request. This is similar to [reqwest::Request], but differs
518/// in some key ways:
519/// - Each [reqwest::Request] can only exist once (from creation to sending),
520///   whereas a record can be hung onto after the launch to keep showing it on
521///   screen.
522/// - This stores additional Slumber-specific metadata
523///
524/// This intentionally does *not* implement `Clone`, because request data could
525/// potentially be large so we want to be intentional about duplicating it only
526/// when necessary.
527#[derive(Debug)]
528#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
529pub struct RequestRecord {
530    /// Unique ID for this request
531    pub id: RequestId,
532    /// The profile used to render this request (for historical context)
533    pub profile_id: Option<ProfileId>,
534    /// The recipe used to generate this request (for historical context)
535    pub recipe_id: RecipeId,
536
537    /// HTTP protocol version. Unlike `method`, we can't use the reqwest type
538    /// here because there's way to externally construct the type.
539    pub http_version: HttpVersion,
540    /// HTTP method
541    pub method: HttpMethod,
542    /// URL, including query params/fragment
543    pub url: Url,
544    pub headers: HeaderMap,
545    /// Body content as bytes
546    pub body: RequestBody,
547}
548
549impl RequestRecord {
550    /// Create a new request record from data and metadata. This is the
551    /// canonical way to create a record for a new request. This should
552    /// *not* be build directly, and instead the data should copy data out of
553    /// a [reqwest::Request]. This is to prevent duplicating request
554    /// construction logic.
555    ///
556    /// This will clone all data out of the request. This could potentially be
557    /// expensive but we don't have any choice if we want to send it to the
558    /// server and show it in the TUI at the same time
559    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                // Body is present and under the size threshold - save it
569                RequestBody::Some(bytes.to_owned().into())
570            }
571            Some(Some(_)) => RequestBody::TooLarge,
572            Some(None) => RequestBody::Stream, // We have a body but no bytes
573            None => RequestBody::None,         // No body, no crime
574        };
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    /// Get the value of the request's `Content-Type` header, if any
589    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/// Customize profile and recipe ID
609#[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/// Customize request, profile and recipe ID
617#[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/// Recorded body for a request
643#[derive(Clone, Debug, EnumDiscriminants)]
644#[strum_discriminants(name(RequestBodyKind))] // Used for DB encoding
645#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
646pub enum RequestBody {
647    /// Request had no body (e.g. a GET request)
648    None,
649    /// Request had a body and here it is
650    Some(Bytes),
651    /// Body was streaming, so it never appeared in memory
652    Stream,
653    /// Body was bigger than `HttpConfig::large_body_size`, so we didn't store
654    /// it
655    TooLarge,
656}
657
658impl RequestBody {
659    /// Get the body as a byte slice, if available
660    ///
661    /// Return `None` for anything other than [RequestBody::Some]
662    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    /// Was there a body on the request that wasn't saved?
670    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/// A resolved HTTP response, with all content loaded and ready to be displayed
686/// to the user. A simpler alternative to [reqwest::Response], because there's
687/// no way to access all resolved data on that type at once. Resolving the
688/// response body requires moving the response.
689///
690/// This intentionally does not implement Clone, because responses could
691/// potentially be very large.
692#[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    /// Get the value of the response's `Content-Type` header, if any
703    pub fn mime(&self) -> Option<Mime> {
704        content_type_header(&self.headers)
705    }
706
707    /// Get a suggested file name for the content of this response. First we'll
708    /// check the Content-Disposition header. If it's missing or doesn't have a
709    /// file name, we'll check the Content-Type to at least guess at an
710    /// extension.
711    pub fn file_name(&self) -> Option<String> {
712        self.headers
713            .get(header::CONTENT_DISPOSITION)
714            .and_then(|value| {
715                // Parse header for the `filename="{}"` parameter
716                // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
717                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                // Grab the extension from the Content-Type header. Don't use
729                // self.conten_type() because we want to accept unknown types.
730                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
768/// Get the value of the `Content-Type` header, parsed as a MIME. `None` if the
769/// header isn't present or isn't a valid MIME type
770fn 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/// HTTP response body. Content is stored as bytes because it may not
777/// necessarily be valid UTF-8. Converted to text only as needed.
778///
779/// The generic type is to make this usable with references to bodies. In most
780/// cases you can just use the default.
781#[derive(Clone, Default)]
782pub struct ResponseBody<T = Bytes> {
783    /// Raw body
784    data: T,
785}
786
787impl<T: AsRef<[u8]>> ResponseBody<T> {
788    pub fn new(data: T) -> Self {
789        Self { data }
790    }
791
792    /// Raw content bytes
793    pub fn bytes(&self) -> &T {
794        &self.data
795    }
796
797    /// Owned raw content bytes
798    pub fn into_bytes(self) -> T {
799        self.data
800    }
801
802    /// Get bytes as text, if valid UTF-8
803    pub fn text(&self) -> Option<&str> {
804        std::str::from_utf8(self.data.as_ref()).ok()
805    }
806
807    /// Get body size, in bytes
808    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        // Don't print the actual body because it could be huge
816        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        // Ignore derived data
853        self.data == other.data
854    }
855}
856
857/// An error that can occur while *building* a request
858#[derive(Debug, Error)]
859#[error("Error building request {id}")]
860pub struct RequestBuildError {
861    /// Underlying error. Boxed to keep the size down
862    #[source]
863    pub error: Box<RequestBuildErrorKind>,
864
865    /// ID of the profile being rendered under
866    pub profile_id: Option<ProfileId>,
867    /// ID of the recipe being rendered
868    pub recipe_id: RecipeId,
869    /// ID of the failed request
870    pub id: RequestId,
871    /// When did the build start?
872    pub start_time: DateTime<Utc>,
873    /// When did the build end, i.e. when did the error occur?
874    pub end_time: DateTime<Utc>,
875}
876
877impl RequestBuildError {
878    /// Does this error have *any* error in its chain that contains
879    /// [TriggeredRequestError::NotAllowed]? This makes it easy to attach
880    /// additional error context.
881    pub fn has_trigger_disabled_error(&self) -> bool {
882        // Walk down the error chain
883        // unstable: Use error.sources()
884        // https://github.com/rust-lang/rust/issues/58520
885        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/// The various errors that can occur while building a request. This provides
912/// the error for [RequestBuildError], which then attaches additional context.
913#[derive(Debug, Error)]
914pub enum RequestBuildErrorKind {
915    /// Error rendering username in Basic auth
916    #[error("Rendering password")]
917    AuthPasswordRender(#[source] RenderError),
918    /// Error rendering token in Bearer auth
919    #[error("Rendering bearer token")]
920    AuthTokenRender(#[source] RenderError),
921    /// Error rendering username in Basic auth
922    #[error("Rendering username")]
923    AuthUsernameRender(#[source] RenderError),
924
925    /// Error streaming directly from a file to a request body (via reqwest)
926    #[error("Streaming request body")]
927    BodyFileStream(#[source] io::Error),
928    /// Error rendering a body to bytes/stream
929    #[error("Rendering form field `{field}`")]
930    BodyFormFieldRender {
931        field: String,
932        #[source]
933        error: RenderError,
934    },
935    /// Attempted to build a new request from a previous request, but the old
936    /// request doesn't have a body saved
937    ///
938    /// This happens if:
939    /// - Body was larger than `HttpEngineConfig::large_body_size`
940    /// - Body was streamed
941    #[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 a body to bytes/stream
947    #[error("Rendering body")]
948    BodyRender(#[source] RenderError),
949    /// Error while streaming bytes for a body
950    #[error("Streaming request body")]
951    BodyStream(#[source] RenderError),
952
953    /// Error assembling the final request
954    #[error(transparent)]
955    Build(#[from] reqwest::Error),
956
957    /// Attempted to generate a cURL command for a request with non-UTF-8
958    /// values, which we don't support representing in the generated command
959    #[error("Non-text value in curl output")]
960    CurlInvalidUtf8(#[source] Utf8Error),
961
962    /// Header name does not meet the HTTP spec
963    #[error("Invalid header name `{header}`")]
964    HeaderInvalidName {
965        header: String,
966        #[source]
967        error: InvalidHeaderName,
968    },
969    /// Header name does not meet the HTTP spec
970    #[error("Invalid header name `{header}`")]
971    HeaderInvalidValue {
972        header: String,
973        #[source]
974        error: InvalidHeaderValue,
975    },
976    /// Header value does not meet the HTTP spec
977    #[error("Invalid value for header `{header}`")]
978    HeaderRender {
979        header: String,
980        #[source]
981        error: RenderError,
982    },
983
984    /// Error parsing JSON override template
985    #[error("Invalid JSON override")]
986    Json(
987        #[from]
988        #[source]
989        JsonTemplateError,
990    ),
991
992    /// Passed a full-body override template for a form body. This is
993    /// disallowed; instead, overrides are applied by individual field
994    #[error(
995        "Cannot override form body; override individual form fields instead"
996    )]
997    OverrideFormBody,
998
999    /// Error rendering query parameter
1000    #[error("Rendering query parameter `{parameter}`")]
1001    QueryRender {
1002        parameter: String,
1003        #[source]
1004        error: RenderError,
1005    },
1006
1007    /// Tried to build a recipe that doesn't exist
1008    #[error(transparent)]
1009    RecipeUnknown(#[from] UnknownRecipeError),
1010
1011    /// URL rendered correctly but the result isn't a valid URL
1012    #[error("Invalid URL")]
1013    UrlInvalid {
1014        url: String,
1015        #[source]
1016        error: url::ParseError,
1017    },
1018    /// Error rendering URL
1019    #[error("Rendering URL")]
1020    UrlRender(#[source] RenderError),
1021}
1022
1023/// An error that can occur during a request. This does *not* including building
1024/// errors.
1025#[derive(Debug, Error)]
1026#[error(
1027    "Error executing request for `{}` (request `{}`)",
1028    .request.recipe_id,
1029    .request.id,
1030)]
1031pub struct RequestError {
1032    /// Underlying error
1033    #[source]
1034    pub error: reqwest::Error,
1035
1036    /// The request that caused all this ruckus
1037    pub request: Arc<RequestRecord>,
1038    /// When was the request launched?
1039    pub start_time: DateTime<Utc>,
1040    /// When did the error occur?
1041    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/// Error fetching a previous request while rendering a new request
1055#[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/// Error occurred while trying to build/execute a triggered request.
1066///
1067/// This type implements `Clone` so it can be shared between deduplicated chain
1068/// renders, hence the `Arc`s on inner errors.
1069#[derive(Clone, Debug, Error)]
1070#[cfg_attr(test, derive(PartialEq))]
1071pub enum TriggeredRequestError {
1072    /// This render was invoked in a way that doesn't support automatic request
1073    /// execution. In some cases the user needs to explicitly opt in to enable
1074    /// it (e.g. with a CLI flag)
1075    #[error("Triggered request execution not allowed in this context")]
1076    NotAllowed,
1077
1078    /// Tried to auto-execute a chained request but couldn't build it
1079    #[error(transparent)]
1080    Build(#[from] Arc<RequestBuildError>),
1081
1082    /// Chained request was triggered, sent and failed
1083    #[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}