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    /// The supervisor serves no versioned operation to a caller that does not
63    /// present the credential it is configured with — its `api.credential`, or
64    /// the one it generated into its state directory and wrote into the client
65    /// configuration beside it — and a call it refuses for that arrives as
66    /// [`ClientError::Refused`] under status `401`.
67    #[must_use]
68    pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
69        self.credential = Some(credential.into());
70        self
71    }
72
73    /// Where this client is pointed.
74    #[must_use]
75    pub fn address(&self) -> &str {
76        &self.address
77    }
78
79    /// Who this client acts as, which every mutating call carries.
80    #[must_use]
81    pub fn actor(&self) -> Actor {
82        self.actor.clone()
83    }
84
85    /// Make one call and read what came back.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`ClientError`] for each of the five ways a call ends other
90    /// than with its answer; a refused action arrives as
91    /// [`ClientError::Rejected`], carrying the reason, the value asked for and
92    /// the range allowed.
93    pub fn call<T: DeserializeOwned>(
94        &self,
95        method: &str,
96        path: &str,
97        query: &[(String, String)],
98        body: Option<&serde_json::Value>,
99    ) -> Result<T, ClientError> {
100        let document = body.map(ToString::to_string);
101        let answered = send(
102            &self.address,
103            self.credential.as_deref(),
104            method,
105            &target(path, query),
106            document.as_deref(),
107        )?;
108        match answered.status {
109            SUCCESS_STATUS => {
110                serde_json::from_str(&answered.body).map_err(|error| ClientError::Unreadable {
111                    status: answered.status,
112                    detail: error.to_string(),
113                })
114            }
115            REJECTED_STATUS => Err(rejection(&answered.body, answered.status)),
116            MACHINE_REFUSED_STATUS => Err(machine_refusal(&answered.body, answered.status)),
117            status => Err(ClientError::Refused {
118                status,
119                detail: said(&answered.body),
120            }),
121        }
122    }
123}
124
125/// The policy's own refusal, as the answer to it carries it.
126fn rejection(body: &str, status: u16) -> ClientError {
127    match serde_json::from_str::<ActionAnswer>(body).map(Rejection::of) {
128        Ok(Some(rejection)) => ClientError::Rejected(Box::new(rejection)),
129        Ok(None) => ClientError::Unreadable {
130            status,
131            detail: "the supervisor refused this action and answered a record whose \
132                     decision is not a refusal"
133                .to_owned(),
134        },
135        Err(error) => ClientError::Unreadable {
136            status,
137            detail: error.to_string(),
138        },
139    }
140}
141
142/// The machine's own refusal of an action the policy accepted.
143fn machine_refusal(body: &str, status: u16) -> ClientError {
144    match serde_json::from_str::<ActionAnswer>(body) {
145        Ok(answer) => ClientError::PrinterRefused {
146            detail: answer
147                .printer_refusal
148                .clone()
149                .unwrap_or_else(|| "it said nothing this client can read".to_owned()),
150            answer: Box::new(answer),
151        },
152        Err(error) => ClientError::Unreadable {
153            status,
154            detail: error.to_string(),
155        },
156    }
157}
158
159/// The request target one call is made to, with what it asks for after the
160/// question mark.
161fn target(path: &str, query: &[(String, String)]) -> String {
162    if query.is_empty() {
163        return path.to_owned();
164    }
165    let asked: Vec<String> = query
166        .iter()
167        .map(|(name, value)| format!("{}={}", escaped(name), escaped(value)))
168        .collect();
169    format!("{path}?{}", asked.join("&"))
170}
171
172/// One value of a request target, with everything that is not unreserved in it
173/// written as an escape.
174fn escaped(value: &str) -> String {
175    value
176        .bytes()
177        .map(|byte| match byte {
178            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
179                (byte as char).to_string()
180            }
181            other => format!("%{other:02X}"),
182        })
183        .collect()
184}
185
186/// What the supervisor said about a request it will not act on.
187fn said(body: &str) -> String {
188    serde_json::from_str::<ErrorAnswer>(body).map_or_else(
189        |_| "it said nothing this client can read".to_owned(),
190        |answer| answer.error,
191    )
192}
193
194#[cfg(test)]
195mod tests {
196    use super::{Client, escaped, said, target};
197    use crate::contract::Actor;
198
199    /// An address is taken as the supervisor's own configuration writes it.
200    #[test]
201    fn an_address_is_taken_as_the_supervisors_configuration_writes_it() {
202        for given in [
203            "http://127.0.0.1:8420",
204            "127.0.0.1:8420",
205            "http://127.0.0.1:8420/",
206        ] {
207            let client = Client::new(given, Actor::Operator);
208            assert_eq!(client.address(), "127.0.0.1:8420", "{given}");
209        }
210    }
211
212    /// A client acts as the actor it was made with, and no call changes that.
213    #[test]
214    fn a_client_acts_as_the_actor_it_was_made_with() {
215        let client = Client::new(
216            "127.0.0.1:8420",
217            Actor::Agent {
218                session_name: "a-session".to_owned(),
219            },
220        );
221        assert_eq!(
222            client.actor(),
223            Actor::Agent {
224                session_name: "a-session".to_owned()
225            }
226        );
227    }
228
229    /// What is asked for after the question mark is escaped where it must be.
230    #[test]
231    fn what_is_asked_for_after_the_question_mark_is_escaped() {
232        assert_eq!(target("/v1/x", &[]), "/v1/x");
233        assert_eq!(
234            target("/v1/x", &[("limit".to_owned(), "20".to_owned())]),
235            "/v1/x?limit=20"
236        );
237        assert_eq!(escaped("a b&c"), "a%20b%26c");
238    }
239
240    /// A refusal this client cannot read is said to be one rather than shown.
241    #[test]
242    fn a_refusal_this_client_cannot_read_is_said_to_be_one() {
243        assert_eq!(said(r#"{"error":"no such print"}"#), "no such print");
244        assert_eq!(said("<html>"), "it said nothing this client can read");
245    }
246}