Skip to main content

soma_palette/
router.rs

1//! `/v1/palette/*` route wiring.
2//!
3//! Handlers are thin: parse the HTTP input, call into `catalog`/`search`/
4//! `schema`/`execute`, and translate the result into a response. All product
5//! logic lives in those sibling modules, not here.
6
7use axum::{
8    extract::{rejection::JsonRejection, Extension, Query, State},
9    response::{IntoResponse, Json, Response},
10    routing::{get, post},
11    Router,
12};
13use soma_application::CatalogSnapshot;
14use soma_http_api::response::json_rejection_response;
15
16use crate::{
17    auth::{palette_execution_context, AuthContext},
18    catalog::catalog_response,
19    dto::{
20        LauncherExecuteRequest, LauncherSchemaQuery, LauncherSearchQuery, LauncherSearchResponse,
21    },
22    error::{launcher_not_found, palette_error_response},
23    execute::{execute_launcher, ExecuteOutcome},
24    schema::find_schema,
25    search::search_entries,
26    state::PaletteState,
27};
28
29pub fn router() -> Router<PaletteState> {
30    Router::new()
31        .route("/v1/palette/catalog", get(get_catalog))
32        .route("/v1/palette/search", get(get_search))
33        .route("/v1/palette/schema", get(get_schema))
34        .route("/v1/palette/execute", post(post_execute))
35}
36
37/// Refresh the file-backed provider registry before taking a catalog
38/// snapshot. Every catalog-dependent `/v1/palette/*` handler goes through
39/// this instead of `catalog_snapshot()` directly — REST (`/v1/providers`)
40/// and MCP already refresh before serving, and reading a snapshot straight
41/// off the live registry without it left palette responses stale until an
42/// unrelated endpoint (or a process restart) happened to refresh it first.
43async fn refreshed_snapshot(state: &PaletteState) -> Result<CatalogSnapshot, Response> {
44    state
45        .application()
46        .refresh_providers()
47        .map_err(palette_error_response)
48}
49
50async fn get_catalog(State(state): State<PaletteState>) -> Response {
51    let snapshot = match refreshed_snapshot(&state).await {
52        Ok(snapshot) => snapshot,
53        Err(response) => return response,
54    };
55    Json(catalog_response(&snapshot)).into_response()
56}
57
58async fn get_search(
59    State(state): State<PaletteState>,
60    Query(query): Query<LauncherSearchQuery>,
61) -> Response {
62    let snapshot = match refreshed_snapshot(&state).await {
63        Ok(snapshot) => snapshot,
64        Err(response) => return response,
65    };
66    let entries = crate::catalog::palette_entries(&snapshot);
67    let results = search_entries(&entries, &query.q, query.limit);
68    Json(LauncherSearchResponse { entries: results }).into_response()
69}
70
71async fn get_schema(
72    State(state): State<PaletteState>,
73    Query(query): Query<LauncherSchemaQuery>,
74) -> Response {
75    let snapshot = match refreshed_snapshot(&state).await {
76        Ok(snapshot) => snapshot,
77        Err(response) => return response,
78    };
79    match find_schema(&snapshot, &query.id) {
80        Some(schema) => Json(schema).into_response(),
81        None => launcher_not_found(&query.id),
82    }
83}
84
85async fn post_execute(
86    State(state): State<PaletteState>,
87    auth: Option<Extension<AuthContext>>,
88    body: Result<Json<LauncherExecuteRequest>, JsonRejection>,
89) -> Response {
90    let Json(request) = match body {
91        Ok(body) => body,
92        // Delegate to `soma-http-api`'s shared rejection renderer — same
93        // 413/400 split and `ErrorBody` shape `soma-api` uses for every
94        // `JsonRejection` (see `soma_api::responses::rest_json_rejection_response`),
95        // instead of a palette-local, always-400, differently-shaped body.
96        Err(error) => return json_rejection_response(error),
97    };
98    let context = palette_execution_context(&state, auth.as_ref().map(|Extension(auth)| auth));
99    let id = request.id.clone();
100
101    match execute_launcher(&state, request, context).await {
102        ExecuteOutcome::Ok(response) => Json(response).into_response(),
103        ExecuteOutcome::NotFound => launcher_not_found(&id),
104        ExecuteOutcome::Failed(error) => palette_error_response(error),
105    }
106}
107
108#[cfg(test)]
109#[path = "router_tests.rs"]
110mod tests;