sdp-request-client 0.1.10

A Rust client for making requests to the SDP API.
Documentation
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::TicketID;

/// SDP API error codes
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SdpErrorCode {
    Success = 2000,
    InvalidValue = 4001,
    Forbidden = 4002,
    ClosureRuleViolation = 4003,
    Internal = 4004,
    ReferenceExists = 4005,
    NotFound = 4007,
    NotUnique = 4008,
    NonEditableField = 4009,
    InternalField = 4010,
    NoSuchField = 4011,
    MissingMandatoryField = 4012,
    UnsupportedContentType = 4013,
    ReadOnlyField = 4014,
    RateLimitExceeded = 4015,
    AlreadyInTrash = 4016,
    NotInTrash = 4017,
    LicenseRestriction = 7001,
    Unknown = 0,
}

impl From<u32> for SdpErrorCode {
    fn from(code: u32) -> Self {
        match code {
            2000 => SdpErrorCode::Success,
            4001 => SdpErrorCode::InvalidValue,
            4002 => SdpErrorCode::Forbidden,
            4003 => SdpErrorCode::ClosureRuleViolation,
            4004 => SdpErrorCode::Internal,
            4005 => SdpErrorCode::ReferenceExists,
            4007 => SdpErrorCode::NotFound,
            4008 => SdpErrorCode::NotUnique,
            4009 => SdpErrorCode::NonEditableField,
            4010 => SdpErrorCode::InternalField,
            4011 => SdpErrorCode::NoSuchField,
            4012 => SdpErrorCode::MissingMandatoryField,
            4013 => SdpErrorCode::UnsupportedContentType,
            4014 => SdpErrorCode::ReadOnlyField,
            4015 => SdpErrorCode::RateLimitExceeded,
            4016 => SdpErrorCode::AlreadyInTrash,
            4017 => SdpErrorCode::NotInTrash,
            7001 => SdpErrorCode::LicenseRestriction,
            _ => SdpErrorCode::Unknown,
        }
    }
}

