Skip to main content

printobserver_sdk/
client.rs

1//! The client every generated method is a method of.
2//!
3//! It carries three things and no more: where the supervisor is, what
4//! authenticates to it, and who this client acts as. The third is why no
5//! generated method takes an actor: a call cannot act as somebody the client
6//! is not.
7
8use serde::de::DeserializeOwned;
9
10use crate::contract::{ActionAnswer, Actor, ErrorAnswer};
11use crate::error::{ClientError, Rejection};
12use crate::transport::send;
13
14/// The status a rejected action is answered under.
15const REJECTED_STATUS: u16 = 409;
16
17/// The status an action the machine itself refused is answered under.
18const MACHINE_REFUSED_STATUS: u16 = 502;
19
20/// The status a read or a write that was carried out is answered under.
21const SUCCESS_STATUS: u16 = 200;
22
23/// The scheme the address a supervisor is configured at may carry.
24const SCHEME: &str = "http://";
25
26/// A typed client of one printobserver supervisor.
27///
28/// One method per public operation that supervisor serves, each generated from
29/// the same checked-in description the server's own routes are folded out of.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Client {
32    /// Where the supervisor answers, as a host and a port.
33    address: String,
34    /// What authenticates to it, where anything does.
35    credential: Option<String>,
36    /// Who this client acts as.
37    actor: Actor,
38}
39
40impl Client {
41    /// A client of the supervisor at one address, acting as one actor.
42    ///
43    /// The address is taken as the supervisor's own configuration writes it —
44    /// `http://127.0.0.1:8420` — and a bare `host:port` is taken as well.
45    #[must_use]
46    pub fn new(address: impl AsRef<str>, actor: Actor) -> Self {
47        Self {
48            address: address
49                .as_ref()
50                .trim()
51                .trim_end_matches('/')
52                .strip_prefix(SCHEME)
53                .unwrap_or_else(|| address.as_ref().trim().trim_end_matches('/'))
54                .to_owned(),
55            credential: None,
56            actor,
57        }
58    }
59
60    /// The same client, authenticating with one credential.
61    ///
62    /// This server requires none of its API callers; a credential is for a
63    /// deployment that has put something in front of it that does.
64    #[must_use]
65    pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
66        self.credential = Some(credential.into());
67        self
68    }
69
70    /// Where this client is pointed.
71    #[must_use]
72    pub fn address(&self) -> &str {
73        &self.address
74    }
75
76    /// Who this client acts as, which every mutating call carries.
77    #[must_use]
78    pub fn actor(&self) -> Actor {
79        self.actor.clone()
80    }
81
82    /// Make one call and read what came back.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`ClientError`] for each of the five ways a call ends other
87    /// than with its answer; a refused action arrives as
88    /// [`ClientError::Rejected`], carrying the reason, the value asked for and
89    /// the range allowed.
90    pub fn call<T: DeserializeOwned>(
91        &self,
92        method: &str,
93        path: &str,
94        query: &[(String, String)],
95        body: Option<&serde_json::Value>,
96    ) -> Result<T, ClientError> {
97        let document = body.map(ToString::to_string);
98        let answered = send(
99            &self.address,
100            self.credential.as_deref(),
101            method,
102            &target(path, query),
103            document.as_deref(),
104        )?;
105        match answered.status {
106            SUCCESS_STATUS => {
107                serde_json::from_str(&answered.body).map_err(|error| ClientError::Unreadable {
108                    status: answered.status,
109                    detail: error.to_string(),
110                })
111            }
112            REJECTED_STATUS => Err(rejection(&answered.body, answered.status)),
113            MACHINE_REFUSED_STATUS => Err(machine_refusal(&answered.body, answered.status)),
114            status => Err(ClientError::Refused {
115                status,
116                detail: said(&answered.body),
117            }),
118        }
119    }
120}
121
122/// The policy's own refusal, as the answer to it carries it.
123fn rejection(body: &str, status: u16) -> ClientError {
124    match serde_json::from_str::<ActionAnswer>(body).map(Rejection::of) {
125        Ok(Some(rejection)) => ClientError::Rejected(Box::new(rejection)),
126        Ok(None) => ClientError::Unreadable {
127            status,
128            detail: "the supervisor refused this action and answered a record whose \
129                     decision is not a refusal"
130                .to_owned(),
131        },
132        Err(error) => ClientError::Unreadable {
133            status,
134            detail: error.to_string(),
135        },
136    }
137}
138
139/// The machine's own refusal of an action the policy accepted.
140fn machine_refusal(body: &str, status: u16) -> ClientError {
141    match serde_json::from_str::<ActionAnswer>(body) {
142        Ok(answer) => ClientError::PrinterRefused {
143            detail: answer
144                .printer_refusal
145                .clone()
146                .unwrap_or_else(|| "it said nothing this client can read".to_owned()),
147            answer: Box::new(answer),
148        },
149        Err(error) => ClientError::Unreadable {
150            status,
151            detail: error.to_string(),
152        },
153    }
154}
155
156/// The request target one call is made to, with what it asks for after the
157/// question mark.
158fn target(path: &str, query: &[(String, String)]) -> String {
159    if query.is_empty() {
160        return path.to_owned();
161    }
162    let asked: Vec<String> = query
163        .iter()
164        .map(|(name, value)| format!("{}={}", escaped(name), escaped(value)))
165        .collect();
166    format!("{path}?{}", asked.join("&"))
167}
168
169/// One value of a request target, with everything that is not unreserved in it
170/// written as an escape.
171fn escaped(value: &str) -> String {
172    value
173        .bytes()
174        .map(|byte| match byte {
175            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
176                (byte as char).to_string()
177            }
178            other => format!("%{other:02X}"),
179        })
180        .collect()
181}
182
183/// What the supervisor said about a request it will not act on.
184fn said(body: &str) -> String {
185    serde_json::from_str::<ErrorAnswer>(body).map_or_else(
186        |_| "it said nothing this client can read".to_owned(),
187        |answer| answer.error,
188    )
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{Client, escaped, said, target};
194    use crate::contract::Actor;
195
196    /// An address is taken as the supervisor's own configuration writes it.
197    #[test]
198    fn an_address_is_taken_as_the_supervisors_configuration_writes_it() {
199        for given in [
200            "http://127.0.0.1:8420",
201            "127.0.0.1:8420",
202            "http://127.0.0.1:8420/",
203        ] {
204            let client = Client::new(given, Actor::Operator);
205            assert_eq!(client.address(), "127.0.0.1:8420", "{given}");
206        }
207    }
208
209    /// A client acts as the actor it was made with, and no call changes that.
210    #[test]
211    fn a_client_acts_as_the_actor_it_was_made_with() {
212        let client = Client::new(
213            "127.0.0.1:8420",
214            Actor::Agent {
215                session_name: "a-session".to_owned(),
216            },
217        );
218        assert_eq!(
219            client.actor(),
220            Actor::Agent {
221                session_name: "a-session".to_owned()
222            }
223        );
224    }
225
226    /// What is asked for after the question mark is escaped where it must be.
227    #[test]
228    fn what_is_asked_for_after_the_question_mark_is_escaped() {
229        assert_eq!(target("/v1/x", &[]), "/v1/x");
230        assert_eq!(
231            target("/v1/x", &[("limit".to_owned(), "20".to_owned())]),
232            "/v1/x?limit=20"
233        );
234        assert_eq!(escaped("a b&c"), "a%20b%26c");
235    }
236
237    /// A refusal this client cannot read is said to be one rather than shown.
238    #[test]
239    fn a_refusal_this_client_cannot_read_is_said_to_be_one() {
240        assert_eq!(said(r#"{"error":"no such print"}"#), "no such print");
241        assert_eq!(said("<html>"), "it said nothing this client can read");
242    }
243}