Skip to main content

synth_ai/
environment_pools.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use serde_json::{Map, Value};
3use std::env;
4
5use synth_ai_core::http::HttpError;
6
7use crate::{Error, Result};
8
9/// Client for Synth Environment Pools rollouts.
10#[derive(Debug)]
11pub struct EnvironmentPoolsClient {
12    client: synth_ai_core::SynthClient,
13    api_version: Option<String>,
14}
15
16impl EnvironmentPoolsClient {
17    /// Create a new Environment Pools client.
18    pub fn new(api_key: impl Into<String>, base_url: Option<&str>) -> Result<Self> {
19        let api_key = api_key.into();
20        let client = synth_ai_core::SynthClient::new(&api_key, base_url).map_err(Error::Core)?;
21        Ok(Self {
22            client,
23            api_version: None,
24        })
25    }
26
27    /// Create a client from environment variables.
28    pub fn from_env() -> Result<Self> {
29        let api_key =
30            env::var("SYNTH_API_KEY").map_err(|_| Error::MissingApiKey)?;
31        let base_url = env::var("SYNTH_BACKEND_URL").ok();
32        Self::new(api_key, base_url.as_deref())
33    }
34
35    /// Override the API version used for endpoints.
36    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
37        self.api_version = Some(version.into());
38        self
39    }
40
41    /// Access the underlying core client.
42    pub fn core(&self) -> &synth_ai_core::SynthClient {
43        &self.client
44    }
45
46    fn resolve_api_version(&self) -> String {
47        if let Some(version) = self.api_version.as_ref() {
48            if !version.trim().is_empty() {
49                return version.clone();
50            }
51        }
52        if let Ok(version) = env::var("ENV_POOLS_API_VERSION") {
53            if !version.trim().is_empty() {
54                return version;
55            }
56        }
57        "v1".to_string()
58    }
59
60    fn public_url(&self, suffix: &str) -> String {
61        let base = self.client.base_url().trim_end_matches('/');
62        format!("{base}/v1/{}", suffix.trim_start_matches('/'))
63    }
64
65    fn legacy_path(&self, suffix: &str) -> String {
66        format!("/api/v1/environment-pools/{}", suffix.trim_start_matches('/'))
67    }
68
69    fn idempotency_headers(idempotency_key: Option<&str>) -> Option<HeaderMap> {
70        let key = idempotency_key.filter(|value| !value.trim().is_empty())?;
71        let mut headers = HeaderMap::new();
72        if let Ok(value) = HeaderValue::from_str(key) {
73            headers.insert("Idempotency-Key", value);
74        }
75        Some(headers)
76    }
77
78    async fn post_json_with_fallback(
79        &self,
80        suffix: &str,
81        body: &Value,
82        idempotency_key: Option<&str>,
83    ) -> Result<Value> {
84        let version = self.resolve_api_version();
85        let public_url = self.public_url(suffix);
86        let legacy_path = self.legacy_path(suffix);
87        let headers = Self::idempotency_headers(idempotency_key);
88        let attempts = if version == "v1" {
89            vec![public_url, legacy_path]
90        } else {
91            vec![legacy_path, public_url]
92        };
93
94        let mut last_error: Option<HttpError> = None;
95        for path in attempts {
96            let response = self
97                .client
98                .http()
99                .post_json_with_headers::<Value>(&path, body, headers.clone())
100                .await;
101            match response {
102                Ok(value) => return Ok(value),
103                Err(err) => {
104                    if err.status() == Some(404) {
105                        last_error = Some(err);
106                        continue;
107                    }
108                    return Err(Error::Core(err.into()));
109                }
110            }
111        }
112
113        Err(Error::Core(
114            last_error
115                .unwrap_or_else(|| HttpError::InvalidUrl("no env pools endpoints available".to_string()))
116                .into(),
117        ))
118    }
119
120    async fn get_json_with_fallback(&self, suffix: &str) -> Result<Value> {
121        let version = self.resolve_api_version();
122        let public_url = self.public_url(suffix);
123        let legacy_path = self.legacy_path(suffix);
124        let attempts = if version == "v1" {
125            vec![public_url, legacy_path]
126        } else {
127            vec![legacy_path, public_url]
128        };
129
130        let mut last_error: Option<HttpError> = None;
131        for path in attempts {
132            let response = self.client.http().get_json(&path, None).await;
133            match response {
134                Ok(value) => return Ok(value),
135                Err(err) => {
136                    if err.status() == Some(404) {
137                        last_error = Some(err);
138                        continue;
139                    }
140                    return Err(Error::Core(err.into()));
141                }
142            }
143        }
144
145        Err(Error::Core(
146            last_error
147                .unwrap_or_else(|| HttpError::InvalidUrl("no env pools endpoints available".to_string()))
148                .into(),
149        ))
150    }
151
152    /// Create a rollout in Environment Pools.
153    pub async fn create_rollout(
154        &self,
155        mut request: Value,
156        idempotency_key: Option<&str>,
157        dry_run: Option<bool>,
158    ) -> Result<Value> {
159        if let Some(dry_run) = dry_run {
160            if let Value::Object(map) = &mut request {
161                map.insert("dry_run".to_string(), Value::Bool(dry_run));
162            }
163        }
164        self.post_json_with_fallback("rollouts", &request, idempotency_key)
165            .await
166    }
167
168    /// Create a batch of rollouts.
169    pub async fn create_rollouts_batch(
170        &self,
171        requests: Vec<Value>,
172        metadata: Option<Value>,
173        idempotency_key: Option<&str>,
174    ) -> Result<Value> {
175        let mut payload = Map::new();
176        payload.insert("requests".to_string(), Value::Array(requests));
177        if let Some(metadata) = metadata {
178            payload.insert("metadata".to_string(), metadata);
179        }
180        self.post_json_with_fallback("rollouts/batch", &Value::Object(payload), idempotency_key)
181            .await
182    }
183
184    /// Fetch rollout status/details.
185    pub async fn get_rollout(&self, rollout_id: &str) -> Result<Value> {
186        let suffix = format!("rollouts/{}", rollout_id);
187        self.get_json_with_fallback(&suffix).await
188    }
189}