Skip to main content

crux_http/
command.rs

1//! The Command based API for `crux_http`
2//!
3//! Use methods on the [`Http`] type. For example:
4//!
5//! ```
6//! # use crux_core::macros::effect;
7//! # use crux_http::HttpRequest;
8//! # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<String>>) }
9//! # #[effect]
10//! # #[allow(unused)]
11//! # enum Effect { Http(HttpRequest) }
12//! # type Http = crux_http::command::Http<Effect, Event>;
13//! Http::get("https://httpbin.org/get")
14//!     .expect_string()
15//!     .build()
16//!     .then_send(Event::ReceiveResponse);
17//! ```
18//!
19//!
20
21use std::{fmt, future::Future, marker::PhantomData};
22
23use crux_core::{Command, command};
24use http::{HeaderValue, Method};
25use mime::Mime;
26use serde::Serialize;
27use serde::de::DeserializeOwned;
28use url::Url;
29
30use crate::{
31    HttpError, Request, Response, Result,
32    body::Body,
33    expect::{ExpectBytes, ExpectJson, ExpectString, ResponseExpectation},
34    middleware::Middleware,
35    protocol::{HttpRequest, HttpResult, ProtocolRequestBuilder},
36};
37
38#[deprecated(since = "0.16.0", note = "Import directly from crate root")]
39pub use crate::Http;
40
41/// Request Builder
42///
43/// Provides an ergonomic way to chain the creation of a request.
44/// This is generally accessed as the return value from
45/// `crux_http::command::Http::{method}()`.
46///
47/// # Examples
48///
49/// ```
50/// # use crux_core::macros::effect;
51/// # use crux_http::HttpRequest;
52/// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
53/// # #[effect]
54/// # #[allow(unused)]
55/// # enum Effect { Http(HttpRequest) }
56/// # type Http = crux_http::command::Http<Effect, Event>;
57/// Http::post("https://httpbin.org/post")
58///     .body("<html>hi</html>")
59///     .header("custom-header", "value")
60///     .content_type(crux_http::mime::TEXT_HTML)
61///     .build()
62///     .then_send(Event::ReceiveResponse);
63/// ```
64#[must_use]
65pub struct RequestBuilder<Effect, Event, ExpectBody = Vec<u8>> {
66    /// Holds the state of the request.
67    req: Option<Request>,
68    effect: PhantomData<Effect>,
69    event: PhantomData<fn() -> Event>,
70    expectation: Box<dyn ResponseExpectation<Body = ExpectBody> + Send>,
71}
72
73impl<Effect, Event> RequestBuilder<Effect, Event, Vec<u8>>
74where
75    Effect: Send + From<crux_core::Request<HttpRequest>> + 'static,
76    Event: 'static,
77{
78    pub(crate) fn new(method: Method, url: Url) -> Self {
79        Self {
80            req: Some(Request::new(method, url)),
81            effect: PhantomData,
82            event: PhantomData,
83            expectation: Box::new(ExpectBytes),
84        }
85    }
86}
87
88impl<Effect, Event, ExpectBody> RequestBuilder<Effect, Event, ExpectBody>
89where
90    Effect: Send + From<crux_core::Request<HttpRequest>> + 'static,
91    Event: Send + 'static,
92    ExpectBody: 'static,
93{
94    /// Sets a header on the request.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// # use crux_core::macros::effect;
100    /// # use crux_http::HttpRequest;
101    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
102    /// # #[effect]
103    /// # #[allow(unused)]
104    /// # enum Effect { Http(HttpRequest) }
105    /// # type Http = crux_http::command::Http<Effect, Event>;
106    /// Http::get("https://httpbin.org/get")
107    ///     .body("<html>hi</html>")
108    ///     .header("header-name", "header-value")
109    ///     .build()
110    ///     .then_send(Event::ReceiveResponse);
111    /// ```
112    ///
113    /// # Panics
114    /// Panics if `value` is not a valid header value, or if the `RequestBuilder` has
115    /// not been initialized.
116    pub fn header(
117        mut self,
118        name: impl http::header::IntoHeaderName,
119        value: impl AsRef<str>,
120    ) -> Self {
121        let value = HeaderValue::from_str(value.as_ref()).expect("invalid header value");
122        self.req.as_mut().unwrap().insert_header(name, value);
123        self
124    }
125
126    /// Sets the Content-Type header on the request.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// # use crux_core::macros::effect;
132    /// # use crux_http::HttpRequest;
133    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
134    /// # #[effect]
135    /// # #[allow(unused)]
136    /// # enum Effect { Http(HttpRequest) }
137    /// # type Http = crux_http::command::Http<Effect, Event>;
138    /// Http::get("https://httpbin.org/get")
139    ///     .content_type(crux_http::mime::TEXT_HTML)
140    ///     .build()
141    ///     .then_send(Event::ReceiveResponse);
142    /// ```
143    #[allow(clippy::missing_panics_doc)]
144    pub fn content_type(mut self, content_type: impl Into<Mime>) -> Self {
145        self.req
146            .as_mut()
147            .unwrap()
148            .set_content_type(&content_type.into());
149        self
150    }
151
152    /// Sets the body of the request from any type that implements `Into<Body>`
153    ///
154    /// # Mime
155    ///
156    /// The encoding is set to `application/octet-stream`.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// # use crux_core::macros::effect;
162    /// # use crux_http::HttpRequest;
163    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
164    /// # #[effect]
165    /// # #[allow(unused)]
166    /// # enum Effect { Http(HttpRequest) }
167    /// # type Http = crux_http::command::Http<Effect, Event>;
168    /// Http::post("https://httpbin.org/post")
169    ///     .body(serde_json::json!({"any": "Into<Body>"}))
170    ///     .content_type(crux_http::mime::TEXT_HTML)
171    ///     .build()
172    ///     .then_send(Event::ReceiveResponse);
173    /// ```
174    #[allow(clippy::missing_panics_doc)]
175    pub fn body(mut self, body: impl Into<Body>) -> Self {
176        self.req.as_mut().unwrap().set_body(body);
177        self
178    }
179
180    /// Pass JSON as the request body.
181    ///
182    /// # Mime
183    ///
184    /// The encoding is set to `application/json`.
185    ///
186    /// # Errors
187    ///
188    /// This method will return an error if the provided data could not be serialized to JSON.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// # use serde::{Deserialize, Serialize};
194    /// # use crux_core::macros::effect;
195    /// # use crux_http::HttpRequest;
196    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
197    /// # #[effect]
198    /// # #[allow(unused)]
199    /// # enum Effect { Http(HttpRequest) }
200    /// # type Http = crux_http::command::Http<Effect, Event>;
201    /// #[derive(Deserialize, Serialize)]
202    /// struct Ip {
203    ///     ip: String
204    /// }
205    ///
206    /// let data = &Ip { ip: "129.0.0.1".into() };
207    /// Http::post("https://httpbin.org/post")
208    ///     .body_json(data)
209    ///     .expect("could not serialize body")
210    ///     .build()
211    ///     .then_send(Event::ReceiveResponse);
212    /// ```
213    pub fn body_json(self, json: &impl Serialize) -> Result<Self> {
214        Ok(self.body(Body::from_json(json)?))
215    }
216
217    /// Pass a string as the request body.
218    ///
219    /// # Mime
220    ///
221    /// The encoding is set to `text/plain; charset=utf-8`.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// # use crux_core::macros::effect;
227    /// # use crux_http::HttpRequest;
228    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
229    /// # #[effect]
230    /// # #[allow(unused)]
231    /// # enum Effect { Http(HttpRequest) }
232    /// # type Http = crux_http::command::Http<Effect, Event>;
233    /// Http::post("https://httpbin.org/post")
234    ///     .body_string("hello_world".to_string())
235    ///     .build()
236    ///     .then_send(Event::ReceiveResponse);
237    /// ```
238    pub fn body_string(self, string: String) -> Self {
239        self.body(Body::from_string(string))
240    }
241
242    /// Pass bytes as the request body.
243    ///
244    /// # Mime
245    ///
246    /// The encoding is set to `application/octet-stream`.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// # use crux_core::macros::effect;
252    /// # use crux_http::HttpRequest;
253    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
254    /// # #[effect]
255    /// # #[allow(unused)]
256    /// # enum Effect { Http(HttpRequest) }
257    /// # type Http = crux_http::command::Http<Effect, Event>;
258    /// Http::post("https://httpbin.org/post")
259    ///     .body_bytes(b"hello_world".to_owned())
260    ///     .build()
261    ///     .then_send(Event::ReceiveResponse);
262    /// ```
263    pub fn body_bytes(self, bytes: impl AsRef<[u8]>) -> Self {
264        self.body(Body::from(bytes.as_ref()))
265    }
266
267    /// Pass form data as the request body. The form data needs to be
268    /// serializable to name-value pairs.
269    ///
270    /// # Mime
271    ///
272    /// The `content-type` is set to `application/x-www-form-urlencoded`.
273    ///
274    /// # Errors
275    ///
276    /// An error will be returned if the provided data cannot be serialized to
277    /// form data.
278    ///
279    /// # Examples
280    ///
281    /// ```
282    /// # use std::collections::HashMap;
283    /// # use crux_core::macros::effect;
284    /// # use crux_http::HttpRequest;
285    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
286    /// # #[effect]
287    /// # #[allow(unused)]
288    /// # enum Effect { Http(HttpRequest) }
289    /// # type Http = crux_http::command::Http<Effect, Event>;
290    /// let form_data = HashMap::from([
291    ///     ("name", "Alice"),
292    ///     ("location", "UK"),
293    /// ]);
294    /// Http::post("https://httpbin.org/post")
295    ///     .body_form(&form_data)
296    ///     .expect("could not serialize body")
297    ///     .build()
298    ///     .then_send(Event::ReceiveResponse);
299    /// ```
300    pub fn body_form(self, form: &impl Serialize) -> Result<Self> {
301        Ok(self.body(Body::from_form(form)?))
302    }
303
304    /// Set the URL querystring.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// # use serde::{Deserialize, Serialize};
310    /// # use crux_core::macros::effect;
311    /// # use crux_http::HttpRequest;
312    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
313    /// # #[effect]
314    /// # #[allow(unused)]
315    /// # enum Effect { Http(HttpRequest) }
316    /// # type Http = crux_http::command::Http<Effect, Event>;
317    /// #[derive(Serialize, Deserialize)]
318    /// struct Index {
319    ///     page: u32
320    /// }
321    ///
322    /// let query = Index { page: 2 };
323    /// Http::post("https://httpbin.org/post")
324    ///     .query(&query)
325    ///     .expect("could not serialize query string")
326    ///     .build()
327    ///     .then_send(Event::ReceiveResponse);
328    /// ```
329    ///
330    /// # Errors
331    /// Returns an error if the query string could not be serialized.
332    #[allow(clippy::missing_panics_doc)]
333    pub fn query(mut self, query: &impl Serialize) -> std::result::Result<Self, HttpError> {
334        self.req.as_mut().unwrap().set_query(query)?;
335
336        Ok(self)
337    }
338
339    /// Push middleware onto a per-request middleware stack.
340    ///
341    /// **Important**: Setting per-request middleware incurs extra allocations.
342    /// Creating a `Client` with middleware is recommended.
343    ///
344    /// Client middleware is run before per-request middleware.
345    ///
346    /// See the [middleware] submodule for more information on middleware.
347    ///
348    /// [middleware]: ../middleware/index.html
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// # use crux_core::macros::effect;
354    /// # use crux_http::HttpRequest;
355    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
356    /// # #[effect]
357    /// # #[allow(unused)]
358    /// # enum Effect { Http(HttpRequest) }
359    /// # type Http = crux_http::command::Http<Effect, Event>;
360    /// Http::get("https://httpbin.org/redirect/2")
361    ///     .middleware(crux_http::middleware::Redirect::default())
362    ///     .build()
363    ///     .then_send(Event::ReceiveResponse);
364    /// ```
365    ///
366    #[allow(clippy::missing_panics_doc)]
367    pub fn middleware(mut self, middleware: impl Middleware) -> Self {
368        self.req.as_mut().unwrap().middleware(middleware);
369        self
370    }
371
372    /// Return the constructed `Request` in a [`crux_core::command::RequestBuilder`].
373    ///
374    #[allow(clippy::missing_panics_doc)]
375    #[must_use]
376    pub fn build(
377        self,
378    ) -> command::RequestBuilder<Effect, Event, impl Future<Output = Result<Response<ExpectBody>>>>
379    {
380        let req = self.req.expect("RequestBuilder::build called twice");
381
382        command::RequestBuilder::new(|ctx| async move {
383            let operation = req
384                .into_protocol_request()
385                .expect("should be able to convert request to protocol request");
386
387            let result = Command::request_from_shell(operation)
388                .into_future(ctx)
389                .await;
390
391            match result {
392                HttpResult::Ok(response) => response
393                    .try_into()
394                    .and_then(Response::<Vec<u8>>::new)
395                    .and_then(|r| self.expectation.decode(r)),
396                HttpResult::Err(error) => Err(error),
397            }
398        })
399    }
400
401    /// Decode a String from the response body prior to dispatching it to the apps `update`
402    /// function.
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// # use crux_core::macros::effect;
408    /// # use crux_http::HttpRequest;
409    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<String>>) }
410    /// # #[effect]
411    /// # #[allow(unused)]
412    /// # enum Effect { Http(HttpRequest) }
413    /// # type Http = crux_http::command::Http<Effect, Event>;
414    /// Http::post("https://httpbin.org/json")
415    ///     .expect_string()
416    ///     .build()
417    ///     .then_send(Event::ReceiveResponse);
418    /// ```
419    pub fn expect_string(self) -> RequestBuilder<Effect, Event, String> {
420        let expectation = Box::<ExpectString>::default();
421        RequestBuilder {
422            req: self.req,
423            effect: PhantomData,
424            event: PhantomData,
425            expectation,
426        }
427    }
428
429    /// Decode a `T` from a JSON response body prior to dispatching it to the apps `update`
430    /// function.
431    ///
432    /// # Examples
433    ///
434    /// ```
435    /// # use serde::{Deserialize, Serialize};
436    /// # use crux_core::macros::effect;
437    /// # use crux_http::HttpRequest;
438    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Slideshow>>) }
439    /// # #[effect]
440    /// # #[allow(unused)]
441    /// # enum Effect { Http(HttpRequest) }
442    /// # type Http = crux_http::command::Http<Effect, Event>;
443    /// #[derive(Deserialize)]
444    /// struct Response {
445    ///     slideshow: Slideshow
446    /// }
447    ///
448    /// #[derive(Deserialize)]
449    /// struct Slideshow {
450    ///     author: String
451    /// }
452    ///
453    /// Http::post("https://httpbin.org/json")
454    ///     .expect_json::<Slideshow>()
455    ///     .build()
456    ///     .then_send(Event::ReceiveResponse);
457    /// ```
458    pub fn expect_json<T>(self) -> RequestBuilder<Effect, Event, T>
459    where
460        T: DeserializeOwned + 'static,
461    {
462        let expectation = Box::<ExpectJson<T>>::default();
463        RequestBuilder {
464            req: self.req,
465            effect: PhantomData,
466            event: PhantomData,
467            expectation,
468        }
469    }
470}
471
472impl<Effect, Event> fmt::Debug for RequestBuilder<Effect, Event> {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        fmt::Debug::fmt(&self.req, f)
475    }
476}