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    /// # Panics
135    /// Panics if the serialization fails.
136    pub fn json(&mut self, body: impl serde::Serialize) -> &mut Self {
137        self.body = Some(serde_json::to_vec(&body).unwrap());
138        self
139    }
140
141    /// Builds the request.
142    ///
143    /// # Panics
144    /// Panics if any required fields are missing.
145    #[must_use]
146    pub fn build(&self) -> HttpRequest {
147        self.fallible_build()
148            .expect("All required fields were initialized")
149    }
150}
151
152#[derive(facet::Facet, Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Builder)]
153#[builder(
154    custom_constructor,
155    build_fn(private, name = "fallible_build"),
156    setter(into)
157)]
158pub struct HttpResponse {
159    pub status: u16, // FIXME this probably should be a giant enum instead.
160    #[builder(setter(custom))]
161    pub headers: Vec<HttpHeader>,
162    #[serde(with = "serde_bytes")]
163    #[facet(typegen::bytes)]
164    pub body: Vec<u8>,
165}
166
167impl HttpResponse {
168    #[must_use]
169    #[allow(clippy::missing_const_for_fn)]
170    pub fn status(status: u16) -> HttpResponseBuilder {
171        HttpResponseBuilder {
172            status: Some(status),
173            headers: Some(vec![]),
174            body: Some(vec![]),
175        }
176    }
177    #[must_use]
178    pub fn ok() -> HttpResponseBuilder {
179        Self::status(200)
180    }
181}
182
183impl HttpResponseBuilder {
184    pub fn header(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
185        self.headers.get_or_insert_with(Vec::new).push(HttpHeader {
186            name: name.into(),
187            value: value.into(),
188        });
189        self
190    }
191
192    /// Sets the body of the response to the given JSON.
193    ///
194    /// # Panics
195    /// If the JSON serialization fails.
196    pub fn json(&mut self, body: impl serde::Serialize) -> &mut Self {
197        self.body = Some(serde_json::to_vec(&body).unwrap());
198        self
199    }
200
201    /// Builds the response.
202    ///
203    /// # Panics
204    /// If a required field has not been initialized.
205    #[must_use]
206    pub fn build(&self) -> HttpResponse {
207        self.fallible_build()
208            .expect("All required fields were initialized")
209    }
210}
211
212/// The result of an HTTP request, as returned by the shell over the protocol boundary.
213///
214/// # Status codes are not errors
215///
216/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
217/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
218/// reach the server at all) produce [`HttpResult::Err`].
219///
220/// To act on an error status, inspect [`HttpResponse::status`]:
221///
222/// ```
223/// # use crux_http::protocol::{HttpResult, HttpResponse};
224/// # use crux_http::HttpError;
225/// # fn handle(result: HttpResult) {
226/// match result {
227///     HttpResult::Ok(response) if response.status == 200 => { /* success */ }
228///     HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
229///     HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
230///     HttpResult::Ok(_) => { /* other status */ }
231///     HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
232/// }
233/// # }
234/// ```
235#[derive(facet::Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
236#[repr(C)]
237pub enum HttpResult {
238    /// The shell completed the HTTP exchange. The response may carry any status code,
239    /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
240    Ok(HttpResponse),
241    /// The shell could not complete the HTTP exchange due to a transport-level failure.
242    /// See [`HttpError`] for the possible causes.
243    Err(HttpError),
244}
245
246impl From<Result<HttpResponse>> for HttpResult {
247    fn from(result: Result<HttpResponse>) -> Self {
248        match result {
249            Ok(response) => Self::Ok(response),
250            Err(err) => Self::Err(err),
251        }
252    }
253}
254
255impl crux_core::capability::Operation for HttpRequest {
256    type Output = HttpResult;
257
258    #[cfg(feature = "typegen")]
259    fn register_types(
260        generator: &mut crux_core::type_generation::serde::TypeGen,
261    ) -> crux_core::type_generation::serde::Result {
262        generator.register_type::<HttpError>()?;
263        generator.register_type::<Self>()?;
264        generator.register_type::<Self::Output>()?;
265        Ok(())
266    }
267}
268
269#[async_trait]
270pub(crate) trait EffectSender {
271    async fn send(&self, effect: HttpRequest) -> HttpResult;
272}
273
274pub(crate) trait ProtocolRequestBuilder {
275    fn into_protocol_request(self) -> Result<HttpRequest>;
276}
277
278impl ProtocolRequestBuilder for Request {
279    fn into_protocol_request(mut self) -> Result<HttpRequest> {
280        let body = self.take_body().into_bytes();
281
282        Ok(HttpRequest {
283            method: self.method().to_string(),
284            url: self.url().to_string(),
285            headers: self
286                .iter()
287                .filter_map(|(name, value)| {
288                    value.to_str().ok().map(|v| HttpHeader {
289                        name: name.to_string(),
290                        value: v.to_string(),
291                    })
292                })
293                .collect(),
294            body,
295        })
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use serde::{Deserialize, Serialize};
303
304    #[test]
305    fn test_http_request_get() {
306        let req = HttpRequest::get("https://example.com").build();
307
308        insta::assert_debug_snapshot!(req, @r#"
309        HttpRequest {
310            method: "GET",
311            url: "https://example.com",
312            body: "",
313        }
314        "#);
315    }
316
317    #[test]
318    fn test_http_request_get_with_fields() {
319        let req = HttpRequest::get("https://example.com")
320            .header("foo", "bar")
321            .body("123")
322            .build();
323
324        insta::assert_debug_snapshot!(req, @r#"
325        HttpRequest {
326            method: "GET",
327            url: "https://example.com",
328            headers: [
329                HttpHeader {
330                    name: "foo",
331                    value: "bar",
332                },
333            ],
334            body: "123",
335        }
336        "#);
337    }
338
339    #[test]
340    fn test_http_response_status() {
341        let req = HttpResponse::status(302).build();
342
343        insta::assert_debug_snapshot!(req, @"
344        HttpResponse {
345            status: 302,
346            headers: [],
347            body: [],
348        }
349        ");
350    }
351
352    #[test]
353    fn test_http_response_status_with_fields() {
354        let req = HttpResponse::status(302)
355            .header("foo", "bar")
356            .body("hey")
357            .build();
358
359        insta::assert_debug_snapshot!(req, @r#"
360        HttpResponse {
361            status: 302,
362            headers: [
363                HttpHeader {
364                    name: "foo",
365                    value: "bar",
366                },
367            ],
368            body: [
369                104,
370                101,
371                121,
372            ],
373        }
374        "#);
375    }
376
377    #[test]
378    fn test_http_request_debug_repr() {
379        {
380            // small
381            let req = HttpRequest::post("http://example.com")
382                .header("foo", "bar")
383                .body("hello world!")
384                .build();
385            let repr = format!("{req:?}");
386            assert_eq!(
387                repr,
388                r#"HttpRequest { method: "POST", url: "http://example.com", headers: [HttpHeader { name: "foo", value: "bar" }], body: "hello world!" }"#
389            );
390        }
391
392        {
393            // big
394            let req = HttpRequest::post("http://example.com")
395                // we check that we handle unicode boundaries correctly
396                .body("abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstu😀😀😀😀😀😀")
397                .build();
398            let repr = format!("{req:?}");
399            assert_eq!(
400                repr,
401                r#"HttpRequest { method: "POST", url: "http://example.com", body: "abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstu😀😀"... }"#
402            );
403        }
404
405        {
406            // binary
407            let req = HttpRequest::post("http://example.com")
408                .body(vec![255, 254, 253, 252])
409                .build();
410            let repr = format!("{req:?}");
411            assert_eq!(
412                repr,
413                r#"HttpRequest { method: "POST", url: "http://example.com", body: <binary data - 4 bytes> }"#
414            );
415        }
416    }
417
418    #[test]
419    fn test_http_request_query() {
420        #[derive(Serialize, Deserialize)]
421        struct QueryParams {
422            page: u32,
423            limit: u32,
424            search: String,
425        }
426
427        let query = QueryParams {
428            page: 2,
429            limit: 10,
430            search: "test".to_string(),
431        };
432
433        let mut builder = HttpRequestBuilder {
434            method: Some("GET".to_string()),
435            url: Some("https://example.com".to_string()),
436            headers: Some(vec![HttpHeader {
437                name: "foo".to_string(),
438                value: "bar".to_string(),
439            }]),
440            body: Some(vec![]),
441        };
442
443        builder
444            .query(&query)
445            .expect("should serialize query params");
446        let req = builder.build();
447
448        insta::assert_debug_snapshot!(req, @r#"
449        HttpRequest {
450            method: "GET",
451            url: "https://example.com?page=2&limit=10&search=test",
452            headers: [
453                HttpHeader {
454                    name: "foo",
455                    value: "bar",
456                },
457            ],
458            body: "",
459        }
460        "#);
461    }
462
463    #[test]
464    fn test_http_request_query_with_special_chars() {
465        #[derive(Serialize, Deserialize)]
466        struct QueryParams {
467            allowed: String,
468            disallowed: String,
469            delimiters: String,
470            alpha_numeric_and_space: String,
471        }
472
473        let query = QueryParams {
474            // allowed chars (RFC 3986)
475            allowed: ";/?:@$,-.!~*'()".to_string(),
476            // disallowed chars (RFC 3986)
477            disallowed: "#".to_string(),
478            // delimiters in key value pairs, need encoding
479            delimiters: "&=+".to_string(),
480            // not RFC 3986 Compliant (space should be %20 not +)
481            // but "+" is very common so we allow it
482            alpha_numeric_and_space: "ABC abc 123".to_string(),
483        };
484
485        let mut builder = HttpRequestBuilder {
486            method: Some("GET".to_string()),
487            url: Some("https://example.com".to_string()),
488            headers: Some(vec![]),
489            body: Some(vec![]),
490        };
491
492        builder
493            .query(&query)
494            .expect("should serialize query params with special chars");
495        let req = builder.build();
496
497        insta::assert_debug_snapshot!(req, @r#"
498        HttpRequest {
499            method: "GET",
500            url: "https://example.com?allowed=;/?:@$,-.!~*'()&disallowed=%23&delimiters=%26%3D%2B&alpha_numeric_and_space=ABC+abc+123",
501            body: "",
502        }
503        "#);
504    }
505
506    #[test]
507    fn test_http_request_query_with_empty_values() {
508        #[derive(Serialize, Deserialize)]
509        struct QueryParams {
510            empty: String,
511            none: Option<String>,
512        }
513
514        let query = QueryParams {
515            empty: String::new(),
516            none: None,
517        };
518
519        let mut builder = HttpRequestBuilder {
520            method: Some("GET".to_string()),
521            url: Some("https://example.com".to_string()),
522            headers: Some(vec![]),
523            body: Some(vec![]),
524        };
525
526        builder
527            .query(&query)
528            .expect("should serialize query params with empty values");
529        let req = builder.build();
530
531        insta::assert_debug_snapshot!(req, @r#"
532        HttpRequest {
533            method: "GET",
534            url: "https://example.com?empty=&none",
535            body: "",
536        }
537        "#);
538    }
539
540    #[test]
541    fn test_http_request_query_with_url_with_existing_query_params() {
542        #[derive(Serialize, Deserialize)]
543        struct QueryParams {
544            name: String,
545            email: String,
546        }
547
548        let query = QueryParams {
549            name: "John Doe".to_string(),
550            email: "john@example.com".to_string(),
551        };
552
553        let mut builder = HttpRequestBuilder {
554            method: Some("GET".to_string()),
555            url: Some("https://example.com?foo=bar".to_string()),
556            headers: Some(vec![]),
557            body: Some(vec![]),
558        };
559
560        builder
561            .query(&query)
562            .expect("should serialize query params");
563        let req = builder.build();
564
565        insta::assert_debug_snapshot!(req, @r#"
566        HttpRequest {
567            method: "GET",
568            url: "https://example.com?foo=bar&name=John+Doe&email=john@example.com",
569            body: "",
570        }
571        "#);
572    }
573
574    #[test]
575    fn into_protocol_request_is_synchronous_and_carries_body() {
576        use crate::{Request, Url, protocol::ProtocolRequestBuilder};
577        use http::Method;
578
579        let mut req = Request::new(Method::POST, Url::parse("https://example.com").unwrap());
580        req.body_json(&serde_json::json!({"x": 1})).unwrap();
581
582        // into_protocol_request is now a plain (sync) fn — no .await needed.
583        let http_req = req.into_protocol_request().expect("must not fail");
584
585        assert_eq!(http_req.method, "POST");
586        assert_eq!(http_req.url, "https://example.com/");
587        assert!(!http_req.body.is_empty(), "body must be present");
588
589        // Content-Type header must be present in the serialised headers.
590        let has_content_type = http_req.headers.iter().any(|h| {
591            h.name.to_lowercase() == "content-type" && h.value.contains("application/json")
592        });
593        assert!(
594            has_content_type,
595            "Content-Type: application/json header expected"
596        );
597    }
598
599    /// Round-trip: `http::Request<Body>` → `crux_http::Request` → `HttpRequest`
600    #[test]
601    fn http_request_body_round_trip_to_protocol() {
602        use crate::{Body, Request, protocol::ProtocolRequestBuilder};
603
604        let http_req = http::Request::builder()
605            .method(http::Method::POST)
606            .uri("https://api.example.com/items")
607            .header("content-type", "application/json")
608            .body(Body::from_json(&serde_json::json!({"name": "widget"})).unwrap())
609            .unwrap();
610
611        let req: Request = http_req.into();
612        let protocol_req = req.into_protocol_request().expect("should convert");
613
614        assert_eq!(protocol_req.method, "POST");
615        assert_eq!(protocol_req.url, "https://api.example.com/items");
616        assert!(!protocol_req.body.is_empty(), "body bytes must be present");
617        assert!(
618            protocol_req.headers.iter().any(|h| {
619                h.name.to_lowercase() == "content-type" && h.value.contains("application/json")
620            }),
621            "Content-Type: application/json must be in headers"
622        );
623        // Deserialise the body back to confirm bytes are correct.
624        let parsed: serde_json::Value = serde_json::from_slice(&protocol_req.body).unwrap();
625        assert_eq!(parsed["name"], "widget");
626    }
627
628    #[test]
629    fn non_ascii_header_values_are_omitted_from_protocol_request() {
630        use crate::{Request, Url, protocol::ProtocolRequestBuilder};
631        use http::{HeaderValue, Method};
632
633        let mut req = Request::new(Method::GET, Url::parse("https://example.com").unwrap());
634
635        // ASCII value — must be forwarded.
636        req.insert_header("x-trace-id", HeaderValue::from_static("abc123"));
637
638        // Opaque bytes (>= 0x80): HeaderValue::from_bytes accepts them, but to_str() fails.
639        // This can arise when headers are set via HeaderValue::from_bytes, e.g. in middleware.
640        let opaque = HeaderValue::from_bytes(b"\x80binary\xff").unwrap();
641        req.insert_header("x-opaque", opaque);
642
643        let protocol_req = req.into_protocol_request().expect("must not fail");
644
645        let has_trace = protocol_req
646            .headers
647            .iter()
648            .any(|h| h.name == "x-trace-id" && h.value == "abc123");
649        let has_opaque = protocol_req.headers.iter().any(|h| h.name == "x-opaque");
650
651        assert!(has_trace, "ASCII header must be forwarded to the shell");
652        assert!(
653            !has_opaque,
654            "header with non-ASCII value must be silently omitted"
655        );
656    }
657}