printobserver_sdk/
client.rs1use serde::de::DeserializeOwned;
9
10use crate::contract::{ActionAnswer, Actor, ErrorAnswer};
11use crate::error::{ClientError, 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]
68 pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
69 self.credential = Some(credential.into());
70 self
71 }
72
73 #[must_use]
75 pub fn address(&self) -> &str {
76 &self.address
77 }
78
79 #[must_use]
81 pub fn actor(&self) -> Actor {
82 self.actor.clone()
83 }
84
85 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
125fn 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
142fn 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
159fn 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
172fn 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
186fn 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 #[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 #[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 #[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 #[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}