Skip to main content

doge_runtime/stdlib/
howl.rs

1//! `howl` — the networking stdlib module. Raw TCP (`listen`/`accept`/`connect`/
2//! `send`/`recv`/`recv_line`/`close`) plus a minimal HTTP(S) client
3//! (`get`/`post`/`request`). Every network failure — a refused connection, a broken
4//! pipe, a TLS or timeout error, an operation on a closed socket — is a catchable
5//! IOError rather than a panic, and every socket carries text as one `Str` type:
6//! `recv` never splits a multi-byte character, and genuinely invalid bytes are an
7//! IOError, never a Rust panic.
8
9use std::io::{Read, Write};
10use std::net::{TcpListener, TcpStream};
11use std::rc::Rc;
12use std::time::Duration;
13
14use crate::error::{DogeError, DogeResult};
15use crate::ordered_map::OrderedMap;
16use crate::stdlib::{bytes_arg, int_arg, str_arg};
17use crate::value::{SocketData, SocketState, Value};
18
19/// How long an HTTP(S) request may run before it is a catchable IOError, so a
20/// script can never hang forever on a stalled server. Raw TCP has no timeout.
21const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// The HTTP methods `howl.request` accepts (case-insensitive). Anything else is a
24/// catchable ValueError, so a typo never reaches the transport as a strange verb.
25const HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
26
27/// The chunk size `recv_line` reads with while scanning for a newline.
28const LINE_CHUNK: usize = 1024;
29
30/// A Socket argument as its shared handle, or a catchable type error. Every raw
31/// TCP member takes a socket as its first argument.
32fn socket_arg<'a>(fname: &str, v: &'a Value) -> DogeResult<&'a Rc<SocketData>> {
33    match v {
34        Value::Socket(s) => Ok(s),
35        _ => Err(DogeError::type_error(format!(
36            "howl.{fname} needs a Socket, got {}",
37            v.describe()
38        ))),
39    }
40}
41
42/// A host/port pair from a Str host and an Int port, or a catchable error. A port
43/// outside `0..=65535` is a `ValueError`.
44fn host_port<'a>(fname: &str, host: &'a Value, port: &Value) -> DogeResult<(&'a str, u16)> {
45    let host = str_arg("howl", fname, host)?;
46    let port = int_arg("howl", fname, port)?;
47    let port = u16::try_from(port).map_err(|_| {
48        DogeError::value_error(format!("a port must be between 0 and 65535, got {port}"))
49    })?;
50    Ok((host, port))
51}
52
53/// `howl.listen(host, port)` — bind a TCP listener on `host:port` (port `0` lets
54/// the OS choose a free one, readable back with `howl.port`). A bind failure is a
55/// catchable IOError.
56pub fn howl_listen(host: &Value, port: &Value) -> DogeResult {
57    let (host, port) = host_port("listen", host, port)?;
58    match TcpListener::bind((host, port)) {
59        Ok(listener) => Ok(Value::socket(SocketState::Listener(listener))),
60        Err(err) => Err(DogeError::io_error(format!(
61            "cannot listen on {host}:{port}: {err}"
62        ))),
63    }
64}
65
66/// `howl.connect(host, port)` — open a TCP connection to `host:port`. A refused
67/// connection or unknown host is a catchable IOError.
68pub fn howl_connect(host: &Value, port: &Value) -> DogeResult {
69    let (host, port) = host_port("connect", host, port)?;
70    match TcpStream::connect((host, port)) {
71        Ok(stream) => Ok(Value::socket(SocketState::Conn {
72            stream,
73            buf: Vec::new(),
74        })),
75        Err(err) => Err(DogeError::io_error(format!(
76            "cannot connect to {host}:{port}: {err}"
77        ))),
78    }
79}
80
81/// `howl.accept(listener)` — block until a client connects, then return the new
82/// connection. A non-listener socket is a catchable TypeError; a closed one is an
83/// IOError.
84pub fn howl_accept(listener: &Value) -> DogeResult {
85    let sock = socket_arg("accept", listener)?;
86    let state = sock.state.borrow();
87    match &*state {
88        SocketState::Listener(l) => match l.accept() {
89            Ok((stream, _)) => Ok(Value::socket(SocketState::Conn {
90                stream,
91                buf: Vec::new(),
92            })),
93            Err(err) => Err(DogeError::io_error(format!("cannot accept: {err}"))),
94        },
95        SocketState::Conn { .. } => Err(DogeError::type_error(
96            "howl.accept needs a listening socket, not a connection",
97        )),
98        SocketState::Closed => Err(closed()),
99    }
100}
101
102/// `howl.port(sock)` — the local port a listener or connection is bound to. A
103/// closed socket is a catchable IOError.
104pub fn howl_port(sock: &Value) -> DogeResult {
105    let sock = socket_arg("port", sock)?;
106    let state = sock.state.borrow();
107    let addr = match &*state {
108        SocketState::Listener(l) => l.local_addr(),
109        SocketState::Conn { stream, .. } => stream.local_addr(),
110        SocketState::Closed => return Err(closed()),
111    };
112    match addr {
113        Ok(addr) => Ok(Value::int(addr.port())),
114        Err(err) => Err(DogeError::io_error(format!("cannot read the port: {err}"))),
115    }
116}
117
118/// `howl.send(conn, text)` — write `text` as UTF-8 to a connection. Returns
119/// `none`. A broken pipe or a non-connection socket is a catchable error.
120pub fn howl_send(conn: &Value, text: &Value) -> DogeResult {
121    let sock = socket_arg("send", conn)?;
122    let text = str_arg("howl", "send", text)?;
123    let mut state = sock.state.borrow_mut();
124    match &mut *state {
125        SocketState::Conn { stream, .. } => match stream.write_all(text.as_bytes()) {
126            Ok(()) => Ok(Value::None),
127            Err(err) => Err(DogeError::io_error(format!("cannot send: {err}"))),
128        },
129        SocketState::Listener(_) => Err(DogeError::type_error(
130            "howl.send needs a connection, not a listening socket",
131        )),
132        SocketState::Closed => Err(closed()),
133    }
134}
135
136/// `howl.send_bytes(conn, bytes)` — write raw `bytes` to a connection, unchanged.
137/// Returns `none`. The binary counterpart of [`howl_send`]: use it to send
138/// arbitrary data (image, PDF, a framed HTTP body) that is not text. A broken pipe
139/// or a non-connection socket is a catchable error.
140pub fn howl_send_bytes(conn: &Value, bytes: &Value) -> DogeResult {
141    let sock = socket_arg("send_bytes", conn)?;
142    let bytes = bytes_arg("howl", "send_bytes", bytes)?;
143    let mut state = sock.state.borrow_mut();
144    match &mut *state {
145        SocketState::Conn { stream, .. } => match stream.write_all(bytes) {
146            Ok(()) => Ok(Value::None),
147            Err(err) => Err(DogeError::io_error(format!("cannot send: {err}"))),
148        },
149        SocketState::Listener(_) => Err(DogeError::type_error(
150            "howl.send_bytes needs a connection, not a listening socket",
151        )),
152        SocketState::Closed => Err(closed()),
153    }
154}
155
156/// `howl.recv(conn, max_bytes)` — read up to `max_bytes` bytes from a connection
157/// and return them as text, or `none` at end of input. Never splits a multi-byte
158/// character: an incomplete trailing sequence is held for the next read, and a
159/// call always yields at least one whole character (or `none`). `max_bytes` must
160/// be a positive Int. Genuinely invalid bytes are a catchable IOError.
161pub fn howl_recv(conn: &Value, max_bytes: &Value) -> DogeResult {
162    let sock = socket_arg("recv", conn)?;
163    let max = int_arg("howl", "recv", max_bytes)?;
164    if max <= 0 {
165        return Err(DogeError::value_error(format!(
166            "howl.recv needs a positive byte count, got {max}"
167        )));
168    }
169    let max = max as usize;
170    let mut state = sock.state.borrow_mut();
171    match &mut *state {
172        SocketState::Conn { stream, buf } => {
173            // Read until at least one whole character is buffered, or EOF. Only a
174            // partial multi-byte sequence at the tail keeps the loop going, so it
175            // runs at most a few times (a character is 4 bytes at most).
176            loop {
177                let valid_len = match std::str::from_utf8(buf) {
178                    Ok(_) => buf.len(),
179                    Err(e) => {
180                        if e.error_len().is_some() {
181                            return Err(DogeError::io_error("received bytes were not valid text"));
182                        }
183                        e.valid_up_to()
184                    }
185                };
186                if valid_len > 0 {
187                    let bytes: Vec<u8> = buf.drain(..valid_len).collect();
188                    let text = String::from_utf8(bytes)
189                        .expect("compiler bug: valid_up_to bytes are valid UTF-8");
190                    return Ok(Value::str(text));
191                }
192                let mut chunk = vec![0u8; max];
193                match stream.read(&mut chunk) {
194                    Ok(0) => {
195                        if buf.is_empty() {
196                            return Ok(Value::None);
197                        }
198                        return Err(DogeError::io_error(
199                            "connection closed in the middle of a character",
200                        ));
201                    }
202                    Ok(got) => buf.extend_from_slice(&chunk[..got]),
203                    Err(err) => return Err(DogeError::io_error(format!("cannot recv: {err}"))),
204                }
205            }
206        }
207        SocketState::Listener(_) => Err(DogeError::type_error(
208            "howl.recv needs a connection, not a listening socket",
209        )),
210        SocketState::Closed => Err(closed()),
211    }
212}
213
214/// `howl.recv_bytes(conn, max_bytes)` — read up to `max_bytes` raw bytes from a
215/// connection and return them as `Bytes`, or `none` at end of input. The binary
216/// counterpart of [`howl_recv`]: no UTF-8 reassembly, so bytes are returned exactly
217/// as they arrive and non-text data is never an error — the way to read a binary or
218/// byte-framed body. `max_bytes` must be a positive Int. Bytes buffered by an
219/// earlier `recv`/`recv_line` are returned first so a mixed-use socket loses
220/// nothing. A broken connection is a catchable IOError.
221pub fn howl_recv_bytes(conn: &Value, max_bytes: &Value) -> DogeResult {
222    let sock = socket_arg("recv_bytes", conn)?;
223    let max = int_arg("howl", "recv_bytes", max_bytes)?;
224    if max <= 0 {
225        return Err(DogeError::value_error(format!(
226            "howl.recv_bytes needs a positive byte count, got {max}"
227        )));
228    }
229    let max = max as usize;
230    let mut state = sock.state.borrow_mut();
231    match &mut *state {
232        SocketState::Conn { stream, buf } => {
233            if !buf.is_empty() {
234                let take = buf.len().min(max);
235                let bytes: Vec<u8> = buf.drain(..take).collect();
236                return Ok(Value::bytes(bytes));
237            }
238            let mut chunk = vec![0u8; max];
239            match stream.read(&mut chunk) {
240                Ok(0) => Ok(Value::None),
241                Ok(got) => Ok(Value::bytes(&chunk[..got])),
242                Err(err) => Err(DogeError::io_error(format!("cannot recv: {err}"))),
243            }
244        }
245        SocketState::Listener(_) => Err(DogeError::type_error(
246            "howl.recv_bytes needs a connection, not a listening socket",
247        )),
248        SocketState::Closed => Err(closed()),
249    }
250}
251
252/// `howl.recv_line(conn)` — read one line from a connection, without the trailing
253/// newline (a `\r\n` is trimmed too), or `none` at end of input. Invalid bytes
254/// are a catchable IOError.
255pub fn howl_recv_line(conn: &Value) -> DogeResult {
256    let sock = socket_arg("recv_line", conn)?;
257    let mut state = sock.state.borrow_mut();
258    match &mut *state {
259        SocketState::Conn { stream, buf } => loop {
260            if let Some(pos) = buf.iter().position(|&b| b == b'\n') {
261                let mut line: Vec<u8> = buf.drain(..=pos).collect();
262                line.pop(); // the '\n'
263                if line.last() == Some(&b'\r') {
264                    line.pop();
265                }
266                return line_to_value(line);
267            }
268            let mut chunk = [0u8; LINE_CHUNK];
269            match stream.read(&mut chunk) {
270                Ok(0) => {
271                    if buf.is_empty() {
272                        return Ok(Value::None);
273                    }
274                    return line_to_value(std::mem::take(buf));
275                }
276                Ok(got) => buf.extend_from_slice(&chunk[..got]),
277                Err(err) => return Err(DogeError::io_error(format!("cannot recv: {err}"))),
278            }
279        },
280        SocketState::Listener(_) => Err(DogeError::type_error(
281            "howl.recv_line needs a connection, not a listening socket",
282        )),
283        SocketState::Closed => Err(closed()),
284    }
285}
286
287/// `howl.close(sock)` — close a listener or connection now. Idempotent: closing an
288/// already-closed socket is fine. Returns `none`. Any later operation on it is a
289/// catchable IOError.
290pub fn howl_close(sock: &Value) -> DogeResult {
291    let sock = socket_arg("close", sock)?;
292    *sock.state.borrow_mut() = SocketState::Closed;
293    Ok(Value::None)
294}
295
296/// `howl.get(url)` — HTTP(S) GET. Returns a Dict `{"status": Int, "body": Str}`.
297/// A non-2xx response is returned like any other (its status and body); only a
298/// transport, TLS, or timeout failure is a catchable IOError.
299pub fn howl_get(url: &Value) -> DogeResult {
300    let url = str_arg("howl", "get", url)?;
301    request_result(url, agent().get(url).call())
302}
303
304/// `howl.post(url, body)` — HTTP(S) POST of `body` as `text/plain; charset=utf-8`.
305/// Same return shape and error rule as [`howl_get`].
306pub fn howl_post(url: &Value, body: &Value) -> DogeResult {
307    let url = str_arg("howl", "post", url)?;
308    let body = str_arg("howl", "post", body)?;
309    request_result(
310        url,
311        agent()
312            .post(url)
313            .set("Content-Type", "text/plain; charset=utf-8")
314            .send_string(body),
315    )
316}
317
318/// `howl.request(method, url, opts)` — the general HTTP(S) client. `method` is one
319/// of [`HTTP_METHODS`] (case-insensitive). `opts` is a Dict (or `none` for no
320/// options) with optional keys `"headers"` (a Dict of Str→Str) and `"body"` (a Str,
321/// sent UTF-8, or Bytes, sent raw); any other key is a ValueError. Same return
322/// shape and transport-error rule as [`howl_get`], with response headers included.
323pub fn howl_request(method: &Value, url: &Value, opts: &Value) -> DogeResult {
324    let method = str_arg("howl", "request", method)?.to_ascii_uppercase();
325    if !HTTP_METHODS.contains(&method.as_str()) {
326        return Err(DogeError::value_error(format!(
327            "unknown HTTP method {method:?}, expected one of {}",
328            HTTP_METHODS.join(", ")
329        )));
330    }
331    let url = str_arg("howl", "request", url)?;
332
333    let opts = opts_dict(opts)?;
334    let mut request = agent().request(&method, url);
335    let mut body: Option<Value> = None;
336    if let Some(opts) = &opts {
337        let opts = opts.borrow();
338        for (key, value) in opts.iter() {
339            match key.as_str() {
340                "headers" => {
341                    for (name, header) in headers_dict(value)?.borrow().iter() {
342                        let header = str_arg("howl", "request", header).map_err(|_| {
343                            DogeError::type_error(format!(
344                                "howl.request header {name:?} needs a Str value, got {}",
345                                header.describe()
346                            ))
347                        })?;
348                        request = request.set(name, header);
349                    }
350                }
351                "body" => body = Some(value.clone()),
352                other => {
353                    return Err(DogeError::value_error(format!(
354                        "howl.request got an unknown option {other:?}, expected \"headers\" or \"body\""
355                    )));
356                }
357            }
358        }
359    }
360
361    let sent = match &body {
362        None | Some(Value::None) => request.call(),
363        Some(Value::Str(text)) => request.send_string(text),
364        Some(Value::Bytes(bytes)) => request.send_bytes(bytes),
365        Some(other) => {
366            return Err(DogeError::type_error(format!(
367                "howl.request body needs a Str or Bytes, got {}",
368                other.describe()
369            )));
370        }
371    };
372    request_result(url, sent)
373}
374
375/// The `opts` argument as a borrowable Dict, or `None` when options are omitted
376/// (`none`). Any other type is a catchable TypeError.
377fn opts_dict(opts: &Value) -> DogeResult<Option<Rc<std::cell::RefCell<OrderedMap>>>> {
378    match opts {
379        Value::None => Ok(None),
380        Value::Dict(entries) => Ok(Some(Rc::clone(entries))),
381        _ => Err(DogeError::type_error(format!(
382            "howl.request options need a Dict, got {}",
383            opts.describe()
384        ))),
385    }
386}
387
388/// The `"headers"` option as a borrowable Dict, or a catchable TypeError.
389fn headers_dict(value: &Value) -> DogeResult<&Rc<std::cell::RefCell<OrderedMap>>> {
390    match value {
391        Value::Dict(entries) => Ok(entries),
392        _ => Err(DogeError::type_error(format!(
393            "howl.request headers need a Dict, got {}",
394            value.describe()
395        ))),
396    }
397}
398
399/// The shared HTTP agent: rustls TLS with a fixed request timeout.
400fn agent() -> ureq::Agent {
401    ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build()
402}
403
404/// Turn a ureq call result into the `{"status", "body"}` Dict. A non-2xx status
405/// (`Error::Status`) is a normal result, not an error; every other ureq error is a
406/// catchable IOError worded without any Rust type names leaking through.
407fn request_result(url: &str, result: Result<ureq::Response, ureq::Error>) -> DogeResult {
408    match result {
409        Ok(response) => response_dict(response),
410        Err(ureq::Error::Status(_, response)) => response_dict(response),
411        Err(ureq::Error::Transport(transport)) => Err(DogeError::io_error(format!(
412            "cannot fetch {url}: {}",
413            transport.message().unwrap_or("the request failed")
414        ))),
415    }
416}
417
418/// Build the response Dict `{"status", "body", "headers"}`, reading the body as
419/// text. Header names are lowercased so a script reads them case-insensitively. A
420/// body that is not valid text is a catchable IOError.
421fn response_dict(response: ureq::Response) -> DogeResult {
422    let status = response.status() as i64;
423    let mut headers = OrderedMap::new();
424    for name in response.headers_names() {
425        let value = response.header(&name).unwrap_or("");
426        headers.insert(name.to_ascii_lowercase(), Value::str(value));
427    }
428    let body = response
429        .into_string()
430        .map_err(|err| DogeError::io_error(format!("cannot read the response: {err}")))?;
431    let mut entries = OrderedMap::new();
432    entries.insert("status".to_string(), Value::int(status));
433    entries.insert("body".to_string(), Value::str(body));
434    entries.insert("headers".to_string(), Value::dict(headers));
435    Ok(Value::dict(entries))
436}
437
438/// The error every operation on a closed socket raises.
439fn closed() -> DogeError {
440    DogeError::io_error("socket is closed")
441}
442
443/// A received line's bytes as a Str value, or a catchable IOError when they are
444/// not valid text.
445fn line_to_value(bytes: Vec<u8>) -> DogeResult {
446    String::from_utf8(bytes)
447        .map(Value::str)
448        .map_err(|_| DogeError::io_error("received bytes were not valid text"))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::error::ErrorKind;
455    use bigdecimal::ToPrimitive;
456    use std::thread;
457
458    /// A listener bound to an OS-assigned loopback port, plus that port.
459    fn loopback() -> (Value, u16) {
460        let listener = howl_listen(&Value::str("127.0.0.1"), &Value::int(0)).unwrap();
461        let port = match howl_port(&listener).unwrap() {
462            Value::Int(p) => p.to_u16().unwrap(),
463            _ => panic!("port is an Int"),
464        };
465        (listener, port)
466    }
467
468    fn recv_str(conn: &Value, n: i64) -> Option<String> {
469        match howl_recv(conn, &Value::int(n)).unwrap() {
470            Value::Str(s) => Some(s.to_string()),
471            Value::None => None,
472            other => panic!("recv gave {}", other.type_name()),
473        }
474    }
475
476    fn recv_bytes(conn: &Value, n: i64) -> Option<Vec<u8>> {
477        match howl_recv_bytes(conn, &Value::int(n)).unwrap() {
478            Value::Bytes(b) => Some(b.to_vec()),
479            Value::None => None,
480            other => panic!("recv_bytes gave {}", other.type_name()),
481        }
482    }
483
484    #[test]
485    fn tcp_round_trip_and_close() {
486        let (listener, port) = loopback();
487        // connect() succeeds into the backlog before accept(), so one thread can
488        // drive both ends.
489        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
490        let server = howl_accept(&listener).unwrap();
491
492        howl_send(&client, &Value::str("much hello\n")).unwrap();
493        assert_eq!(howl_recv_line(&server).unwrap().to_string(), "much hello");
494
495        howl_send(&server, &Value::str("wow\n")).unwrap();
496        assert_eq!(howl_recv_line(&client).unwrap().to_string(), "wow");
497
498        // After the server closes, the client reads end-of-input.
499        howl_close(&server).unwrap();
500        assert!(matches!(howl_recv_line(&client).unwrap(), Value::None));
501
502        // Every op on a closed socket is a catchable IOError.
503        assert_eq!(
504            howl_send(&server, &Value::str("x")).unwrap_err().kind,
505            ErrorKind::IOError
506        );
507        // close is idempotent.
508        howl_close(&server).unwrap();
509    }
510
511    #[test]
512    fn recv_reassembles_a_split_multibyte_character() {
513        let (listener, port) = loopback();
514        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
515        let server = howl_accept(&listener).unwrap();
516
517        // "é" is two UTF-8 bytes; reading one byte at a time must still yield the
518        // whole character, never a split.
519        howl_send(&client, &Value::str("é")).unwrap();
520        assert_eq!(recv_str(&server, 1).as_deref(), Some("é"));
521    }
522
523    #[test]
524    fn recv_reports_eof_as_none() {
525        let (listener, port) = loopback();
526        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
527        let server = howl_accept(&listener).unwrap();
528        howl_close(&client).unwrap();
529        assert_eq!(recv_str(&server, 16), None);
530    }
531
532    #[test]
533    fn bytes_round_trip_preserves_non_text_data() {
534        let (listener, port) = loopback();
535        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
536        let server = howl_accept(&listener).unwrap();
537
538        // Bytes that are not valid UTF-8 (0xff, 0x00) survive send/recv untouched,
539        // where recv would raise an IOError.
540        let payload = vec![0xffu8, 0x00, 0x50, 0x44, 0x46];
541        howl_send_bytes(&client, &Value::bytes(&payload)).unwrap();
542        assert_eq!(recv_bytes(&server, 16).as_deref(), Some(&payload[..]));
543    }
544
545    #[test]
546    fn recv_bytes_reports_eof_as_none() {
547        let (listener, port) = loopback();
548        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
549        let server = howl_accept(&listener).unwrap();
550        howl_close(&client).unwrap();
551        assert_eq!(recv_bytes(&server, 16), None);
552    }
553
554    #[test]
555    fn recv_bytes_drains_buffer_left_by_recv_line() {
556        let (listener, port) = loopback();
557        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
558        let server = howl_accept(&listener).unwrap();
559
560        // A header line then a byte body arriving in one write: recv_line consumes
561        // the line and over-reads the body into the buffer, which recv_bytes must
562        // hand back rather than drop.
563        howl_send(&client, &Value::str("Head: v\r\nBODY")).unwrap();
564        assert_eq!(howl_recv_line(&server).unwrap().to_string(), "Head: v");
565        assert_eq!(recv_bytes(&server, 4).as_deref(), Some(&b"BODY"[..]));
566    }
567
568    #[test]
569    fn recv_bytes_and_send_bytes_reject_bad_args() {
570        let (listener, port) = loopback();
571        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
572        // send_bytes on a listener is a TypeError; a non-Bytes payload too.
573        assert_eq!(
574            howl_send_bytes(&listener, &Value::bytes(b"x"))
575                .unwrap_err()
576                .kind,
577            ErrorKind::TypeError
578        );
579        assert_eq!(
580            howl_send_bytes(&client, &Value::str("x")).unwrap_err().kind,
581            ErrorKind::TypeError
582        );
583        // A non-positive recv_bytes size is a ValueError.
584        assert_eq!(
585            howl_recv_bytes(&client, &Value::int(0)).unwrap_err().kind,
586            ErrorKind::ValueError
587        );
588    }
589
590    #[test]
591    fn wrong_socket_role_and_types_are_catchable() {
592        let (listener, port) = loopback();
593        let client = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap();
594        // accept on a connection, send on a listener: both TypeErrors.
595        assert_eq!(howl_accept(&client).unwrap_err().kind, ErrorKind::TypeError);
596        assert_eq!(
597            howl_send(&listener, &Value::str("x")).unwrap_err().kind,
598            ErrorKind::TypeError
599        );
600        // Non-Str host, non-Socket receiver, zero recv size.
601        assert_eq!(
602            howl_connect(&Value::int(1), &Value::int(port as i64))
603                .unwrap_err()
604                .kind,
605            ErrorKind::TypeError
606        );
607        assert_eq!(
608            howl_send(&Value::int(1), &Value::str("x"))
609                .unwrap_err()
610                .kind,
611            ErrorKind::TypeError
612        );
613        assert_eq!(
614            howl_recv(&client, &Value::int(0)).unwrap_err().kind,
615            ErrorKind::ValueError
616        );
617        assert_eq!(
618            howl_listen(&Value::str("127.0.0.1"), &Value::int(99999))
619                .unwrap_err()
620                .kind,
621            ErrorKind::ValueError
622        );
623    }
624
625    #[test]
626    fn connection_refused_is_a_catchable_io_error() {
627        // Bind, read the port, then drop the listener so nothing is listening.
628        let port = {
629            let (listener, port) = loopback();
630            howl_close(&listener).unwrap();
631            port
632        };
633        let err = howl_connect(&Value::str("127.0.0.1"), &Value::int(port as i64)).unwrap_err();
634        assert_eq!(err.kind, ErrorKind::IOError);
635    }
636
637    #[test]
638    fn http_get_returns_status_and_body() {
639        // A one-shot HTTP/1.1 server on loopback, so the test never touches the
640        // network. It replies 404 to prove a non-2xx status is a normal result.
641        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
642        let port = listener.local_addr().unwrap().port();
643        let handle = thread::spawn(move || {
644            if let Ok((mut stream, _)) = listener.accept() {
645                let mut buf = [0u8; 1024];
646                let _ = stream.read(&mut buf);
647                let body = "much not found";
648                let response = format!(
649                    "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
650                    body.len(),
651                    body
652                );
653                let _ = stream.write_all(response.as_bytes());
654            }
655        });
656
657        let url = Value::str(format!("http://127.0.0.1:{port}/"));
658        let result = howl_get(&url).unwrap();
659        handle.join().unwrap();
660
661        match result {
662            Value::Dict(entries) => {
663                let entries = entries.borrow();
664                assert!(entries
665                    .get("status")
666                    .is_some_and(|v| crate::values_equal(v, &Value::int(404))));
667                assert!(
668                    matches!(entries.get("body"), Some(Value::Str(s)) if &**s == "much not found")
669                );
670            }
671            other => panic!("expected a Dict, got {}", other.type_name()),
672        }
673    }
674
675    #[test]
676    fn http_get_on_a_dead_port_is_a_catchable_io_error() {
677        // Bind to grab a free port, then drop the listener so nothing answers.
678        let port = {
679            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
680            l.local_addr().unwrap().port()
681        };
682        let err = howl_get(&Value::str(format!("http://127.0.0.1:{port}/"))).unwrap_err();
683        assert_eq!(err.kind, ErrorKind::IOError);
684    }
685
686    /// A one-shot HTTP/1.1 loopback server that captures the whole request (headers
687    /// plus any `Content-Length` body) and replies `200 OK` with a JSON content
688    /// type. Returns the listener's port and a handle yielding the raw request text.
689    fn capture_server() -> (u16, thread::JoinHandle<String>) {
690        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
691        let port = listener.local_addr().unwrap().port();
692        let handle = thread::spawn(move || {
693            let (mut stream, _) = listener.accept().unwrap();
694            let mut request = Vec::new();
695            let mut buf = [0u8; 1024];
696            loop {
697                let n = stream.read(&mut buf).unwrap();
698                if n == 0 {
699                    break;
700                }
701                request.extend_from_slice(&buf[..n]);
702                if let Some(head_end) = find_subslice(&request, b"\r\n\r\n") {
703                    let head = String::from_utf8_lossy(&request[..head_end]).to_lowercase();
704                    let content_length = head
705                        .split("\r\n")
706                        .find_map(|line| line.strip_prefix("content-length:"))
707                        .and_then(|v| v.trim().parse::<usize>().ok())
708                        .unwrap_or(0);
709                    if request.len() >= head_end + 4 + content_length {
710                        break;
711                    }
712                }
713            }
714            let body = "wow ok";
715            let response = format!(
716                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
717                body.len(),
718                body
719            );
720            let _ = stream.write_all(response.as_bytes());
721            String::from_utf8_lossy(&request).into_owned()
722        });
723        (port, handle)
724    }
725
726    fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
727        haystack.windows(needle.len()).position(|w| w == needle)
728    }
729
730    /// A Dict `Value` from string keys and values, for building `request` options.
731    fn dict_of(pairs: &[(&str, Value)]) -> Value {
732        let mut entries = OrderedMap::new();
733        for (key, value) in pairs {
734            entries.insert((*key).to_string(), value.clone());
735        }
736        Value::dict(entries)
737    }
738
739    #[test]
740    fn http_request_sends_method_headers_and_body() {
741        let (port, handle) = capture_server();
742        let url = Value::str(format!("http://127.0.0.1:{port}/invoices"));
743        let opts = dict_of(&[
744            (
745                "headers",
746                dict_of(&[
747                    ("Authorization", Value::str("Bearer secret")),
748                    ("Content-Type", Value::str("application/json")),
749                ]),
750            ),
751            ("body", Value::str("{\"much\":\"json\"}")),
752        ]);
753        let result = howl_request(&Value::str("post"), &url, &opts).unwrap();
754        let request = handle.join().unwrap();
755
756        assert!(request.starts_with("POST /invoices HTTP/1.1\r\n"));
757        assert!(request.contains("Authorization: Bearer secret\r\n"));
758        assert!(request.contains("Content-Type: application/json\r\n"));
759        assert!(request.ends_with("{\"much\":\"json\"}"));
760
761        match result {
762            Value::Dict(entries) => {
763                let entries = entries.borrow();
764                assert!(entries
765                    .get("status")
766                    .is_some_and(|v| crate::values_equal(v, &Value::int(200))));
767                let headers = match entries.get("headers") {
768                    Some(Value::Dict(h)) => h.borrow(),
769                    other => panic!("expected a headers Dict, got {other:?}"),
770                };
771                assert!(matches!(
772                    headers.get("content-type"),
773                    Some(Value::Str(s)) if &**s == "application/json"
774                ));
775            }
776            other => panic!("expected a Dict, got {}", other.type_name()),
777        }
778    }
779
780    #[test]
781    fn http_request_sends_a_bytes_body_raw() {
782        let (port, handle) = capture_server();
783        let url = Value::str(format!("http://127.0.0.1:{port}/"));
784        let opts = dict_of(&[("body", Value::bytes([0x50, 0x44, 0x46]))]);
785        howl_request(&Value::str("PUT"), &url, &opts).unwrap();
786        let request = handle.join().unwrap();
787        assert!(request.starts_with("PUT / HTTP/1.1\r\n"));
788        assert!(request.ends_with("PDF"));
789    }
790
791    #[test]
792    fn http_request_without_options_is_a_bare_request() {
793        let (port, handle) = capture_server();
794        let url = Value::str(format!("http://127.0.0.1:{port}/"));
795        // An omitted `opts` reaches the runtime as `none`.
796        let result = howl_request(&Value::str("GET"), &url, &Value::None).unwrap();
797        let request = handle.join().unwrap();
798        assert!(request.starts_with("GET / HTTP/1.1\r\n"));
799        assert!(matches!(result, Value::Dict(_)));
800    }
801
802    #[test]
803    fn http_request_rejects_an_unknown_method() {
804        let err =
805            howl_request(&Value::str("FETCH"), &Value::str("http://x/"), &Value::None).unwrap_err();
806        assert_eq!(err.kind, ErrorKind::ValueError);
807    }
808
809    #[test]
810    fn http_request_rejects_an_unknown_option() {
811        let opts = dict_of(&[("query", Value::str("nope"))]);
812        let err = howl_request(&Value::str("GET"), &Value::str("http://x/"), &opts).unwrap_err();
813        assert_eq!(err.kind, ErrorKind::ValueError);
814    }
815
816    #[test]
817    fn http_request_rejects_a_non_text_body() {
818        let opts = dict_of(&[("body", Value::int(42))]);
819        let err = howl_request(&Value::str("POST"), &Value::str("http://x/"), &opts).unwrap_err();
820        assert_eq!(err.kind, ErrorKind::TypeError);
821    }
822
823    #[test]
824    fn http_request_rejects_a_non_str_header_value() {
825        let opts = dict_of(&[("headers", dict_of(&[("X-Count", Value::int(3))]))]);
826        let err = howl_request(&Value::str("GET"), &Value::str("http://x/"), &opts).unwrap_err();
827        assert_eq!(err.kind, ErrorKind::TypeError);
828    }
829
830    #[test]
831    fn http_get_response_includes_headers() {
832        let (port, handle) = capture_server();
833        let result = howl_get(&Value::str(format!("http://127.0.0.1:{port}/"))).unwrap();
834        handle.join().unwrap();
835        match result {
836            Value::Dict(entries) => {
837                let entries = entries.borrow();
838                let headers = match entries.get("headers") {
839                    Some(Value::Dict(h)) => h.borrow(),
840                    other => panic!("expected a headers Dict, got {other:?}"),
841                };
842                assert!(headers.get("content-type").is_some());
843            }
844            other => panic!("expected a Dict, got {}", other.type_name()),
845        }
846    }
847}