Skip to main content

edgestore_repl/
http_server.rs

1//! HTTP replication server exposing 3 pull-only endpoints.
2//!
3//! Endpoints:
4//!   GET /merkle              → MessagePack `{root: Vec<u8>}`
5//!   GET /segments            → MessagePack `[{segment_id, segment_hash}]`
6//!   GET /segments/{hash_hex} → raw segment bytes (application/octet-stream)
7//!
8//! All endpoints support `?debug=json` to re-serialize as JSON for human inspection (D07).
9//!
10//! Engine access is serialized via `Arc<Mutex<Engine>>` — correct for single-writer (D08).
11
12use std::sync::{Arc, Mutex};
13
14use serde::{Deserialize, Serialize};
15
16use edgestore::EdgestoreError;
17use edgestore::Engine;
18
19/// MessagePack wire struct for GET /merkle response.
20#[derive(Serialize, Deserialize)]
21struct MerkleResponse {
22    root: Vec<u8>,
23}
24
25/// MessagePack wire struct for one item in GET /segments response.
26#[derive(Serialize, Deserialize)]
27struct SegmentEntry {
28    segment_id: u64,
29    segment_hash: Vec<u8>,
30}
31
32/// HTTP replication server wrapping an `Arc<Mutex<Engine>>`.
33///
34/// Call `start(bind_addr)` to spawn the server loop in a background thread.
35pub struct HttpReplicationServer {
36    engine: Arc<Mutex<Engine>>,
37}
38
39impl HttpReplicationServer {
40    /// Create a new server wrapping the provided engine.
41    pub fn new(engine: Arc<Mutex<Engine>>) -> Self {
42        HttpReplicationServer { engine }
43    }
44
45    /// Bind to `bind_addr` and start the request loop in a background thread.
46    ///
47    /// Returns `(JoinHandle, bound_port)`. The bound port is useful when `bind_addr`
48    /// uses port 0 (OS-assigned ephemeral port). The server runs until the process exits.
49    pub fn start(
50        &self,
51        bind_addr: &str,
52    ) -> Result<(std::thread::JoinHandle<()>, u16), EdgestoreError> {
53        let server = tiny_http::Server::http(bind_addr)
54            .map_err(|e| EdgestoreError::ReplicationError(format!("bind error: {}", e)))?;
55
56        // Extract the bound port before moving `server` into the thread.
57        let bound_port = match server.server_addr() {
58            tiny_http::ListenAddr::IP(addr) => addr.port(),
59            #[cfg(unix)]
60            tiny_http::ListenAddr::Unix(_) => {
61                return Err(EdgestoreError::ReplicationError(
62                    "Unix socket bind not supported for replication server".into(),
63                ))
64            }
65        };
66
67        let engine = Arc::clone(&self.engine);
68
69        let handle = std::thread::spawn(move || {
70            for request in server.incoming_requests() {
71                handle_request(request, &engine);
72            }
73        });
74
75        Ok((handle, bound_port))
76    }
77}
78
79/// Parse path and query string from a request URL.
80///
81/// Returns `(path_parts, debug_json)` where `path_parts` is the non-empty path
82/// components and `debug_json` is `true` when `?debug=json` is present.
83fn parse_url(url: &str) -> (Vec<&str>, bool) {
84    let (path_part, query_part) = match url.split_once('?') {
85        Some((p, q)) => (p, q),
86        None => (url, ""),
87    };
88
89    let debug_json = query_part.split('&').any(|kv| kv == "debug=json");
90
91    let parts: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect();
92
93    (parts, debug_json)
94}
95
96/// Respond with a MessagePack-serialized value, or JSON if `debug_json` is set.
97fn respond_msgpack<T: Serialize>(request: tiny_http::Request, value: &T, debug_json: bool) {
98    if debug_json {
99        match serde_json::to_vec(value) {
100            Ok(json_bytes) => {
101                let response = tiny_http::Response::from_data(json_bytes).with_header(
102                    tiny_http::Header::from_bytes("Content-Type", "application/json").unwrap(),
103                );
104                let _ = request.respond(response);
105            }
106            Err(e) => {
107                respond_error(request, 500, &format!("json serialization error: {}", e));
108            }
109        }
110    } else {
111        match rmp_serde::to_vec_named(value) {
112            Ok(msgpack_bytes) => {
113                let response = tiny_http::Response::from_data(msgpack_bytes).with_header(
114                    tiny_http::Header::from_bytes("Content-Type", "application/msgpack").unwrap(),
115                );
116                let _ = request.respond(response);
117            }
118            Err(e) => {
119                respond_error(request, 500, &format!("msgpack serialization error: {}", e));
120            }
121        }
122    }
123}
124
125/// Respond with raw bytes and `application/octet-stream`.
126fn respond_raw(request: tiny_http::Request, data: Vec<u8>) {
127    let response = tiny_http::Response::from_data(data).with_header(
128        tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap(),
129    );
130    let _ = request.respond(response);
131}
132
133/// Respond with an error status and plain-text body.
134fn respond_error(request: tiny_http::Request, status: u16, msg: &str) {
135    let response = tiny_http::Response::from_string(msg.to_string())
136        .with_status_code(tiny_http::StatusCode(status));
137    let _ = request.respond(response);
138}
139
140/// Dispatch a single HTTP request.
141fn handle_request(mut request: tiny_http::Request, engine: &Arc<Mutex<Engine>>) {
142    // Drain the request body to avoid blocking the client.
143    let mut _body = Vec::new();
144    let _ = request.as_reader().read_to_end(&mut _body);
145
146    let method = request.method().clone();
147    let url = request.url().to_string();
148
149    let (parts, debug_json) = parse_url(&url);
150
151    match (method, parts.as_slice()) {
152        (tiny_http::Method::Get, ["merkle"]) => {
153            // GET /merkle — return Merkle root as MessagePack.
154            match engine.lock() {
155                Ok(eng) => match eng.range_merkle_root() {
156                    Ok(root) => {
157                        let resp = MerkleResponse {
158                            root: root.to_vec(),
159                        };
160                        respond_msgpack(request, &resp, debug_json);
161                    }
162                    Err(e) => {
163                        respond_error(request, 500, &format!("merkle root error: {}", e));
164                    }
165                },
166                Err(_) => {
167                    respond_error(request, 500, "engine lock poisoned");
168                }
169            }
170        }
171
172        (tiny_http::Method::Get, ["segments"]) => {
173            // GET /segments — return full manifest as MessagePack.
174            match engine.lock() {
175                Ok(eng) => match eng.export_manifest() {
176                    Ok(refs) => {
177                        let entries: Vec<SegmentEntry> = refs
178                            .into_iter()
179                            .map(|sr| SegmentEntry {
180                                segment_id: sr.segment_id,
181                                segment_hash: sr.segment_hash.to_vec(),
182                            })
183                            .collect();
184                        respond_msgpack(request, &entries, debug_json);
185                    }
186                    Err(e) => {
187                        respond_error(request, 500, &format!("export manifest error: {}", e));
188                    }
189                },
190                Err(_) => {
191                    respond_error(request, 500, "engine lock poisoned");
192                }
193            }
194        }
195
196        (tiny_http::Method::Get, ["segments", hash_hex]) => {
197            // GET /segments/{hash_hex} — return raw segment bytes.
198            // Parse 64-char hex string into 32 bytes.
199            let hash_hex = *hash_hex;
200            if hash_hex.len() != 64 {
201                respond_error(request, 400, "hash_hex must be 64 hex characters");
202                return;
203            }
204
205            // Decode hex.
206            let mut hash_bytes = [0u8; 32];
207            let mut valid = true;
208            for i in 0..32 {
209                match u8::from_str_radix(&hash_hex[i * 2..i * 2 + 2], 16) {
210                    Ok(b) => hash_bytes[i] = b,
211                    Err(_) => {
212                        valid = false;
213                        break;
214                    }
215                }
216            }
217            if !valid {
218                respond_error(request, 400, "invalid hex encoding in hash");
219                return;
220            }
221
222            // Determine the segment file path from the engine's db path.
223            // The canonical segment filename is segment-{id:08}.dat, but we need to find it
224            // by content hash. Look up via export_manifest to find the segment_id, then
225            // read the canonical .dat file. If not found, fall back to {hash_hex}.dat.
226            let dat_bytes = {
227                let eng_guard = match engine.lock() {
228                    Ok(g) => g,
229                    Err(_) => {
230                        respond_error(request, 500, "engine lock poisoned");
231                        return;
232                    }
233                };
234
235                // Find the segment_id for this hash.
236                let manifest = match eng_guard.export_manifest() {
237                    Ok(m) => m,
238                    Err(e) => {
239                        respond_error(request, 500, &format!("manifest error: {}", e));
240                        return;
241                    }
242                };
243
244                let segment_id = manifest
245                    .iter()
246                    .find(|sr| sr.segment_hash == hash_bytes)
247                    .map(|sr| sr.segment_id);
248
249                let base_path = eng_guard.db_path().to_path_buf();
250
251                drop(eng_guard); // release lock before file I/O
252
253                match segment_id {
254                    Some(sid) => {
255                        // Read the canonical segment-{id:08}.dat file.
256                        let dat_path = base_path.join(format!("segment-{:08}.dat", sid));
257                        match std::fs::read(&dat_path) {
258                            Ok(bytes) => bytes,
259                            Err(_) => {
260                                // Fall back to hash-named file.
261                                let fallback = base_path.join(format!("{}.dat", hash_hex));
262                                match std::fs::read(&fallback) {
263                                    Ok(b) => b,
264                                    Err(e) => {
265                                        respond_error(
266                                            request,
267                                            404,
268                                            &format!("segment not found: {}", e),
269                                        );
270                                        return;
271                                    }
272                                }
273                            }
274                        }
275                    }
276                    None => {
277                        // Hash not in manifest; try direct hash-named file.
278                        let dat_path = base_path.join(format!("{}.dat", hash_hex));
279                        match std::fs::read(&dat_path) {
280                            Ok(b) => b,
281                            Err(_) => {
282                                respond_error(request, 404, "segment not found");
283                                return;
284                            }
285                        }
286                    }
287                }
288            };
289
290            respond_raw(request, dat_bytes);
291        }
292
293        _ => {
294            respond_error(request, 404, "not found");
295        }
296    }
297}