Skip to main content

soma_client/
client.rs

1//! Transport client for the Soma runtime.
2//!
3//! **Soma note**: this client has two modes:
4//!   - empty `SOMA_API_URL` keeps the offline stub working;
5//!   - non-empty `SOMA_API_URL` forwards operations to a deployed `soma serve`
6//!     REST API, which is the local CLI/stdio adapter shape for platform servers.
7//!
8//! The pattern:
9//!   - `SomaClient::new()` builds the transport (HTTP client, connection pool, etc.)
10//!   - Each method corresponds to one remote operation and returns `Result<Value>`
11//!   - `SomaService` (in `soma-service`) wraps this and adds any business logic
12//!   - MCP tools in `mcp/tools.rs` call `SomaService`, never `SomaClient` directly
13
14#[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// Unit tests live in a sidecar file — see src/client_tests.rs for the pattern.
27// CUSTOMIZE: Copy this block into every module that needs unit tests.
28#[cfg(test)]
29#[path = "client_tests.rs"]
30mod tests;
31
32/// HTTP (or other transport) client for the Soma runtime.
33///
34/// For application/platform servers, the lightweight local binary uses this as
35/// an adapter to the deployed `soma serve` REST API. For upstream-client
36/// servers, replace the REST envelope with the upstream service's native API.
37#[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    /// Offline stub mode used by Soma when no deployed API is configured.
48    Stub,
49    /// Deployed platform API reached by the local CLI/stdio adapter.
50    #[cfg(feature = "client")]
51    DeployedApi {
52        base_url: Url,
53        bearer_token: Option<String>,
54    },
55}
56
57impl SomaClient {
58    /// Construct a new client from configuration.
59    ///
60    /// If `SOMA_API_URL` is empty, Soma uses local stub responses so
61    /// tests and first-run scaffolds work without a deployed service. If it is
62    /// set, operations are forwarded to direct `{SOMA_API_URL}/v1/*` routes.
63    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    /// Say hello to `name`, or "World" if not provided.
81    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    /// Echo a message back unchanged.
96    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    /// Return a status snapshot of the remote service.
108    ///
109    /// Note: this value is returned by the unauthenticated `/status` endpoint,
110    /// so it must not include secrets or sensitive topology (e.g. `api_url`).
111    /// CUSTOMIZE: Add non-sensitive runtime metrics (uptime, version, etc.).
112    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            // api_url intentionally omitted — topology leak on unauthenticated endpoint.
120            "note": "stub — replace with real health endpoint",
121        });
122        add_status_warnings(&mut status);
123        Ok(status)
124    }
125
126    /// Call one direct REST action on a running Soma HTTP server.
127    ///
128    /// This is used by remote adapter surfaces (`soma <command>` and eventually
129    /// stdio MCP adapter mode) so the local binary does not need local provider
130    /// manifests to execute provider-backed actions.
131    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, &params).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    /// Fetch the live provider catalog from a deployed Soma HTTP server.
142    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    /// Readiness probe of the upstream dependency.
149    ///
150    /// Stub mode is always ready (there is no upstream). Deployed mode issues a
151    /// short, timeout-bounded GET against the upstream `/health` so a wedged or
152    /// unreachable upstream surfaces as not-ready instead of hanging the probe.
153    /// Used by the `/readyz` route; keep it cheap and side-effect free.
154    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) {}