use crate::clients::SINGLE_REQUEST_BUDGET;
use crate::error::{self, CliError, Operation, Phase, Plane, SdkContext, SecretSet};
use scm::{ErrorKind, RemoteOutcome};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
TopicCreated,
TopicUpdated,
TopicDeleted,
ProfileCreated,
ProfileUpdated,
ProfileDeleted,
Words(&'static str, &'static str),
}
impl Change {
pub fn words(self) -> (&'static str, &'static str) {
match self {
Self::TopicCreated => ("topic", "created"),
Self::TopicUpdated => ("topic", "updated"),
Self::TopicDeleted => ("topic", "deleted"),
Self::ProfileCreated => ("profile", "created"),
Self::ProfileUpdated => ("profile", "updated"),
Self::ProfileDeleted => ("profile", "deleted"),
Self::Words(thing, done) => (thing, done),
}
}
pub fn unknown_line(self) -> String {
let (thing, done) = self.words();
format!("Outcome unknown: the {thing} may have been {done}.")
}
pub fn unreadable_line(self) -> String {
let (thing, done) = self.words();
format!("Outcome: the {thing} was {done}, but the response could not be read.")
}
}
fn replayable(error: &scm::Error) -> bool {
error.kind() == ErrorKind::Http
&& error.status() == Some(401)
&& error.remote_outcome() == RemoteOutcome::Rejected
}
pub async fn attempt<T>(mut call: impl AsyncFnMut() -> scm::Result<T>) -> scm::Result<T> {
match call().await {
Err(error) if replayable(&error) => call().await,
other => other,
}
}
pub async fn send<T>(
secrets: &SecretSet,
change: Change,
call: impl AsyncFnMut() -> scm::Result<T>,
) -> Result<T, CliError> {
attempt(call)
.await
.map_err(|error| describe(&error, change, secrets))
}
pub async fn send_hinted<T>(
secrets: &SecretSet,
change: Change,
hint: Option<&str>,
call: impl AsyncFnMut() -> scm::Result<T>,
) -> Result<T, CliError> {
attempt(call).await.map_err(|error| {
let mut failure = describe(&error, change, secrets);
if let (CliError::Operational(failure), Some(hint)) = (&mut failure, hint)
&& failure
.details
.iter()
.any(|line| *line == change.unknown_line() || *line == change.unreadable_line())
{
failure.details.push(hint.to_owned());
}
failure
})
}
pub fn describe(error: &scm::Error, change: Change, secrets: &SecretSet) -> CliError {
let mut failure = error::from_sdk(
error,
&SdkContext {
plane: Plane::Management,
phase: Phase::Api,
operation: Operation::Write,
timeout: SINGLE_REQUEST_BUDGET,
secrets,
},
);
if let CliError::Operational(failure) = &mut failure {
let applied = error.kind() == ErrorKind::Decode
&& error
.status()
.is_some_and(|status| (200..300).contains(&status));
if applied {
failure.details.push(change.unreadable_line());
} else if error.remote_outcome() == RemoteOutcome::Unknown {
failure.details.push(change.unknown_line());
}
}
failure
}
pub fn invalid_body(method: &str, path: &str) -> CliError {
CliError::operational(format!(
"AISEC_USER_REQUEST_PAYLOAD_ERROR:Request body for {method} {path} did not match schema"
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{Failure, InterruptContext};
#[test]
fn outcome_lines() {
assert_eq!(
Change::TopicCreated.unknown_line(),
"Outcome unknown: the topic may have been created."
);
assert_eq!(
Change::ProfileDeleted.unreadable_line(),
"Outcome: the profile was deleted, but the response could not be read."
);
assert_eq!(Change::ProfileUpdated.words(), ("profile", "updated"));
}
#[test]
fn local_and_rejected_failures_add_no_outcome_line() {
let secrets = SecretSet::default();
let validation = describe(
&scm::Error::new(ErrorKind::Validation),
Change::TopicCreated,
&secrets,
);
assert_eq!(
validation,
CliError::Operational(Failure {
message: "AISEC_USER_REQUEST_PAYLOAD_ERROR:Validation error".into(),
status: None,
details: Vec::new(),
})
);
let cancelled = describe(
&scm::Error::new(ErrorKind::Cancelled),
Change::TopicDeleted,
&secrets,
);
assert_eq!(
cancelled,
CliError::Interrupted(InterruptContext::NothingChanged)
);
assert_eq!(
invalid_body("POST", "/v1/mgmt/profile").message(),
"AISEC_USER_REQUEST_PAYLOAD_ERROR:Request body for POST /v1/mgmt/profile did not match schema"
);
}
}