openapi-contract 0.1.1

Compile-time OpenAPI contract checking for Rust HTTP clients. Validates paths, parameters, and response types against your OpenAPI spec at macro expansion.
Documentation
use std::fmt;

#[derive(Debug)]
pub enum ApiError {
    Http(reqwest::Error),
    Serialization(serde_json::Error),
    Api {
        status: u16,
        message: String,
    },
    Defined {
        status: u16,
        code: String,
        message: String,
    },
}

#[derive(serde::Deserialize)]
pub(crate) struct DefinedErrorBody {
    #[serde(default)]
    pub defined: bool,
    #[serde(default)]
    pub code: String,
    #[serde(default)]
    pub message: String,
}

impl ApiError {
    pub fn code(&self) -> Option<&str> {
        match self {
            Self::Defined { code, .. } => Some(code),
            _ => None,
        }
    }

    pub fn status(&self) -> Option<u16> {
        match self {
            Self::Api { status, .. } | Self::Defined { status, .. } => Some(*status),
            _ => None,
        }
    }

    pub fn is_code(&self, expected: &str) -> bool {
        self.code() == Some(expected)
    }
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Http(e) => write!(f, "HTTP error: {e}"),
            Self::Serialization(e) => write!(f, "serialization error: {e}"),
            Self::Api { status, message } => write!(f, "API error {status}: {message}"),
            Self::Defined {
                status,
                code,
                message,
            } => {
                write!(f, "API error {status} [{code}]: {message}")
            }
        }
    }
}

impl std::error::Error for ApiError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Http(e) => Some(e),
            Self::Serialization(e) => Some(e),
            Self::Api { .. } | Self::Defined { .. } => None,
        }
    }
}

impl From<reqwest::Error> for ApiError {
    fn from(e: reqwest::Error) -> Self {
        Self::Http(e)
    }
}

impl From<serde_json::Error> for ApiError {
    fn from(e: serde_json::Error) -> Self {
        Self::Serialization(e)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    fn make_reqwest_error() -> reqwest::Error {
        reqwest::Client::new()
            .get("http://localhost:1/x")
            .header("bad\0header", "v")
            .build()
            .unwrap_err()
    }

    #[test]
    fn display_and_from() {
        // Http via From + Display
        let e = ApiError::from(make_reqwest_error());
        assert!(matches!(e, ApiError::Http(_)));
        assert!(e.to_string().starts_with("HTTP error:"));

        // Serialization via From + Display
        let e = ApiError::from(serde_json::from_str::<i32>("x").unwrap_err());
        assert!(matches!(e, ApiError::Serialization(_)));
        assert!(e.to_string().starts_with("serialization error:"));

        // Api Display
        let e = ApiError::Api {
            status: 404,
            message: "not found".into(),
        };
        assert_eq!(e.to_string(), "API error 404: not found");

        // Defined Display
        let e = ApiError::Defined {
            status: 404,
            code: "TEAM_NOT_FOUND".into(),
            message: "Team not found".into(),
        };
        assert_eq!(
            e.to_string(),
            "API error 404 [TEAM_NOT_FOUND]: Team not found"
        );
    }

    #[test]
    fn source_delegation() {
        // Http and Serialization have sources
        assert!(ApiError::Http(make_reqwest_error()).source().is_some());
        assert!(
            ApiError::from(serde_json::from_str::<i32>("x").unwrap_err())
                .source()
                .is_some()
        );

        // Api and Defined do not
        assert!(
            ApiError::Api {
                status: 500,
                message: "oops".into()
            }
            .source()
            .is_none()
        );
        assert!(
            ApiError::Defined {
                status: 403,
                code: "F".into(),
                message: "f".into()
            }
            .source()
            .is_none()
        );
    }

    #[test]
    fn code_status_is_code() {
        let defined = ApiError::Defined {
            status: 404,
            code: "TEAM_NOT_FOUND".into(),
            message: "not found".into(),
        };
        assert_eq!(defined.code(), Some("TEAM_NOT_FOUND"));
        assert_eq!(defined.status(), Some(404));
        assert!(defined.is_code("TEAM_NOT_FOUND"));
        assert!(!defined.is_code("OTHER"));

        let api = ApiError::Api {
            status: 500,
            message: "oops".into(),
        };
        assert_eq!(api.code(), None);
        assert_eq!(api.status(), Some(500));
        assert!(!api.is_code("ANYTHING"));

        let http = ApiError::Http(make_reqwest_error());
        assert_eq!(http.code(), None);
        assert_eq!(http.status(), None);
    }
}