1#[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
309pub 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
317pub 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;