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 options::{HttpDirOptions, normalize_options, validate_options},
19 transport::{HttpRequest, send},
20};
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct HttpDir {
25 options: HttpDirOptions,
26}
27
28impl HttpDir {
29 pub fn new(options: HttpDirOptions) -> Result<Self> {
31 validate_options(&options)?;
32 Ok(Self {
33 options: normalize_options(options),
34 })
35 }
36
37 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
267pub 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}