Skip to main content

printobserver_sdk/
transport.rs

1//! One request to the one address this client was given.
2//!
3//! Nothing here uses an HTTP client crate. One request, written out, is less
4//! in a dependent's build than a client library and its transport-layer
5//! security stack would be — and this client speaks to a supervisor over a
6//! loopback or a local network rather than to the internet, because the
7//! supervisor is the thing beside the printer.
8//!
9//! An answer that declared more than it sent is a truncated document, and
10//! handing it to a caller would be handing back less than the server said. It
11//! is refused instead.
12
13use std::io::{Read as _, Write as _};
14use std::net::TcpStream;
15
16use crate::error::ClientError;
17
18/// The media type every operation takes a body in and answers in.
19pub const MEDIA_TYPE: &str = "application/json";
20
21/// The header a configured credential travels in.
22pub const CREDENTIAL_HEADER: &str = "Authorization";
23
24/// What the supervisor answered.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Answered {
27    /// The status it answered under.
28    pub status: u16,
29    /// The whole document it answered with.
30    pub body: String,
31}
32
33/// Ask the configured supervisor one thing.
34///
35/// # Errors
36///
37/// Returns [`ClientError::Unreachable`] when nothing answered at the address,
38/// when the request could not be written, and when what came back is not an
39/// answer this client can read — which includes an answer declaring more than
40/// it sent.
41pub fn send(
42    address: &str,
43    credential: Option<&str>,
44    method: &str,
45    target: &str,
46    body: Option<&str>,
47) -> Result<Answered, ClientError> {
48    let failing = |detail: String| ClientError::Unreachable {
49        address: address.to_owned(),
50        detail,
51    };
52    let mut stream =
53        TcpStream::connect(address).map_err(|error| failing(format!("{:?}", error.kind())))?;
54
55    let authenticating = credential.map_or_else(String::new, |credential| {
56        format!("{CREDENTIAL_HEADER}: Bearer {credential}\r\n")
57    });
58    let carried = body.map_or_else(
59        || "\r\n".to_owned(),
60        |document| {
61            format!(
62                "Content-Type: {MEDIA_TYPE}\r\nContent-Length: {}\r\n\r\n{document}",
63                document.len()
64            )
65        },
66    );
67    let request = format!(
68        "{method} {target} HTTP/1.1\r\nHost: {address}\r\nAccept: {MEDIA_TYPE}\r\n\
69         Connection: close\r\n{authenticating}{carried}"
70    );
71    stream.write_all(request.as_bytes()).map_err(|error| {
72        failing(format!(
73            "the request could not be sent ({:?})",
74            error.kind()
75        ))
76    })?;
77
78    let mut answer = Vec::new();
79    stream
80        .read_to_end(&mut answer)
81        .map_err(|error| failing(format!("the answer could not be read ({:?})", error.kind())))?;
82    read_answer(&answer).map_err(failing)
83}
84
85/// The status and the whole body of one HTTP answer.
86fn read_answer(answer: &[u8]) -> Result<Answered, String> {
87    let separator = answer
88        .windows(4)
89        .position(|window| window == b"\r\n\r\n")
90        .ok_or_else(|| "the supervisor answered something this client cannot read".to_owned())?;
91    let head = String::from_utf8_lossy(&answer[..separator]).into_owned();
92    let body = &answer[separator + 4..];
93
94    let status = head
95        .split_whitespace()
96        .nth(1)
97        .filter(|_| head.starts_with("HTTP/1.1 "))
98        .and_then(|code| code.parse::<u16>().ok())
99        .ok_or_else(|| format!("the supervisor answered no status: {head}"))?;
100    let declared = head
101        .lines()
102        .filter_map(|line| line.split_once(':'))
103        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
104        .and_then(|(_, value)| value.trim().parse::<usize>().ok())
105        .ok_or_else(|| format!("the supervisor declared no length for what it answered: {head}"))?;
106    if body.len() != declared {
107        return Err(format!(
108            "the supervisor declared {declared} bytes and sent {}",
109            body.len()
110        ));
111    }
112    Ok(Answered {
113        status,
114        body: String::from_utf8_lossy(body).into_owned(),
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::{Answered, read_answer, send};
121    use crate::error::ClientError;
122
123    /// A whole answer is read as its status and its body.
124    #[test]
125    fn a_whole_answer_is_read_as_its_status_and_its_body() {
126        let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}";
127        assert_eq!(
128            read_answer(raw).expect("a whole answer"),
129            Answered {
130                status: 200,
131                body: "{}".to_owned(),
132            }
133        );
134    }
135
136    /// An answer that declared more than it sent is refused rather than handed on.
137    #[test]
138    fn an_answer_that_declared_more_than_it_sent_is_refused() {
139        let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\n{}";
140        let refusal = read_answer(raw).expect_err("a truncated answer is refused");
141        assert!(refusal.contains("declared 9 bytes and sent 2"), "{refusal}");
142    }
143
144    /// An answer with no status, and one with no declared length, are refused.
145    #[test]
146    fn an_answer_with_no_status_and_one_with_no_length_are_refused() {
147        assert!(read_answer(b"nothing at all").is_err());
148        assert!(read_answer(b"ICY 200 OK\r\nContent-Length: 0\r\n\r\n").is_err());
149        assert!(read_answer(b"HTTP/1.1 200 OK\r\n\r\n").is_err());
150    }
151
152    /// An address nothing is listening on is a failure naming that address.
153    #[test]
154    fn an_address_nothing_is_listening_on_names_that_address() {
155        // Port zero is never listened on, so this connects to nothing without
156        // depending on which ports this host happens to have free.
157        let refusal = send("127.0.0.1:0", None, "GET", "/v1/status", None)
158            .expect_err("nothing answers on port zero");
159        assert!(
160            matches!(&refusal, ClientError::Unreachable { address, .. } if address == "127.0.0.1:0"),
161            "{refusal}"
162        );
163    }
164}