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    transport::{HttpRequest, send},
19};
20
21/// The HTTP method used for table `set`.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum HttpWriteMethod {
24    /// Write with `PUT`.
25    #[default]
26    Put,
27    /// Write with `POST`.
28    Post,
29}
30
31impl HttpWriteMethod {
32    /// Returns the wire method token.
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Put => "PUT",
36            Self::Post => "POST",
37        }
38    }
39
40    fn from_str(value: &str) -> Result<Self> {
41        match value {
42            "PUT" => Ok(Self::Put),
43            "POST" => Ok(Self::Post),
44            other => Err(Error::Eval(format!(
45                "table/http: unsupported write method {other}"
46            ))),
47        }
48    }
49}
50
51/// Configuration for an [`HttpDir`].
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct HttpDirOptions {
54    /// Base URL whose children are addressed by table keys.
55    pub base_url: String,
56    /// Codec used to decode response bodies and encode request bodies.
57    pub codec: Symbol,
58    /// Write method used by [`Table::set`].
59    pub write_method: HttpWriteMethod,
60    /// Socket read/write timeout in milliseconds.
61    pub timeout_ms: u64,
62    /// Maximum response body size in bytes.
63    pub max_body_bytes: usize,
64}
65
66impl HttpDirOptions {
67    /// Builds options for `base_url` with the Lisp codec, `PUT`, a five-second
68    /// timeout, and a 1 MiB response body cap.
69    pub fn new(base_url: impl Into<String>) -> Self {
70        Self {
71            base_url: base_url.into(),
72            codec: Symbol::qualified("codec", "lisp"),
73            write_method: HttpWriteMethod::Put,
74            timeout_ms: 5_000,
75            max_body_bytes: 1024 * 1024,
76        }
77    }
78
79    /// Returns options using `codec`.
80    pub fn with_codec(mut self, codec: Symbol) -> Self {
81        self.codec = codec;
82        self
83    }
84
85    /// Returns options using `write_method` for `set`.
86    pub fn with_write_method(mut self, write_method: HttpWriteMethod) -> Self {
87        self.write_method = write_method;
88        self
89    }
90
91    /// Returns options using `timeout_ms`.
92    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
93        self.timeout_ms = timeout_ms;
94        self
95    }
96
97    /// Returns options using `max_body_bytes`.
98    pub fn with_max_body_bytes(mut self, max_body_bytes: usize) -> Self {
99        self.max_body_bytes = max_body_bytes;
100        self
101    }
102}
103
104/// A table directory backed by direct HTTP resources.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct HttpDir {
107    options: HttpDirOptions,
108}
109
110impl HttpDir {
111    /// Builds an HTTP directory from `options`.
112    pub fn new(options: HttpDirOptions) -> Result<Self> {
113        validate_options(&options)?;
114        Ok(Self {
115            options: normalize_options(options),
116        })
117    }
118
119    /// Returns this directory's options.
120    pub fn options(&self) -> &HttpDirOptions {
121        &self.options
122    }
123
124    fn child(&self, key: &Symbol) -> Result<Self> {
125        Self::new(HttpDirOptions {
126            base_url: self.url_for_key(key)?,
127            codec: self.options.codec.clone(),
128            write_method: self.options.write_method,
129            timeout_ms: self.options.timeout_ms,
130            max_body_bytes: self.options.max_body_bytes,
131        })
132    }
133
134    fn request(&self, method: &'static str, url: String, body: Vec<u8>) -> HttpRequest {
135        HttpRequest {
136            method,
137            url,
138            headers: Vec::new(),
139            body,
140            timeout: Duration::from_millis(self.options.timeout_ms),
141            max_body_bytes: self.options.max_body_bytes,
142        }
143    }
144
145    fn url_for_key(&self, key: &Symbol) -> Result<String> {
146        let segment = key.name.as_ref();
147        if !sim_table_core::is_legal_table_segment(segment) {
148            return Err(Error::Eval(format!("table/http: illegal name {segment:?}")));
149        }
150        Ok(format!("{}/{segment}", self.options.base_url))
151    }
152
153    fn decode_body(&self, cx: &mut Cx, body: Vec<u8>) -> Result<Value> {
154        let expr = decode_with_codec(
155            cx,
156            &self.options.codec,
157            Input::Bytes(body),
158            ReadPolicy::default(),
159        )?;
160        cx.factory().expr(expr)
161    }
162
163    fn encode_value(&self, cx: &mut Cx, value: Value) -> Result<Vec<u8>> {
164        let expr = value.object().as_expr(cx)?;
165        match encode_with_codec(cx, &self.options.codec, &expr, EncodeOptions::default())? {
166            Output::Text(text) => Ok(text.into_bytes()),
167            Output::Bytes(bytes) => Ok(bytes),
168        }
169    }
170}
171
172impl Object for HttpDir {
173    fn display(&self, _cx: &mut Cx) -> Result<String> {
174        Ok(format!("table/http[{}]", self.options.base_url))
175    }
176
177    fn as_any(&self) -> &dyn std::any::Any {
178        self
179    }
180}
181
182impl sim_kernel::ObjectCompat for HttpDir {
183    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
184        let symbol = http_dir_class_symbol();
185        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
186            return Ok(value.clone());
187        }
188        let symbol = Symbol::qualified("core", "Table");
189        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
190            return Ok(value.clone());
191        }
192        cx.factory().class_stub(CORE_TABLE_CLASS_ID, symbol)
193    }
194
195    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
196        self.as_table_expr(cx)
197    }
198
199    fn truth(&self, _cx: &mut Cx) -> Result<bool> {
200        Ok(true)
201    }
202
203    fn as_table_impl(&self) -> Option<&dyn Table> {
204        Some(self)
205    }
206
207    fn as_dir(&self) -> Option<&dyn Dir> {
208        Some(self)
209    }
210
211    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
212        Some(self)
213    }
214}
215
216impl ObjectEncode for HttpDir {
217    fn object_encoding(&self, _cx: &mut Cx) -> Result<ObjectEncoding> {
218        Ok(ObjectEncoding::Constructor {
219            class: http_dir_class_symbol(),
220            args: vec![
221                Expr::Symbol(Symbol::new("v0")),
222                self.options.base_url.encode_field(),
223                self.options.codec.encode_field(),
224                self.options.write_method.as_str().to_owned().encode_field(),
225                self.options.timeout_ms.encode_field(),
226                self.options.max_body_bytes.encode_field(),
227            ],
228        })
229    }
230}
231
232impl sim_citizen::Citizen for HttpDir {
233    fn citizen_symbol() -> Symbol {
234        http_dir_class_symbol()
235    }
236
237    fn citizen_version() -> u32 {
238        0
239    }
240
241    fn citizen_arity() -> usize {
242        5
243    }
244
245    fn citizen_fields() -> &'static [&'static str] {
246        &[
247            "base_url",
248            "codec",
249            "write_method",
250            "timeout_ms",
251            "max_body_bytes",
252        ]
253    }
254}
255
256impl Table for HttpDir {
257    fn backend_symbol(&self) -> Symbol {
258        Symbol::qualified("table", "http")
259    }
260
261    fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
262        require_table_http(cx)?;
263        let response = send(self.request("GET", self.url_for_key(&key)?, Vec::new()))?;
264        ensure_success(response.status, response.reason.as_deref(), &response.body)?;
265        self.decode_body(cx, response.body)
266    }
267
268    fn set(&self, cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
269        require_table_http(cx)?;
270        let body = self.encode_value(cx, value)?;
271        let response = send(self.request(
272            self.options.write_method.as_str(),
273            self.url_for_key(&key)?,
274            body,
275        ))?;
276        ensure_success(response.status, response.reason.as_deref(), &response.body)?;
277        Ok(())
278    }
279
280    fn has(&self, cx: &mut Cx, key: Symbol) -> Result<bool> {
281        require_table_http(cx)?;
282        let response = send(self.request("HEAD", self.url_for_key(&key)?, Vec::new()))?;
283        match response.status {
284            status if (200..300).contains(&status) => Ok(true),
285            404 => Ok(false),
286            status => Err(status_error(
287                status,
288                response.reason.as_deref(),
289                &response.body,
290            )),
291        }
292    }
293
294    fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
295        require_table_http(cx)?;
296        let response = send(self.request("DELETE", self.url_for_key(&key)?, Vec::new()))?;
297        match response.status {
298            status if (200..300).contains(&status) || status == 404 => cx.factory().nil(),
299            status => Err(status_error(
300                status,
301                response.reason.as_deref(),
302                &response.body,
303            )),
304        }
305    }
306
307    fn keys(&self, _cx: &mut Cx) -> Result<Vec<Symbol>> {
308        Err(Error::Eval(
309            "table/http: keys are not available without an index resource".to_owned(),
310        ))
311    }
312
313    fn entries(&self, _cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
314        Err(Error::Eval(
315            "table/http: entries are not available without an index resource".to_owned(),
316        ))
317    }
318
319    fn len(&self, _cx: &mut Cx) -> Result<usize> {
320        Err(Error::Eval(
321            "table/http: len is not available without an index resource".to_owned(),
322        ))
323    }
324
325    fn clear(&self, _cx: &mut Cx) -> Result<()> {
326        Err(Error::Eval(
327            "table/http: clear is not available without an index resource".to_owned(),
328        ))
329    }
330}
331
332impl Dir for HttpDir {
333    fn mkdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
334        require_table_http(cx)?;
335        cx.factory().opaque(Arc::new(self.child(&name)?))
336    }
337
338    fn opendir(&self, cx: &mut Cx, name: Symbol) -> Result<Option<Value>> {
339        require_table_http(cx)?;
340        Ok(Some(cx.factory().opaque(Arc::new(self.child(&name)?))?))
341    }
342
343    fn rmdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
344        self.del(cx, name)
345    }
346
347    fn is_dir(&self, cx: &mut Cx, name: Symbol) -> Result<bool> {
348        require_table_http(cx)?;
349        let _ = self.url_for_key(&name)?;
350        Ok(true)
351    }
352}
353
354/// Creates an HTTP directory value from `options`.
355pub fn install_http_dir_lib(cx: &mut Cx, options: HttpDirOptions) -> Result<Value> {
356    cx.factory().opaque(Arc::new(HttpDir::new(options)?))
357}
358
359fn validate_options(options: &HttpDirOptions) -> Result<()> {
360    if options.timeout_ms == 0 {
361        return Err(Error::Eval(
362            "table/http: timeout_ms must be non-zero".to_owned(),
363        ));
364    }
365    if options.base_url.trim().is_empty() {
366        return Err(Error::Eval("table/http: base_url is empty".to_owned()));
367    }
368    let _ = sim_lib_net_core::parse_url(options.base_url.trim())
369        .map_err(|err| Error::Eval(format!("table/http: {err}")))?;
370    Ok(())
371}
372
373fn normalize_options(mut options: HttpDirOptions) -> HttpDirOptions {
374    options.base_url = options.base_url.trim().trim_end_matches('/').to_owned();
375    options
376}
377
378fn ensure_success(status: u16, reason: Option<&str>, body: &[u8]) -> Result<()> {
379    if (200..300).contains(&status) {
380        Ok(())
381    } else {
382        Err(status_error(status, reason, body))
383    }
384}
385
386fn status_error(status: u16, reason: Option<&str>, body: &[u8]) -> Error {
387    let reason = reason.unwrap_or_default();
388    let body = String::from_utf8_lossy(body);
389    let detail = if body.is_empty() {
390        reason.to_owned()
391    } else if reason.is_empty() {
392        body.into_owned()
393    } else {
394        format!("{reason}: {body}")
395    };
396    Error::HostError(format!("table/http: http {status}: {detail}"))
397}
398
399impl TryFrom<crate::HttpDirDescriptor> for HttpDirOptions {
400    type Error = Error;
401
402    fn try_from(value: crate::HttpDirDescriptor) -> Result<Self> {
403        Ok(Self {
404            base_url: value.base_url,
405            codec: value.codec,
406            write_method: HttpWriteMethod::from_str(&value.write_method)?,
407            timeout_ms: value.timeout_ms,
408            max_body_bytes: value.max_body_bytes,
409        })
410    }
411}