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::{rejection::JsonRejection, Extension, Path, State},
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::{json, Value};
27
28use soma_application::ExecuteActionRequest;
29use soma_domain::actions::SomaAction;
30use soma_http_api::json::{json_body_or_else, JsonBodyOutcome};
31
32use crate::responses::{
33    application_error_response, rest_error_response, rest_json_rejection_response,
34};
35use crate::ApiState;
36pub use probes::{health, readyz, status};
37pub use route_inventory::{CapabilitiesResponse, RestRoute, REST_ROUTES};
38
39#[derive(Debug, Deserialize, Serialize)]
40#[serde(deny_unknown_fields)]
41pub struct GreetRequest {
42    #[serde(default)]
43    pub name: Option<String>,
44}
45
46#[derive(Debug, Deserialize, Serialize)]
47#[serde(deny_unknown_fields)]
48pub struct EchoRequest {
49    pub message: String,
50}
51
52pub async fn v1_capabilities() -> impl IntoResponse {
53    Json(route_inventory::capabilities_response())
54}
55
56pub async fn v1_providers(State(state): State<ApiState>) -> axum::response::Response {
57    if let Some(response) = refresh_file_providers(&state) {
58        return response;
59    }
60    Json(state.application().provider_inspection_report()).into_response()
61}
62
63pub async fn v1_greet(
64    State(state): State<ApiState>,
65    auth: Option<Extension<AuthContext>>,
66    body: Result<Json<GreetRequest>, JsonRejection>,
67) -> axum::response::Response {
68    let Json(body) = match body {
69        Ok(body) => body,
70        Err(error) => return rest_json_rejection_response(error),
71    };
72    run_rest_action_request(
73        state,
74        auth.as_ref().map(|Extension(auth)| auth),
75        SomaAction::from_rest("greet", &optional_name_params(body.name)),
76        "greet",
77    )
78    .await
79}
80
81pub async fn v1_echo(
82    State(state): State<ApiState>,
83    auth: Option<Extension<AuthContext>>,
84    body: Result<Json<EchoRequest>, JsonRejection>,
85) -> axum::response::Response {
86    let Json(body) = match body {
87        Ok(body) => body,
88        Err(error) => return rest_json_rejection_response(error),
89    };
90    run_rest_action_request(
91        state,
92        auth.as_ref().map(|Extension(auth)| auth),
93        SomaAction::from_rest("echo", &json!({ "message": body.message })),
94        "echo",
95    )
96    .await
97}
98
99pub async fn v1_service_status(
100    State(state): State<ApiState>,
101    auth: Option<Extension<AuthContext>>,
102) -> axum::response::Response {
103    run_rest_action_request(
104        state,
105        auth.as_ref().map(|Extension(auth)| auth),
106        Ok(SomaAction::Status),
107        "status",
108    )
109    .await
110}
111
112pub async fn v1_help(
113    State(state): State<ApiState>,
114    auth: Option<Extension<AuthContext>>,
115) -> axum::response::Response {
116    run_rest_action_request(
117        state,
118        auth.as_ref().map(|Extension(auth)| auth),
119        Ok(SomaAction::Help),
120        "help",
121    )
122    .await
123}
124
125pub async fn v1_provider_tool_action(
126    State(state): State<ApiState>,
127    auth: Option<Extension<AuthContext>>,
128    Path(action): Path<String>,
129    body: Result<Json<Value>, JsonRejection>,
130) -> axum::response::Response {
131    let params = match json_body_or_else(body, true, || json!({})) {
132        JsonBodyOutcome::Params(params) => params,
133        JsonBodyOutcome::Response(response) => return response,
134    };
135
136    run_provider_rest_action(
137        state,
138        auth.as_ref().map(|Extension(auth)| auth),
139        action,
140        params,
141    )
142    .await
143}
144
145pub async fn v1_dynamic_provider_route(
146    State(state): State<ApiState>,
147    auth: Option<Extension<AuthContext>>,
148    method: Method,
149    Path(path): Path<String>,
150    body: Result<Json<Value>, JsonRejection>,
151) -> axum::response::Response {
152    let route_path = format!("/v1/{path}");
153    let method = method.as_str().to_ascii_uppercase();
154    if let Some(response) = refresh_file_providers(&state) {
155        return response;
156    }
157    let action = match state.application().resolve_rest_route(&method, &route_path) {
158        Some(action) => action,
159        None => {
160            return (
161                StatusCode::NOT_FOUND,
162                Json(json!({
163                    "error": "not_found",
164                    "message": format!("No provider route registered for {method} {route_path}"),
165                })),
166            )
167                .into_response();
168        }
169    };
170
171    let params = match json_body_or_else(body, method == "GET" || method == "DELETE", || json!({}))
172    {
173        JsonBodyOutcome::Params(params) => params,
174        JsonBodyOutcome::Response(response) => return response,
175    };
176
177    run_provider_rest_action(
178        state,
179        auth.as_ref().map(|Extension(auth)| auth),
180        action,
181        params,
182    )
183    .await
184}
185
186async fn run_rest_action_request(
187    state: ApiState,
188    auth: Option<&AuthContext>,
189    action: Result<SomaAction>,
190    action_name: &str,
191) -> axum::response::Response {
192    match action {
193        Ok(action) => {
194            let action_name = action.name().to_owned();
195            run_provider_rest_action(state, auth, action_name, rest_params(&action)).await
196        }
197        Err(error) => rest_error_response(error, action_name),
198    }
199}
200
201async fn run_provider_rest_action(
202    state: ApiState,
203    auth: Option<&AuthContext>,
204    action_name: String,
205    params: Value,
206) -> axum::response::Response {
207    if let Some(response) = refresh_file_providers(&state) {
208        return response;
209    }
210    let request = ExecuteActionRequest {
211        action: action_name.clone(),
212        params,
213    };
214
215    match state
216        .application()
217        .execute_action(request, rest_execution_context(&state, auth))
218        .await
219    {
220        Ok(output) => Json(output.output).into_response(),
221        Err(error) => application_error_response(error),
222    }
223}
224
225fn rest_params(action: &SomaAction) -> Value {
226    match action {
227        SomaAction::Greet { name } => optional_name_params(name.clone()),
228        SomaAction::Echo { message } => json!({ "message": message }),
229        SomaAction::Status
230        | SomaAction::Help
231        | SomaAction::ElicitName
232        | SomaAction::ScaffoldIntent => json!({}),
233    }
234}
235
236fn rest_execution_context(
237    state: &ApiState,
238    auth: Option<&AuthContext>,
239) -> soma_application::ExecutionContext {
240    let scopes = auth.map(|auth| auth.scopes.as_slice()).unwrap_or_default();
241    state.execution_context(auth.map(|auth| auth.sub.as_str()), scopes)
242}
243
244fn optional_name_params(name: Option<String>) -> Value {
245    match name {
246        Some(name) => json!({ "name": name }),
247        None => json!({}),
248    }
249}
250
251/// `GET /openapi.json` — generated OpenAPI schema for the REST surface.
252pub async fn openapi_json(State(state): State<ApiState>) -> axum::response::Response {
253    match build_openapi_document(&state).await {
254        Ok(value) => Json(value).into_response(),
255        Err(response) => response,
256    }
257}
258
259/// Refresh, build, and gateway-augment the OpenAPI document, returning the
260/// raw `Value` rather than a `Response`. `openapi_json` wraps this directly;
261/// the composition root (`apps/soma`) also calls it so it can layer its own
262/// route augmentation (e.g. Palette's `/v1/palette/*`) on top without
263/// `soma-api` depending on a peer product-surface crate.
264pub async fn build_openapi_document(state: &ApiState) -> Result<Value, axum::response::Response> {
265    if let Some(response) = refresh_file_providers(state) {
266        return Err(response);
267    }
268    match state.application().openapi_document() {
269        Ok(mut value) => {
270            openapi::augment_with_gateway_route(&mut value);
271            Ok(value)
272        }
273        Err(error) => Err(application_error_response(error)),
274    }
275}
276
277fn refresh_file_providers(state: &ApiState) -> Option<axum::response::Response> {
278    match state.application().refresh_providers() {
279        Ok(_) => None,
280        Err(error) => {
281            tracing::error!(%error, "provider refresh failed");
282            Some(application_error_response(error))
283        }
284    }
285}
286
287#[cfg(test)]
288#[path = "api_tests.rs"]
289mod tests;