1#![cfg(feature = "user-profiles")]
37
38use std::collections::HashMap;
39
40use serde::{Deserialize, Serialize};
41
42use crate::client::Client;
43use crate::error::Result;
44use crate::pagination::PaginatedNextPage;
45
46const USER_PROFILES_BETA: &[&str] = &["user-profiles-2026-03-24"];
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum TrustGrantStatus {
58 Active,
60 Pending,
62 Rejected,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct TrustGrant {
74 pub status: TrustGrantStatus,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[non_exhaustive]
81pub struct UserProfile {
82 pub id: String,
84 #[serde(rename = "type", default = "default_user_profile_kind")]
86 pub kind: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub external_id: Option<String>,
91 #[serde(default)]
93 pub metadata: HashMap<String, String>,
94 #[serde(default)]
97 pub trust_grants: HashMap<String, TrustGrant>,
98 pub created_at: String,
100 pub updated_at: String,
102}
103
104fn default_user_profile_kind() -> String {
105 "user_profile".to_owned()
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct EnrollmentUrl {
115 #[serde(rename = "type", default = "default_enrollment_url_kind")]
117 pub kind: String,
118 pub url: String,
120 pub expires_at: String,
123}
124
125fn default_enrollment_url_kind() -> String {
126 "enrollment_url".to_owned()
127}
128
129#[derive(Debug, Clone, Default, Serialize)]
138#[non_exhaustive]
139pub struct CreateUserProfileRequest {
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub external_id: Option<String>,
143 #[serde(skip_serializing_if = "HashMap::is_empty")]
145 pub metadata: HashMap<String, String>,
146}
147
148impl CreateUserProfileRequest {
149 #[must_use]
151 pub fn new() -> Self {
152 Self::default()
153 }
154
155 #[must_use]
157 pub fn external_id(mut self, id: impl Into<String>) -> Self {
158 self.external_id = Some(id.into());
159 self
160 }
161
162 #[must_use]
164 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
165 self.metadata = metadata;
166 self
167 }
168
169 #[must_use]
171 pub fn metadata_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
172 self.metadata.insert(key.into(), value.into());
173 self
174 }
175}
176
177#[derive(Debug, Clone, Default, Serialize)]
184#[non_exhaustive]
185pub struct UpdateUserProfileRequest {
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub external_id: Option<String>,
189 #[serde(skip_serializing_if = "HashMap::is_empty")]
191 pub metadata: HashMap<String, String>,
192}
193
194impl UpdateUserProfileRequest {
195 #[must_use]
197 pub fn new() -> Self {
198 Self::default()
199 }
200
201 #[must_use]
203 pub fn external_id(mut self, id: impl Into<String>) -> Self {
204 self.external_id = Some(id.into());
205 self
206 }
207
208 #[must_use]
210 pub fn set_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
211 self.metadata.insert(key.into(), value.into());
212 self
213 }
214
215 #[must_use]
218 pub fn remove_metadata(mut self, key: impl Into<String>) -> Self {
219 self.metadata.insert(key.into(), String::new());
220 self
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "lowercase")]
231#[non_exhaustive]
232pub enum ListOrder {
233 Asc,
235 Desc,
237}
238
239impl ListOrder {
240 fn as_str(self) -> &'static str {
241 match self {
242 Self::Asc => "asc",
243 Self::Desc => "desc",
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default)]
250#[non_exhaustive]
251pub struct ListUserProfilesParams {
252 pub limit: Option<u32>,
254 pub page: Option<String>,
256 pub order: Option<ListOrder>,
258}
259
260impl ListUserProfilesParams {
261 #[must_use]
263 pub fn limit(mut self, limit: u32) -> Self {
264 self.limit = Some(limit);
265 self
266 }
267
268 #[must_use]
270 pub fn page(mut self, cursor: impl Into<String>) -> Self {
271 self.page = Some(cursor.into());
272 self
273 }
274
275 #[must_use]
277 pub fn order(mut self, order: ListOrder) -> Self {
278 self.order = Some(order);
279 self
280 }
281
282 fn to_query(&self) -> Vec<(&'static str, String)> {
283 let mut q = Vec::new();
284 if let Some(l) = self.limit {
285 q.push(("limit", l.to_string()));
286 }
287 if let Some(p) = &self.page {
288 q.push(("page", p.clone()));
289 }
290 if let Some(o) = self.order {
291 q.push(("order", o.as_str().to_owned()));
292 }
293 q
294 }
295}
296
297pub struct UserProfiles<'a> {
305 client: &'a Client,
306}
307
308impl<'a> UserProfiles<'a> {
309 pub(crate) fn new(client: &'a Client) -> Self {
310 Self { client }
311 }
312
313 pub async fn create(&self, request: CreateUserProfileRequest) -> Result<UserProfile> {
315 let body = &request;
316 self.client
317 .execute_with_retry(
318 || {
319 self.client
320 .request_builder(reqwest::Method::POST, "/v1/user_profiles")
321 .json(body)
322 },
323 USER_PROFILES_BETA,
324 )
325 .await
326 }
327
328 pub async fn list(
330 &self,
331 params: ListUserProfilesParams,
332 ) -> Result<PaginatedNextPage<UserProfile>> {
333 let query = params.to_query();
334 self.client
335 .execute_with_retry(
336 || {
337 let mut req = self
338 .client
339 .request_builder(reqwest::Method::GET, "/v1/user_profiles");
340 for (k, v) in &query {
341 req = req.query(&[(k, v)]);
342 }
343 req
344 },
345 USER_PROFILES_BETA,
346 )
347 .await
348 }
349
350 pub async fn get(&self, user_profile_id: &str) -> Result<UserProfile> {
352 let path = format!("/v1/user_profiles/{user_profile_id}");
353 self.client
354 .execute_with_retry(
355 || self.client.request_builder(reqwest::Method::GET, &path),
356 USER_PROFILES_BETA,
357 )
358 .await
359 }
360
361 pub async fn update(
364 &self,
365 user_profile_id: &str,
366 request: UpdateUserProfileRequest,
367 ) -> Result<UserProfile> {
368 let path = format!("/v1/user_profiles/{user_profile_id}");
369 let body = &request;
370 self.client
371 .execute_with_retry(
372 || {
373 self.client
374 .request_builder(reqwest::Method::POST, &path)
375 .json(body)
376 },
377 USER_PROFILES_BETA,
378 )
379 .await
380 }
381
382 pub async fn create_enrollment_url(&self, user_profile_id: &str) -> Result<EnrollmentUrl> {
386 let path = format!("/v1/user_profiles/{user_profile_id}/enrollment_url");
387 self.client
388 .execute_with_retry(
389 || self.client.request_builder(reqwest::Method::POST, &path),
390 USER_PROFILES_BETA,
391 )
392 .await
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use pretty_assertions::assert_eq;
400 use serde_json::json;
401 use wiremock::matchers::{header_exists, method, path, query_param};
402 use wiremock::{Mock, MockServer, ResponseTemplate};
403
404 fn client_for(mock: &MockServer) -> Client {
405 Client::builder()
406 .api_key("sk-ant-test")
407 .base_url(mock.uri())
408 .build()
409 .unwrap()
410 }
411
412 fn user_profile_json(id: &str) -> serde_json::Value {
413 json!({
414 "id": id,
415 "type": "user_profile",
416 "external_id": "user_12345",
417 "metadata": {"plan": "pro"},
418 "trust_grants": {
419 "cyber": {"status": "active"}
420 },
421 "created_at": "2026-03-15T10:00:00Z",
422 "updated_at": "2026-03-15T10:00:00Z"
423 })
424 }
425
426 #[tokio::test]
427 async fn create_sends_optional_body_and_decodes_profile() {
428 let mock = MockServer::start().await;
429 Mock::given(method("POST"))
430 .and(path("/v1/user_profiles"))
431 .and(header_exists("anthropic-beta"))
432 .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_C1")))
433 .mount(&mock)
434 .await;
435
436 let client = client_for(&mock);
437 let p = client
438 .user_profiles()
439 .create(
440 CreateUserProfileRequest::new()
441 .external_id("user_12345")
442 .metadata_entry("plan", "pro"),
443 )
444 .await
445 .unwrap();
446 assert_eq!(p.id, "uprof_C1");
447 assert_eq!(p.external_id.as_deref(), Some("user_12345"));
448 assert_eq!(p.metadata.get("plan").map(String::as_str), Some("pro"));
449 assert_eq!(
450 p.trust_grants.get("cyber").map(|g| g.status),
451 Some(TrustGrantStatus::Active)
452 );
453
454 let recv = &mock.received_requests().await.unwrap()[0];
455 let beta = recv
456 .headers
457 .get("anthropic-beta")
458 .unwrap()
459 .to_str()
460 .unwrap();
461 assert!(beta.contains("user-profiles-2026-03-24"), "{beta}");
462 }
463
464 #[tokio::test]
465 async fn create_omits_empty_metadata_from_request_body() {
466 let mock = MockServer::start().await;
467 Mock::given(method("POST"))
468 .and(path("/v1/user_profiles"))
469 .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_C2")))
470 .mount(&mock)
471 .await;
472
473 let client = client_for(&mock);
474 let _ = client
475 .user_profiles()
476 .create(CreateUserProfileRequest::new())
477 .await
478 .unwrap();
479 let recv = &mock.received_requests().await.unwrap()[0];
480 let body: serde_json::Value = serde_json::from_slice(&recv.body).unwrap();
481 assert!(body.get("metadata").is_none(), "{body}");
483 assert!(body.get("external_id").is_none(), "{body}");
484 }
485
486 #[tokio::test]
487 async fn list_passes_limit_page_order_and_decodes_no_has_more() {
488 let mock = MockServer::start().await;
489 Mock::given(method("GET"))
490 .and(path("/v1/user_profiles"))
491 .and(query_param("limit", "10"))
492 .and(query_param("order", "desc"))
493 .and(query_param("page", "page_X"))
494 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
495 "data": [user_profile_json("uprof_L1")],
496 "next_page": "page_Y"
497 })))
498 .mount(&mock)
499 .await;
500
501 let client = client_for(&mock);
502 let page = client
503 .user_profiles()
504 .list(
505 ListUserProfilesParams::default()
506 .limit(10)
507 .page("page_X")
508 .order(ListOrder::Desc),
509 )
510 .await
511 .unwrap();
512 assert_eq!(page.data.len(), 1);
513 assert_eq!(page.next_cursor(), Some("page_Y"));
514 assert!(!page.has_more);
516 }
517
518 #[tokio::test]
519 async fn get_decodes_single_profile() {
520 let mock = MockServer::start().await;
521 Mock::given(method("GET"))
522 .and(path("/v1/user_profiles/uprof_G1"))
523 .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_G1")))
524 .mount(&mock)
525 .await;
526
527 let client = client_for(&mock);
528 let p = client.user_profiles().get("uprof_G1").await.unwrap();
529 assert_eq!(p.id, "uprof_G1");
530 assert_eq!(p.kind, "user_profile");
531 }
532
533 #[tokio::test]
534 async fn update_sends_metadata_merge_patch_with_empty_string_for_deletion() {
535 let mock = MockServer::start().await;
536 Mock::given(method("POST"))
537 .and(path("/v1/user_profiles/uprof_U1"))
538 .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_U1")))
539 .mount(&mock)
540 .await;
541
542 let client = client_for(&mock);
543 let _ = client
544 .user_profiles()
545 .update(
546 "uprof_U1",
547 UpdateUserProfileRequest::new()
548 .set_metadata("plan", "enterprise")
549 .remove_metadata("legacy_flag"),
550 )
551 .await
552 .unwrap();
553
554 let recv = &mock.received_requests().await.unwrap()[0];
555 let body: serde_json::Value = serde_json::from_slice(&recv.body).unwrap();
556 assert_eq!(body["metadata"]["plan"], "enterprise");
557 assert_eq!(body["metadata"]["legacy_flag"], "");
559 }
560
561 #[tokio::test]
562 async fn create_enrollment_url_returns_signed_url_and_expiry() {
563 let mock = MockServer::start().await;
564 Mock::given(method("POST"))
565 .and(path("/v1/user_profiles/uprof_E1/enrollment_url"))
566 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
567 "type": "enrollment_url",
568 "url": "https://platform.claude.com/user-profiles/enrollment/abc123",
569 "expires_at": "2026-03-15T10:15:00Z"
570 })))
571 .mount(&mock)
572 .await;
573
574 let client = client_for(&mock);
575 let url = client
576 .user_profiles()
577 .create_enrollment_url("uprof_E1")
578 .await
579 .unwrap();
580 assert!(url.url.contains("enrollment/abc123"));
581 assert_eq!(url.expires_at, "2026-03-15T10:15:00Z");
582 assert_eq!(url.kind, "enrollment_url");
583 }
584
585 #[test]
586 fn trust_grant_status_round_trips_known_values() {
587 let g: TrustGrant = serde_json::from_value(json!({"status": "pending"})).unwrap();
588 assert_eq!(g.status, TrustGrantStatus::Pending);
589 let g: TrustGrant = serde_json::from_value(json!({"status": "rejected"})).unwrap();
590 assert_eq!(g.status, TrustGrantStatus::Rejected);
591 let json = serde_json::to_value(TrustGrant {
592 status: TrustGrantStatus::Active,
593 })
594 .unwrap();
595 assert_eq!(json, json!({"status": "active"}));
596 }
597
598 #[test]
599 fn list_order_serializes_as_lowercase() {
600 assert_eq!(ListOrder::Asc.as_str(), "asc");
601 assert_eq!(ListOrder::Desc.as_str(), "desc");
602 let v = serde_json::to_value(ListOrder::Desc).unwrap();
603 assert_eq!(v, json!("desc"));
604 }
605
606 #[test]
607 fn user_profile_tolerates_missing_optional_fields() {
608 let raw = json!({
611 "id": "uprof_M1",
612 "type": "user_profile",
613 "created_at": "2026-03-15T10:00:00Z",
614 "updated_at": "2026-03-15T10:00:00Z"
615 });
616 let p: UserProfile = serde_json::from_value(raw).unwrap();
617 assert_eq!(p.id, "uprof_M1");
618 assert!(p.external_id.is_none());
619 assert!(p.metadata.is_empty());
620 assert!(p.trust_grants.is_empty());
621 }
622}