1use 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
17const REJECTED_STATUS: u16 = 409;
19
20const MACHINE_REFUSED_STATUS: u16 = 502;
22
23const SUCCESS_STATUS: u16 = 200;
25
26const SCHEME: &str = "http://";
28
29#[derive(Debug, Clone, PartialEq)]
34pub struct Client {
35 address: String,
37 credential: Option<String>,
39 actor: Actor,
41}
42
43impl Client {
44 #[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 #[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 #[must_use]
79 pub fn address(&self) -> &str {
80 &self.address
81 }
82
83 #[must_use]
85 pub fn actor(&self) -> Actor {
86 self.actor.clone()
87 }
88
89 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
129fn rejection(body: &str, status: u16) -> ClientError {
131 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 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
177fn 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
213fn 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
226fn 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
240fn 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}