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