1use 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#[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 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 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 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 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 #[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, #[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 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 #[must_use]
246 pub fn build(&self) -> HttpResponse {
247 self.fallible_build()
248 .expect("All required fields were initialized")
249 }
250}
251
252#[derive(facet::Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
276#[repr(C)]
277pub enum HttpResult {
278 Ok(HttpResponse),
281 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 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 let req = HttpRequest::post("http://example.com")
435 .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 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: ";/?:@$,-.!~*'()".to_string(),
516 disallowed: "#".to_string(),
518 delimiters: "&=+".to_string(),
520 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 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 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 #[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 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 req.insert_header("x-trace-id", HeaderValue::from_static("abc123"));
677
678 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}