#[derive(Debug, Error)]
pub enum Error {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Authentication failed: invalid or expired token")]
    Unauthorized,
    #[error("Permission denied: {0}")]
    Forbidden(String),
    #[error("Resource not found: {0}")]
    NotFound(String),
    #[error("request has been merged into parent {parent_request_id}: {message}")]
    RequestMerged {
        parent_request_id: TicketID,
        message: String,
    },
    #[error("Invalid value: {0}")]
    InvalidValue(String),
    #[error("Resource already exists (not unique): {0}")]
    NotUnique(String),
    #[error("Cannot delete: resource is referenced elsewhere")]
    ReferenceExists,
    #[error("Missing mandatory field: {0}")]
    MissingField(String),
    #[error("Field is not editable: {0}")]
    NotEditable(String),
    #[error("Field does not exist: {0}")]
    NoSuchField(String),
    #[error("Closure rule violation: {0}")]
    ClosureRuleViolation(String),
    #[error("Rate limit exceeded")]
    RateLimited,
    #[error("License restriction: operation not allowed")]
    LicenseRestricted,
    #[error("SDP internal error")]
    Internal,
    #[error("Serialization error: {0}")]
    Serialization(#[from] serde_json::Error),
    #[error("URL parsing error: {0}")]
    UrlParse(#[from] url::ParseError),
    #[error("Form encoding error: {0}")]
    FormEncoding(#[from] serde_urlencoded::ser::Error),
    #[error("SDP error (code {code}): {message}")]
    Sdp { code: u32, message: String },
    #[error("Required field missing: {0}")]
    FieldRequired(String),
    #[error("{0}")]
    Other(String),
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub(crate) enum SdpErrorMessage {
    Merged {
        parent_request: ParentRequest,
        message: String,
    },
    Text(String),
    Other(serde_json::Value),
}

impl SdpErrorMessage {
    fn into_text(self) -> String {
        match self {
            Self::Merged { message, .. } | Self::Text(message) => message,
            Self::Other(value) => value.to_string(),
        }
    }
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub(crate) struct ParentRequest {
    id: TicketID,
}

impl Error {
    pub(crate) fn from_sdp_message(code: u32, response: SdpErrorMessage) -> Self {
        if let SdpErrorMessage::Merged {
            parent_request,
            message,
        } = response
        {
            return Self::RequestMerged {
                parent_request_id: parent_request.id,
                message,
            };
        }

        Self::from_sdp(code, response.into_text(), None)
    }

    /// Create an error from SDP response status code and message
    #[must_use]
    pub fn from_sdp(code: u32, message: String, field: Option<String>) -> Self {
        let field_info = field.clone().unwrap_or_else(|| message.clone());

        match SdpErrorCode::from(code) {
            SdpErrorCode::InvalidValue => Error::InvalidValue(field_info),
            SdpErrorCode::Forbidden => Error::Forbidden(field_info),
            SdpErrorCode::ClosureRuleViolation => Error::ClosureRuleViolation(field_info),
            SdpErrorCode::Internal => Error::Internal,
            SdpErrorCode::ReferenceExists => Error::ReferenceExists,
            SdpErrorCode::NotFound => Error::NotFound(field_info),
            SdpErrorCode::NotUnique => Error::NotUnique(field_info),
            SdpErrorCode::NonEditableField | SdpErrorCode::ReadOnlyField => {
                Error::NotEditable(field_info)
            }
            SdpErrorCode::InternalField => {
                Error::NotEditable(format!("internal field: {field_info}"))
            }
            SdpErrorCode::NoSuchField => Error::NoSuchField(field_info),
            SdpErrorCode::MissingMandatoryField => Error::MissingField(field_info),
            SdpErrorCode::RateLimitExceeded => Error::RateLimited,
            SdpErrorCode::LicenseRestriction => Error::LicenseRestricted,
            SdpErrorCode::AlreadyInTrash | SdpErrorCode::NotInTrash => Error::Sdp { code, message },
            SdpErrorCode::UnsupportedContentType => Error::Sdp { code, message },
            SdpErrorCode::Success => Error::Other("Unexpected success code in error path".into()),
            SdpErrorCode::Unknown => Error::Sdp { code, message },
        }
    }
}

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

    #[test]
    fn sdp_error_code_from_u32() {
        assert_eq!(SdpErrorCode::from(2000), SdpErrorCode::Success);
        assert_eq!(SdpErrorCode::from(4001), SdpErrorCode::InvalidValue);
        assert_eq!(SdpErrorCode::from(4002), SdpErrorCode::Forbidden);
        assert_eq!(SdpErrorCode::from(4003), SdpErrorCode::ClosureRuleViolation);
        assert_eq!(SdpErrorCode::from(4004), SdpErrorCode::Internal);
        assert_eq!(SdpErrorCode::from(4005), SdpErrorCode::ReferenceExists);
        assert_eq!(SdpErrorCode::from(4007), SdpErrorCode::NotFound);
        assert_eq!(SdpErrorCode::from(4008), SdpErrorCode::NotUnique);
        assert_eq!(SdpErrorCode::from(4009), SdpErrorCode::NonEditableField);
        assert_eq!(SdpErrorCode::from(4010), SdpErrorCode::InternalField);
        assert_eq!(SdpErrorCode::from(4011), SdpErrorCode::NoSuchField);
        assert_eq!(
            SdpErrorCode::from(4012),
            SdpErrorCode::MissingMandatoryField
        );
        assert_eq!(
            SdpErrorCode::from(4013),
            SdpErrorCode::UnsupportedContentType
        );
        assert_eq!(SdpErrorCode::from(4014), SdpErrorCode::ReadOnlyField);
        assert_eq!(SdpErrorCode::from(4015), SdpErrorCode::RateLimitExceeded);
        assert_eq!(SdpErrorCode::from(4016), SdpErrorCode::AlreadyInTrash);
        assert_eq!(SdpErrorCode::from(4017), SdpErrorCode::NotInTrash);
        assert_eq!(SdpErrorCode::from(7001), SdpErrorCode::LicenseRestriction);
        assert_eq!(SdpErrorCode::from(9999), SdpErrorCode::Unknown);
        assert_eq!(SdpErrorCode::from(0), SdpErrorCode::Unknown);
    }

    #[test]
    fn error_from_sdp_maps_correctly() {
        assert!(matches!(
            Error::from_sdp(4001, "msg".into(), None),
            Error::InvalidValue(_)
        ));
        assert!(matches!(
            Error::from_sdp(4002, "msg".into(), None),
            Error::Forbidden(_)
        ));
        assert!(matches!(
            Error::from_sdp(4004, "msg".into(), None),
            Error::Internal
        ));
        assert!(matches!(
            Error::from_sdp(4005, "msg".into(), None),
            Error::ReferenceExists
        ));
        assert!(matches!(
            Error::from_sdp(4007, "msg".into(), None),
            Error::NotFound(_)
        ));
        assert!(matches!(
            Error::from_sdp(4009, "msg".into(), None),
            Error::NotEditable(_)
        ));
        assert!(matches!(
            Error::from_sdp(4014, "msg".into(), None),
            Error::NotEditable(_)
        ));
        assert!(matches!(
            Error::from_sdp(4012, "msg".into(), None),
            Error::MissingField(_)
        ));
        assert!(matches!(
            Error::from_sdp(4015, "msg".into(), None),
            Error::RateLimited
        ));
        assert!(matches!(
            Error::from_sdp(7001, "msg".into(), None),
            Error::LicenseRestricted
        ));
        assert!(matches!(
            Error::from_sdp(9999, "msg".into(), None),
            Error::Sdp { .. }
        ));
    }

    #[test]
    fn error_from_sdp_uses_field_when_provided() {
        let err = Error::from_sdp(4001, "message".into(), Some("field_name".into()));
        match err {
            Error::InvalidValue(s) => assert_eq!(s, "field_name"),
            _ => panic!("expected InvalidValue"),
        }
    }

    #[test]
    fn error_from_sdp_uses_message_when_no_field() {
        let err = Error::from_sdp(4001, "message".into(), None);
        match err {
            Error::InvalidValue(s) => assert_eq!(s, "message"),
            _ => panic!("expected InvalidValue"),
        }
    }

    #[test]
    fn structured_merged_response_maps_to_request_merged() {
        let response: SdpErrorMessage = serde_json::from_value(serde_json::json!({
            "parent_request": { "id": 583415 },
            "message": "Request Id(s) 583550 has been Merged with Parent Request 583415."
        }))
        .unwrap();

        let error = Error::from_sdp_message(4007, response);

        assert!(matches!(
            error,
            Error::RequestMerged {
                parent_request_id: TicketID(583415),
                message
            } if message.contains("583550")
        ));
    }
}