dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! 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 reqwest::Method;
use serde::Serialize;
use serde_json::{Map, Value, json};

use crate::client::{Client, object_or_null, segment};
use crate::error::{Error, Result};
use crate::event::{Serialized, object_or_empty};
use crate::models::{
    Analysis, Identity, IdentityReview, LinkedIdentity, ScoreHistoryEntry, Scores,
};
use crate::paging::{ItemStream, ListCall, Page, Paging, filter, paging_setters};

/// Read identities, their scores, and their analysis. 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 one page of identities, most recently updated first.
    ///
    /// ```no_run
    /// # async fn run(client: dregs::Client) -> dregs::Result<()> {
    /// use dregs::{ListIdentities, Order};
    ///
    /// let page = client
    ///     .identities()
    ///     .list(ListIdentities::new().term("example.com").sort("humanityScore").order(Order::Asc))
    ///     .await?;
    ///
    /// for identity in &page {
    ///     println!("{:?} {:?}", identity.id, identity.humanity_score);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// Use [`list_all`](Self::list_all) to walk every page.
    pub async fn list(&self, params: ListIdentities) -> Result<Page<Identity>> {
        params.call().page(self.client).await
    }

    /// Every identity that matches, fetching pages as it goes. See [`list`](Self::list).
    ///
    /// ```no_run
    /// # async fn run(client: dregs::Client) -> dregs::Result<()> {
    /// use dregs::ListIdentities;
    /// use futures_util::TryStreamExt;
    ///
    /// let mut identities = client.identities().list_all(ListIdentities::new().group("org_678"));
    ///
    /// while let Some(identity) = identities.try_next().await? {
    ///     println!("{:?}", identity.id);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn list_all(&self, params: ListIdentities) -> ItemStream<Identity> {
        params.call().walk(self.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. For a past
    /// cycle, use [`analysis_cycle`](Self::analysis_cycle).
    ///
    /// 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)))
    }

    /// Returns a past analysis cycle, with the observations behind each score.
    ///
    /// `cycle_id` comes from [`history`](Self::history), as
    /// [`ScoreHistoryEntry::cycle_id`].
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when the identity or
    /// the cycle is unknown.
    pub async fn analysis_cycle(&self, identity_id: &str, cycle_id: i64) -> Result<Analysis> {
        let suffix = format!("/analysis/{cycle_id}");
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, &suffix)?, &[], None)
            .await?;

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

    /// Returns the scores of past analysis cycles, newest first.
    ///
    /// `limit` is the most cycles to return. The API's default is 100.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn history(
        &self,
        identity_id: &str,
        limit: Option<u32>,
    ) -> Result<Vec<ScoreHistoryEntry>> {
        let mut query = Vec::new();

        filter(&mut query, "limit", limit);

        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "/history")?, &query, None)
            .await?;

        Ok(list(payload, ScoreHistoryEntry::from_value))
    }

    /// Returns the other identities Dregs believes are connected to this one.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn links(&self, identity_id: &str) -> Result<Vec<LinkedIdentity>> {
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "/links")?, &[], None)
            .await?;

        Ok(list(payload, LinkedIdentity::from_value))
    }

    /// 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(())
    }

    /// Updates an identity and returns it.
    ///
    /// ```no_run
    /// # async fn run(client: dregs::Client) -> dregs::Result<()> {
    /// use dregs::IdentityUpdate;
    ///
    /// client
    ///     .identities()
    ///     .update("qa_bot_7", IdentityUpdate::new().disregarded(true))
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// An update that sets nothing is refused with [`Error::InvalidArgument`] before any request.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn update(&self, identity_id: &str, update: IdentityUpdate) -> Result<Identity> {
        let path = path(identity_id, "")?;
        let body = update.into_body()?;
        let payload = self
            .client
            .request(Method::PATCH, &path, &[], Some(&body))
            .await?;

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

    /// Returns the AI reviews requested for this identity, newest first.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn reviews(&self, identity_id: &str) -> Result<Vec<IdentityReview>> {
        let payload = self
            .client
            .request(Method::GET, &path(identity_id, "/reviews")?, &[], None)
            .await?;

        Ok(list(payload, IdentityReview::from_value))
    }

    /// Asks Dregs's AI reviewer to look over the identity's analysis.
    ///
    /// `feedback` is what you think the analysis got wrong, such as "this is a real customer".
    ///
    /// The review runs in the background, so the result comes back `"PENDING"`. Read it again
    /// with [`reviews`](Self::reviews) once it has finished.
    ///
    /// Returns an error of kind [`NotFound`](crate::ApiErrorKind::NotFound) when Dregs has never
    /// seen this identity.
    pub async fn review(
        &self,
        identity_id: &str,
        feedback: Option<&str>,
    ) -> Result<IdentityReview> {
        let path = path(identity_id, "/actions/review")?;
        let body = match feedback.filter(|feedback| !feedback.is_empty()) {
            Some(feedback) => json!({ "feedback": feedback }),
            None => json!({}),
        };
        let payload = self
            .client
            .request(Method::POST, &path, &[], Some(&body))
            .await?;

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

/// The filters and paging for [`Identities::list`] and [`Identities::list_all`].
///
/// Sort on fields such as `"lastTrackedAt"` or `"humanityScore"`. A filter left unset is not sent.
#[derive(Debug, Clone, Default)]
pub struct ListIdentities {
    term: Option<String>,
    group: Option<String>,
    group_type: Option<String>,
    device: Option<String>,
    badge: Option<String>,
    paging: Paging,
}

impl ListIdentities {
    /// Every identity, from the first page.
    pub fn new() -> Self {
        ListIdentities::default()
    }

    /// Free text matched against ids, names, emails, and usernames.
    pub fn term(mut self, term: impl Into<String>) -> Self {
        self.term = Some(term.into());
        self
    }

    /// Only members of the group with this id. Pair it with [`group_type`](Self::group_type)
    /// when your group ids are not unique across types.
    pub fn group(mut self, group_id: impl Into<String>) -> Self {
        self.group = Some(group_id.into());
        self
    }

    /// Only members of groups of this type.
    pub fn group_type(mut self, group_type: impl Into<String>) -> Self {
        self.group_type = Some(group_type.into());
        self
    }

    /// Only identities seen on the device with this fingerprint.
    pub fn device(mut self, fingerprint: impl Into<String>) -> Self {
        self.device = Some(fingerprint.into());
        self
    }

    /// Only identities carrying the badge with this slug.
    pub fn badge(mut self, slug: impl Into<String>) -> Self {
        self.badge = Some(slug.into());
        self
    }

    paging_setters!();

    fn call(self) -> ListCall<Identity> {
        let mut filters = Vec::new();

        filter(&mut filters, "term", self.term);
        filter(&mut filters, "group", self.group);
        filter(&mut filters, "groupType", self.group_type);
        filter(&mut filters, "device", self.device);
        filter(&mut filters, "badge", self.badge);

        ListCall {
            path: "/identities",
            filters,
            paging: self.paging,
            parse: Identity::from_value,
        }
    }
}

/// The changes [`Identities::update`] makes. Set at least one.
///
/// ```
/// use dregs::IdentityUpdate;
/// use serde_json::json;
///
/// let update = IdentityUpdate::new()
///     .disregarded(false)
///     .attributes(json!({ "plan": "enterprise" }));
/// ```
#[derive(Debug, Clone, Default)]
pub struct IdentityUpdate {
    disregarded: Option<bool>,
    attributes: Option<Serialized>,
}

impl IdentityUpdate {
    /// An update that changes nothing yet.
    pub fn new() -> Self {
        IdentityUpdate::default()
    }

    /// `true` to leave the identity out of scoring and escalations, such as for your own staff or
    /// test accounts; `false` to bring it back. Linked identities are re-scored.
    pub fn disregarded(mut self, disregarded: bool) -> Self {
        self.disregarded = Some(disregarded);
        self
    }

    /// Attributes to merge into the identity, as [`Event::identity_data`](crate::Event::identity_data)
    /// would. Anything that serializes to a JSON object works.
    pub fn attributes(mut self, attributes: impl Serialize) -> Self {
        self.attributes = Some(Serialized::from(attributes));
        self
    }

    fn into_body(self) -> Result<Value> {
        let mut body = Map::new();

        if let Some(disregarded) = self.disregarded {
            body.insert("disregarded".into(), disregarded.into());
        }

        if self.attributes.is_some() {
            let attributes = object_or_empty(self.attributes, "attributes")?;

            body.insert("attributes".into(), Value::Object(attributes));
        }

        if body.is_empty() {
            return Err(Error::InvalidArgument(
                "Set disregarded, attributes, or both.".into(),
            ));
        }

        Ok(Value::Object(body))
    }
}

fn path(identity_id: &str, suffix: &str) -> Result<String> {
    Ok(format!(
        "/identities/{}{suffix}",
        segment(identity_id, "An identity id")?
    ))
}

/// Parses each object in a JSON array, skipping anything that is not one.
pub(crate) fn list<T>(payload: Option<Value>, parse: fn(Value) -> T) -> Vec<T> {
    match payload {
        Some(Value::Array(items)) => items
            .into_iter()
            .filter(Value::is_object)
            .map(parse)
            .collect(),
        _ => Vec::new(),
    }
}