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::{
11    ActionAnswer, Actor, ErrorAnswer, ExecutionOutcome, RecoveryAnswer, RecoveryDecision,
12    SpoolCommandAnswer,
13};
14use crate::error::{ClientError, RecoveryRejected, RejectedAnswer, Rejection};
15use crate::transport::send;
16
17/// The status a rejected action is answered under.
18const REJECTED_STATUS: u16 = 409;
19
20/// The status an action the machine itself refused is answered under.
21const MACHINE_REFUSED_STATUS: u16 = 502;
22
23/// The status a read or a write that was carried out is answered under.
24const SUCCESS_STATUS: u16 = 200;
25
26/// The scheme the address a supervisor is configured at may carry.
27const SCHEME: &str = "http://";
28
29/// A typed client of one printobserver supervisor.
30///
31/// One method per public operation that supervisor serves, each generated from
32/// the same checked-in description the server's own routes are folded out of.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Client {
35    /// Where the supervisor answers, as a host and a port.
36    address: String,
37    /// What authenticates to it, where anything does.
38    credential: Option<String>,
39    /// Who this client acts as.
40    actor: Actor,
41}
42
43impl Client {
44    /// A client of the supervisor at one address, acting as one actor.
45    ///
46    /// The address is taken as the supervisor's own configuration writes it —
47    /// `http://127.0.0.1:8420` — and a bare `host:port` is taken as well.
48    #[must_use]
49    pub fn new(address: impl AsRef<str>, actor: Actor) -> Self {
50        Self {
51            address: address
52                .as_ref()
53                .trim()
54                .trim_end_matches('/')
55                .strip_prefix(SCHEME)
56                .unwrap_or_else(|| address.as_ref().trim().trim_end_matches('/'))
57                .to_owned(),
58            credential: None,
59            actor,
60        }
61    }
62
63    /// The same client, authenticating with one credential.
64    ///
65    /// The supervisor serves no versioned operation to a caller that does not
66    /// present a credential it admits — the operator's, whose verifier it is
67    /// configured with and which `printobserver credential issue` issues, or the
68    /// one a supervision turn is handed — and a call it refuses for that arrives
69    /// as [`ClientError::Refused`] under status `401`. A call whose actor is not
70    /// the identity that credential is arrives under status `403`.
71    #[must_use]
72    pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
73        self.credential = Some(credential.into());
74        self
75    }
76
77    /// Where this client is pointed.
78    #[must_use]
79    pub fn address(&self) -> &str {
80        &self.address
81    }
82
83    /// Who this client acts as, which every mutating call carries.
84    #[must_use]
85    pub fn actor(&self) -> Actor {
86        self.actor.clone()
87    }
88
89    /// Make one call and read what came back.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`ClientError`] for each of the five ways a call ends other
94    /// than with its answer; a refused action arrives as
95    /// [`ClientError::Rejected`], carrying the reason, the value asked for and
96    /// the range allowed.
97    pub fn call<T: DeserializeOwned>(
98        &self,
99        method: &str,
100        path: &str,
101        query: &[(String, String)],
102        body: Option<&serde_json::Value>,
103    ) -> Result<T, ClientError> {
104        let document = body.map(ToString::to_string);
105        let answered = send(
106            &self.address,
107            self.credential.as_deref(),
108            method,
109            &target(path, query),
110            document.as_deref(),
111        )?;
112        match answered.status {
113            SUCCESS_STATUS => {
114                serde_json::from_str(&answered.body).map_err(|error| ClientError::Unreadable {
115                    status: answered.status,
116                    detail: error.to_string(),
117                })
118            }
119            REJECTED_STATUS => Err(rejection(&answered.body, answered.status)),
120            MACHINE_REFUSED_STATUS => Err(machine_refusal(&answered.body, answered.status)),
121            status => Err(ClientError::Refused {
122                status,
123                detail: said(&answered.body),
124            }),
125        }
126    }
127}
128
129/// The policy's own refusal, as the answer to it carries it.
130fn rejection(body: &str, status: u16) -> ClientError {
131    // A recovery's answer names its action and carries its own decision.
132    if let Ok(document) = serde_json::from_str::<serde_json::Value>(body)
133        && document.get("action").is_some()
134        && document.get("decision").is_some()
135    {
136        return match serde_json::from_value::<RecoveryAnswer>(document) {
137            Ok(answer) => RecoveryRejected::of(answer).map_or_else(
138                || ClientError::Unreadable {
139                    status,
140                    detail: "the supervisor refused this recovery and answered no refusal"
141                        .to_owned(),
142                },
143                |rejected| ClientError::RecoveryRejected(Box::new(rejected)),
144            ),
145            Err(error) => ClientError::Unreadable {
146                status,
147                detail: error.to_string(),
148            },
149        };
150    }
151    // An action's answer carries its decision in its record; a spool command's
152    // carries it at the top.
153    let answered = serde_json::from_str::<serde_json::Value>(body).and_then(|document| {
154        if document.get("record").is_some() {
155            serde_json::from_value::<ActionAnswer>(document)
156                .map(|answer| RejectedAnswer::Action(Box::new(answer)))
157        } else {
158            serde_json::from_value::<SpoolCommandAnswer>(document)
159                .map(|answer| RejectedAnswer::SpoolCommand(Box::new(answer)))
160        }
161    });
162    match answered.map(Rejection::of) {
163        Ok(Some(rejection)) => ClientError::Rejected(Box::new(rejection)),
164        Ok(None) => ClientError::Unreadable {
165            status,
166            detail: "the supervisor refused this action and answered a record whose \
167                     decision is not a refusal"
168                .to_owned(),
169        },
170        Err(error) => ClientError::Unreadable {
171            status,
172            detail: error.to_string(),
173        },
174    }
175}
176
177/// The machine's own refusal of an action the policy accepted.
178///
179/// A recovery the machine refused carries no action record, and arrives as the
180/// supervisor's own words for it — and only a recovery carried out and failed
181/// at the printer is one: any other decision under this status is no answer
182/// this client can read.
183fn machine_refusal(body: &str, status: u16) -> ClientError {
184    if let Ok(answer) = serde_json::from_str::<RecoveryAnswer>(body) {
185        return match answer.decision {
186            RecoveryDecision::Executed(ExecutionOutcome::Failed { .. }) => ClientError::Refused {
187                status,
188                detail: answer.detail,
189            },
190            _ => ClientError::Unreadable {
191                status,
192                detail: "the supervisor answered a machine refusal of a recovery that did not \
193                         fail at the printer"
194                    .to_owned(),
195            },
196        };
197    }
198    match serde_json::from_str::<ActionAnswer>(body) {
199        Ok(answer) => ClientError::PrinterRefused {
200            detail: answer
201                .printer_refusal
202                .clone()
203                .unwrap_or_else(|| "it said nothing this client can read".to_owned()),
204            answer: Box::new(answer),
205        },
206        Err(error) => ClientError::Unreadable {
207            status,
208            detail: error.to_string(),
209        },
210    }
211}
212
213/// The request target one call is made to, with what it asks for after the
214/// question mark.
215fn target(path: &str, query: &[(String, String)]) -> String {
216    if query.is_empty() {
217        return path.to_owned();
218    }
219    let asked: Vec<String> = query
220        .iter()
221        .map(|(name, value)| format!("{}={}", escaped(name), escaped(value)))
222        .collect();
223    format!("{path}?{}", asked.join("&"))
224}
225
226/// One value of a request target, with everything that is not unreserved in it
227/// written as an escape.
228fn escaped(value: &str) -> String {
229    value
230        .bytes()
231        .map(|byte| match byte {
232            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
233                (byte as char).to_string()
234            }
235            other => format!("%{other:02X}"),
236        })
237        .collect()
238}
239
240/// What the supervisor said about a request it will not act on.
241fn said(body: &str) -> String {
242    serde_json::from_str::<ErrorAnswer>(body).map_or_else(
243        |_| "it said nothing this client can read".to_owned(),
244        |answer| answer.error,
245    )
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{Client, escaped, said, target};
251    use crate::contract::Actor;
252
253    /// An address is taken as the supervisor's own configuration writes it.
254    #[test]
255    fn an_address_is_taken_as_the_supervisors_configuration_writes_it() {
256        for given in [
257            "http://127.0.0.1:8420",
258            "127.0.0.1:8420",
259            "http://127.0.0.1:8420/",
260        ] {
261            let client = Client::new(given, Actor::Operator);
262            assert_eq!(client.address(), "127.0.0.1:8420", "{given}");
263        }
264    }
265
266    /// A client acts as the actor it was made with, and no call changes that.
267    #[test]
268    fn a_client_acts_as_the_actor_it_was_made_with() {
269        let client = Client::new(
270            "127.0.0.1:8420",
271            Actor::Agent {
272                session_name: "a-session".to_owned(),
273            },
274        );
275        assert_eq!(
276            client.actor(),
277            Actor::Agent {
278                session_name: "a-session".to_owned()
279            }
280        );
281    }
282
283    /// What is asked for after the question mark is escaped where it must be.
284    #[test]
285    fn what_is_asked_for_after_the_question_mark_is_escaped() {
286        assert_eq!(target("/v1/x", &[]), "/v1/x");
287        assert_eq!(
288            target("/v1/x", &[("limit".to_owned(), "20".to_owned())]),
289            "/v1/x?limit=20"
290        );
291        assert_eq!(escaped("a b&c"), "a%20b%26c");
292    }
293
294    /// A recovery's answer, around one decision.
295    fn recovery(decision: &str) -> String {
296        format!(
297            r#"{{"action":{{"action":"recover_fan","percent":50.0}},"decision":{decision},"detail":"recover_fan reached the printer, which refused it: the fan is stuck","events":[]}}"#
298        )
299    }
300
301    /// A recovery the machine refused carries the supervisor's own sentence.
302    #[test]
303    fn a_recovery_the_machine_refused_carries_the_supervisors_words() {
304        let refused = super::machine_refusal(
305            &recovery(r#"{"executed":{"failed":{"reason":"the fan is stuck"}}}"#),
306            502,
307        );
308        let super::ClientError::Refused { status, detail } = refused else {
309            panic!("the recovery's refusal arrived as {refused:?}");
310        };
311        assert_eq!(status, 502);
312        assert!(detail.contains("the fan is stuck"), "{detail}");
313    }
314
315    /// A recovery refused is a typed refusal carrying its bounds, and one
316    /// whose decision is no refusal at all is said to be unreadable.
317    #[test]
318    fn a_recovery_refusal_is_typed_and_one_that_is_none_is_unreadable() {
319        let refused = super::rejection(
320            &recovery(
321                r#"{"rejected":{"out_of_bounds":{"field":"percent","requested":150.0,"allowed":{"min":0.0,"max":100.0}}}}"#,
322            ),
323            409,
324        );
325        let super::ClientError::RecoveryRejected(rejected) = refused else {
326            panic!("the recovery's refusal arrived as {refused:?}");
327        };
328        assert_eq!(rejected.requested, Some(150.0));
329        let carried = super::rejection(&recovery(r#"{"executed":"succeeded"}"#), 409);
330        assert!(
331            matches!(carried, super::ClientError::Unreadable { status: 409, .. }),
332            "{carried:?}"
333        );
334        let malformed = super::rejection(&recovery(r#"{"rejected":{"out_of_bounds":7}}"#), 409);
335        assert!(
336            matches!(
337                malformed,
338                super::ClientError::Unreadable { status: 409, .. }
339            ),
340            "{malformed:?}"
341        );
342    }
343
344    /// An allowance is read as `call` reads every answer, and only when its
345    /// `allowed` and `refusal` agree: either contradiction is refused as it
346    /// is read, rather than handed on as a value no supervisor sends.
347    #[test]
348    fn an_allowance_whose_two_halves_disagree_is_not_read() {
349        use crate::contract::ActionAllowance;
350        for agreeing in [
351            r#"{"action":"recover_lift","allowed":true}"#,
352            r#"{"action":"recover_heat","allowed":false,"refusal":"z_untrusted"}"#,
353        ] {
354            let read = serde_json::from_str::<ActionAllowance>(agreeing);
355            assert!(read.is_ok(), "{agreeing}: {read:?}");
356        }
357        for contradiction in [
358            r#"{"action":"recover_lift","allowed":true,"refusal":"z_untrusted"}"#,
359            r#"{"action":"recover_heat","allowed":false}"#,
360        ] {
361            let error = serde_json::from_str::<ActionAllowance>(contradiction)
362                .expect_err("a contradictory allowance is not read");
363            assert!(
364                error.to_string().contains("they disagree"),
365                "{contradiction}: {error}"
366            );
367        }
368    }
369
370    /// A refusal this client cannot read is said to be one rather than shown.
371    #[test]
372    fn a_refusal_this_client_cannot_read_is_said_to_be_one() {
373        assert_eq!(said(r#"{"error":"no such print"}"#), "no such print");
374        assert_eq!(said("<html>"), "it said nothing this client can read");
375    }
376}