Skip to main content

claude_api/admin/
workspaces.rs

1//! Workspaces: create / retrieve / list / update / archive.
2
3use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7use crate::pagination::Paginated;
8
9use super::ListParams;
10
11/// Permitted-inference-geos value: either `"unrestricted"` (allow
12/// every geo) or an explicit list.
13///
14/// Forward-compatible: the wire form is either the literal string
15/// `"unrestricted"` or an array.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(untagged)]
18#[non_exhaustive]
19pub enum AllowedInferenceGeos {
20    /// Unrestricted (string sentinel).
21    Unrestricted(UnrestrictedSentinel),
22    /// Explicit allow-list of geo codes.
23    List(Vec<String>),
24}
25
26/// Type-tag witness for [`AllowedInferenceGeos::Unrestricted`]: the
27/// literal string `"unrestricted"`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub enum UnrestrictedSentinel {
31    /// Always serializes as `"unrestricted"`.
32    #[serde(rename = "unrestricted")]
33    Unrestricted,
34}
35
36impl AllowedInferenceGeos {
37    /// Build the `"unrestricted"` form.
38    #[must_use]
39    pub fn unrestricted() -> Self {
40        Self::Unrestricted(UnrestrictedSentinel::Unrestricted)
41    }
42
43    /// Build an explicit allow-list.
44    #[must_use]
45    pub fn list<I, S>(geos: I) -> Self
46    where
47        I: IntoIterator<Item = S>,
48        S: Into<String>,
49    {
50        Self::List(geos.into_iter().map(Into::into).collect())
51    }
52}
53
54/// Data residency configuration on a [`Workspace`].
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct DataResidency {
58    /// Permitted inference geos.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub allowed_inference_geos: Option<AllowedInferenceGeos>,
61    /// Default geo applied when a request omits the parameter.
62    /// Defaults server-side to `"global"`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub default_inference_geo: Option<String>,
65    /// Geographic region for workspace data storage. **Immutable**
66    /// after creation. Defaults to `"us"` server-side. Only present
67    /// on response payloads.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub workspace_geo: Option<String>,
70}
71
72/// A workspace.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[non_exhaustive]
75pub struct Workspace {
76    /// Stable workspace ID.
77    pub id: String,
78    /// Wire type tag (`"workspace"`).
79    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
80    pub ty: Option<String>,
81    /// Display name.
82    pub name: String,
83    /// Hex color code shown in the Console.
84    pub display_color: String,
85    /// Data residency configuration.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub data_residency: Option<DataResidency>,
88    /// Creation timestamp.
89    pub created_at: String,
90    /// Set when archived; `None` for live workspaces.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub archived_at: Option<String>,
93}
94
95/// Request body for `POST /v1/organizations/workspaces`.
96#[derive(Debug, Clone, Serialize)]
97#[non_exhaustive]
98pub struct CreateWorkspaceRequest {
99    /// Workspace name.
100    pub name: String,
101    /// Optional data residency. `workspace_geo` is immutable after
102    /// creation.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub data_residency: Option<DataResidency>,
105}
106
107impl CreateWorkspaceRequest {
108    /// Build with the given name; default residency.
109    #[must_use]
110    pub fn new(name: impl Into<String>) -> Self {
111        Self {
112            name: name.into(),
113            data_residency: None,
114        }
115    }
116
117    /// Attach a residency configuration.
118    #[must_use]
119    pub fn with_data_residency(mut self, residency: DataResidency) -> Self {
120        self.data_residency = Some(residency);
121        self
122    }
123}
124
125/// Request body for `POST /v1/organizations/workspaces/{id}` (update).
126#[derive(Debug, Clone, Serialize)]
127#[non_exhaustive]
128pub struct UpdateWorkspaceRequest {
129    /// New name.
130    pub name: String,
131    /// Optional residency patch (cannot change `workspace_geo`).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub data_residency: Option<DataResidency>,
134}
135
136impl UpdateWorkspaceRequest {
137    /// Build with the new name.
138    #[must_use]
139    pub fn new(name: impl Into<String>) -> Self {
140        Self {
141            name: name.into(),
142            data_residency: None,
143        }
144    }
145
146    /// Attach a residency patch.
147    #[must_use]
148    pub fn with_data_residency(mut self, residency: DataResidency) -> Self {
149        self.data_residency = Some(residency);
150        self
151    }
152}
153
154/// Filters for [`Workspaces::list`].
155#[derive(Debug, Clone, Default)]
156#[non_exhaustive]
157pub struct ListWorkspacesParams {
158    /// Underlying pagination params.
159    pub paging: ListParams,
160    /// Whether to include archived workspaces.
161    pub include_archived: Option<bool>,
162}
163
164impl ListWorkspacesParams {
165    fn to_query(&self) -> Vec<(&'static str, String)> {
166        let mut q = self.paging.to_query();
167        if let Some(b) = self.include_archived {
168            q.push(("include_archived", b.to_string()));
169        }
170        q
171    }
172}
173
174/// Namespace handle for workspace endpoints.
175pub struct Workspaces<'a> {
176    client: &'a Client,
177}
178
179impl<'a> Workspaces<'a> {
180    pub(crate) fn new(client: &'a Client) -> Self {
181        Self { client }
182    }
183
184    /// `POST /v1/organizations/workspaces`.
185    pub async fn create(&self, request: CreateWorkspaceRequest) -> Result<Workspace> {
186        let body = &request;
187        self.client
188            .execute_with_retry(
189                || {
190                    self.client
191                        .request_builder(reqwest::Method::POST, "/v1/organizations/workspaces")
192                        .json(body)
193                },
194                &[],
195            )
196            .await
197    }
198
199    /// `GET /v1/organizations/workspaces/{id}`.
200    pub async fn retrieve(&self, workspace_id: &str) -> Result<Workspace> {
201        let path = format!("/v1/organizations/workspaces/{workspace_id}");
202        self.client
203            .execute_with_retry(
204                || self.client.request_builder(reqwest::Method::GET, &path),
205                &[],
206            )
207            .await
208    }
209
210    /// `GET /v1/organizations/workspaces`.
211    pub async fn list(&self, params: ListWorkspacesParams) -> Result<Paginated<Workspace>> {
212        let query = params.to_query();
213        self.client
214            .execute_with_retry(
215                || {
216                    let mut req = self
217                        .client
218                        .request_builder(reqwest::Method::GET, "/v1/organizations/workspaces");
219                    for (k, v) in &query {
220                        req = req.query(&[(k, v)]);
221                    }
222                    req
223                },
224                &[],
225            )
226            .await
227    }
228
229    /// `POST /v1/organizations/workspaces/{id}` (update).
230    pub async fn update(
231        &self,
232        workspace_id: &str,
233        request: UpdateWorkspaceRequest,
234    ) -> Result<Workspace> {
235        let path = format!("/v1/organizations/workspaces/{workspace_id}");
236        let body = &request;
237        self.client
238            .execute_with_retry(
239                || {
240                    self.client
241                        .request_builder(reqwest::Method::POST, &path)
242                        .json(body)
243                },
244                &[],
245            )
246            .await
247    }
248
249    /// `POST /v1/organizations/workspaces/{id}/archive`.
250    pub async fn archive(&self, workspace_id: &str) -> Result<Workspace> {
251        let path = format!("/v1/organizations/workspaces/{workspace_id}/archive");
252        self.client
253            .execute_with_retry(
254                || self.client.request_builder(reqwest::Method::POST, &path),
255                &[],
256            )
257            .await
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use pretty_assertions::assert_eq;
265    use serde_json::json;
266    use wiremock::matchers::{body_partial_json, method, path};
267    use wiremock::{Mock, MockServer, ResponseTemplate};
268
269    fn client_for(mock: &MockServer) -> Client {
270        Client::builder()
271            .api_key("sk-ant-admin-test")
272            .base_url(mock.uri())
273            .build()
274            .unwrap()
275    }
276
277    fn fake_workspace() -> serde_json::Value {
278        json!({
279            "id": "ws_01",
280            "type": "workspace",
281            "name": "Default",
282            "display_color": "#0a84ff",
283            "created_at": "2026-05-01T00:00:00Z"
284        })
285    }
286
287    #[test]
288    fn allowed_inference_geos_serializes_unrestricted_as_string() {
289        let v = serde_json::to_value(AllowedInferenceGeos::unrestricted()).unwrap();
290        assert_eq!(v, json!("unrestricted"));
291    }
292
293    #[test]
294    fn allowed_inference_geos_serializes_list_form() {
295        let v = serde_json::to_value(AllowedInferenceGeos::list(["us", "eu"])).unwrap();
296        assert_eq!(v, json!(["us", "eu"]));
297    }
298
299    #[test]
300    fn allowed_inference_geos_round_trips_both_forms() {
301        let s: AllowedInferenceGeos = serde_json::from_value(json!("unrestricted")).unwrap();
302        assert_eq!(s, AllowedInferenceGeos::unrestricted());
303        let l: AllowedInferenceGeos = serde_json::from_value(json!(["us"])).unwrap();
304        assert_eq!(l, AllowedInferenceGeos::list(["us"]));
305    }
306
307    #[tokio::test]
308    async fn create_workspace_minimal_body() {
309        let mock = MockServer::start().await;
310        Mock::given(method("POST"))
311            .and(path("/v1/organizations/workspaces"))
312            .and(body_partial_json(json!({"name": "Default"})))
313            .respond_with(ResponseTemplate::new(200).set_body_json(fake_workspace()))
314            .mount(&mock)
315            .await;
316        let client = client_for(&mock);
317        let w = client
318            .admin()
319            .workspaces()
320            .create(CreateWorkspaceRequest::new("Default"))
321            .await
322            .unwrap();
323        assert_eq!(w.id, "ws_01");
324    }
325
326    #[tokio::test]
327    async fn list_workspaces_passes_include_archived() {
328        let mock = MockServer::start().await;
329        Mock::given(method("GET"))
330            .and(path("/v1/organizations/workspaces"))
331            .and(wiremock::matchers::query_param("include_archived", "true"))
332            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
333                "data": [fake_workspace()],
334                "has_more": false,
335                "first_id": "ws_01",
336                "last_id": "ws_01"
337            })))
338            .mount(&mock)
339            .await;
340        let client = client_for(&mock);
341        let page = client
342            .admin()
343            .workspaces()
344            .list(ListWorkspacesParams {
345                include_archived: Some(true),
346                ..Default::default()
347            })
348            .await
349            .unwrap();
350        assert_eq!(page.data.len(), 1);
351    }
352
353    #[tokio::test]
354    async fn update_workspace_round_trips() {
355        let mock = MockServer::start().await;
356        Mock::given(method("POST"))
357            .and(path("/v1/organizations/workspaces/ws_01"))
358            .and(body_partial_json(json!({"name": "Renamed"})))
359            .respond_with(ResponseTemplate::new(200).set_body_json(fake_workspace()))
360            .mount(&mock)
361            .await;
362        let client = client_for(&mock);
363        client
364            .admin()
365            .workspaces()
366            .update("ws_01", UpdateWorkspaceRequest::new("Renamed"))
367            .await
368            .unwrap();
369    }
370
371    #[tokio::test]
372    async fn archive_workspace_posts_to_archive_subpath() {
373        let mock = MockServer::start().await;
374        Mock::given(method("POST"))
375            .and(path("/v1/organizations/workspaces/ws_01/archive"))
376            .respond_with(ResponseTemplate::new(200).set_body_json({
377                let mut w = fake_workspace();
378                w["archived_at"] = json!("2026-05-01T12:00:00Z");
379                w
380            }))
381            .mount(&mock)
382            .await;
383        let client = client_for(&mock);
384        let w = client.admin().workspaces().archive("ws_01").await.unwrap();
385        assert!(w.archived_at.is_some());
386    }
387
388    #[tokio::test]
389    async fn retrieve_workspace_returns_typed_record() {
390        let mock = MockServer::start().await;
391        Mock::given(method("GET"))
392            .and(path("/v1/organizations/workspaces/ws_R1"))
393            .respond_with(ResponseTemplate::new(200).set_body_json(fake_workspace()))
394            .mount(&mock)
395            .await;
396        let client = client_for(&mock);
397        let w = client.admin().workspaces().retrieve("ws_R1").await.unwrap();
398        // fake_workspace() always returns id=ws_01 regardless of path.
399        assert_eq!(w.id, "ws_01");
400    }
401}