Skip to main content

agent_framework_purview/
client.rs

1//! [`PurviewClient`]: calls the Microsoft Graph `processContent` endpoint.
2//!
3//! # Scope
4//!
5//! Python's `PurviewClient` exposes three Graph calls
6//! (`get_protection_scopes`, `process_content`, `send_content_activities`),
7//! orchestrated by a separate `ScopedContentProcessor` that: computes
8//! applicable protection scopes first (cached, with ETag-based
9//! invalidation), only calls `process_content` when a scope actually applies
10//! (inline) or queues it in the background (offline execution mode), and
11//! logs a `contentActivities` entry in the background when no scope applies
12//! at all. This work package's brief scopes this crate down to just
13//! `processContent` — "POST the processContent route Python uses ... parse
14//! verdict" — so **only that one endpoint is called here**: no protection-
15//! scopes precheck, no response caching, no background content-activity
16//! logging. This is a deliberate, brief-directed scope cut, not an
17//! oversight; see [`crate::processor`] for where the request-building logic
18//! that *is* ported lives, and the crate docs for the full list of what's
19//! out of scope.
20
21use std::sync::Arc;
22
23use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
24
25use agent_framework_core::error::{Error, Result};
26
27use crate::auth::TokenProvider;
28use crate::models::{ProcessContentRequest, ProcessContentResponse};
29use crate::settings::PurviewSettings;
30
31const PURVIEW_USER_AGENT: &str = concat!("agent-framework-rs-purview/", env!("CARGO_PKG_VERSION"));
32
33/// Calls the Microsoft Graph `dataSecurityAndGovernance/processContent`
34/// endpoint. See the module docs for how this differs in scope from
35/// Python's `PurviewClient`.
36pub struct PurviewClient {
37    http: reqwest::Client,
38    token_provider: Arc<dyn TokenProvider>,
39    /// `graph_base_uri`, trailing slash trimmed (mirrors Python's
40    /// `self._graph_uri = settings.graph_base_uri.rstrip("/")`).
41    graph_base_uri: String,
42    /// Captured from [`PurviewSettings::ignore_payment_required`] at
43    /// construction time: on a 402, this client itself returns an empty
44    /// [`ProcessContentResponse`] instead of an error when `true` —
45    /// mirrors Python's `PurviewClient._post`, which performs this same
46    /// check (not the middleware) before ever raising
47    /// `PurviewPaymentRequiredError`.
48    ignore_payment_required: bool,
49}
50
51impl PurviewClient {
52    pub fn new(token_provider: impl TokenProvider + 'static, settings: &PurviewSettings) -> Self {
53        Self {
54            http: reqwest::Client::new(),
55            token_provider: Arc::new(token_provider),
56            graph_base_uri: settings.graph_base_uri.trim_end_matches('/').to_string(),
57            ignore_payment_required: settings.ignore_payment_required,
58        }
59    }
60
61    /// `POST {graph_base_uri}/users/{userId}/dataSecurityAndGovernance/processContent`.
62    ///
63    /// On a non-2xx status: 402 is swallowed into an empty
64    /// [`ProcessContentResponse`] when `ignore_payment_required` was set at
65    /// construction time (see the field docs); every other non-2xx status
66    /// (401/403/402-when-not-ignored/429/anything else) becomes an
67    /// [`Error::ServiceStatus`] carrying the real status code, so callers
68    /// (namely [`crate::middleware`]) can distinguish "payment required" via
69    /// [`Error::status`] the same way Python's `except
70    /// PurviewPaymentRequiredError` clause does.
71    pub async fn process_content(
72        &self,
73        request: &ProcessContentRequest,
74    ) -> Result<ProcessContentResponse> {
75        let token = self.token_provider.get_token().await?;
76        let url = format!(
77            "{}/users/{}/dataSecurityAndGovernance/processContent",
78            self.graph_base_uri, request.user_id
79        );
80        let resp = self
81            .http
82            .post(&url)
83            .header(AUTHORIZATION, format!("Bearer {token}"))
84            .header(CONTENT_TYPE, "application/json")
85            .header(USER_AGENT, PURVIEW_USER_AGENT)
86            .json(request)
87            .send()
88            .await
89            .map_err(|e| Error::service(format!("Purview request to {url} failed: {e}")))?;
90
91        let status = resp.status();
92        let text = resp
93            .text()
94            .await
95            .map_err(|e| Error::service(format!("failed reading Purview response body: {e}")))?;
96
97        if status.as_u16() == 402 && self.ignore_payment_required {
98            return Ok(ProcessContentResponse::default());
99        }
100        if !status.is_success() {
101            return Err(Error::service_status(status.as_u16(), text, None));
102        }
103
104        serde_json::from_str(&text).map_err(|e| {
105            Error::service(format!(
106                "invalid Purview processContent response JSON: {e} (body: {text})"
107            ))
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::auth::StaticTokenProvider;
116
117    #[test]
118    fn graph_base_uri_is_trimmed_of_trailing_slash() {
119        let settings = PurviewSettings::new("Test App");
120        let client = PurviewClient::new(StaticTokenProvider::new("t"), &settings);
121        assert_eq!(client.graph_base_uri, "https://graph.microsoft.com/v1.0");
122    }
123
124    #[test]
125    fn ignore_payment_required_is_captured_from_settings() {
126        let settings = PurviewSettings::new("Test App").with_ignore_payment_required(true);
127        let client = PurviewClient::new(StaticTokenProvider::new("t"), &settings);
128        assert!(client.ignore_payment_required);
129
130        let settings2 = PurviewSettings::new("Test App");
131        let client2 = PurviewClient::new(StaticTokenProvider::new("t"), &settings2);
132        assert!(!client2.ignore_payment_required);
133    }
134}