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: 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,
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,
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        let codec = codec_for(&self.format)
60            .ok_or_else(|| ClientError::Codec(format!("unsupported format: {}", self.format)))?;
61
62        let response = if GET_OPS.contains(&op) {
63            // GET request for side-effect-free ops
64            self.client
65                .get(&url)
66                .header(
67                    "Authorization",
68                    format!("BEARER authToken={}", self.auth_token),
69                )
70                .header("Accept", codec.mime_type())
71                .send()
72                .await
73                .map_err(|e| ClientError::Transport(e.to_string()))?
74        } else {
75            // POST request for all other ops
76            let body = codec
77                .encode_grid(req)
78                .map_err(|e| ClientError::Codec(e.to_string()))?;
79
80            self.client
81                .post(&url)
82                .header(
83                    "Authorization",
84                    format!("BEARER authToken={}", self.auth_token),
85                )
86                .header("Content-Type", codec.mime_type())
87                .header("Accept", codec.mime_type())
88                .body(body)
89                .send()
90                .await
91                .map_err(|e| ClientError::Transport(e.to_string()))?
92        };
93
94        let status = response.status();
95        let resp_body = response
96            .text()
97            .await
98            .map_err(|e| ClientError::Transport(e.to_string()))?;
99
100        if !status.is_success() {
101            return Err(ClientError::ServerError(format!(
102                "HTTP {} — {}",
103                status, resp_body
104            )));
105        }
106
107        // Decode the response grid
108        let grid = codec
109            .decode_grid(&resp_body)
110            .map_err(|e| ClientError::Codec(e.to_string()))?;
111
112        // Check for error grid (meta has "err" marker)
113        if grid.is_err() {
114            let dis = grid
115                .meta
116                .get("dis")
117                .and_then(|k| {
118                    if let Kind::Str(s) = k {
119                        Some(s.as_str())
120                    } else {
121                        None
122                    }
123                })
124                .unwrap_or("unknown server error");
125            return Err(ClientError::ServerError(dis.to_string()));
126        }
127
128        Ok(grid)
129    }
130
131    async fn close(&self) -> Result<(), ClientError> {
132        // HTTP is stateless; nothing to close.
133        Ok(())
134    }
135}