Skip to main content

crux_http/
protocol.rs

1//! The protocol for communicating with the shell
2//!
3//! Crux capabilities don't interface with the outside world themselves, they carry
4//! out all their operations by exchanging messages with the platform specific shell.
5//! This module defines the protocol for `crux_http` to communicate with the shell.
6
7use async_trait::async_trait;
8use derive_builder::Builder;
9use facet_generate_attrs as typegen;
10use serde::{Deserialize, Serialize};
11
12use crate::{HttpError, Request, Result};
13
14#[derive(facet::Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
15pub struct HttpHeader {
16    pub name: String,
17    pub value: String,
18}
19
20/// A raw HTTP request, as sent to the shell over the protocol boundary.
21///
22/// # No header validation
23///
24/// All fields are plain strings. Header names and values are carried as-is with no
25/// validation against the HTTP specification. This is intentional: `HttpRequest` is a
26/// cross-language data carrier deserialised by Swift, Kotlin, and TypeScript shells;
27/// Rust's `http`-crate validation rules cannot be enforced on the other side of that
28/// boundary.
29///
30/// **Shell authors must not assume that header names or values are well-formed.**
31/// Pass them to your underlying HTTP client as-is — it will apply its own rules.
32///
33/// For the ergonomic Rust-side builder that *does* validate header values (and panics
34/// on invalid input), see [`crate::command::RequestBuilder`].
35#[derive(facet::Facet, Serialize, Deserialize, Default, Clone, PartialEq, Eq, Builder)]
36#[builder(
37    custom_constructor,
38    build_fn(private, name = "fallible_build"),
39    setter(into)
40)]
41pub struct HttpRequest {
42    pub method: String,
43    pub url: String,
44    #[builder(setter(custom))]
45    pub headers: Vec<HttpHeader>,
46    #[serde(with = "serde_bytes")]
47    #[facet(typegen::bytes)]
48    pub body: Vec<u8>,
49}
50
51impl std::fmt::Debug for HttpRequest {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        let body_repr = std::str::from_utf8(&self.body).map_or_else(
54            |_| format!("<binary data - {} bytes>", self.body.len()),
55            |s| {
56                if s.len() < 50 {
57                    format!("\"{s}\"")
58                } else {
59                    format!("\"{}\"...", s.chars().take(50).collect::<String>())
60                }
61            },
62        );
63        let mut builder = f.debug_struct("HttpRequest");
64        builder
65            .field("method", &self.method)
66            .field("url", &self.url);
67        if !self.headers.is_empty() {
68            builder.field("headers", &self.headers);
69        }
70        builder.field("body", &format_args!("{body_repr}")).finish()
71    }
72}
73
74macro_rules! http_method {
75    ($name:ident, $method:expr) => {
76        pub fn $name(url: impl Into<String>) -> HttpRequestBuilder {
77            HttpRequestBuilder {
78                method: Some($method.to_string()),
79                url: Some(url.into()),
80                headers: Some(vec![]),
81                body: Some(vec![]),
82            }
83        }
84    };
85}
86
87impl HttpRequest {
88    http_method!(get, "GET");
89    http_method!(put, "PUT");
90    http_method!(delete, "DELETE");
91    http_method!(post, "POST");
92    http_method!(patch, "PATCH");
93    http_method!(head, "HEAD");
94    http_method!(options, "OPTIONS");
95}
96
97impl HttpRequestBuilder {
98    /// Appends a header to the request.
99    ///
100    /// Both `name` and `value` are accepted as plain strings with **no validation**.
101    /// `HttpRequestBuilder` constructs protocol-layer values (primarily for tests),
102    /// not validated HTTP requests, so arbitrary strings — including deliberately
103    /// malformed ones — are allowed.
104    ///
105    /// For validated header setting in app code, use
106    /// [`crate::command::RequestBuilder::header`], which panics on invalid values.
107    pub fn header(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
108        self.headers.get_or_insert_with(Vec::new).push(HttpHeader {
109            name: name.into(),
110            value: value.into(),
111        });
112        self
113    }
114
115    /// Sets the query parameters of the request to the given value.
116    ///
117    /// # Errors
118    /// Returns an [`HttpError`] if the serialization fails.
119    pub fn query(&mut self, query: &impl Serialize) -> Result<&mut Self> {
120        if let Some(url) = &mut self.url {
121            if url.contains('?') {
122                url.push('&');
123            } else {
124                url.push('?');
125            }
126            url.push_str(&serde_qs::to_string(query)?);
127        }
128
129        Ok(self)
130    }
131
132    /// Sets the body of the request to the JSON representation of the given value.
133    ///
134    /// Sets **only** the body. To build the request a capability call produces —
135    /// body *and* `content-type` — use [`body_json`](Self::body_json).
136    ///
137    /// # Panics
138    /// Panics if the serialization fails.
139    pub fn json(&mut self, body: impl serde::Serialize) -> &mut Self {
140        self.body = Some(serde_json::to_vec(&body).unwrap());
141        self
142    }
143
144    /// Sets the body to the JSON representation of `body` **and** sets
145    /// `content-type: application/json`, mirroring
146    /// [`crate::command::RequestBuilder::body_json`].
147    ///
148    /// This is what you want when asserting on a request an app actually made,
149    /// where spelling the header out by hand is easy to forget:
150    ///
151    /// ```rust
152    /// # use crux_http::protocol::HttpRequest;
153    /// let body = serde_json::json!({ "title": "New Post" });
154    ///
155    /// assert_eq!(
156    ///     HttpRequest::post("https://example.com/posts")
157    ///         .body_json(&body)
158    ///         .build(),
159    ///     HttpRequest::post("https://example.com/posts")
160    ///         .header("content-type", "application/json")
161    ///         .json(&body)
162    ///         .build(),
163    /// );
164    /// ```
165    ///
166    /// [`json`](Self::json) deliberately does not set the header — these builders
167    /// construct protocol-layer values, so they must stay able to express a JSON
168    /// body with no `content-type`, or a malformed one. But because the capability
169    /// side sets the mime (via `Body::from_json`), mirroring a real request with
170    /// `json` alone fails on a header-only difference. Hence this method.
171    ///
172    /// # Panics
173    /// Panics if the serialization fails.
174    pub fn body_json(&mut self, body: impl serde::Serialize) -> &mut Self {
175        self.json(body).header(
176            http::header::CONTENT_TYPE.as_str(),
177            mime::APPLICATION_JSON.as_ref(),
178        )
179    }
180
181    /// Builds the request.
182    ///
183    /// # Panics
184    /// Panics if any required fields are missing.
185    #[must_use]
186    pub fn build(&self) -> HttpRequest {
187        self.fallible_build()
188            .expect("All required fields were initialized")
189    }
190}
191
192#[derive(facet::Facet, Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Builder)]
193#[builder(
194    custom_constructor,
195    build_fn(private, name = "fallible_build"),
196    setter(into)
197)]
198pub struct HttpResponse {
199    pub status: u16, // FIXME this probably should be a giant enum instead.
200    #[builder(setter(custom))]
201    pub headers: Vec<HttpHeader>,
202    #[serde(with = "serde_bytes")]
203    #[facet(typegen::bytes)]
204    pub body: Vec<u8>,
205}
206
207impl HttpResponse {
208    #[must_use]
209    #[allow(clippy::missing_const_for_fn)]
210    pub fn status(status: u16) -> HttpResponseBuilder {
211        HttpResponseBuilder {
212            status: Some(status),
213            headers: Some(vec![]),
214            body: Some(vec![]),
215        }
216    }
217    #[must_use]
218    pub fn ok() -> HttpResponseBuilder {
219        Self::status(200)
220    }
221}
222
223impl HttpResponseBuilder {
224    pub fn header(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
225        self.headers.get_or_insert_with(Vec::new).push(HttpHeader {
226            name: name.into(),
227            value: value.into(),
228        });
229        self
230    }
231
232    /// Sets the body of the response to the given JSON.
233    ///
234    /// # Panics
235    /// If the JSON serialization fails.
236    pub fn json(&mut self, body: impl serde::Serialize) -> &mut Self {
237        self.body = Some(serde_json::to_vec(&body).unwrap());
238        self
239    }
240
241    /// Builds the response.
242    ///
243    /// # Panics
244    /// If a required field has not been initialized.
245    #[must_use]
246    pub fn build(&self) -> HttpResponse {
247        self.fallible_build()
248            .expect("All required fields were initialized")
249    }
250}
251
252/// The result of an HTTP request, as returned by the shell over the protocol boundary.
253///
254/// # Status codes are not errors
255///
256/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
257/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
258/// reach the server at all) produce [`HttpResult::Err`].
259///
260/// To act on an error status, inspect [`HttpResponse::status`]:
261///
262/// ```
263/// # use crux_http::protocol::{HttpResult, HttpResponse};
264/// # use crux_http::HttpError;
265/// # fn handle(result: HttpResult) {
266/// match result {
267///     HttpResult::Ok(response) if response.status == 200 => { /* success */ }
268///     HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
269///     HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
270///     HttpResult::Ok(_) => { /* other status */ }
271///     HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
272/// }
273/// # }
274/// ```
275#[derive(facet::Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
276#[repr(C)]
277pub enum HttpResult {
278    /// The shell completed the HTTP exchange. The response may carry any status code,
279    /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
280    Ok(HttpResponse),
281    /// The shell could not complete the HTTP exchange due to a transport-level failure.
282    /// See [`HttpError`] for the possible causes.
283    Err(HttpError),
284}
285
286impl From<Result<HttpResponse>> for HttpResult {
287    fn from(result: Result<HttpResponse>) -> Self {
288        match result {
289            Ok(response) => Self::Ok(response),
290            Err(err) => Self::Err(err),
291        }
292    }
293}
294
295impl crux_core::capability::Operation for HttpRequest {
296    type Output = HttpResult;
297
298    #[cfg(feature = "typegen")]
299    fn register_types(
300        generator: &mut crux_core::type_generation::serde::TypeGen,
301    ) -> crux_core::type_generation::serde::Result {
302        generator.register_type::<HttpError>()?;
303        generator.register_type::<Self>()?;
304        generator.register_type::<Self::Output>()?;
305        Ok(())
306    }
307}
308
309#[async_trait]
310pub(crate) trait EffectSender {
311    async fn send(&self, effect: HttpRequest) -> HttpResult;
312}
313
314pub(crate) trait ProtocolRequestBuilder {
315    fn into_protocol_request(self) -> Result<HttpRequest>;
316}
317
318impl ProtocolRequestBuilder for Request {
319    fn into_protocol_request(mut self) -> Result<HttpRequest> {
320        let body = self.take_body().into_bytes();
321
322        Ok(HttpRequest {
323            method: self.method().to_string(),
324            url: self.url().to_string(),
325            headers: self
326                .iter()
327                .filter_map(|(name, value)| {
328                    value.to_str().ok().map(|v| HttpHeader {
329                        name: name.to_string(),
330                        value: v.to_string(),
331                    })
332                })
333                .collect(),
334            body,
335        })
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use serde::{Deserialize, Serialize};
343
344    #[test]
345    fn test_http_request_get() {
346        let req = HttpRequest::get("https://example.com").build();
347
348        insta::assert_debug_snapshot!(req, @r#"
349        HttpRequest {
350            method: "GET",
351            url: "https://example.com",
352            body: "",
353        }
354        "#);
355    }
356
357    #[test]
358    fn test_http_request_get_with_fields() {
359        let req = HttpRequest::get("https://example.com")
360            .header("foo", "bar")
361            .body("123")
362            .build();
363
364        insta::assert_debug_snapshot!(req, @r#"
365        HttpRequest {
366            method: "GET",
367            url: "https://example.com",
368            headers: [
369                HttpHeader {
370                    name: "foo",
371                    value: "bar",
372                },
373            ],
374            body: "123",
375        }
376        "#);
377    }
378
379    #[test]
380    fn test_http_response_status() {
381        let req = HttpResponse::status(302).build();
382
383        insta::assert_debug_snapshot!(req, @"
384        HttpResponse {
385            status: 302,
386            headers: [],
387            body: [],
388        }
389        ");
390    }
391
392    #[test]
393    fn test_http_response_status_with_fields() {
394        let req = HttpResponse::status(302)
395            .header("foo", "bar")
396            .body("hey")
397            .build();
398
399        insta::assert_debug_snapshot!(req, @r#"
400        HttpResponse {
401            status: 302,
402            headers: [
403                HttpHeader {
404                    name: "foo",
405                    value: "bar",
406                },
407            ],
408            body: [
409                104,
410                101,
411                121,
412            ],
413        }
414        "#);
415    }
416
417    #[test]
418    fn test_http_request_debug_repr() {
419        {
420            // small
421            let req = HttpRequest::post("http://example.com")
422                .header("foo", "bar")
423                .body("hello world!")
424                .build();
425            let repr = format!("{req:?}");
426            assert_eq!(
427                repr,
428                r#"HttpRequest { method: "POST", url: "http://example.com", headers: [HttpHeader { name: "foo", value: "bar" }], body: "hello world!" }"#
429            );
430        }
431
432        {
433            // big
434            let req = HttpRequest::post("http://example.com")
435                // we check that we handle unicode boundaries correctly
436                .body("abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstu😀😀😀😀😀😀")
437                .build();
438            let repr = format!("{req:?}");
439            assert_eq!(
440                repr,
441                r#"HttpRequest { method: "POST", url: "http://example.com", body: "abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstu😀😀"... }"#
442            );
443        }
444
445        {
446            // binary
447            let req = HttpRequest::post("http://example.com")
448                .body(vec![255, 254, 253, 252])
449                .build();
450            let repr = format!("{req:?}");
451            assert_eq!(
452                repr,
453                r#"HttpRequest { method: "POST", url: "http://example.com", body: <binary data - 4 bytes> }"#
454            );
455        }
456    }
457
458    #[test]
459    fn test_http_request_query() {
460        #[derive(Serialize, Deserialize)]
461        struct QueryParams {
462            page: u32,
463            limit: u32,
464            search: String,
465        }
466
467        let query = QueryParams {
468            page: 2,
469            limit: 10,
470            search: "test".to_string(),
471        };
472
473        let mut builder = HttpRequestBuilder {
474            method: Some("GET".to_string()),
475            url: Some("https://example.com".to_string()),
476            headers: Some(vec![HttpHeader {
477                name: "foo".to_string(),
478                value: "bar".to_string(),
479            }]),
480            body: Some(vec![]),
481        };
482
483        builder
484            .query(&query)
485            .expect("should serialize query params");
486        let req = builder.build();
487
488        insta::assert_debug_snapshot!(req, @r#"
489        HttpRequest {
490            method: "GET",
491            url: "https://example.com?page=2&limit=10&search=test",
492            headers: [
493                HttpHeader {
494                    name: "foo",
495                    value: "bar",
496                },
497            ],
498            body: "",
499        }
500        "#);
501    }
502
503    #[test]
504    fn test_http_request_query_with_special_chars() {
505        #[derive(Serialize, Deserialize)]
506        struct QueryParams {
507            allowed: String,
508            disallowed: String,
509            delimiters: String,
510            alpha_numeric_and_space: String,
511        }
512
513        let query = QueryParams {
514            // allowed chars (RFC 3986)
515            allowed: ";/?:@$,-.!~*'()".to_string(),
516            // disallowed chars (RFC 3986)
517            disallowed: "#".to_string(),
518            // delimiters in key value pairs, need encoding
519            delimiters: "&=+".to_string(),
520            // not RFC 3986 Compliant (space should be %20 not +)
521            // but "+" is very common so we allow it
522            alpha_numeric_and_space: "ABC abc 123".to_string(),
523        };
524
525        let mut builder = HttpRequestBuilder {
526            method: Some("GET".to_string()),
527            url: Some("https://example.com".to_string()),
528            headers: Some(vec![]),
529            body: Some(vec![]),
530        };
531
532        builder
533            .query(&query)
534            .expect("should serialize query params with special chars");
535        let req = builder.build();
536
537        insta::assert_debug_snapshot!(req, @r#"
538        HttpRequest {
539            method: "GET",
540            url: "https://example.com?allowed=;/?:@$,-.!~*'()&disallowed=%23&delimiters=%26%3D%2B&alpha_numeric_and_space=ABC+abc+123",
541            body: "",
542        }
543        "#);
544    }
545
546    #[test]
547    fn test_http_request_query_with_empty_values() {
548        #[derive(Serialize, Deserialize)]
549        struct QueryParams {
550            empty: String,
551            none: Option<String>,
552        }
553
554        let query = QueryParams {
555            empty: String::new(),
556            none: None,
557        };
558
559        let mut builder = HttpRequestBuilder {
560            method: Some("GET".to_string()),
561            url: Some("https://example.com".to_string()),
562            headers: Some(vec![]),
563            body: Some(vec![]),
564        };
565
566        builder
567            .query(&query)
568            .expect("should serialize query params with empty values");
569        let req = builder.build();
570
571        insta::assert_debug_snapshot!(req, @r#"
572        HttpRequest {
573            method: "GET",
574            url: "https://example.com?empty=&none",
575            body: "",
576        }
577        "#);
578    }
579
580    #[test]
581    fn test_http_request_query_with_url_with_existing_query_params() {
582        #[derive(Serialize, Deserialize)]
583        struct QueryParams {
584            name: String,
585            email: String,
586        }
587
588        let query = QueryParams {
589            name: "John Doe".to_string(),
590            email: "john@example.com".to_string(),
591        };
592
593        let mut builder = HttpRequestBuilder {
594            method: Some("GET".to_string()),
595            url: Some("https://example.com?foo=bar".to_string()),
596            headers: Some(vec![]),
597            body: Some(vec![]),
598        };
599
600        builder
601            .query(&query)
602            .expect("should serialize query params");
603        let req = builder.build();
604
605        insta::assert_debug_snapshot!(req, @r#"
606        HttpRequest {
607            method: "GET",
608            url: "https://example.com?foo=bar&name=John+Doe&email=john@example.com",
609            body: "",
610        }
611        "#);
612    }
613
614    #[test]
615    fn into_protocol_request_is_synchronous_and_carries_body() {
616        use crate::{Request, Url, protocol::ProtocolRequestBuilder};
617        use http::Method;
618
619        let mut req = Request::new(Method::POST, Url::parse("https://example.com").unwrap());
620        req.body_json(&serde_json::json!({"x": 1})).unwrap();
621
622        // into_protocol_request is now a plain (sync) fn — no .await needed.
623        let http_req = req.into_protocol_request().expect("must not fail");
624
625        assert_eq!(http_req.method, "POST");
626        assert_eq!(http_req.url, "https://example.com/");
627        assert!(!http_req.body.is_empty(), "body must be present");
628
629        // Content-Type header must be present in the serialised headers.
630        let has_content_type = http_req.headers.iter().any(|h| {
631            h.name.to_lowercase() == "content-type" && h.value.contains("application/json")
632        });
633        assert!(
634            has_content_type,
635            "Content-Type: application/json header expected"
636        );
637    }
638
639    /// Round-trip: `http::Request<Body>` → `crux_http::Request` → `HttpRequest`
640    #[test]
641    fn http_request_body_round_trip_to_protocol() {
642        use crate::{Body, Request, protocol::ProtocolRequestBuilder};
643
644        let http_req = http::Request::builder()
645            .method(http::Method::POST)
646            .uri("https://api.example.com/items")
647            .header("content-type", "application/json")
648            .body(Body::from_json(&serde_json::json!({"name": "widget"})).unwrap())
649            .unwrap();
650
651        let req: Request = http_req.into();
652        let protocol_req = req.into_protocol_request().expect("should convert");
653
654        assert_eq!(protocol_req.method, "POST");
655        assert_eq!(protocol_req.url, "https://api.example.com/items");
656        assert!(!protocol_req.body.is_empty(), "body bytes must be present");
657        assert!(
658            protocol_req.headers.iter().any(|h| {
659                h.name.to_lowercase() == "content-type" && h.value.contains("application/json")
660            }),
661            "Content-Type: application/json must be in headers"
662        );
663        // Deserialise the body back to confirm bytes are correct.
664        let parsed: serde_json::Value = serde_json::from_slice(&protocol_req.body).unwrap();
665        assert_eq!(parsed["name"], "widget");
666    }
667
668    #[test]
669    fn non_ascii_header_values_are_omitted_from_protocol_request() {
670        use crate::{Request, Url, protocol::ProtocolRequestBuilder};
671        use http::{HeaderValue, Method};
672
673        let mut req = Request::new(Method::GET, Url::parse("https://example.com").unwrap());
674
675        // ASCII value — must be forwarded.
676        req.insert_header("x-trace-id", HeaderValue::from_static("abc123"));
677
678        // Opaque bytes (>= 0x80): HeaderValue::from_bytes accepts them, but to_str() fails.
679        // This can arise when headers are set via HeaderValue::from_bytes, e.g. in middleware.
680        let opaque = HeaderValue::from_bytes(b"\x80binary\xff").unwrap();
681        req.insert_header("x-opaque", opaque);
682
683        let protocol_req = req.into_protocol_request().expect("must not fail");
684
685        let has_trace = protocol_req
686            .headers
687            .iter()
688            .any(|h| h.name == "x-trace-id" && h.value == "abc123");
689        let has_opaque = protocol_req.headers.iter().any(|h| h.name == "x-opaque");
690
691        assert!(has_trace, "ASCII header must be forwarded to the shell");
692        assert!(
693            !has_opaque,
694            "header with non-ASCII value must be silently omitted"
695        );
696    }
697}