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_types::{
25    Body, Method, Mime, Url,
26    convert::DeserializeOwned,
27    headers::{HeaderName, ToHeaderValues},
28};
29use serde::Serialize;
30
31use crate::{
32    HttpError, Request, Response,
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::http::mime::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    #[allow(clippy::missing_panics_doc)]
114    pub fn header(mut self, key: impl Into<HeaderName>, value: impl ToHeaderValues) -> Self {
115        self.req.as_mut().unwrap().insert_header(key, value);
116        self
117    }
118
119    /// Sets the Content-Type header on the request.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// # use crux_core::macros::effect;
125    /// # use crux_http::HttpRequest;
126    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
127    /// # #[effect]
128    /// # #[allow(unused)]
129    /// # enum Effect { Http(HttpRequest) }
130    /// # type Http = crux_http::command::Http<Effect, Event>;
131    /// Http::get("https://httpbin.org/get")
132    ///     .content_type(crux_http::http::mime::HTML)
133    ///     .build()
134    ///     .then_send(Event::ReceiveResponse);
135    /// ```
136    #[allow(clippy::missing_panics_doc)]
137    pub fn content_type(mut self, content_type: impl Into<Mime>) -> Self {
138        self.req
139            .as_mut()
140            .unwrap()
141            .set_content_type(content_type.into());
142        self
143    }
144
145    /// Sets the body of the request from any type that implements `Into<Body>`
146    ///
147    /// # Mime
148    ///
149    /// The encoding is set to `application/octet-stream`.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// # use crux_core::macros::effect;
155    /// # use crux_http::HttpRequest;
156    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
157    /// # #[effect]
158    /// # #[allow(unused)]
159    /// # enum Effect { Http(HttpRequest) }
160    /// # type Http = crux_http::command::Http<Effect, Event>;
161    /// Http::post("https://httpbin.org/post")
162    ///     .body(serde_json::json!({"any": "Into<Body>"}))
163    ///     .content_type(crux_http::http::mime::HTML)
164    ///     .build()
165    ///     .then_send(Event::ReceiveResponse);
166    /// ```
167    #[allow(clippy::missing_panics_doc)]
168    pub fn body(mut self, body: impl Into<Body>) -> Self {
169        self.req.as_mut().unwrap().set_body(body);
170        self
171    }
172
173    /// Pass JSON as the request body.
174    ///
175    /// # Mime
176    ///
177    /// The encoding is set to `application/json`.
178    ///
179    /// # Errors
180    ///
181    /// This method will return an error if the provided data could not be serialized to JSON.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// # use serde::{Deserialize, Serialize};
187    /// # use crux_core::macros::effect;
188    /// # use crux_http::HttpRequest;
189    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
190    /// # #[effect]
191    /// # #[allow(unused)]
192    /// # enum Effect { Http(HttpRequest) }
193    /// # type Http = crux_http::command::Http<Effect, Event>;
194    /// #[derive(Deserialize, Serialize)]
195    /// struct Ip {
196    ///     ip: String
197    /// }
198    ///
199    /// let data = &Ip { ip: "129.0.0.1".into() };
200    /// Http::post("https://httpbin.org/post")
201    ///     .body_json(data)
202    ///     .expect("could not serialize body")
203    ///     .build()
204    ///     .then_send(Event::ReceiveResponse);
205    /// ```
206    pub fn body_json(self, json: &impl Serialize) -> crate::Result<Self> {
207        Ok(self.body(Body::from_json(json)?))
208    }
209
210    /// Pass a string as the request body.
211    ///
212    /// # Mime
213    ///
214    /// The encoding is set to `text/plain; charset=utf-8`.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// # use crux_core::macros::effect;
220    /// # use crux_http::HttpRequest;
221    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
222    /// # #[effect]
223    /// # #[allow(unused)]
224    /// # enum Effect { Http(HttpRequest) }
225    /// # type Http = crux_http::command::Http<Effect, Event>;
226    /// Http::post("https://httpbin.org/post")
227    ///     .body_string("hello_world".to_string())
228    ///     .build()
229    ///     .then_send(Event::ReceiveResponse);
230    /// ```
231    pub fn body_string(self, string: String) -> Self {
232        self.body(Body::from_string(string))
233    }
234
235    /// Pass bytes as the request body.
236    ///
237    /// # Mime
238    ///
239    /// The encoding is set to `application/octet-stream`.
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// # use crux_core::macros::effect;
245    /// # use crux_http::HttpRequest;
246    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
247    /// # #[effect]
248    /// # #[allow(unused)]
249    /// # enum Effect { Http(HttpRequest) }
250    /// # type Http = crux_http::command::Http<Effect, Event>;
251    /// Http::post("https://httpbin.org/post")
252    ///     .body_bytes(b"hello_world".to_owned())
253    ///     .build()
254    ///     .then_send(Event::ReceiveResponse);
255    /// ```
256    pub fn body_bytes(self, bytes: impl AsRef<[u8]>) -> Self {
257        self.body(Body::from(bytes.as_ref()))
258    }
259
260    /// Pass form data as the request body. The form data needs to be
261    /// serializable to name-value pairs.
262    ///
263    /// # Mime
264    ///
265    /// The `content-type` is set to `application/x-www-form-urlencoded`.
266    ///
267    /// # Errors
268    ///
269    /// An error will be returned if the provided data cannot be serialized to
270    /// form data.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// # use std::collections::HashMap;
276    /// # use crux_core::macros::effect;
277    /// # use crux_http::HttpRequest;
278    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
279    /// # #[effect]
280    /// # #[allow(unused)]
281    /// # enum Effect { Http(HttpRequest) }
282    /// # type Http = crux_http::command::Http<Effect, Event>;
283    /// let form_data = HashMap::from([
284    ///     ("name", "Alice"),
285    ///     ("location", "UK"),
286    /// ]);
287    /// Http::post("https://httpbin.org/post")
288    ///     .body_form(&form_data)
289    ///     .expect("could not serialize body")
290    ///     .build()
291    ///     .then_send(Event::ReceiveResponse);
292    /// ```
293    pub fn body_form(self, form: &impl Serialize) -> crate::Result<Self> {
294        Ok(self.body(Body::from_form(form)?))
295    }
296
297    /// Set the URL querystring.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// # use serde::{Deserialize, Serialize};
303    /// # use crux_core::macros::effect;
304    /// # use crux_http::HttpRequest;
305    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
306    /// # #[effect]
307    /// # #[allow(unused)]
308    /// # enum Effect { Http(HttpRequest) }
309    /// # type Http = crux_http::command::Http<Effect, Event>;
310    /// #[derive(Serialize, Deserialize)]
311    /// struct Index {
312    ///     page: u32
313    /// }
314    ///
315    /// let query = Index { page: 2 };
316    /// Http::post("https://httpbin.org/post")
317    ///     .query(&query)
318    ///     .expect("could not serialize query string")
319    ///     .build()
320    ///     .then_send(Event::ReceiveResponse);
321    /// ```
322    ///
323    /// # Errors
324    /// Returns an error if the query string could not be serialized.
325    #[allow(clippy::missing_panics_doc)]
326    pub fn query(mut self, query: &impl Serialize) -> std::result::Result<Self, HttpError> {
327        self.req.as_mut().unwrap().set_query(query)?;
328
329        Ok(self)
330    }
331
332    /// Push middleware onto a per-request middleware stack.
333    ///
334    /// **Important**: Setting per-request middleware incurs extra allocations.
335    /// Creating a `Client` with middleware is recommended.
336    ///
337    /// Client middleware is run before per-request middleware.
338    ///
339    /// See the [middleware] submodule for more information on middleware.
340    ///
341    /// [middleware]: ../middleware/index.html
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// # use crux_core::macros::effect;
347    /// # use crux_http::HttpRequest;
348    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
349    /// # #[effect]
350    /// # #[allow(unused)]
351    /// # enum Effect { Http(HttpRequest) }
352    /// # type Http = crux_http::command::Http<Effect, Event>;
353    /// Http::get("https://httpbin.org/redirect/2")
354    ///     .middleware(crux_http::middleware::Redirect::default())
355    ///     .build()
356    ///     .then_send(Event::ReceiveResponse);
357    /// ```
358    ///
359    #[allow(clippy::missing_panics_doc)]
360    pub fn middleware(mut self, middleware: impl Middleware) -> Self {
361        self.req.as_mut().unwrap().middleware(middleware);
362        self
363    }
364
365    /// Return the constructed `Request` in a [`crux_core::command::RequestBuilder`].
366    ///
367    #[allow(clippy::missing_panics_doc)]
368    #[must_use]
369    pub fn build(
370        self,
371    ) -> command::RequestBuilder<
372        Effect,
373        Event,
374        impl Future<Output = Result<Response<ExpectBody>, HttpError>>,
375    > {
376        let req = self.req.expect("RequestBuilder::build called twice");
377
378        command::RequestBuilder::new(|ctx| async move {
379            let operation = req
380                .into_protocol_request()
381                .await
382                .expect("should be able to convert request to protocol request");
383
384            let result = Command::request_from_shell(operation)
385                .into_future(ctx)
386                .await;
387
388            match result {
389                HttpResult::Ok(response) => Response::<Vec<u8>>::new(response.into())
390                    .await
391                    .and_then(|r| self.expectation.decode(r)),
392                HttpResult::Err(error) => Err(error),
393            }
394        })
395    }
396
397    /// Decode a String from the response body prior to dispatching it to the apps `update`
398    /// function.
399    ///
400    /// # Examples
401    ///
402    /// ```
403    /// # use crux_core::macros::effect;
404    /// # use crux_http::HttpRequest;
405    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<String>>) }
406    /// # #[effect]
407    /// # #[allow(unused)]
408    /// # enum Effect { Http(HttpRequest) }
409    /// # type Http = crux_http::command::Http<Effect, Event>;
410    /// Http::post("https://httpbin.org/json")
411    ///     .expect_string()
412    ///     .build()
413    ///     .then_send(Event::ReceiveResponse);
414    /// ```
415    pub fn expect_string(self) -> RequestBuilder<Effect, Event, String> {
416        let expectation = Box::<ExpectString>::default();
417        RequestBuilder {
418            req: self.req,
419            effect: PhantomData,
420            event: PhantomData,
421            expectation,
422        }
423    }
424
425    /// Decode a `T` from a JSON response body prior to dispatching it to the apps `update`
426    /// function.
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// # use serde::{Deserialize, Serialize};
432    /// # use crux_core::macros::effect;
433    /// # use crux_http::HttpRequest;
434    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Slideshow>>) }
435    /// # #[effect]
436    /// # #[allow(unused)]
437    /// # enum Effect { Http(HttpRequest) }
438    /// # type Http = crux_http::command::Http<Effect, Event>;
439    /// #[derive(Deserialize)]
440    /// struct Response {
441    ///     slideshow: Slideshow
442    /// }
443    ///
444    /// #[derive(Deserialize)]
445    /// struct Slideshow {
446    ///     author: String
447    /// }
448    ///
449    /// Http::post("https://httpbin.org/json")
450    ///     .expect_json::<Slideshow>()
451    ///     .build()
452    ///     .then_send(Event::ReceiveResponse);
453    /// ```
454    pub fn expect_json<T>(self) -> RequestBuilder<Effect, Event, T>
455    where
456        T: DeserializeOwned + 'static,
457    {
458        let expectation = Box::<ExpectJson<T>>::default();
459        RequestBuilder {
460            req: self.req,
461            effect: PhantomData,
462            event: PhantomData,
463            expectation,
464        }
465    }
466}
467
468impl<Effect, Event> fmt::Debug for RequestBuilder<Effect, Event> {
469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470        fmt::Debug::fmt(&self.req, f)
471    }
472}