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 {
137 self.body = Some(serde_json::to_vec(&body).unwrap());
138 self
139 }
140
141 #[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, #[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 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 #[must_use]
206 pub fn build(&self) -> HttpResponse {
207 self.fallible_build()
208 .expect("All required fields were initialized")
209 }
210}
211
212#[derive(facet::Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
236#[repr(C)]
237pub enum HttpResult {
238 Ok(HttpResponse),
241 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 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 let req = HttpRequest::post("http://example.com")
395 .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 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: ";/?:@$,-.!~*'()".to_string(),
476 disallowed: "#".to_string(),
478 delimiters: "&=+".to_string(),
480 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 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 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 #[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 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 req.insert_header("x-trace-id", HeaderValue::from_static("abc123"));
637
638 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}