1use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7use crate::pagination::Paginated;
8
9use super::ListParams;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(untagged)]
18#[non_exhaustive]
19pub enum AllowedInferenceGeos {
20 Unrestricted(UnrestrictedSentinel),
22 List(Vec<String>),
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub enum UnrestrictedSentinel {
31 #[serde(rename = "unrestricted")]
33 Unrestricted,
34}
35
36impl AllowedInferenceGeos {
37 #[must_use]
39 pub fn unrestricted() -> Self {
40 Self::Unrestricted(UnrestrictedSentinel::Unrestricted)
41 }
42
43 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct DataResidency {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub allowed_inference_geos: Option<AllowedInferenceGeos>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub default_inference_geo: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub workspace_geo: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[non_exhaustive]
75pub struct Workspace {
76 pub id: String,
78 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
80 pub ty: Option<String>,
81 pub name: String,
83 pub display_color: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub data_residency: Option<DataResidency>,
88 pub created_at: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub archived_at: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize)]
97#[non_exhaustive]
98pub struct CreateWorkspaceRequest {
99 pub name: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
104 pub data_residency: Option<DataResidency>,
105}
106
107impl CreateWorkspaceRequest {
108 #[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 #[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#[derive(Debug, Clone, Serialize)]
127#[non_exhaustive]
128pub struct UpdateWorkspaceRequest {
129 pub name: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub data_residency: Option<DataResidency>,
134}
135
136impl UpdateWorkspaceRequest {
137 #[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 #[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#[derive(Debug, Clone, Default)]
156#[non_exhaustive]
157pub struct ListWorkspacesParams {
158 pub paging: ListParams,
160 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
174pub 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 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 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 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 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 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 assert_eq!(w.id, "ws_01");
400 }
401}