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::{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
251pub 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
259pub 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;