use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum NextAction {
RegisterApp,
SignIn,
SelectApp,
InspectStore,
EnrollApp,
#[doc(hidden)]
ShowHelp,
}
const ENROLLMENT_DOCS: &str = "https://github.com/brettdavies/xurl-rs#x-platform-enrollment";
const AUTHENTICATION_DOCS: &str = "https://docs.x.com/resources/fundamentals/authentication";
const RATE_LIMIT_DOCS: &str = "https://docs.x.com/resources/fundamentals/rate-limits";
#[must_use]
pub fn refuses_enrollment(status: u16, body: &str) -> bool {
if status != 403 {
return false;
}
let haystack = body.to_ascii_lowercase();
haystack.contains("client-not-enrolled") || haystack.contains("client-forbidden")
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{0}")]
Http(String),
#[error("{0}")]
Io(String),
#[error("invalid HTTP method: {0}")]
InvalidMethod(String),
#[error("{body}")]
Api {
status: u16,
body: String,
},
#[error("{0}")]
Validation(String),
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("invalid path parameter {name:?}: value {value:?} contains a reserved character")]
InvalidPathParam {
name: String,
value: String,
},
#[error("internal error: {0}")]
Internal(String),
#[error("{0}")]
Json(String),
#[error("{0}")]
Auth(String),
#[error("{0}")]
TokenStore(String),
#[error("{0}")]
AuthMethodMismatch(Box<AuthMismatch>),
}
crate::assert_send_sync!(Error);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthMismatch {
pub endpoint: String,
pub rendered_url: Option<String>,
pub method: String,
pub requested: Option<String>,
pub supported: Vec<String>,
pub available_in_app: Option<Vec<String>>,
pub app: Option<String>,
pub other_apps_with_creds: Option<Vec<String>>,
}
crate::assert_send_sync!(AuthMismatch);
impl AuthMismatch {
#[doc(hidden)]
#[must_use]
pub fn shape(&self) -> MismatchShape<'_> {
mismatch_shape(
self.requested.as_deref(),
self.available_in_app.as_deref(),
self.other_apps_with_creds.as_deref(),
)
}
}
impl std::fmt::Display for AuthMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path = self.rendered_url.as_deref().unwrap_or(&self.endpoint);
let method = &self.method;
let app_name = self.app.as_deref().unwrap_or("the active app");
let list = |items: &[String]| {
if items.is_empty() {
"none".to_string()
} else {
items.join(", ")
}
};
match self.shape() {
MismatchShape::Explicit { requested } if self.supported.is_empty() => {
write!(f, "{requested} auth is not accepted at {method} {path}")
}
MismatchShape::Explicit { requested } => {
let accepts = list(&self.supported);
write!(
f,
"{requested} auth is not accepted at {method} {path} (accepts {accepts})"
)
}
MismatchShape::WrongApp { others } => {
let alts = others.join(", ");
write!(
f,
"app '{app_name}' holds no credentials for {method} {path} (other apps with credentials: {alts})"
)
}
MismatchShape::EmptyIntersection { available } => {
let has = list(available);
let accepts = list(&self.supported);
write!(
f,
"no stored auth method on app '{app_name}' is accepted at {method} {path} (app has {has}; endpoint accepts {accepts})"
)
}
MismatchShape::Unknown => write!(f, "auth method is not accepted at {method} {path}"),
}
}
}
impl From<AuthMismatch> for Error {
fn from(mismatch: AuthMismatch) -> Self {
Self::AuthMethodMismatch(Box::new(mismatch))
}
}
#[doc(hidden)]
#[derive(Debug)]
pub enum MismatchShape<'a> {
Explicit { requested: &'a str },
WrongApp { others: &'a [String] },
EmptyIntersection { available: &'a [String] },
Unknown,
}
#[doc(hidden)]
pub fn mismatch_shape<'a>(
requested: Option<&'a str>,
available_in_app: Option<&'a [String]>,
other_apps_with_creds: Option<&'a [String]>,
) -> MismatchShape<'a> {
match (requested, available_in_app, other_apps_with_creds) {
(Some(requested), _, _) => MismatchShape::Explicit { requested },
(None, Some(_), Some(others)) if !others.is_empty() => MismatchShape::WrongApp { others },
(None, Some(available), _) => MismatchShape::EmptyIntersection { available },
(None, None, _) => MismatchShape::Unknown,
}
}
#[allow(dead_code)] impl Error {
pub fn api(status: u16, body: impl Into<String>) -> Self {
Self::Api {
status,
body: body.into(),
}
}
pub fn validation(body: impl Into<String>) -> Self {
Self::Validation(body.into())
}
pub fn auth(message: impl Into<String>) -> Self {
Self::Auth(message.into())
}
pub fn auth_with_cause(message: &str, cause: &dyn std::fmt::Display) -> Self {
Self::Auth(format!("{message} (cause: {cause})"))
}
pub fn token_store(message: impl Into<String>) -> Self {
Self::TokenStore(message.into())
}
#[must_use]
pub fn next_action(&self) -> Option<NextAction> {
match self {
Self::Api { status, body } if refuses_enrollment(*status, body) => {
Some(NextAction::EnrollApp)
}
Self::Api { .. }
| Self::Http(_)
| Self::Io(_)
| Self::InvalidMethod(_)
| Self::Validation(_)
| Self::InvalidUrl(_)
| Self::InvalidPathParam { .. }
| Self::Internal(_)
| Self::Json(_)
| Self::Auth(_)
| Self::TokenStore(_) => None,
Self::AuthMethodMismatch(_) => None,
}
}
#[must_use]
pub fn docs_url(&self) -> Option<&'static str> {
match self {
Self::Api { status, body } if refuses_enrollment(*status, body) => {
Some(ENROLLMENT_DOCS)
}
Self::Api { status: 401, .. } | Self::Auth(_) | Self::AuthMethodMismatch(_) => {
Some(AUTHENTICATION_DOCS)
}
Self::Api { status: 429, .. } => Some(RATE_LIMIT_DOCS),
Self::Api { .. }
| Self::Http(_)
| Self::Io(_)
| Self::InvalidMethod(_)
| Self::Validation(_)
| Self::InvalidUrl(_)
| Self::InvalidPathParam { .. }
| Self::Internal(_)
| Self::Json(_)
| Self::TokenStore(_) => None,
}
}
#[must_use]
pub fn is_api(&self) -> bool {
matches!(self, Self::Api { .. })
}
#[must_use]
pub fn is_validation(&self) -> bool {
matches!(self, Self::Validation(_))
}
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Auth(_) => "auth-required",
Self::TokenStore(_) => "token-store",
Self::Api { status: 401, .. } => "auth-required",
Self::Api { status: 403, .. } => "forbidden",
Self::Api { status: 404, .. } => "not-found",
Self::Api { status: 429, .. } => "rate-limited",
Self::Api {
status: 400 | 422, ..
} => "invalid-request",
Self::Api {
status: 500..=599, ..
} => "server-error",
Self::Api { .. } => "api-error",
Self::Http(_) => "network-error",
Self::Io(_) => "io",
Self::Json(_) => "serialization",
Self::InvalidMethod(_) => "invalid-method",
Self::AuthMethodMismatch(_) => "auth-method-mismatch",
Self::Validation(_) => "validation",
Self::InvalidUrl(_) => "invalid-url",
Self::InvalidPathParam { .. } => "invalid-path-param",
Self::Internal(_) => "internal",
}
}
#[must_use]
pub fn exit_code(&self) -> i32 {
match self {
Self::Auth(_) | Self::TokenStore(_) => EXIT_AUTH_REQUIRED,
Self::Api { status: 401, .. } => EXIT_AUTH_REQUIRED,
Self::Api { status: 429, .. } => EXIT_RATE_LIMITED,
Self::Api { status: 404, .. } => EXIT_NOT_FOUND,
Self::Api { .. } => EXIT_GENERAL_ERROR,
Self::Http(_) | Self::Io(_) => EXIT_NETWORK_ERROR,
Self::Json(_)
| Self::InvalidMethod(_)
| Self::Validation(_)
| Self::InvalidUrl(_)
| Self::InvalidPathParam { .. }
| Self::Internal(_) => EXIT_GENERAL_ERROR,
Self::AuthMethodMismatch(_) => EXIT_AUTH_MISMATCH,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Self::Http(err.to_string())
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::Json(err.to_string())
}
}
impl From<serde_yaml::Error> for Error {
fn from(err: serde_yaml::Error) -> Self {
Self::Json(err.to_string())
}
}
impl From<url::ParseError> for Error {
fn from(err: url::ParseError) -> Self {
Self::Http(err.to_string())
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub const NO_AUTH_METHOD: &str = "NoAuthMethod: no authentication method available";
pub const NO_OAUTH2_TOKEN: &str = "TokenNotFound: oauth2 token not found";
#[allow(dead_code)] pub const EXIT_SUCCESS: i32 = 0;
#[allow(dead_code)] pub const EXIT_GENERAL_ERROR: i32 = 1;
#[allow(dead_code)] pub const EXIT_AUTH_MISMATCH: i32 = 2;
#[allow(dead_code)] pub const EXIT_USAGE_ERROR: i32 = 2;
#[allow(dead_code)] pub const EXIT_AUTH_REQUIRED: i32 = 77;
#[allow(dead_code)] pub const EXIT_RATE_LIMITED: i32 = 3;
#[allow(dead_code)] pub const EXIT_NOT_FOUND: i32 = 4;
#[allow(dead_code)] pub const EXIT_NETWORK_ERROR: i32 = 5;
#[allow(dead_code)] #[must_use]
pub fn exit_code_for_error(e: &Error) -> i32 {
e.exit_code()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refused_enrollment_names_the_enroll_step() {
let refused = Error::api(403, r#"{"reason":"client-not-enrolled","detail":"x"}"#);
assert_eq!(refused.next_action(), Some(NextAction::EnrollApp));
let forbidden = Error::api(403, "CLIENT-FORBIDDEN");
assert_eq!(forbidden.next_action(), Some(NextAction::EnrollApp));
}
#[test]
fn error_fits_under_the_result_large_err_threshold() {
let size = std::mem::size_of::<Error>();
assert!(
size <= 128,
"Error is {size} bytes; clippy warns embedders above 128"
);
}
#[test]
fn other_errors_carry_no_step() {
assert_eq!(Error::api(403, "plain forbidden").next_action(), None);
assert_eq!(Error::api(401, "client-not-enrolled").next_action(), None);
assert_eq!(Error::auth("x").next_action(), None);
}
}