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]
65 pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
66 self.credential = Some(credential.into());
67 self
68 }
69
70 #[must_use]
72 pub fn address(&self) -> &str {
73 &self.address
74 }
75
76 #[must_use]
78 pub fn actor(&self) -> Actor {
79 self.actor.clone()
80 }
81
82 pub fn call<T: DeserializeOwned>(
91 &self,
92 method: &str,
93 path: &str,
94 query: &[(String, String)],
95 body: Option<&serde_json::Value>,
96 ) -> Result<T, ClientError> {
97 let document = body.map(ToString::to_string);
98 let answered = send(
99 &self.address,
100 self.credential.as_deref(),
101 method,
102 &target(path, query),
103 document.as_deref(),
104 )?;
105 match answered.status {
106 SUCCESS_STATUS => {
107 serde_json::from_str(&answered.body).map_err(|error| ClientError::Unreadable {
108 status: answered.status,
109 detail: error.to_string(),
110 })
111 }
112 REJECTED_STATUS => Err(rejection(&answered.body, answered.status)),
113 MACHINE_REFUSED_STATUS => Err(machine_refusal(&answered.body, answered.status)),
114 status => Err(ClientError::Refused {
115 status,
116 detail: said(&answered.body),
117 }),
118 }
119 }
120}
121
122fn rejection(body: &str, status: u16) -> ClientError {
124 match serde_json::from_str::<ActionAnswer>(body).map(Rejection::of) {
125 Ok(Some(rejection)) => ClientError::Rejected(Box::new(rejection)),
126 Ok(None) => ClientError::Unreadable {
127 status,
128 detail: "the supervisor refused this action and answered a record whose \
129 decision is not a refusal"
130 .to_owned(),
131 },
132 Err(error) => ClientError::Unreadable {
133 status,
134 detail: error.to_string(),
135 },
136 }
137}
138
139fn machine_refusal(body: &str, status: u16) -> ClientError {
141 match serde_json::from_str::<ActionAnswer>(body) {
142 Ok(answer) => ClientError::PrinterRefused {
143 detail: answer
144 .printer_refusal
145 .clone()
146 .unwrap_or_else(|| "it said nothing this client can read".to_owned()),
147 answer: Box::new(answer),
148 },
149 Err(error) => ClientError::Unreadable {
150 status,
151 detail: error.to_string(),
152 },
153 }
154}
155
156fn target(path: &str, query: &[(String, String)]) -> String {
159 if query.is_empty() {
160 return path.to_owned();
161 }
162 let asked: Vec<String> = query
163 .iter()
164 .map(|(name, value)| format!("{}={}", escaped(name), escaped(value)))
165 .collect();
166 format!("{path}?{}", asked.join("&"))
167}
168
169fn escaped(value: &str) -> String {
172 value
173 .bytes()
174 .map(|byte| match byte {
175 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
176 (byte as char).to_string()
177 }
178 other => format!("%{other:02X}"),
179 })
180 .collect()
181}
182
183fn said(body: &str) -> String {
185 serde_json::from_str::<ErrorAnswer>(body).map_or_else(
186 |_| "it said nothing this client can read".to_owned(),
187 |answer| answer.error,
188 )
189}
190
191#[cfg(test)]
192mod tests {
193 use super::{Client, escaped, said, target};
194 use crate::contract::Actor;
195
196 #[test]
198 fn an_address_is_taken_as_the_supervisors_configuration_writes_it() {
199 for given in [
200 "http://127.0.0.1:8420",
201 "127.0.0.1:8420",
202 "http://127.0.0.1:8420/",
203 ] {
204 let client = Client::new(given, Actor::Operator);
205 assert_eq!(client.address(), "127.0.0.1:8420", "{given}");
206 }
207 }
208
209 #[test]
211 fn a_client_acts_as_the_actor_it_was_made_with() {
212 let client = Client::new(
213 "127.0.0.1:8420",
214 Actor::Agent {
215 session_name: "a-session".to_owned(),
216 },
217 );
218 assert_eq!(
219 client.actor(),
220 Actor::Agent {
221 session_name: "a-session".to_owned()
222 }
223 );
224 }
225
226 #[test]
228 fn what_is_asked_for_after_the_question_mark_is_escaped() {
229 assert_eq!(target("/v1/x", &[]), "/v1/x");
230 assert_eq!(
231 target("/v1/x", &[("limit".to_owned(), "20".to_owned())]),
232 "/v1/x?limit=20"
233 );
234 assert_eq!(escaped("a b&c"), "a%20b%26c");
235 }
236
237 #[test]
239 fn a_refusal_this_client_cannot_read_is_said_to_be_one() {
240 assert_eq!(said(r#"{"error":"no such print"}"#), "no such print");
241 assert_eq!(said("<html>"), "it said nothing this client can read");
242 }
243}