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}