Skip to main content

mockforge_registry_server/handlers/
runtime_logs.rs

1//! Workspace request-log handler (#462) — queries `runtime_captures` for the
2//! workspace and returns them in the UI-friendly `RequestLog` shape used by
3//! the local `/__mockforge/logs` endpoint. Lets the same `LogsPage` UI work
4//! against the registry.
5//!
6//! Today this reaches captures shipped with `workspace_id` populated — i.e.
7//! `--cloud-ship` (local mockforge sending to cloud). Hosted-mock captures
8//! still land with `workspace_id IS NULL` because the in-container shipper
9//! doesn't have the workspace context at ingest time; backfilling that is
10//! tracked separately. The endpoint returns `[]` for those today and will
11//! light up automatically once the shipper fix lands.
12//!
13//! Route: `GET /api/v1/workspaces/{workspace_id}/request-logs`
14
15use axum::{
16    extract::{Path, Query, State},
17    http::HeaderMap,
18    Json,
19};
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use crate::{
25    error::{ApiError, ApiResult},
26    middleware::{resolve_org_context, AuthUser},
27    models::CloudWorkspace,
28    AppState,
29};
30
31#[derive(Debug, Deserialize)]
32pub struct ListLogsQuery {
33    /// Exact HTTP method filter (case-insensitive).
34    #[serde(default)]
35    pub method: Option<String>,
36    /// Substring match against `path`.
37    #[serde(default)]
38    pub path: Option<String>,
39    /// Status-class filter: `2xx`, `4xx`, `5xx`, or an exact code like `404`.
40    #[serde(default)]
41    pub status: Option<String>,
42    /// Max rows. Capped at 1000.
43    #[serde(default)]
44    pub limit: Option<i64>,
45}
46
47/// Response shape mirrors the UI's `RequestLog` interface in
48/// `crates/mockforge-ui/ui/src/types/index.ts` so the same `LogsPage` can
49/// render either source unchanged.
50#[derive(Debug, Serialize)]
51pub struct RequestLogEntry {
52    pub id: String,
53    pub timestamp: DateTime<Utc>,
54    pub method: String,
55    pub path: String,
56    pub status_code: i32,
57    pub response_time_ms: i64,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub client_ip: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub user_agent: Option<String>,
62    pub headers: serde_json::Value,
63    pub response_size_bytes: i64,
64}
65
66pub async fn list_workspace_request_logs(
67    State(state): State<AppState>,
68    AuthUser(user_id): AuthUser,
69    Path(workspace_id): Path<Uuid>,
70    Query(query): Query<ListLogsQuery>,
71    headers: HeaderMap,
72) -> ApiResult<Json<Vec<RequestLogEntry>>> {
73    authorize_workspace(&state, user_id, &headers, workspace_id).await?;
74
75    let limit = query.limit.unwrap_or(100).clamp(1, 1000);
76    let method_filter = query.method.as_ref().map(|s| s.to_uppercase());
77    let path_filter = query.path.as_deref().filter(|s| !s.is_empty());
78    let (status_min, status_max) = parse_status_filter(query.status.as_deref());
79
80    let rows: Vec<RuntimeCaptureRow> = sqlx::query_as::<_, RuntimeCaptureRow>(
81        r#"
82        SELECT id, occurred_at, method, path,
83               COALESCE(response_status_code, status_code, 0) AS effective_status,
84               COALESCE(duration_ms, 0) AS duration_ms,
85               client_ip,
86               request_headers,
87               COALESCE(response_size_bytes, 0) AS response_size_bytes
88        FROM runtime_captures
89        WHERE workspace_id = $1
90          AND ($2::text IS NULL OR UPPER(method) = $2)
91          AND ($3::text IS NULL OR position($3 IN path) > 0)
92          AND ($4::int IS NULL OR COALESCE(response_status_code, status_code, 0) BETWEEN $4 AND $5)
93        ORDER BY occurred_at DESC
94        LIMIT $6
95        "#,
96    )
97    .bind(workspace_id)
98    .bind(method_filter)
99    .bind(path_filter)
100    .bind(status_min)
101    .bind(status_max)
102    .bind(limit)
103    .fetch_all(state.db.pool())
104    .await
105    .map_err(ApiError::Database)?;
106
107    let entries = rows.into_iter().map(row_to_entry).collect();
108    Ok(Json(entries))
109}
110
111#[derive(sqlx::FromRow)]
112struct RuntimeCaptureRow {
113    id: i64,
114    occurred_at: DateTime<Utc>,
115    method: String,
116    path: String,
117    effective_status: i32,
118    duration_ms: i64,
119    client_ip: Option<String>,
120    request_headers: String,
121    response_size_bytes: i64,
122}
123
124fn row_to_entry(row: RuntimeCaptureRow) -> RequestLogEntry {
125    let (headers, user_agent) = parse_request_headers(&row.request_headers);
126    RequestLogEntry {
127        id: row.id.to_string(),
128        timestamp: row.occurred_at,
129        method: row.method,
130        path: row.path,
131        status_code: row.effective_status,
132        response_time_ms: row.duration_ms,
133        client_ip: row.client_ip,
134        user_agent,
135        headers,
136        response_size_bytes: row.response_size_bytes,
137    }
138}
139
140/// `request_headers` is stored as a JSON-encoded TEXT (per the shipper's
141/// `RecordedRequest::headers` serialization). Parse to an object; lift
142/// User-Agent out so the UI can show it without re-parsing headers per row.
143/// Malformed input falls back to an empty object — never fail the whole
144/// listing because one row's headers are unparsable.
145fn parse_request_headers(raw: &str) -> (serde_json::Value, Option<String>) {
146    let parsed: serde_json::Value =
147        serde_json::from_str(raw).unwrap_or(serde_json::Value::Object(Default::default()));
148    let user_agent = parsed
149        .as_object()
150        .and_then(|m| {
151            m.iter().find_map(|(k, v)| {
152                if k.eq_ignore_ascii_case("user-agent") {
153                    v.as_str().map(str::to_string)
154                } else {
155                    None
156                }
157            })
158        })
159        .filter(|s| !s.is_empty());
160    (parsed, user_agent)
161}
162
163/// Accepts `2xx`, `4xx`, `5xx`, or an exact `404`-style integer. Returns
164/// the inclusive (min, max) status range, or `(None, None)` when unset /
165/// unparsable so the SQL WHERE clause skips the filter.
166fn parse_status_filter(raw: Option<&str>) -> (Option<i32>, Option<i32>) {
167    let Some(s) = raw else { return (None, None) };
168    let trimmed = s.trim().to_lowercase();
169    match trimmed.as_str() {
170        "2xx" => (Some(200), Some(299)),
171        "3xx" => (Some(300), Some(399)),
172        "4xx" => (Some(400), Some(499)),
173        "5xx" => (Some(500), Some(599)),
174        other => match other.parse::<i32>() {
175            Ok(n) if (100..=599).contains(&n) => (Some(n), Some(n)),
176            _ => (None, None),
177        },
178    }
179}
180
181async fn authorize_workspace(
182    state: &AppState,
183    user_id: Uuid,
184    headers: &HeaderMap,
185    workspace_id: Uuid,
186) -> ApiResult<()> {
187    let workspace = CloudWorkspace::find_by_id(state.db.pool(), workspace_id)
188        .await?
189        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".into()))?;
190    let ctx = resolve_org_context(state, user_id, headers, None)
191        .await
192        .map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
193    if ctx.org_id != workspace.org_id {
194        return Err(ApiError::InvalidRequest("Workspace not found".into()));
195    }
196    Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn status_filter_parses_classes() {
205        assert_eq!(parse_status_filter(Some("2xx")), (Some(200), Some(299)));
206        assert_eq!(parse_status_filter(Some("4XX")), (Some(400), Some(499)));
207        assert_eq!(parse_status_filter(Some("5xx")), (Some(500), Some(599)));
208    }
209
210    #[test]
211    fn status_filter_parses_exact_code() {
212        assert_eq!(parse_status_filter(Some("404")), (Some(404), Some(404)));
213        assert_eq!(parse_status_filter(Some("200")), (Some(200), Some(200)));
214    }
215
216    #[test]
217    fn status_filter_rejects_garbage() {
218        assert_eq!(parse_status_filter(Some("9xx")), (None, None));
219        assert_eq!(parse_status_filter(Some("abc")), (None, None));
220        assert_eq!(parse_status_filter(Some("99")), (None, None));
221        assert_eq!(parse_status_filter(None), (None, None));
222    }
223
224    #[test]
225    fn headers_parse_extracts_user_agent_case_insensitive() {
226        let (h, ua) = parse_request_headers(r#"{"User-Agent":"curl/8.4"}"#);
227        assert_eq!(ua.as_deref(), Some("curl/8.4"));
228        assert!(h.is_object());
229
230        let (_, ua) = parse_request_headers(r#"{"user-agent":"foo"}"#);
231        assert_eq!(ua.as_deref(), Some("foo"));
232    }
233
234    #[test]
235    fn headers_parse_handles_malformed_input() {
236        let (h, ua) = parse_request_headers("not json");
237        assert!(h.is_object() && h.as_object().unwrap().is_empty());
238        assert!(ua.is_none());
239    }
240}