1use facet::Facet;
8use serde::{Deserialize, Serialize};
9
10use crate::{Cx, PluginResponse};
11
12#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
18#[repr(C)]
19pub struct HttpHeader {
20 pub name: String,
21 pub value: String,
22}
23
24#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
26#[repr(C)]
27pub enum HttpOutcome {
28 Response { status: u16, headers: Vec<HttpHeader>, body: Vec<u8> },
31 TransportError { message: String },
36}
37
38impl HttpOutcome {
39 pub fn status(&self) -> Option<u16> {
41 match self {
42 Self::Response { status, .. } => Some(*status),
43 Self::TransportError { .. } => None,
44 }
45 }
46
47 pub fn is_success(&self) -> bool {
49 matches!(self, Self::Response { status, .. } if (200..300).contains(status))
50 }
51
52 pub fn body(&self) -> &[u8] {
54 match self {
55 Self::Response { body, .. } => body,
56 Self::TransportError { .. } => &[],
57 }
58 }
59
60 pub fn text(&self) -> Option<&str> {
64 std::str::from_utf8(self.body()).ok().filter(|_| matches!(self, Self::Response { .. }))
65 }
66
67 pub fn header(&self, name: &str) -> Option<&str> {
69 match self {
70 Self::Response { headers, .. } => headers
71 .iter()
72 .find(|h| h.name.eq_ignore_ascii_case(name))
73 .map(|h| h.value.as_str()),
74 Self::TransportError { .. } => None,
75 }
76 }
77
78 pub fn encode(&self) -> Vec<u8> {
86 use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
87 let mut buffer = Vec::new();
88 BincodeFfiFormat::serialize(&mut buffer, self).expect("encode HttpOutcome");
89 buffer
90 }
91
92 pub fn decode(bytes: &[u8]) -> Result<Self, String> {
94 use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
95 BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
96 }
97}
98
99#[derive(Serialize)]
102struct HttpReq {
103 url: String,
104 headers: Vec<HttpHeader>,
105 body: Option<String>,
106}
107
108#[must_use = "a RequestBuilder does nothing until you call .send()"]
115pub struct RequestBuilder<'a, E> {
116 cx: &'a mut Cx<E>,
117 method: String,
118 url: String,
119 headers: Vec<HttpHeader>,
120 body: Option<String>,
121}
122
123impl<'a, E> RequestBuilder<'a, E> {
124 pub(crate) fn new(cx: &'a mut Cx<E>, method: String, url: String) -> Self {
125 Self { cx, method, url, headers: Vec::new(), body: None }
126 }
127
128 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
134 self.headers.push(HttpHeader { name: name.into(), value: value.into() });
135 self
136 }
137
138 pub fn bearer(self, token: impl AsRef<str>) -> Self {
140 self.header("Authorization", format!("Bearer {}", token.as_ref()))
141 }
142
143 pub fn body(mut self, body: impl Into<String>) -> Self {
145 self.body = Some(body.into());
146 self
147 }
148
149 pub fn send(self, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
152 let input = serde_json::to_string(&HttpReq {
153 url: self.url,
154 headers: self.headers,
155 body: self.body,
156 })
157 .expect("serialize http request");
158
159 self.cx.plugin("http", self.method, input, move |r: PluginResponse| {
160 then(decode_outcome(&r))
161 });
162 }
163}
164
165fn decode_outcome(r: &PluginResponse) -> HttpOutcome {
175 HttpOutcome::decode(&r.output).unwrap_or_else(|e| {
176 let message = r
180 .as_text()
181 .filter(|t| !t.is_empty())
182 .map(str::to_string)
183 .unwrap_or_else(|| format!("malformed http response: {e}"));
184 HttpOutcome::TransportError { message }
185 })
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn resp(status: u16, body: &str) -> HttpOutcome {
193 HttpOutcome::Response {
194 status,
195 headers: vec![HttpHeader { name: "Content-Type".into(), value: "application/json".into() }],
196 body: body.as_bytes().to_vec(),
197 }
198 }
199
200 #[test]
201 fn status_is_some_for_response_and_none_for_transport_error() {
202 assert_eq!(resp(200, "").status(), Some(200));
203 assert_eq!(HttpOutcome::TransportError { message: "offline".into() }.status(), None);
204 }
205
206 #[test]
207 fn is_success_covers_exactly_2xx() {
208 assert!(!resp(199, "").is_success());
209 assert!(resp(200, "").is_success());
210 assert!(resp(299, "").is_success());
211 assert!(!resp(300, "").is_success());
212 assert!(!resp(409, "").is_success());
213 assert!(!HttpOutcome::TransportError { message: "x".into() }.is_success());
214 }
215
216 #[test]
217 fn body_and_text_behave_on_valid_and_invalid_utf8() {
218 assert_eq!(resp(200, "hi").body(), b"hi");
219 assert_eq!(resp(200, "hi").text(), Some("hi"));
220
221 let binary = HttpOutcome::Response { status: 200, headers: vec![], body: vec![0xff, 0xfe] };
222 assert_eq!(binary.body(), &[0xff, 0xfe]);
223 assert_eq!(binary.text(), None, "invalid UTF-8 must not panic or lossily convert");
224
225 let err = HttpOutcome::TransportError { message: "x".into() };
226 assert_eq!(err.body(), b"");
227 assert_eq!(err.text(), None);
228 }
229
230 #[test]
231 fn header_lookup_is_case_insensitive() {
232 assert_eq!(resp(200, "").header("content-type"), Some("application/json"));
233 assert_eq!(resp(200, "").header("CONTENT-TYPE"), Some("application/json"));
234 assert_eq!(resp(200, "").header("missing"), None);
235 }
236
237 #[test]
238 fn bincode_round_trips_both_variants() {
239 for original in [
240 resp(409, "conflict"),
241 HttpOutcome::TransportError { message: "connection refused".into() },
242 ] {
243 let bytes = original.encode();
244 assert_eq!(HttpOutcome::decode(&bytes).unwrap(), original);
245 }
246 }
247
248 #[test]
249 fn decode_rejects_garbage_without_panicking() {
250 assert!(HttpOutcome::decode(&[0xff, 0xff, 0xff]).is_err());
251 }
252
253 #[test]
254 fn decode_outcome_falls_back_to_plain_text_for_undecodable_utf8_payloads() {
255 let r = PluginResponse::text(false, "plugin 'http' not available");
259 match decode_outcome(&r) {
260 HttpOutcome::TransportError { message } => {
261 assert_eq!(message, "plugin 'http' not available");
262 }
263 other => panic!("expected TransportError, got {other:?}"),
264 }
265
266 let r = PluginResponse { ok: false, output: vec![0xff, 0xfe, 0xfd] };
269 match decode_outcome(&r) {
270 HttpOutcome::TransportError { message } => {
271 assert!(message.starts_with("malformed http response:"), "got: {message}");
272 }
273 other => panic!("expected TransportError, got {other:?}"),
274 }
275 }
276
277 #[test]
278 fn decode_outcome_keeps_the_bincode_error_for_an_empty_payload() {
279 let r = PluginResponse { ok: false, output: Vec::new() };
283 match decode_outcome(&r) {
284 HttpOutcome::TransportError { message } => {
285 assert!(!message.is_empty(), "expected a diagnostic message, got empty string");
286 assert!(message.starts_with("malformed http response:"), "got: {message}");
287 }
288 other => panic!("expected TransportError, got {other:?}"),
289 }
290 }
291}