Skip to main content

haystack_client/transport/
http.rs

1use std::time::Duration;
2
3use reqwest::Client;
4
5use crate::error::ClientError;
6use crate::transport::Transport;
7use haystack_core::codecs::codec_for;
8use haystack_core::data::HGrid;
9use haystack_core::kinds::Kind;
10
11/// Operations that use GET (noSideEffects).
12const GET_OPS: &[&str] = &["about", "ops", "formats"];
13
14/// HTTP transport for communicating with a Haystack server.
15///
16/// Sends requests as encoded grids over HTTP using the configured wire format
17/// (default: `text/zinc`). GET is used for side-effect-free ops; POST for all others.
18pub struct HttpTransport {
19    client: Client,
20    base_url: String,
21    auth_token: zeroize::Zeroizing<String>,
22    format: String,
23}
24
25impl HttpTransport {
26    /// Create a new HTTP transport.
27    ///
28    /// `base_url` should be the server API root (e.g. `http://localhost:8080/api`).
29    /// `auth_token` is the bearer token obtained from SCRAM authentication.
30    pub fn new(base_url: &str, auth_token: String) -> Self {
31        Self {
32            client: Client::builder()
33                .timeout(Duration::from_secs(30))
34                .build()
35                .unwrap_or_else(|_| Client::new()),
36            base_url: base_url.trim_end_matches('/').to_string(),
37            auth_token: zeroize::Zeroizing::new(auth_token),
38            format: "text/zinc".to_string(),
39        }
40    }
41
42    /// Create a new HTTP transport with a specific wire format.
43    pub fn with_format(base_url: &str, auth_token: String, format: &str) -> Self {
44        Self {
45            client: Client::builder()
46                .timeout(Duration::from_secs(30))
47                .build()
48                .unwrap_or_else(|_| Client::new()),
49            base_url: base_url.trim_end_matches('/').to_string(),
50            auth_token: zeroize::Zeroizing::new(auth_token),
51            format: format.to_string(),
52        }
53    }
54}
55
56impl Transport for HttpTransport {
57    async fn call(&self, op: &str, req: &HGrid) -> Result<HGrid, ClientError> {
58        let url = format!("{}/{}", self.base_url, op);
59
60        let response = if GET_OPS.contains(&op) {
61            // GET request for side-effect-free ops
62            self.client
63                .get(&url)
64                .header(
65                    "Authorization",
66                    format!("BEARER authToken={}", &*self.auth_token),
67                )
68                .header("Accept", &self.format)
69                .send()
70                .await
71                .map_err(|e| ClientError::Transport(e.to_string()))?
72        } else {
73            let codec = codec_for(&self.format).ok_or_else(|| {
74                ClientError::Codec(format!("unsupported format: {}", self.format))
75            })?;
76            let text = codec
77                .encode_grid(req)
78                .map_err(|e| ClientError::Codec(e.to_string()))?;
79            let body_bytes = text.into_bytes();
80            let content_type = codec.mime_type();
81
82            self.client
83                .post(&url)
84                .header(
85                    "Authorization",
86                    format!("BEARER authToken={}", &*self.auth_token),
87                )
88                .header("Content-Type", content_type)
89                .header("Accept", &self.format)
90                .body(body_bytes)
91                .send()
92                .await
93                .map_err(|e| ClientError::Transport(e.to_string()))?
94        };
95
96        let status = response.status();
97
98        let resp_body = response
99            .text()
100            .await
101            .map_err(|e| ClientError::Transport(e.to_string()))?;
102        if !status.is_success() {
103            return Err(ClientError::ServerError(format!(
104                "HTTP {} — {}",
105                status, resp_body
106            )));
107        }
108        let codec = codec_for(&self.format)
109            .ok_or_else(|| ClientError::Codec(format!("unsupported format: {}", self.format)))?;
110        let grid = codec
111            .decode_grid(&resp_body)
112            .map_err(|e| ClientError::Codec(e.to_string()))?;
113
114        // Check for error grid (meta has "err" marker)
115        if grid.is_err() {
116            let dis = grid
117                .meta
118                .get("dis")
119                .and_then(|k| {
120                    if let Kind::Str(s) = k {
121                        Some(s.as_str())
122                    } else {
123                        None
124                    }
125                })
126                .unwrap_or("unknown server error");
127            return Err(ClientError::ServerError(dis.to_string()));
128        }
129
130        Ok(grid)
131    }
132
133    async fn close(&self) -> Result<(), ClientError> {
134        // HTTP is stateless; nothing to close.
135        Ok(())
136    }
137}