Skip to main content

roas_arazzo_executor/
http.rs

1//! What a step sends and what comes back, and the two traits a caller
2//! implements to carry them.
3//!
4//! This crate performs no IO of its own. The engine decides *what* to
5//! send; a client decides *how* — which is what lets the same engine run
6//! under a blocking client, an async one, or a fake that never touches a
7//! network. The traits mirror
8//! [`roas::loader::ResourceFetcher`](https://docs.rs/roas/latest/roas/loader/trait.ResourceFetcher.html)
9//! and its async sibling, so a caller that already has fetchers for the
10//! loader will recognize the shape.
11
12use std::borrow::Cow;
13use std::future::Future;
14use std::pin::Pin;
15use std::time::Duration;
16
17/// A request a step wants performed.
18///
19/// Header names are kept as written — a runtime expression may name one
20/// in any case, and matching is case-insensitive where it is read.
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct HttpRequest {
24    /// Upper-case HTTP method, e.g. `GET`.
25    pub method: String,
26    /// The fully resolved URL, query string included.
27    pub url: String,
28    /// Headers in the order the step named them.
29    pub headers: Vec<(String, String)>,
30    /// The encoded body, if the step has one.
31    pub body: Option<Vec<u8>>,
32    /// How long the step is willing to wait, from `Step.timeout`.
33    pub timeout: Option<Duration>,
34}
35
36impl HttpRequest {
37    /// The first value of `name`, matched case-insensitively.
38    #[must_use]
39    pub fn header(&self, name: &str) -> Option<&str> {
40        header(&self.headers, name)
41    }
42
43    /// The body as text, lossily decoded. Empty when there is no body.
44    #[must_use]
45    pub fn text(&self) -> Cow<'_, str> {
46        match &self.body {
47            Some(body) => String::from_utf8_lossy(body),
48            None => Cow::Borrowed(""),
49        }
50    }
51}
52
53/// What a client got back.
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55#[non_exhaustive]
56pub struct HttpResponse {
57    /// The HTTP status code.
58    pub status: u16,
59    /// Response headers, in the order received.
60    pub headers: Vec<(String, String)>,
61    /// The raw body. Empty rather than absent, as HTTP has it.
62    pub body: Vec<u8>,
63}
64
65impl HttpResponse {
66    /// A response that carries a JSON body, for tests and fakes.
67    #[must_use]
68    pub fn json(status: u16, body: &serde_json::Value) -> Self {
69        Self {
70            status,
71            headers: vec![("content-type".to_owned(), "application/json".to_owned())],
72            body: body.to_string().into_bytes(),
73        }
74    }
75
76    /// The first value of `name`, matched case-insensitively.
77    #[must_use]
78    pub fn header(&self, name: &str) -> Option<&str> {
79        header(&self.headers, name)
80    }
81
82    /// The body as text, lossily decoded.
83    #[must_use]
84    pub fn text(&self) -> Cow<'_, str> {
85        String::from_utf8_lossy(&self.body)
86    }
87
88    /// The body parsed as JSON, or `None` when it is not JSON.
89    ///
90    /// A runtime expression may point into a response body, and only a
91    /// parsed body can be pointed into.
92    #[must_use]
93    pub fn body_as_json(&self) -> Option<serde_json::Value> {
94        serde_json::from_slice(&self.body).ok()
95    }
96}
97
98fn header<'h>(headers: &'h [(String, String)], name: &str) -> Option<&'h str> {
99    headers
100        .iter()
101        .find(|(header, _)| header.eq_ignore_ascii_case(name))
102        .map(|(_, value)| value.as_str())
103}
104
105/// Whatever went wrong carrying a request out.
106///
107/// The engine does not interpret it: a client failure ends the run and
108/// is reported as it was reported here.
109#[derive(Debug, thiserror::Error)]
110#[error("{0}")]
111pub struct ClientError(pub String);
112
113impl ClientError {
114    /// A failure described by any error the client already has.
115    pub fn new(error: impl std::fmt::Display) -> Self {
116        Self(error.to_string())
117    }
118}
119
120/// Performs a request and waits for the answer.
121pub trait HttpClient {
122    /// Send `request` and return what came back.
123    ///
124    /// # Errors
125    ///
126    /// Whatever prevented the exchange from completing — a connection
127    /// failure, a timeout, an unreadable body.
128    fn send(&mut self, request: &HttpRequest) -> Result<HttpResponse, ClientError>;
129}
130
131/// The future an [`AsyncHttpClient`] returns from `send`.
132pub type SendFuture<'a> =
133    Pin<Box<dyn Future<Output = Result<HttpResponse, ClientError>> + Send + 'a>>;
134
135/// The future an [`AsyncHttpClient`] returns from `sleep`.
136pub type SleepFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
137
138/// Performs a request without blocking the thread.
139pub trait AsyncHttpClient {
140    /// Send `request` and return what came back.
141    ///
142    /// # Errors
143    ///
144    /// As [`HttpClient::send`].
145    fn send<'a>(&'a mut self, request: &'a HttpRequest) -> SendFuture<'a>;
146
147    /// Wait for `duration`.
148    ///
149    /// A retry may ask for a delay, and an executor that performs no IO
150    /// has no runtime to wait on either — the client, which has one,
151    /// says how. A test client can return immediately.
152    fn sleep(&self, duration: Duration) -> SleepFuture<'_>;
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use serde_json::json;
159
160    #[test]
161    fn a_header_is_found_whatever_case_it_was_written_in() {
162        let request = HttpRequest {
163            headers: vec![("Content-Type".to_owned(), "application/json".to_owned())],
164            ..HttpRequest::default()
165        };
166        assert_eq!(request.header("content-type"), Some("application/json"));
167        assert_eq!(request.header("CONTENT-TYPE"), Some("application/json"));
168        assert_eq!(request.header("accept"), None);
169    }
170
171    #[test]
172    fn a_json_response_carries_its_body_both_ways() {
173        let response = HttpResponse::json(201, &json!({ "id": 7 }));
174        assert_eq!(response.status, 201);
175        assert_eq!(response.header("Content-Type"), Some("application/json"));
176        assert_eq!(response.body_as_json(), Some(json!({ "id": 7 })));
177        assert_eq!(response.text(), r#"{"id":7}"#);
178    }
179
180    #[test]
181    fn a_body_that_is_not_json_is_still_text() {
182        let response = HttpResponse {
183            status: 200,
184            headers: Vec::new(),
185            body: b"not json".to_vec(),
186        };
187        assert_eq!(response.body_as_json(), None);
188        assert_eq!(response.text(), "not json");
189    }
190
191    #[test]
192    fn a_request_without_a_body_reads_as_empty() {
193        assert_eq!(HttpRequest::default().text(), "");
194    }
195
196    #[test]
197    fn a_client_error_says_what_it_was_told() {
198        let error = ClientError::new(std::io::Error::other("connection reset"));
199        assert_eq!(error.to_string(), "connection reset");
200    }
201}