synth_ai/
environment_pools.rs1use 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#[derive(Debug)]
11pub struct EnvironmentPoolsClient {
12 client: synth_ai_core::SynthClient,
13 api_version: Option<String>,
14}
15
16impl EnvironmentPoolsClient {
17 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 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 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
37 self.api_version = Some(version.into());
38 self
39 }
40
41 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 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 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 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}