use serde::de::DeserializeOwned;
use crate::contract::{
ActionAnswer, Actor, ErrorAnswer, ExecutionOutcome, RecoveryAnswer, RecoveryDecision,
SpoolCommandAnswer,
};
use crate::error::{ClientError, RecoveryRejected, RejectedAnswer, Rejection};
use crate::transport::send;
const REJECTED_STATUS: u16 = 409;
const MACHINE_REFUSED_STATUS: u16 = 502;
const SUCCESS_STATUS: u16 = 200;
const SCHEME: &str = "http://";
#[derive(Debug, Clone, PartialEq)]
pub struct Client {
address: String,
credential: Option<String>,
actor: Actor,
}
impl Client {
#[must_use]
pub fn new(address: impl AsRef<str>, actor: Actor) -> Self {
Self {
address: address
.as_ref()
.trim()
.trim_end_matches('/')
.strip_prefix(SCHEME)
.unwrap_or_else(|| address.as_ref().trim().trim_end_matches('/'))
.to_owned(),
credential: None,
actor,
}
}
#[must_use]
pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
self.credential = Some(credential.into());
self
}
#[must_use]
pub fn address(&self) -> &str {
&self.address
}
#[must_use]
pub fn actor(&self) -> Actor {
self.actor.clone()
}
pub fn call<T: DeserializeOwned>(
&self,
method: &str,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<T, ClientError> {
let document = body.map(ToString::to_string);
let answered = send(
&self.address,
self.credential.as_deref(),
method,
&target(path, query),
document.as_deref(),
)?;
match answered.status {
SUCCESS_STATUS => {
serde_json::from_str(&answered.body).map_err(|error| ClientError::Unreadable {
status: answered.status,
detail: error.to_string(),
})
}
REJECTED_STATUS => Err(rejection(&answered.body, answered.status)),
MACHINE_REFUSED_STATUS => Err(machine_refusal(&answered.body, answered.status)),
status => Err(ClientError::Refused {
status,
detail: said(&answered.body),
}),
}
}
}
fn rejection(body: &str, status: u16) -> ClientError {
if let Ok(document) = serde_json::from_str::<serde_json::Value>(body)
&& document.get("action").is_some()
&& document.get("decision").is_some()
{
return match serde_json::from_value::<RecoveryAnswer>(document) {
Ok(answer) => RecoveryRejected::of(answer).map_or_else(
|| ClientError::Unreadable {
status,
detail: "the supervisor refused this recovery and answered no refusal"
.to_owned(),
},
|rejected| ClientError::RecoveryRejected(Box::new(rejected)),
),
Err(error) => ClientError::Unreadable {
status,
detail: error.to_string(),
},
};
}
let answered = serde_json::from_str::<serde_json::Value>(body).and_then(|document| {
if document.get("record").is_some() {
serde_json::from_value::<ActionAnswer>(document)
.map(|answer| RejectedAnswer::Action(Box::new(answer)))
} else {
serde_json::from_value::<SpoolCommandAnswer>(document)
.map(|answer| RejectedAnswer::SpoolCommand(Box::new(answer)))
}
});
match answered.map(Rejection::of) {
Ok(Some(rejection)) => ClientError::Rejected(Box::new(rejection)),
Ok(None) => ClientError::Unreadable {
status,
detail: "the supervisor refused this action and answered a record whose \
decision is not a refusal"
.to_owned(),
},
Err(error) => ClientError::Unreadable {
status,
detail: error.to_string(),
},
}
}
fn machine_refusal(body: &str, status: u16) -> ClientError {
if let Ok(answer) = serde_json::from_str::<RecoveryAnswer>(body) {
return match answer.decision {
RecoveryDecision::Executed(ExecutionOutcome::Failed { .. }) => ClientError::Refused {
status,
detail: answer.detail,
},
_ => ClientError::Unreadable {
status,
detail: "the supervisor answered a machine refusal of a recovery that did not \
fail at the printer"
.to_owned(),
},
};
}
match serde_json::from_str::<ActionAnswer>(body) {
Ok(answer) => ClientError::PrinterRefused {
detail: answer
.printer_refusal
.clone()
.unwrap_or_else(|| "it said nothing this client can read".to_owned()),
answer: Box::new(answer),
},
Err(error) => ClientError::Unreadable {
status,
detail: error.to_string(),
},
}
}
fn target(path: &str, query: &[(String, String)]) -> String {
if query.is_empty() {
return path.to_owned();
}
let asked: Vec<String> = query
.iter()
.map(|(name, value)| format!("{}={}", escaped(name), escaped(value)))
.collect();
format!("{path}?{}", asked.join("&"))
}
fn escaped(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(byte as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
fn said(body: &str) -> String {
serde_json::from_str::<ErrorAnswer>(body).map_or_else(
|_| "it said nothing this client can read".to_owned(),
|answer| answer.error,
)
}
#[cfg(test)]
mod tests {
use super::{Client, escaped, said, target};
use crate::contract::Actor;
#[test]
fn an_address_is_taken_as_the_supervisors_configuration_writes_it() {
for given in [
"http://127.0.0.1:8420",
"127.0.0.1:8420",
"http://127.0.0.1:8420/",
] {
let client = Client::new(given, Actor::Operator);
assert_eq!(client.address(), "127.0.0.1:8420", "{given}");
}
}
#[test]
fn a_client_acts_as_the_actor_it_was_made_with() {
let client = Client::new(
"127.0.0.1:8420",
Actor::Agent {
session_name: "a-session".to_owned(),
},
);
assert_eq!(
client.actor(),
Actor::Agent {
session_name: "a-session".to_owned()
}
);
}
#[test]
fn what_is_asked_for_after_the_question_mark_is_escaped() {
assert_eq!(target("/v1/x", &[]), "/v1/x");
assert_eq!(
target("/v1/x", &[("limit".to_owned(), "20".to_owned())]),
"/v1/x?limit=20"
);
assert_eq!(escaped("a b&c"), "a%20b%26c");
}
fn recovery(decision: &str) -> String {
format!(
r#"{{"action":{{"action":"recover_fan","percent":50.0}},"decision":{decision},"detail":"recover_fan reached the printer, which refused it: the fan is stuck","events":[]}}"#
)
}
#[test]
fn a_recovery_the_machine_refused_carries_the_supervisors_words() {
let refused = super::machine_refusal(
&recovery(r#"{"executed":{"failed":{"reason":"the fan is stuck"}}}"#),
502,
);
let super::ClientError::Refused { status, detail } = refused else {
panic!("the recovery's refusal arrived as {refused:?}");
};
assert_eq!(status, 502);
assert!(detail.contains("the fan is stuck"), "{detail}");
}
#[test]
fn a_recovery_refusal_is_typed_and_one_that_is_none_is_unreadable() {
let refused = super::rejection(
&recovery(
r#"{"rejected":{"out_of_bounds":{"field":"percent","requested":150.0,"allowed":{"min":0.0,"max":100.0}}}}"#,
),
409,
);
let super::ClientError::RecoveryRejected(rejected) = refused else {
panic!("the recovery's refusal arrived as {refused:?}");
};
assert_eq!(rejected.requested, Some(150.0));
let carried = super::rejection(&recovery(r#"{"executed":"succeeded"}"#), 409);
assert!(
matches!(carried, super::ClientError::Unreadable { status: 409, .. }),
"{carried:?}"
);
let malformed = super::rejection(&recovery(r#"{"rejected":{"out_of_bounds":7}}"#), 409);
assert!(
matches!(
malformed,
super::ClientError::Unreadable { status: 409, .. }
),
"{malformed:?}"
);
}
#[test]
fn an_allowance_whose_two_halves_disagree_is_not_read() {
use crate::contract::ActionAllowance;
for agreeing in [
r#"{"action":"recover_lift","allowed":true}"#,
r#"{"action":"recover_heat","allowed":false,"refusal":"z_untrusted"}"#,
] {
let read = serde_json::from_str::<ActionAllowance>(agreeing);
assert!(read.is_ok(), "{agreeing}: {read:?}");
}
for contradiction in [
r#"{"action":"recover_lift","allowed":true,"refusal":"z_untrusted"}"#,
r#"{"action":"recover_heat","allowed":false}"#,
] {
let error = serde_json::from_str::<ActionAllowance>(contradiction)
.expect_err("a contradictory allowance is not read");
assert!(
error.to_string().contains("they disagree"),
"{contradiction}: {error}"
);
}
}
#[test]
fn a_refusal_this_client_cannot_read_is_said_to_be_one() {
assert_eq!(said(r#"{"error":"no such print"}"#), "no such print");
assert_eq!(said("<html>"), "it said nothing this client can read");
}
}