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    /// # Warning: middleware does not run on this API
342    ///
343    /// Nothing executes the stack this pushes onto. [`build`](Self::build) turns the request
344    /// straight into a protocol request for the shell, and the only executor of a middleware
345    /// stack is `Client::send`, reachable solely from the deprecated capability API. So
346    /// middleware added here is accepted and then silently ignored — including
347    /// [`Redirect`](crate::middleware::Redirect), which means redirects are **not** followed
348    /// and a 3xx arrives at your app as a `Response` with a `Location` header.
349    ///
350    /// Until [issue #556](https://github.com/redbadger/crux/issues/556) is resolved, treat
351    /// this method as a no-op rather than a way to intercept requests. `Config` (base URL,
352    /// per-client default headers) is skipped on this API for the same reason.
353    ///
354    /// **Important**: Setting per-request middleware incurs extra allocations.
355    /// Creating a `Client` with middleware is recommended.
356    ///
357    /// Client middleware is run before per-request middleware.
358    ///
359    /// See the [middleware] submodule for more information on middleware.
360    ///
361    /// [middleware]: ../middleware/index.html
362    ///
363    /// # Examples
364    ///
365    /// ```
366    /// # use crux_core::macros::effect;
367    /// # use crux_http::HttpRequest;
368    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Vec<u8>>>) }
369    /// # #[effect]
370    /// # #[allow(unused)]
371    /// # enum Effect { Http(HttpRequest) }
372    /// # type Http = crux_http::command::Http<Effect, Event>;
373    /// Http::get("https://httpbin.org/redirect/2")
374    ///     // accepted, but never run — the two redirects are not followed
375    ///     .middleware(crux_http::middleware::Redirect::default())
376    ///     .build()
377    ///     .then_send(Event::ReceiveResponse);
378    /// ```
379    ///
380    #[allow(clippy::missing_panics_doc)]
381    pub fn middleware(mut self, middleware: impl Middleware) -> Self {
382        self.req.as_mut().unwrap().middleware(middleware);
383        self
384    }
385
386    /// Return the constructed `Request` in a [`crux_core::command::RequestBuilder`].
387    ///
388    #[allow(clippy::missing_panics_doc)]
389    #[must_use]
390    pub fn build(
391        self,
392    ) -> command::RequestBuilder<Effect, Event, impl Future<Output = Result<Response<ExpectBody>>>>
393    {
394        let req = self.req.expect("RequestBuilder::build called twice");
395
396        command::RequestBuilder::new(|ctx| async move {
397            let operation = req
398                .into_protocol_request()
399                .expect("should be able to convert request to protocol request");
400
401            let result = Command::request_from_shell(operation)
402                .into_future(ctx)
403                .await;
404
405            match result {
406                HttpResult::Ok(response) => response
407                    .try_into()
408                    .and_then(Response::<Vec<u8>>::new)
409                    .and_then(|r| self.expectation.decode(r)),
410                HttpResult::Err(error) => Err(error),
411            }
412        })
413    }
414
415    /// Decode a String from the response body prior to dispatching it to the apps `update`
416    /// function.
417    ///
418    /// # Examples
419    ///
420    /// ```
421    /// # use crux_core::macros::effect;
422    /// # use crux_http::HttpRequest;
423    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<String>>) }
424    /// # #[effect]
425    /// # #[allow(unused)]
426    /// # enum Effect { Http(HttpRequest) }
427    /// # type Http = crux_http::command::Http<Effect, Event>;
428    /// Http::post("https://httpbin.org/json")
429    ///     .expect_string()
430    ///     .build()
431    ///     .then_send(Event::ReceiveResponse);
432    /// ```
433    pub fn expect_string(self) -> RequestBuilder<Effect, Event, String> {
434        let expectation = Box::<ExpectString>::default();
435        RequestBuilder {
436            req: self.req,
437            effect: PhantomData,
438            event: PhantomData,
439            expectation,
440        }
441    }
442
443    /// Decode a `T` from a JSON response body prior to dispatching it to the apps `update`
444    /// function.
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// # use serde::{Deserialize, Serialize};
450    /// # use crux_core::macros::effect;
451    /// # use crux_http::HttpRequest;
452    /// # enum Event { ReceiveResponse(crux_http::Result<crux_http::Response<Slideshow>>) }
453    /// # #[effect]
454    /// # #[allow(unused)]
455    /// # enum Effect { Http(HttpRequest) }
456    /// # type Http = crux_http::command::Http<Effect, Event>;
457    /// #[derive(Deserialize)]
458    /// struct Response {
459    ///     slideshow: Slideshow
460    /// }
461    ///
462    /// #[derive(Deserialize)]
463    /// struct Slideshow {
464    ///     author: String
465    /// }
466    ///
467    /// Http::post("https://httpbin.org/json")
468    ///     .expect_json::<Slideshow>()
469    ///     .build()
470    ///     .then_send(Event::ReceiveResponse);
471    /// ```
472    pub fn expect_json<T>(self) -> RequestBuilder<Effect, Event, T>
473    where
474        T: DeserializeOwned + 'static,
475    {
476        let expectation = Box::<ExpectJson<T>>::default();
477        RequestBuilder {
478            req: self.req,
479            effect: PhantomData,
480            event: PhantomData,
481            expectation,
482        }
483    }
484}
485
486impl<Effect, Event> fmt::Debug for RequestBuilder<Effect, Event> {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        fmt::Debug::fmt(&self.req, f)
489    }
490}