1#[cfg(feature = "client")]
15use anyhow::Context;
16use anyhow::Result;
17use serde_json::{json, Value};
18
19use soma_config::{EffectiveRuntimeMode, SomaConfig};
20
21#[cfg(feature = "client")]
22use reqwest::{header, Url};
23#[cfg(feature = "client")]
24use std::time::Duration;
25
26#[cfg(test)]
29#[path = "client_tests.rs"]
30mod tests;
31
32#[derive(Clone)]
38pub struct SomaClient {
39 #[cfg_attr(not(feature = "client"), allow(dead_code))]
40 target: SomaTarget,
41 #[cfg(feature = "client")]
42 client: reqwest::Client,
43}
44
45#[derive(Clone)]
46enum SomaTarget {
47 Stub,
49 #[cfg(feature = "client")]
51 DeployedApi {
52 base_url: Url,
53 bearer_token: Option<String>,
54 },
55}
56
57impl SomaClient {
58 pub fn new(cfg: &SomaConfig) -> Result<Self> {
64 let target = build_target(cfg)?;
65
66 #[cfg(feature = "client")]
67 {
68 let client = reqwest::ClientBuilder::new()
69 .timeout(Duration::from_secs(30))
70 .build()
71 .context("failed to build HTTP client")?;
72 Ok(Self { target, client })
73 }
74 #[cfg(not(feature = "client"))]
75 {
76 Ok(Self { target })
77 }
78 }
79
80 pub async fn greet(&self, name: Option<&str>) -> Result<Value> {
82 let body = name.map_or_else(|| json!({}), |name| json!({ "name": name }));
83 if let Some(value) = self.post_deployed_api("greet", "v1/greet", body).await? {
84 return Ok(value);
85 }
86
87 let target = name.unwrap_or("World");
88 Ok(json!({
89 "greeting": format!("Hello, {target}!"),
90 "target": target,
91 "server": "",
92 }))
93 }
94
95 pub async fn echo(&self, message: &str) -> Result<Value> {
97 if let Some(value) = self
98 .post_deployed_api("echo", "v1/echo", json!({ "message": message }))
99 .await?
100 {
101 return Ok(value);
102 }
103
104 Ok(json!({ "echo": message }))
105 }
106
107 pub async fn status(&self) -> Result<Value> {
113 if let Some(value) = self.get_deployed_api("status", "v1/status").await? {
114 return Ok(value);
115 }
116
117 let mut status = json!({
118 "status": "ok",
119 "note": "stub — replace with real health endpoint",
121 });
122 add_status_warnings(&mut status);
123 Ok(status)
124 }
125
126 pub async fn call_rest_action(&self, action: &str, params: Value) -> Result<Value> {
132 validate_action_path_segment(action)?;
133 let call = self.resolve_remote_rest_call(action, ¶ms).await?;
134 self.call_deployed_api_method(action, &call.method, &call.relative_path, call.body)
135 .await?
136 .ok_or_else(|| {
137 anyhow::anyhow!("SOMA_API_URL is required for remote runtime mode action={action}")
138 })
139 }
140
141 pub async fn provider_catalog(&self) -> Result<Value> {
143 self.get_deployed_api("providers", "v1/providers")
144 .await?
145 .ok_or_else(|| anyhow::anyhow!("SOMA_API_URL is required to read remote providers"))
146 }
147
148 pub async fn ready(&self) -> Result<()> {
155 #[cfg(not(feature = "client"))]
156 {
157 Ok(())
158 }
159 #[cfg(feature = "client")]
160 {
161 let SomaTarget::DeployedApi {
162 base_url,
163 bearer_token,
164 } = &self.target
165 else {
166 return Ok(());
167 };
168
169 let url = api_url(base_url, "health")?;
170 let mut request = self.client.get(url).timeout(Duration::from_secs(2));
171 if let Some(token) = bearer_token {
172 request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
173 }
174
175 let response = request
176 .send()
177 .await
178 .context("upstream readiness probe failed")?;
179 if !response.status().is_success() {
180 anyhow::bail!("upstream not ready: HTTP {}", response.status());
181 }
182 Ok(())
183 }
184 }
185
186 async fn post_deployed_api(
187 &self,
188 action: &str,
189 relative_path: &str,
190 body: Value,
191 ) -> Result<Option<Value>> {
192 self.call_deployed_api(action, relative_path, Some(body))
193 .await
194 }
195
196 async fn get_deployed_api(&self, action: &str, relative_path: &str) -> Result<Option<Value>> {
197 self.call_deployed_api(action, relative_path, None).await
198 }
199
200 async fn call_deployed_api(
201 &self,
202 action: &str,
203 relative_path: &str,
204 body: Option<Value>,
205 ) -> Result<Option<Value>> {
206 let method = if body.is_some() { "POST" } else { "GET" };
207 self.call_deployed_api_method(action, method, relative_path, body)
208 .await
209 }
210
211 async fn call_deployed_api_method(
212 &self,
213 action: &str,
214 method: &str,
215 relative_path: &str,
216 body: Option<Value>,
217 ) -> Result<Option<Value>> {
218 #[cfg(not(feature = "client"))]
219 {
220 let _ = (action, method, relative_path, body);
221 Ok(None)
222 }
223 #[cfg(feature = "client")]
224 {
225 let SomaTarget::DeployedApi {
226 base_url,
227 bearer_token,
228 } = &self.target
229 else {
230 return Ok(None);
231 };
232
233 let url = api_url(base_url, relative_path)?;
234 let method = reqwest::Method::from_bytes(method.as_bytes())
235 .with_context(|| format!("invalid remote REST method action={action}"))?;
236 let mut request = self.client.request(method, url);
237 if let Some(body) = body {
238 request = request.json(&body);
239 }
240 if let Some(token) = bearer_token {
241 request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
242 }
243
244 let response = request
245 .send()
246 .await
247 .with_context(|| format!("failed to call deployed API action={action}"))?;
248 let status = response.status();
249 let body = response
250 .text()
251 .await
252 .with_context(|| format!("failed to read deployed API response action={action}"))?;
253
254 if !status.is_success() {
255 anyhow::bail!("deployed API action={action} returned HTTP {status}: {body}");
256 }
257
258 let value = serde_json::from_str(&body)
259 .with_context(|| format!("deployed API returned invalid JSON action={action}"))?;
260 Ok(Some(value))
261 }
262 }
263}
264
265#[derive(Debug)]
266struct RemoteRestCall {
267 method: String,
268 relative_path: String,
269 body: Option<Value>,
270}
271
272impl SomaClient {
273 async fn resolve_remote_rest_call(
274 &self,
275 action: &str,
276 params: &Value,
277 ) -> Result<RemoteRestCall> {
278 if remote_action_uses_get(action, params) {
279 return Ok(RemoteRestCall {
280 method: "GET".to_owned(),
281 relative_path: format!("v1/{action}"),
282 body: None,
283 });
284 }
285
286 if matches!(action, "greet" | "echo") {
287 return Ok(RemoteRestCall {
288 method: "POST".to_owned(),
289 relative_path: format!("v1/{action}"),
290 body: Some(params.clone()),
291 });
292 }
293
294 let catalog = self.provider_catalog().await?;
295 if let Some(route) = remote_provider_route(&catalog, action)? {
296 return Ok(RemoteRestCall {
297 method: route.method,
298 relative_path: route.relative_path,
299 body: Some(params.clone()),
300 });
301 }
302
303 Ok(RemoteRestCall {
304 method: "POST".to_owned(),
305 relative_path: format!("v1/tools/{action}"),
306 body: Some(params.clone()),
307 })
308 }
309}
310
311#[cfg(feature = "client")]
312fn build_target(cfg: &SomaConfig) -> Result<SomaTarget> {
313 if cfg.effective_runtime_mode() == EffectiveRuntimeMode::Local {
314 return Ok(SomaTarget::Stub);
315 }
316 let api_url = cfg.api_url.trim();
317 if api_url.is_empty() {
318 return Ok(SomaTarget::Stub);
319 }
320 let base_url =
321 Url::parse(api_url).with_context(|| format!("invalid SOMA_API_URL: {api_url}"))?;
322 let bearer_token = non_empty(&cfg.api_key);
323 Ok(SomaTarget::DeployedApi {
324 base_url,
325 bearer_token,
326 })
327}
328
329#[cfg(not(feature = "client"))]
330fn build_target(cfg: &SomaConfig) -> Result<SomaTarget> {
331 if cfg.effective_runtime_mode() == EffectiveRuntimeMode::Remote && !cfg.api_url.is_empty() {
332 anyhow::bail!("soma-client was built without the `client` feature");
333 }
334 Ok(SomaTarget::Stub)
335}
336
337#[cfg(feature = "client")]
338fn api_url(base_url: &Url, relative_path: &str) -> Result<Url> {
339 let mut url = base_url.clone();
340 {
341 let mut segments = url
342 .path_segments_mut()
343 .map_err(|_| anyhow::anyhow!("SOMA_API_URL cannot be a base for REST paths"))?;
344 segments.pop_if_empty();
345 for segment in relative_path.split('/') {
346 if !segment.is_empty() {
347 segments.push(segment);
348 }
349 }
350 }
351 Ok(url)
352}
353
354#[cfg(feature = "client")]
355fn non_empty(value: &str) -> Option<String> {
356 let value = value.trim();
357 (!value.is_empty()).then(|| value.to_owned())
358}
359
360fn remote_action_uses_get(action: &str, params: &Value) -> bool {
361 params.as_object().is_some_and(|object| object.is_empty())
362 && matches!(action, "status" | "help")
363}
364
365#[derive(Debug)]
366struct RemoteProviderRoute {
367 method: String,
368 relative_path: String,
369}
370
371fn remote_provider_route(catalog: &Value, action: &str) -> Result<Option<RemoteProviderRoute>> {
372 let Some(providers) = catalog.get("providers").and_then(Value::as_array) else {
373 return Ok(None);
374 };
375 for provider in providers {
376 let Some(tools) = provider.get("tools").and_then(Value::as_array) else {
377 continue;
378 };
379 for tool in tools {
380 if !remote_tool_matches_action(tool, action) {
381 continue;
382 }
383 if tool
384 .get("surfaces")
385 .and_then(|surfaces| surfaces.get("rest"))
386 .and_then(Value::as_bool)
387 == Some(false)
388 {
389 anyhow::bail!("remote provider action `{action}` is not REST-exposed");
390 }
391 let canonical = tool
392 .get("name")
393 .and_then(Value::as_str)
394 .ok_or_else(|| anyhow::anyhow!("remote provider catalog tool missing name"))?;
395 if let Some(rest) = tool.get("rest") {
396 if let Some(path) = rest.get("path").and_then(Value::as_str) {
397 return Ok(Some(RemoteProviderRoute {
398 method: rest_method(rest),
399 relative_path: trim_relative_rest_path(path),
400 }));
401 }
402 }
403 if let Some(rest) = tool.get("generic_rest") {
404 if let Some(path) = rest.get("path").and_then(Value::as_str) {
405 return Ok(Some(RemoteProviderRoute {
406 method: rest_method(rest),
407 relative_path: trim_relative_rest_path(path),
408 }));
409 }
410 }
411 return Ok(Some(RemoteProviderRoute {
412 method: "POST".to_owned(),
413 relative_path: format!("v1/tools/{canonical}"),
414 }));
415 }
416 }
417 Ok(None)
418}
419
420fn rest_method(rest: &Value) -> String {
421 rest.get("method")
422 .and_then(Value::as_str)
423 .unwrap_or("POST")
424 .to_ascii_uppercase()
425}
426
427fn remote_tool_matches_action(tool: &Value, action: &str) -> bool {
428 if tool.get("name").and_then(Value::as_str) == Some(action) {
429 return true;
430 }
431 let Some(cli) = tool.get("cli") else {
432 return false;
433 };
434 if cli.get("command").and_then(Value::as_str) == Some(action) {
435 return true;
436 }
437 cli.get("aliases")
438 .and_then(Value::as_array)
439 .into_iter()
440 .flatten()
441 .any(|alias| alias.as_str() == Some(action))
442}
443
444fn trim_relative_rest_path(path: &str) -> String {
445 path.trim_start_matches('/').to_owned()
446}
447
448fn validate_action_path_segment(action: &str) -> Result<()> {
449 if action.is_empty() || action.contains('/') {
450 anyhow::bail!("remote REST action must be a non-empty path segment");
451 }
452 Ok(())
453}
454
455#[cfg(feature = "observability")]
456fn add_status_warnings(status: &mut Value) {
457 if let Some(warning) = soma_observability::binary_status::stale_binary_warning() {
458 status["warnings"] = json!([warning]);
459 }
460}
461
462#[cfg(not(feature = "observability"))]
463fn add_status_warnings(_status: &mut Value) {}