use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("not signed in — run `bitrouter cloud login` first")]
NotSignedIn,
#[error(
"credential is not scoped to a namespace — run `bitrouter cloud login` to get a \
namespace-scoped credential"
)]
NoNamespace,
#[error("failed to resolve a BitRouter Cloud access token: {0:#}")]
Auth(#[source] anyhow::Error),
#[error("bad request: {message}")]
BadRequest {
message: String,
},
#[error("unauthorized: {message}")]
Unauthorized {
message: String,
},
#[error("forbidden: {message}")]
Forbidden {
message: String,
missing_scope: Option<String>,
},
#[error("not found: {message}")]
NotFound {
message: String,
},
#[error("conflict: {message}")]
Conflict {
message: String,
},
#[error("server error (HTTP {status}): {message}")]
Server {
status: u16,
message: String,
},
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("decoding server response: {0}")]
Decode(#[from] serde_json::Error),
}
impl Error {
pub fn missing_scope(&self) -> Option<&str> {
match self {
Error::Forbidden { missing_scope, .. } => missing_scope.as_deref(),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub(super) struct ErrorBody {
pub error: String,
pub error_description: String,
}
impl ErrorBody {
pub(super) fn into_error(self, status: u16) -> Error {
match self.error.as_str() {
"bad_request" => Error::BadRequest {
message: self.error_description,
},
"unauthorized" => Error::Unauthorized {
message: self.error_description,
},
"forbidden" => {
let missing_scope = parse_missing_scope(&self.error_description);
Error::Forbidden {
message: self.error_description,
missing_scope,
}
}
"not_found" => Error::NotFound {
message: self.error_description,
},
"conflict" => Error::Conflict {
message: self.error_description,
},
_ => Error::Server {
status,
message: self.error_description,
},
}
}
}
fn parse_missing_scope(description: &str) -> Option<String> {
let trimmed = description.trim();
trimmed
.strip_prefix("missing required scope: ")
.map(|s| s.trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn body_maps_to_forbidden_with_scope() {
let body = ErrorBody {
error: "forbidden".into(),
error_description: "missing required scope: keys:write".into(),
};
let err = body.into_error(403);
assert_eq!(err.missing_scope(), Some("keys:write"));
assert!(matches!(err, Error::Forbidden { .. }));
}
#[test]
fn body_maps_unknown_code_to_server() -> anyhow::Result<()> {
let body = ErrorBody {
error: "something_new".into(),
error_description: "huh".into(),
};
let err = body.into_error(500);
match err {
Error::Server { status, message } => {
assert_eq!(status, 500);
assert_eq!(message, "huh");
}
other => anyhow::bail!("expected Server, got {other:?}"),
}
Ok(())
}
#[test]
fn missing_scope_is_none_for_other_403_descriptions() {
let body = ErrorBody {
error: "forbidden".into(),
error_description: "calling principal has no account_id".into(),
};
let err = body.into_error(403);
assert_eq!(err.missing_scope(), None);
}
}