1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum HttpWriteMethod {
24 #[default]
26 Put,
27 Post,
29}
30
31impl HttpWriteMethod {
32 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#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct HttpDirOptions {
54 pub base_url: String,
56 pub codec: Symbol,
58 pub write_method: HttpWriteMethod,
60 pub timeout_ms: u64,
62 pub max_body_bytes: usize,
64}
65
66impl HttpDirOptions {
67 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 pub fn with_codec(mut self, codec: Symbol) -> Self {
81 self.codec = codec;
82 self
83 }
84
85 pub fn with_write_method(mut self, write_method: HttpWriteMethod) -> Self {
87 self.write_method = write_method;
88 self
89 }
90
91 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
93 self.timeout_ms = timeout_ms;
94 self
95 }
96
97 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#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct HttpDir {
107 options: HttpDirOptions,
108}
109
110impl HttpDir {
111 pub fn new(options: HttpDirOptions) -> Result<Self> {
113 validate_options(&options)?;
114 Ok(Self {
115 options: normalize_options(options),
116 })
117 }
118
119 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
354pub 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}