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}