Skip to main content

sim_table_http/
http_dir.rs

1//! The [`HttpDir`] object: a capability-gated HTTP-backed table directory.
2
3use std::{sync::Arc, time::Duration};
4
5use sim_citizen::CitizenField;
6use sim_codec::{Input, Output, decode_with_codec, encode_with_codec};
7use sim_kernel::{
8    Cx, EncodeOptions, Error, Expr, Object, ObjectEncode, ObjectEncoding, ReadPolicy, Result,
9    Symbol, Value,
10    id::CORE_TABLE_CLASS_ID,
11    object::ClassRef,
12    table::{Dir, Table},
13};
14
15use crate::{
16    capabilities::require_table_http,
17    citizen::http_dir_class_symbol,
18    options::{HttpDirOptions, normalize_options, validate_options},
19    transport::{HttpRequest, send},
20};
21
22/// A table directory backed by direct HTTP resources.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct HttpDir {
25    options: HttpDirOptions,
26}
27
28impl HttpDir {
29    /// Builds an HTTP directory from `options`.
30    pub fn new(options: HttpDirOptions) -> Result<Self> {
31        validate_options(&options)?;
32        Ok(Self {
33            options: normalize_options(options),
34        })
35    }
36
37    /// Returns this directory's options.
38    pub fn options(&self) -> &HttpDirOptions {
39        &self.options
40    }
41
42    fn request(&self, method: &'static str, url: String, body: Vec<u8>) -> HttpRequest {
43        HttpRequest {
44            method,
45            url,
46            headers: Vec::new(),
47            body,
48            timeout: Duration::from_millis(self.options.timeout_ms),
49            max_body_bytes: self.options.max_body_bytes,
50        }
51    }
52
53    fn url_for_key(&self, key: &Symbol) -> Result<String> {
54        let segment = key.name.as_ref();
55        if !sim_table_core::is_legal_table_segment(segment) {
56            return Err(Error::Eval(format!("table/http: illegal name {segment:?}")));
57        }
58        let encoded = encode_path_segment(segment);
59        Ok(format!("{}/{}", self.options.base_url, encoded))
60    }
61
62    fn decode_body(&self, cx: &mut Cx, body: Vec<u8>) -> Result<Value> {
63        let expr = decode_with_codec(
64            cx,
65            &self.options.codec,
66            Input::Bytes(body),
67            ReadPolicy::default(),
68        )?;
69        cx.factory().expr(expr)
70    }
71
72    fn encode_value(&self, cx: &mut Cx, value: Value) -> Result<Vec<u8>> {
73        let expr = value.object().as_expr(cx)?;
74        match encode_with_codec(cx, &self.options.codec, &expr, EncodeOptions::default())? {
75            Output::Text(text) => Ok(text.into_bytes()),
76            Output::Bytes(bytes) => Ok(bytes),
77        }
78    }
79}
80
81impl Object for HttpDir {
82    fn display(&self, _cx: &mut Cx) -> Result<String> {
83        Ok(format!("table/http[{}]", self.options.base_url))
84    }
85
86    fn as_any(&self) -> &dyn std::any::Any {
87        self
88    }
89}
90
91impl sim_kernel::ObjectCompat for HttpDir {
92    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
93        let symbol = http_dir_class_symbol();
94        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
95            return Ok(value.clone());
96        }
97        let symbol = Symbol::qualified("core", "Table");
98        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
99            return Ok(value.clone());
100        }
101        cx.factory().class_stub(CORE_TABLE_CLASS_ID, symbol)
102    }
103
104    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
105        self.as_table_expr(cx)
106    }
107
108    fn truth(&self, _cx: &mut Cx) -> Result<bool> {
109        Ok(true)
110    }
111
112    fn as_table_impl(&self) -> Option<&dyn Table> {
113        Some(self)
114    }
115
116    fn as_dir(&self) -> Option<&dyn Dir> {
117        Some(self)
118    }
119
120    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
121        Some(self)
122    }
123}
124
125impl ObjectEncode for HttpDir {
126    fn object_encoding(&self, _cx: &mut Cx) -> Result<ObjectEncoding> {
127        Ok(ObjectEncoding::Constructor {
128            class: http_dir_class_symbol(),
129            args: vec![
130                Expr::Symbol(Symbol::new("v0")),
131                self.options.base_url.encode_field(),
132                self.options.codec.encode_field(),
133                self.options.write_method.as_str().to_owned().encode_field(),
134                self.options.timeout_ms.encode_field(),
135                self.options.max_body_bytes.encode_field(),
136            ],
137        })
138    }
139}
140
141impl sim_citizen::Citizen for HttpDir {
142    fn citizen_symbol() -> Symbol {
143        http_dir_class_symbol()
144    }
145
146    fn citizen_version() -> u32 {
147        0
148    }
149
150    fn citizen_arity() -> usize {
151        5
152    }
153
154    fn citizen_fields() -> &'static [&'static str] {
155        &[
156            "base_url",
157            "codec",
158            "write_method",
159            "timeout_ms",
160            "max_body_bytes",
161        ]
162    }
163}
164
165impl Table for HttpDir {
166    fn backend_symbol(&self) -> Symbol {
167        Symbol::qualified("table", "http")
168    }
169
170    fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
171        require_table_http(cx)?;
172        let response = send(self.request("GET", self.url_for_key(&key)?, Vec::new()))?;
173        ensure_success(response.status, response.reason.as_deref(), &response.body)?;
174        self.decode_body(cx, response.body)
175    }
176
177    fn set(&self, cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
178        require_table_http(cx)?;
179        let body = self.encode_value(cx, value)?;
180        let response = send(self.request(
181            self.options.write_method.as_str(),
182            self.url_for_key(&key)?,
183            body,
184        ))?;
185        ensure_success(response.status, response.reason.as_deref(), &response.body)?;
186        Ok(())
187    }
188
189    fn has(&self, cx: &mut Cx, key: Symbol) -> Result<bool> {
190        require_table_http(cx)?;
191        let response = send(self.request("HEAD", self.url_for_key(&key)?, Vec::new()))?;
192        match response.status {
193            status if (200..300).contains(&status) => Ok(true),
194            404 => Ok(false),
195            status => Err(status_error(
196                status,
197                response.reason.as_deref(),
198                &response.body,
199            )),
200        }
201    }
202
203    fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
204        require_table_http(cx)?;
205        let response = send(self.request("DELETE", self.url_for_key(&key)?, Vec::new()))?;
206        match response.status {
207            status if (200..300).contains(&status) || status == 404 => cx.factory().nil(),
208            status => Err(status_error(
209                status,
210                response.reason.as_deref(),
211                &response.body,
212            )),
213        }
214    }
215
216    fn keys(&self, _cx: &mut Cx) -> Result<Vec<Symbol>> {
217        Err(Error::Eval(
218            "table/http: keys are not available without an index resource".to_owned(),
219        ))
220    }
221
222    fn entries(&self, _cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
223        Err(Error::Eval(
224            "table/http: entries are not available without an index resource".to_owned(),
225        ))
226    }
227
228    fn len(&self, _cx: &mut Cx) -> Result<usize> {
229        Err(Error::Eval(
230            "table/http: len is not available without an index resource".to_owned(),
231        ))
232    }
233
234    fn clear(&self, _cx: &mut Cx) -> Result<()> {
235        Err(Error::Eval(
236            "table/http: clear is not available without an index resource".to_owned(),
237        ))
238    }
239}
240
241impl Dir for HttpDir {
242    fn mkdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
243        require_table_http(cx)?;
244        let _ = self.url_for_key(&name)?;
245        Err(index_resource_error("mkdir"))
246    }
247
248    fn opendir(&self, cx: &mut Cx, name: Symbol) -> Result<Option<Value>> {
249        require_table_http(cx)?;
250        let _ = self.url_for_key(&name)?;
251        Err(index_resource_error("opendir"))
252    }
253
254    fn rmdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
255        require_table_http(cx)?;
256        let _ = self.url_for_key(&name)?;
257        Err(index_resource_error("rmdir"))
258    }
259
260    fn is_dir(&self, cx: &mut Cx, name: Symbol) -> Result<bool> {
261        require_table_http(cx)?;
262        let _ = self.url_for_key(&name)?;
263        Err(index_resource_error("is_dir"))
264    }
265}
266
267/// Creates an HTTP directory value from `options`.
268pub fn install_http_dir_lib(cx: &mut Cx, options: HttpDirOptions) -> Result<Value> {
269    cx.factory().opaque(Arc::new(HttpDir::new(options)?))
270}
271
272fn encode_path_segment(segment: &str) -> String {
273    const HEX: &[u8; 16] = b"0123456789ABCDEF";
274
275    let mut encoded = String::with_capacity(segment.len());
276    for &byte in segment.as_bytes() {
277        if is_unreserved_path_byte(byte) {
278            encoded.push(byte as char);
279        } else {
280            encoded.push('%');
281            encoded.push(HEX[(byte >> 4) as usize] as char);
282            encoded.push(HEX[(byte & 0x0f) as usize] as char);
283        }
284    }
285    encoded
286}
287
288fn is_unreserved_path_byte(byte: u8) -> bool {
289    matches!(
290        byte,
291        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
292    )
293}
294
295fn index_resource_error(operation: &str) -> Error {
296    Error::Eval(format!(
297        "table/http: {operation} requires an index resource"
298    ))
299}
300
301fn ensure_success(status: u16, reason: Option<&str>, body: &[u8]) -> Result<()> {
302    if (200..300).contains(&status) {
303        Ok(())
304    } else {
305        Err(status_error(status, reason, body))
306    }
307}
308
309fn status_error(status: u16, reason: Option<&str>, body: &[u8]) -> Error {
310    let reason = reason.unwrap_or_default();
311    let body = String::from_utf8_lossy(body);
312    let detail = if body.is_empty() {
313        reason.to_owned()
314    } else if reason.is_empty() {
315        body.into_owned()
316    } else {
317        format!("{reason}: {body}")
318    };
319    Error::HostError(format!("table/http: http {status}: {detail}"))
320}