lodestone-client 0.0.16

Current-only asynchronous Rust client for the Lodestone API
Documentation
use lodestone_wire::{DiscoverRequest, ErrorCode, GetBindingRequest};

use crate::{CallOptions, Client, ClientError, ProfileVerification, SubmissionDisposition};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConnectionDoctorStage {
    DiscoveryCall,
    DiscoveryVerification,
    BindingCall,
    BindingVerification,
    Complete,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConnectionDoctorFailureClass {
    Profile,
    Credential,
    Transport,
    Server,
    Contract,
    Local,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConnectionDoctorFailure {
    pub stage: ConnectionDoctorStage,
    pub class: ConnectionDoctorFailureClass,
    pub submission_disposition: SubmissionDisposition,
    pub server_code: Option<ErrorCode>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConnectionDoctorReport {
    pub stage: ConnectionDoctorStage,
    pub verification: Option<ProfileVerification>,
    pub failure: Option<ConnectionDoctorFailure>,
}

impl ConnectionDoctorReport {
    #[must_use]
    pub const fn passed(&self) -> bool {
        matches!(self.stage, ConnectionDoctorStage::Complete) && self.failure.is_none()
    }
}

impl Client {
    /// Probes discovery and binding with one caller-owned absolute deadline.
    ///
    /// This deliberately does not install the observed authority. A caller
    /// that wants a mutation-capable session must still use `connect` or
    /// `verify_connection_profile`; a diagnostic probe therefore cannot
    /// consume or change a later mutation's deadline or submission outcome.
    pub async fn diagnose_connection(&self, options: CallOptions) -> ConnectionDoctorReport {
        let discovered = match self.discover(DiscoverRequest {}, options.clone()).await {
            Ok(discovered) => discovered,
            Err(error) => return failed(ConnectionDoctorStage::DiscoveryCall, &error),
        };
        if let Err(error) = self.validate_discovery_observation(&discovered) {
            return failed(ConnectionDoctorStage::DiscoveryVerification, &error);
        }
        let binding = match self.get_binding(GetBindingRequest {}, options).await {
            Ok(binding) => binding,
            Err(error) => return failed(ConnectionDoctorStage::BindingCall, &error),
        };
        match self.validate_authority_binding_observation(&discovered, &binding) {
            Ok(verification) => ConnectionDoctorReport {
                stage: ConnectionDoctorStage::Complete,
                verification: Some(verification),
                failure: None,
            },
            Err(error) => failed(ConnectionDoctorStage::BindingVerification, &error),
        }
    }
}

fn failed(stage: ConnectionDoctorStage, error: &ClientError) -> ConnectionDoctorReport {
    let server_code = match error {
        ClientError::Server(body) => Some(body.code),
        _ => None,
    };
    ConnectionDoctorReport {
        stage,
        verification: None,
        failure: Some(ConnectionDoctorFailure {
            stage,
            class: failure_class(error),
            submission_disposition: error.submission_disposition(),
            server_code,
        }),
    }
}

fn failure_class(error: &ClientError) -> ConnectionDoctorFailureClass {
    match error {
        ClientError::ProfileIdentityMismatch
        | ClientError::ProfileStreamMismatch
        | ClientError::CollectionIndexRefMismatch => ConnectionDoctorFailureClass::Profile,
        ClientError::Credential(_) => ConnectionDoctorFailureClass::Credential,
        ClientError::Transport { .. } => ConnectionDoctorFailureClass::Transport,
        ClientError::Server(_) => ConnectionDoctorFailureClass::Server,
        ClientError::Config(_)
        | ClientError::InvalidCallOptions
        | ClientError::InvalidEndpoint
        | ClientError::InvalidCollectionIndexRef
        | ClientError::InvalidStateIndex
        | ClientError::InvalidStateActionId
        | ClientError::InvalidStateKey
        | ClientError::InvalidStateBatch
        | ClientError::CollectionIndexRefRequired
        | ClientError::ProfileNotVerified
        | ClientError::MissingCommandContract
        | ClientError::RequestCodec
        | ClientError::RequestTooLarge
        | ClientError::InvalidRequestIdentity => ConnectionDoctorFailureClass::Local,
        ClientError::UnexpectedStateResult
        | ClientError::ResponseCodec
        | ClientError::ResponseTooLarge
        | ClientError::NonCanonicalResponse
        | ClientError::ResponseIdentityMismatch
        | ClientError::ResponseCommandMismatch
        | ClientError::InvalidErrorShape => ConnectionDoctorFailureClass::Contract,
        ClientError::RandomUnavailable | ClientError::ClockUnavailable | ClientError::InternalStateUnavailable => {
            ConnectionDoctorFailureClass::Local
        }
    }
}

#[cfg(test)]
mod tests {
    use lodestone_wire::{ErrorBody, ErrorDetail, ErrorDisposition, RecoveryAction};

    use super::*;
    use crate::TransportErrorKind;

    #[test]
    fn doctor_failure_preserves_typed_refusal_and_transport_disposition() {
        let refusal = ClientError::Server(ErrorBody {
            code: ErrorCode::PermissionDenied,
            message: "denied".to_owned(),
            disposition: ErrorDisposition::NotSubmitted,
            recovery: RecoveryAction::Stop,
            details: ErrorDetail::PermissionDenied {
                action: None,
                workload_class: None,
            },
        });
        let report = failed(ConnectionDoctorStage::DiscoveryCall, &refusal);
        let failure = report.failure.as_ref();
        assert_eq!(
            failure.map(|item| item.submission_disposition),
            Some(SubmissionDisposition::NotSubmitted)
        );
        assert_eq!(
            failure.and_then(|item| item.server_code),
            Some(ErrorCode::PermissionDenied)
        );

        let indeterminate = ClientError::Transport {
            kind: TransportErrorKind::Read,
            disposition: SubmissionDisposition::Indeterminate,
        };
        let report = failed(ConnectionDoctorStage::BindingCall, &indeterminate);
        assert_eq!(
            report.failure.map(|item| item.submission_disposition),
            Some(SubmissionDisposition::Indeterminate)
        );
    }
}