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}