automaat_processor_http_request/
lib.rs

1//! An [Automaat] processor to execute HTTP requests.
2//!
3//! This processor allows you to make simple HTTP requests. It supports request
4//! headers, setting a request body, and asserting the response status.
5//!
6//! If more power or functionality is needed, we can add it as needed. However,
7//! you can always use the [Shell Command] processor combined with a utility
8//! like [`cURL`] if you need more advanced functionality.
9//!
10//! [Automaat]: automaat_core
11//! [Shell Command]: https://docs.rs/automaat-processor-shell-command
12//! [`cURL`]: https://curl.haxx.se/
13//!
14//! # Examples
15//!
16//! ## GET
17//!
18//! A GET request with headers attached.
19//!
20//! ```rust
21//! # fn main() -> Result<(), Box<std::error::Error>> {
22//! use automaat_core::{Context, Processor};
23//! use automaat_processor_http_request::{HttpRequest, Method, Header};
24//! use url::Url;
25//!
26//! let context = Context::new()?;
27//! let url = Url::parse("https://httpbin.org/headers")?;
28//! let headers = vec![
29//!     Header::new("accept", "application/json"),
30//!     Header::new("content-type", "text/html"),
31//! ];
32//!
33//! let processor = HttpRequest {
34//!     url: url,
35//!     method: Method::GET,
36//!     headers: headers,
37//!     body: None,
38//!     assert_status: vec![],
39//! };
40//!
41//! let output = processor.run(&context)?;
42//! # assert!(output.clone().unwrap().contains(r#""Content-Type": "text/html""#));
43//! # assert!(output.unwrap().contains(r#""Accept": "application/json""#));
44//! #     Ok(())
45//! # }
46//! ```
47//!
48//! ## POST
49//!
50//! Simple POST request with a query parameter and a body.
51//!
52//! ```rust
53//! # fn main() -> Result<(), Box<std::error::Error>> {
54//! use automaat_core::{Context, Processor};
55//! use automaat_processor_http_request::{Method, HttpRequest };
56//! use url::Url;
57//!
58//! let context = Context::new()?;
59//! let url = Url::parse("https://httpbin.org/response-headers?hello=world")?;
60//!
61//! let processor = HttpRequest {
62//!     url: url,
63//!     method: Method::POST,
64//!     headers: vec![],
65//!     body: Some("universe".to_owned()),
66//!     assert_status: vec![200],
67//! };
68//!
69//! let output = processor.run(&context)?;
70//! # assert!(output.clone().unwrap().contains(r#""hello": "world""#));
71//! #     Ok(())
72//! # }
73//! ```
74//!
75//! # Package Features
76//!
77//! * `juniper` – creates a set of objects to be used in GraphQL-based
78//!   requests/responses.
79#![deny(
80    clippy::all,
81    clippy::cargo,
82    clippy::nursery,
83    clippy::pedantic,
84    deprecated_in_future,
85    future_incompatible,
86    missing_docs,
87    nonstandard_style,
88    rust_2018_idioms,
89    rustdoc,
90    warnings,
91    unused_results,
92    unused_qualifications,
93    unused_lifetimes,
94    unused_import_braces,
95    unsafe_code,
96    unreachable_pub,
97    trivial_casts,
98    trivial_numeric_casts,
99    missing_debug_implementations,
100    missing_copy_implementations
101)]
102#![warn(variant_size_differences)]
103#![allow(clippy::multiple_crate_versions, missing_doc_code_examples)]
104#![doc(html_root_url = "https://docs.rs/automaat-processor-http-request/0.1.0")]
105
106use automaat_core::{Context, Processor};
107use reqwest::{header, Client};
108use serde::{Deserialize, Serialize};
109use std::{error, fmt, str::FromStr};
110use url::Url;
111
112/// The processor configuration.
113#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
114#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
115pub struct HttpRequest {
116    /// The URL to make the request to.
117    #[serde(with = "url_serde")]
118    pub url: Url,
119
120    /// The HTTP method (GET, POST, etc.) to use.
121    pub method: Method,
122
123    /// An optional set of headers to add to the request.
124    pub headers: Vec<Header>,
125
126    /// The optional body of the request.
127    pub body: Option<String>,
128
129    /// An assertion to validate the status code of the response matches one of
130    /// the provided values.
131    pub assert_status: Vec<i32>,
132}
133
134/// The processor configuration.
135#[cfg_attr(feature = "juniper", derive(juniper::GraphQLEnum))]
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub enum Method {
138    /// The CONNECT request method.
139    CONNECT,
140
141    /// The DELETE request method.
142    DELETE,
143
144    /// The GET request method.
145    GET,
146
147    /// The HEAD request method.
148    HEAD,
149
150    /// The OPTIONS request method.
151    OPTIONS,
152
153    /// The PATCH request method.
154    PATCH,
155
156    /// The POST request method.
157    POST,
158
159    /// The PUT request method.
160    PUT,
161
162    /// The TRACE request method.
163    TRACE,
164}
165
166impl From<Method> for reqwest::Method {
167    fn from(method: Method) -> Self {
168        match method {
169            Method::CONNECT => Self::CONNECT,
170            Method::DELETE => Self::DELETE,
171            Method::GET => Self::GET,
172            Method::HEAD => Self::HEAD,
173            Method::OPTIONS => Self::OPTIONS,
174            Method::PATCH => Self::PATCH,
175            Method::POST => Self::POST,
176            Method::PUT => Self::PUT,
177            Method::TRACE => Self::TRACE,
178        }
179    }
180}
181
182/// A request header.
183#[cfg_attr(feature = "juniper", derive(juniper::GraphQLInputObject))]
184#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub struct Header {
186    /// The name of the header.
187    pub name: String,
188
189    /// The value of the header.
190    pub value: String,
191}
192
193impl Header {
194    /// Create a header, based on a name and value string.
195    pub fn new(name: &str, value: &str) -> Self {
196        Self {
197            name: name.to_owned(),
198            value: value.to_owned(),
199        }
200    }
201}
202
203/// The GraphQL [Input Object][io] used to initialize the processor via an API.
204///
205/// [`HttpRequest`] implements `From<Input>`, so you can directly initialize
206/// the processor using this type.
207///
208/// _requires the `juniper` package feature to be enabled_
209///
210/// [io]: https://graphql.github.io/graphql-spec/June2018/#sec-Input-Objects
211#[cfg(feature = "juniper")]
212#[graphql(name = "HttpRequestInput")]
213#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, juniper::GraphQLInputObject)]
214pub struct Input {
215    #[serde(with = "url_serde")]
216    url: Url,
217    method: Method,
218    headers: Option<Vec<Header>>,
219    body: Option<String>,
220    assert_status: Option<Vec<i32>>,
221}
222
223#[cfg(feature = "juniper")]
224impl From<Input> for HttpRequest {
225    fn from(input: Input) -> Self {
226        Self {
227            url: input.url,
228            method: input.method,
229            headers: input.headers.unwrap_or_else(Default::default),
230            body: input.body,
231            assert_status: input.assert_status.unwrap_or_else(Default::default),
232        }
233    }
234}
235
236impl<'a> Processor<'a> for HttpRequest {
237    const NAME: &'static str = "HTTP Request";
238
239    type Error = Error;
240    type Output = String;
241
242    /// Validate the `HttpRequest` configuration.
243    ///
244    /// # Errors
245    ///
246    /// This method returns an error if one of the provided HTTP headers has an
247    /// invalid format.
248    fn validate(&self) -> Result<(), Self::Error> {
249        for header in &self.headers {
250            let _ = header::HeaderName::from_str(header.name.as_str())?;
251            let _ = header::HeaderValue::from_str(header.value.as_str())?;
252        }
253
254        Ok(())
255    }
256
257    /// Do the configured HTTP request, and return its results.
258    ///
259    /// # Output
260    ///
261    /// If the request was successful, and the response status matches the
262    /// optional status assertion, the body of the response is returned.
263    ///
264    /// If the body is an empty string, `None` is returned instead.
265    ///
266    /// # Errors
267    ///
268    /// If the provided HTTP headers are invalid, the [`Error::Header`] error
269    /// variant is returned.
270    ///
271    /// If the request fails, or the response body cannot be read, the
272    /// [`Error::Response`] error variant is returned.
273    ///
274    /// If the response status does not match one of the provided status
275    /// assertions, the [`Error::Status`] error variant is returned.
276    fn run(&self, _context: &Context) -> Result<Option<Self::Output>, Self::Error> {
277        // request builder
278        let mut request = Client::new().request(self.method.into(), self.url.as_str());
279
280        // headers
281        let mut map = header::HeaderMap::new();
282        for header in &self.headers {
283            let _ = map.insert(
284                header.name.as_str().parse::<header::HeaderName>()?,
285                header.value.as_str().parse()?,
286            );
287        }
288
289        // body
290        if let Some(body) = self.body.to_owned() {
291            request = request.body(body);
292        }
293
294        // response
295        let mut response = request.headers(map).send()?;
296
297        // status check
298        let status = i32::from(response.status().as_u16());
299        if !self.assert_status.is_empty() && !self.assert_status.contains(&status) {
300            return Err(Error::Status(status));
301        }
302
303        // response body
304        let body = response.text()?;
305        if body.is_empty() {
306            Ok(None)
307        } else {
308            Ok(Some(body))
309        }
310    }
311}
312
313/// Represents all the ways that [`HttpRequest`] can fail.
314///
315/// This type is not intended to be exhaustively matched, and new variants may
316/// be added in the future without a major version bump.
317#[derive(Debug)]
318pub enum Error {
319    /// The response returned an error
320    Response(reqwest::Error),
321
322    /// One of the provided request headers has an invalid format.
323    Header(String),
324
325    /// The expected response status did not match the actual status.
326    Status(i32),
327
328    #[doc(hidden)]
329    __Unknown, // Match against _ instead, more variants may be added in the future.
330}
331
332impl fmt::Display for Error {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        match *self {
335            Error::Response(ref err) => write!(f, "Response error: {}", err),
336            Error::Header(ref err) => write!(f, "Invalid header: {}", err),
337            Error::Status(status) => write!(f, "Invalid status code: {}", status),
338            Error::__Unknown => unreachable!(),
339        }
340    }
341}
342
343impl error::Error for Error {
344    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
345        match *self {
346            Error::Response(ref err) => Some(err),
347            Error::Header(_) | Error::Status(_) => None,
348            Error::__Unknown => unreachable!(),
349        }
350    }
351}
352
353impl From<reqwest::Error> for Error {
354    fn from(err: reqwest::Error) -> Self {
355        Error::Response(err)
356    }
357}
358
359impl From<header::InvalidHeaderName> for Error {
360    fn from(err: header::InvalidHeaderName) -> Self {
361        Error::Header(err.to_string())
362    }
363}
364
365impl From<header::InvalidHeaderValue> for Error {
366    fn from(err: header::InvalidHeaderValue) -> Self {
367        Error::Header(err.to_string())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn processor_stub() -> HttpRequest {
376        HttpRequest {
377            url: Url::parse("https://httpbin.org/status/200").unwrap(),
378            method: Method::GET,
379            headers: vec![],
380            body: None,
381            assert_status: vec![],
382        }
383    }
384
385    mod run {
386        use super::*;
387
388        #[test]
389        fn test_empty_response() {
390            let processor = processor_stub();
391
392            let context = Context::new().unwrap();
393            let output = processor.run(&context).unwrap();
394
395            assert!(output.is_none())
396        }
397
398        #[test]
399        fn test_response_body() {
400            let mut processor = processor_stub();
401            processor.url = Url::parse("https://httpbin.org/range/5").unwrap();
402
403            let context = Context::new().unwrap();
404            let output = processor.run(&context).unwrap();
405
406            assert_eq!(output, Some("abcde".to_owned()))
407        }
408
409        #[test]
410        fn test_request_body() {
411            let mut processor = processor_stub();
412            processor.url = Url::parse("https://httpbin.org/anything").unwrap();
413            processor.body = Some("hello world".to_owned());
414
415            let context = Context::new().unwrap();
416            let output = processor.run(&context).unwrap().expect("Some");
417
418            assert!(output.contains("hello world"));
419        }
420
421        #[test]
422        fn test_valid_status() {
423            let mut processor = processor_stub();
424            processor.url = Url::parse("https://httpbin.org/status/200").unwrap();
425            processor.assert_status = vec![200, 204];
426
427            let context = Context::new().unwrap();
428            let output = processor.run(&context).unwrap();
429
430            assert_eq!(output, None)
431        }
432
433        #[test]
434        fn test_invalid_status() {
435            let mut processor = processor_stub();
436            processor.url = Url::parse("https://httpbin.org/status/404").unwrap();
437            processor.assert_status = vec![200, 201];
438
439            let context = Context::new().unwrap();
440            let error = processor.run(&context).unwrap_err();
441
442            assert_eq!(error.to_string(), "Invalid status code: 404".to_owned());
443        }
444    }
445
446    #[test]
447    fn test_readme_deps() {
448        version_sync::assert_markdown_deps_updated!("README.md");
449    }
450
451    #[test]
452    fn test_html_root_url() {
453        version_sync::assert_html_root_url_updated!("src/lib.rs");
454    }
455}