Skip to main content

moonlight_http/proxy/
api.rs

1use crate::AppState;
2use axum::{
3    extract::{Path, Query, State},
4    http::{header, HeaderMap, StatusCode},
5    response::{IntoResponse, Response},
6    Json,
7};
8use moonlight_core::{
9    report::{render_report, ReportFormat},
10    review::{ReviewUpdate, RunReviewState},
11    Adapter, Classification, ComparisonRun, MetricsSnapshot, RunFilter, RunPage,
12};
13use std::sync::Arc;
14use uuid::Uuid;
15
16pub async fn get_health() -> impl IntoResponse {
17    Json(serde_json::json!({ "ok": true }))
18}
19
20pub async fn get_config(
21    State(state): State<Arc<AppState>>,
22    headers: HeaderMap,
23) -> Result<Json<moonlight_core::config::AppConfig>, StatusCode> {
24    require_admin(&state, &headers)?;
25    Ok(Json(state.config.clone()))
26}
27
28#[derive(Debug, serde::Deserialize)]
29pub struct RunsQuery {
30    limit: Option<usize>,
31    offset: Option<usize>,
32    classification: Option<String>,
33    adapter: Option<String>,
34    q: Option<String>,
35    status: Option<u16>,
36    has_noise: Option<bool>,
37    has_diff: Option<bool>,
38}
39
40pub async fn get_runs(
41    State(state): State<Arc<AppState>>,
42    headers: HeaderMap,
43    Query(query): Query<RunsQuery>,
44) -> Result<Json<RunPage>, StatusCode> {
45    require_admin(&state, &headers)?;
46    refresh_storage(&state).await;
47    let limit = query.limit.unwrap_or(100).min(1000);
48    let offset = query.offset.unwrap_or(0);
49    let filter = query.into_filter().map_err(|_| StatusCode::BAD_REQUEST)?;
50    Ok(Json(
51        state.storage.filtered_page(&filter, limit, offset).await,
52    ))
53}
54
55pub async fn get_run(
56    State(state): State<Arc<AppState>>,
57    headers: HeaderMap,
58    Path(id): Path<Uuid>,
59) -> Result<Json<ComparisonRun>, StatusCode> {
60    require_admin(&state, &headers)?;
61    refresh_storage(&state).await;
62    state
63        .storage
64        .get(id)
65        .await
66        .map(Json)
67        .ok_or(StatusCode::NOT_FOUND)
68}
69
70pub async fn get_stats(
71    State(state): State<Arc<AppState>>,
72    headers: HeaderMap,
73) -> Result<Json<moonlight_core::StatsSummary>, StatusCode> {
74    require_admin(&state, &headers)?;
75    refresh_storage(&state).await;
76    Ok(Json(state.storage.stats().await))
77}
78
79pub async fn get_metrics(
80    State(state): State<Arc<AppState>>,
81    headers: HeaderMap,
82) -> Result<Json<MetricsSnapshot>, StatusCode> {
83    require_admin(&state, &headers)?;
84    Ok(Json(state.metrics.snapshot()))
85}
86
87#[derive(Debug, serde::Deserialize)]
88pub struct ReportQuery {
89    format: Option<String>,
90}
91
92pub async fn get_run_report(
93    State(state): State<Arc<AppState>>,
94    headers: HeaderMap,
95    Path(id): Path<Uuid>,
96    Query(query): Query<ReportQuery>,
97) -> Result<Response, StatusCode> {
98    require_admin(&state, &headers)?;
99    refresh_storage(&state).await;
100    let run = state.storage.get(id).await.ok_or(StatusCode::NOT_FOUND)?;
101    let format = query
102        .format
103        .as_deref()
104        .unwrap_or("markdown")
105        .parse::<ReportFormat>()
106        .map_err(|_| StatusCode::BAD_REQUEST)?;
107    let content = render_report(&run, Some(&state.config), format)
108        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
109    let content_type = match format {
110        ReportFormat::Markdown => "text/markdown; charset=utf-8",
111        ReportFormat::Json => "application/json; charset=utf-8",
112    };
113    Ok(([(header::CONTENT_TYPE, content_type)], content).into_response())
114}
115
116pub async fn get_run_review(
117    State(state): State<Arc<AppState>>,
118    headers: HeaderMap,
119    Path(id): Path<Uuid>,
120) -> Result<Json<RunReviewState>, StatusCode> {
121    require_admin(&state, &headers)?;
122    Ok(Json(state.review_store.get(id).await))
123}
124
125pub async fn put_run_review(
126    State(state): State<Arc<AppState>>,
127    headers: HeaderMap,
128    Path(id): Path<Uuid>,
129    Json(update): Json<ReviewUpdate>,
130) -> Result<Json<RunReviewState>, StatusCode> {
131    require_admin(&state, &headers)?;
132    state
133        .review_store
134        .put(id, update)
135        .await
136        .map(Json)
137        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
138}
139
140async fn refresh_storage(state: &AppState) {
141    if let Err(error) = state.storage.refresh().await {
142        state.metrics.record_storage_refresh_failure();
143        tracing::warn!(error = %error, "failed to refresh moonlight run storage");
144    }
145}
146
147fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), StatusCode> {
148    let Some(token) = state.config.admin_token.as_deref() else {
149        return Ok(());
150    };
151
152    if bearer_token_matches(headers, token) || header_token_matches(headers, token) {
153        Ok(())
154    } else {
155        Err(StatusCode::UNAUTHORIZED)
156    }
157}
158
159impl RunsQuery {
160    fn into_filter(self) -> anyhow::Result<RunFilter> {
161        Ok(RunFilter {
162            classification: self
163                .classification
164                .map(|value| value.parse::<Classification>())
165                .transpose()?,
166            adapter: self
167                .adapter
168                .map(|value| value.parse::<Adapter>())
169                .transpose()?,
170            query: self.q,
171            status: self.status,
172            has_noise: self.has_noise,
173            has_diff: self.has_diff,
174        })
175    }
176}
177
178fn bearer_token_matches(headers: &HeaderMap, token: &str) -> bool {
179    headers
180        .get(axum::http::header::AUTHORIZATION)
181        .and_then(|value| value.to_str().ok())
182        .and_then(|value| value.strip_prefix("Bearer "))
183        .is_some_and(|value| value == token)
184}
185
186fn header_token_matches(headers: &HeaderMap, token: &str) -> bool {
187    headers
188        .get("x-moonlight-admin-token")
189        .and_then(|value| value.to_str().ok())
190        .is_some_and(|value| value == token)
191}