mockforge_registry_server/handlers/
runtime_logs.rs1use 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 #[serde(default)]
35 pub method: Option<String>,
36 #[serde(default)]
38 pub path: Option<String>,
39 #[serde(default)]
41 pub status: Option<String>,
42 #[serde(default)]
44 pub limit: Option<i64>,
45}
46
47#[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
140fn 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
163fn 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}