printobserver_sdk/
client.rs1use serde::de::DeserializeOwned;
9
10use crate::contract::{ActionAnswer, Actor, ErrorAnswer, SpoolCommandAnswer};
11use crate::error::{ClientError, RejectedAnswer, Rejection};
12use crate::transport::send;
13
14const REJECTED_STATUS: u16 = 409;
16
17const MACHINE_REFUSED_STATUS: u16 = 502;
19
20const SUCCESS_STATUS: u16 = 200;
22
23const SCHEME: &str = "http://";
25
26#[derive(Debug, Clone, PartialEq)]
31pub struct Client {
32 address: String,
34 credential: Option<String>,
36 actor: Actor,
38}
39
40impl Client {
41 #[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 #[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 #[must_use]
76 pub fn address(&self) -> &str {
77 &self.address
78 }
79
80 #[must_use]
82 pub fn actor(&self) -> Actor {
83 self.actor.clone()
84 }
85
86 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
126fn rejection(body: &str, status: u16) -> ClientError {
128 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
154fn 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
171fn 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
184fn 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
198fn 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 #[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 #[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 #[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 #[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}