Skip to main content

soma_api/
api.rs

1//! REST API handlers — direct `/v1/*` routes plus public health/status docs.
2//!
3//! All handlers are thin: parse HTTP input, call `SomaApplication`, return JSON.
4
5#[path = "openapi.rs"]
6mod openapi;
7#[path = "probes.rs"]
8mod probes;
9#[path = "route_inventory.rs"]
10mod route_inventory;
11
12use anyhow::Result;
13use axum::{
14    extract::{Extension, Path, State, rejection::JsonRejection},
15    http::{Method, StatusCode},
16    response::{IntoResponse, Json},
17};
18#[cfg(feature = "auth")]
19use soma_auth::AuthContext;
20#[cfg(not(feature = "auth"))]
21pub struct AuthContext {
22    sub: String,
23    scopes: Vec<String>,
24}
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use soma_application::ExecuteActionRequest;
29use soma_domain::{
30    Confirmation,
31    actions::{SomaAction, action_spec},
32};
33use soma_http_api::json::{JsonBodyOutcome, json_body_or_else};
34
35use crate::ApiState;
36use crate::responses::{
37    application_error_response, rest_error_response, rest_json_rejection_response,
38};
39pub use probes::{health, readyz, status};
40pub use route_inventory::{CapabilitiesResponse, REST_ROUTES, RestRoute};
41
42#[derive(Debug, Deserialize, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct GreetRequest {
45    #[serde(default)]
46    pub name: Option<String>,
47}
48
49#[derive(Debug, Deserialize, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct EchoRequest {
52    pub message: String,
53}
54
55pub async fn v1_capabilities() -> impl IntoResponse {
56    Json(route_inventory::capabilities_response())
57}
58
59pub async fn v1_providers(State(state): State<ApiState>) -> axum::response::Response {
60    if let Some(response) = refresh_file_providers(&state).await {
61        return response;
62    }
63    Json(state.application().provider_inspection_report()).into_response()
64}
65
66pub async fn v1_greet(
67    State(state): State<ApiState>,
68    auth: Option<Extension<AuthContext>>,
69    body: Result<Json<GreetRequest>, JsonRejection>,
70) -> axum::response::Response {
71    let Json(body) = match body {
72        Ok(body) => body,
73        Err(error) => return rest_json_rejection_response(error),
74    };
75    run_rest_action_request(
76        state,
77        auth.as_ref().map(|Extension(auth)| auth),
78        SomaAction::from_rest("greet", &optional_name_params(body.name)),
79        "greet",
80    )
81    .await
82}
83
84pub async fn v1_echo(
85    State(state): State<ApiState>,
86    auth: Option<Extension<AuthContext>>,
87    body: Result<Json<EchoRequest>, JsonRejection>,
88) -> axum::response::Response {
89    let Json(body) = match body {
90        Ok(body) => body,
91        Err(error) => return rest_json_rejection_response(error),
92    };
93    run_rest_action_request(
94        state,
95        auth.as_ref().map(|Extension(auth)| auth),
96        SomaAction::from_rest("echo", &json!({ "message": body.message })),
97        "echo",
98    )
99    .await
100}
101
102pub async fn v1_service_status(
103    State(state): State<ApiState>,
104    auth: Option<Extension<AuthContext>>,
105) -> axum::response::Response {
106    run_rest_action_request(
107        state,
108        auth.as_ref().map(|Extension(auth)| auth),
109        Ok(SomaAction::Status),
110        "status",
111    )
112    .await
113}
114
115pub async fn v1_help(
116    State(state): State<ApiState>,
117    auth: Option<Extension<AuthContext>>,
118) -> axum::response::Response {
119    run_rest_action_request(
120        state,
121        auth.as_ref().map(|Extension(auth)| auth),
122        Ok(SomaAction::Help),
123        "help",
124    )
125    .await
126}
127
128pub async fn v1_provider_tool_action(
129    State(state): State<ApiState>,
130    auth: Option<Extension<AuthContext>>,
131    Path(action): Path<String>,
132    body: Result<Json<Value>, JsonRejection>,
133) -> axum::response::Response {
134    let params = match json_body_or_else(body, true, || json!({})) {
135        JsonBodyOutcome::Params(params) => params,
136        JsonBodyOutcome::Response(response) => return response,
137    };
138
139    run_provider_rest_action(
140        state,
141        auth.as_ref().map(|Extension(auth)| auth),
142        action,
143        params,
144    )
145    .await
146}
147
148pub async fn v1_dynamic_provider_route(
149    State(state): State<ApiState>,
150    auth: Option<Extension<AuthContext>>,
151    method: Method,
152    Path(path): Path<String>,
153    body: Result<Json<Value>, JsonRejection>,
154) -> axum::response::Response {
155    let route_path = format!("/v1/{path}");
156    let method = method.as_str().to_ascii_uppercase();
157    if let Some(response) = refresh_file_providers(&state).await {
158        return response;
159    }
160    let action = match state.application().resolve_rest_route(&method, &route_path) {
161        Some(action) => action,
162        None => {
163            return (
164                StatusCode::NOT_FOUND,
165                Json(json!({
166                    "error": "not_found",
167                    "message": format!("No provider route registered for {method} {route_path}"),
168                })),
169            )
170                .into_response();
171        }
172    };
173
174    let params = match json_body_or_else(body, method == "GET" || method == "DELETE", || json!({}))
175    {
176        JsonBodyOutcome::Params(params) => params,
177        JsonBodyOutcome::Response(response) => return response,
178    };
179
180    run_provider_rest_action(
181        state,
182        auth.as_ref().map(|Extension(auth)| auth),
183        action,
184        params,
185    )
186    .await
187}
188
189async fn run_rest_action_request(
190    state: ApiState,
191    auth: Option<&AuthContext>,
192    action: Result<SomaAction>,
193    action_name: &str,
194) -> axum::response::Response {
195    match action {
196        Ok(action) => {
197            let action_name = action.name().to_owned();
198            run_provider_rest_action(state, auth, action_name, rest_params(&action)).await
199        }
200        Err(error) => rest_error_response(error, action_name),
201    }
202}
203
204async fn run_provider_rest_action(
205    state: ApiState,
206    auth: Option<&AuthContext>,
207    action_name: String,
208    params: Value,
209) -> axum::response::Response {
210    if let Some(response) = refresh_file_providers(&state).await {
211        return response;
212    }
213    let (params, confirmation) = split_rest_transport_metadata(&action_name, params);
214    let request = ExecuteActionRequest {
215        action: action_name.clone(),
216        params,
217    };
218    let mut context = rest_execution_context(&state, auth);
219    context.destructive_confirmation = confirmation;
220
221    match state.application().execute_action(request, context).await {
222        Ok(output) => Json(output.into_surface_value()).into_response(),
223        Err(error) => application_error_response(error),
224    }
225}
226
227fn rest_params(action: &SomaAction) -> Value {
228    match action {
229        SomaAction::Greet { name } => optional_name_params(name.clone()),
230        SomaAction::Echo { message } => json!({ "message": message }),
231        SomaAction::PythonEnvironmentPrunePlan {
232            stale_before_unix_seconds,
233            max_entries,
234        }
235        | SomaAction::PythonEnvironmentPrune {
236            stale_before_unix_seconds,
237            max_entries,
238        } => json!({
239            "stale_before_unix_seconds": stale_before_unix_seconds,
240            "max_entries": max_entries,
241        }),
242        SomaAction::PythonEnvironmentRepair { provider_path }
243        | SomaAction::PythonEnvironmentUpdate { provider_path } => {
244            json!({"provider_path": provider_path})
245        }
246        SomaAction::PythonWorkerCancel { provider }
247        | SomaAction::PythonWorkerReset { provider } => json!({"provider": provider}),
248        SomaAction::PythonGenerationRollback { generation_id } => {
249            json!({"generation_id": generation_id})
250        }
251        SomaAction::PythonGraduationStatus { workspace } => {
252            json!({"workspace": workspace})
253        }
254        SomaAction::PythonGraduationApply {
255            operation,
256            workspace,
257            source,
258            component,
259            fixtures,
260            wheelhouse,
261        } => json!({
262            "operation": operation,
263            "workspace": workspace,
264            "source": source,
265            "component": component,
266            "fixtures": fixtures,
267            "wheelhouse": wheelhouse,
268        }),
269        SomaAction::Status
270        | SomaAction::PythonEnvironmentStatus
271        | SomaAction::PythonWorkerStatus
272        | SomaAction::PythonGenerationStatus
273        | SomaAction::Help
274        | SomaAction::ElicitName
275        | SomaAction::ScaffoldIntent => json!({}),
276    }
277}
278
279fn rest_execution_context(
280    state: &ApiState,
281    auth: Option<&AuthContext>,
282) -> soma_application::ExecutionContext {
283    let scopes = auth.map(|auth| auth.scopes.as_slice()).unwrap_or_default();
284    state.execution_context(auth.map(|auth| auth.sub.as_str()), scopes)
285}
286
287fn split_rest_transport_metadata(action_name: &str, mut params: Value) -> (Value, Confirmation) {
288    if !action_spec(action_name).is_some_and(|spec| spec.destructive) {
289        return (params, Confirmation::Missing);
290    }
291    let confirm = params
292        .as_object_mut()
293        .and_then(|params| params.remove("confirm"));
294    let confirmation = if confirm.as_ref().and_then(Value::as_bool) == Some(true) {
295        Confirmation::Confirmed
296    } else {
297        Confirmation::Missing
298    };
299    (params, confirmation)
300}
301
302fn optional_name_params(name: Option<String>) -> Value {
303    match name {
304        Some(name) => json!({ "name": name }),
305        None => json!({}),
306    }
307}
308
309/// `GET /openapi.json` — generated OpenAPI schema for the REST surface.
310pub async fn openapi_json(State(state): State<ApiState>) -> axum::response::Response {
311    match build_openapi_document(&state).await {
312        Ok(value) => Json(value).into_response(),
313        Err(response) => response,
314    }
315}
316
317/// Refresh, build, and gateway-augment the OpenAPI document, returning the
318/// raw `Value` rather than a `Response`. `openapi_json` wraps this directly;
319/// the composition root (`apps/soma`) also calls it so it can layer its own
320/// route augmentation (e.g. Palette's `/v1/palette/*`) on top without
321/// `soma-api` depending on a peer product-surface crate.
322pub async fn build_openapi_document(state: &ApiState) -> Result<Value, axum::response::Response> {
323    if let Some(response) = refresh_file_providers(state).await {
324        return Err(response);
325    }
326    match state.application().openapi_document() {
327        Ok(mut value) => {
328            openapi::augment_with_static_action_routes(&mut value);
329            openapi::augment_with_gateway_route(&mut value);
330            Ok(value)
331        }
332        Err(error) => Err(application_error_response(error)),
333    }
334}
335
336async fn refresh_file_providers(state: &ApiState) -> Option<axum::response::Response> {
337    match state.application().refresh_providers_async().await {
338        Ok(_) => None,
339        Err(error) => {
340            tracing::error!(%error, "provider refresh failed");
341            Some(application_error_response(error))
342        }
343    }
344}
345
346#[cfg(test)]
347#[path = "api_tests.rs"]
348mod tests;