dregs 0.1.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
//! The `client.identities()` namespace.
//!
//! These are thin: they name the endpoint, then hand the response to a model. The transport,
//! retries, and error mapping all live on the client.

use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use reqwest::Method;
use serde_json::Value;

use crate::client::Client;
use crate::error::{Error, Result};
use crate::models::{Analysis, Identity, Scores};

/// Everything but the unreserved characters of RFC 3986, so an identity id is always a single
/// path segment.
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-')
    .remove(b'.')
    .remove(b'_')
    .remove(b'~');

/// Read identities and their scores. Get one from [`Client::identities`].
#[derive(Debug, Clone, Copy)]
pub struct Identities<'a> {
    client: &'a Client,
}

impl<'a> Identities<'a> {
    pub(crate) fn new(client: &'a Client) -> Self {
        Identities { client }
    }

    /// Returns the identity, with its current scores, badges, and attributes.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn get(&self, identity_id: &str) -> Result<Identity> {
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "")?, None)
            .await?;

        Ok(Identity::from_value(object_or_null(payload)))
    }

    /// Returns the four current category scores.
    ///
    /// This is the cheap read and the one most integrations want. It reports the scores Dregs has
    /// already computed without triggering any work. For the observations behind them, use
    /// [`analysis`](Self::analysis).
    ///
    /// A category that has not been scored yet is absent, so a brand-new identity comes back
    /// empty.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn scores(&self, identity_id: &str) -> Result<Scores> {
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "/scores")?, None)
            .await?;

        Ok(Scores::from_value(&payload.unwrap_or(Value::Null)))
    }

    /// Returns the most recent analysis cycle, with the observations behind each score.
    ///
    /// Use this when you need to show or log *why* an identity scored the way it did.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when the identity is
    /// unknown, or it has not been analyzed yet.
    pub async fn analysis(&self, identity_id: &str) -> Result<Analysis> {
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "/analysis")?, None)
            .await?;

        Ok(Analysis::from_value(object_or_null(payload)))
    }

    /// Queues a re-analysis of the identity.
    ///
    /// Scoring is asynchronous: this returns as soon as the job is queued, not when it has run.
    /// Poll [`scores`](Self::scores) or watch for a webhook rather than expecting fresh scores on
    /// the next line.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn analyze(&self, identity_id: &str) -> Result<()> {
        self.client
            .request(Method::POST, &path(identity_id, "/actions/analyze")?, None)
            .await?;

        Ok(())
    }
}

fn path(identity_id: &str, suffix: &str) -> Result<String> {
    if identity_id.is_empty() {
        return Err(Error::InvalidArgument("An identity id is required.".into()));
    }

    // Identity ids are the caller's own user ids and routinely contain characters that need
    // escaping (an email address being the common one).
    Ok(format!(
        "/identities/{}{suffix}",
        utf8_percent_encode(identity_id, PATH_SEGMENT)
    ))
}

fn object_or_null(payload: Option<Value>) -> Value {
    payload.filter(Value::is_object).unwrap_or(Value::Null)
}