notedthat_api_http/
search_route.rs1use axum::{
4 Json,
5 extract::{Path, Request, State},
6 http::{StatusCode, header},
7 response::{IntoResponse, Response},
8};
9use bytes::Bytes;
10use notedthat_core::{Error as CoreError, KbSlug, search::SearchRequest};
11
12use crate::{
13 error::{ApiError, ApiErrorResponse},
14 state::AppState,
15};
16
17pub const SEARCH_BODY_MAX_BYTES: usize = 64 * 1024;
22
23pub async fn search_kb(
25 State(state): State<AppState>,
26 Path(kb_slug_raw): Path<String>,
27 req: Request,
28) -> Result<Response, ApiErrorResponse> {
29 let request_id = crate::middleware::extract_request_id(&req);
30 let err = |error: ApiError| ApiErrorResponse {
31 error,
32 request_id: request_id.clone(),
33 };
34
35 let kb_slug = KbSlug::try_new(kb_slug_raw).map_err(|e| err(ApiError::Core(e)))?;
38 let kb = crate::router::lookup_kb(&state, kb_slug.as_str()).map_err(err)?;
39
40 let (parts, body) = req.into_parts();
41 let body_bytes: Bytes = axum::body::to_bytes(body, SEARCH_BODY_MAX_BYTES)
42 .await
43 .map_err(|_| {
44 err(ApiError::Core(CoreError::PayloadTooLarge {
45 size: SEARCH_BODY_MAX_BYTES as u64 + 1,
46 limit: SEARCH_BODY_MAX_BYTES as u64,
47 }))
48 })?;
49
50 let content_type = parts
51 .headers
52 .get(header::CONTENT_TYPE)
53 .and_then(|value| value.to_str().ok());
54 if content_type.is_none_or(|value| !value.starts_with("application/json")) {
55 return Err(err(ApiError::Core(CoreError::InvalidInput {
56 message: "Content-Type must be application/json".into(),
57 })));
58 }
59
60 let raw: SearchRequest = serde_json::from_slice(&body_bytes).map_err(|e| {
61 err(ApiError::Core(CoreError::InvalidInput {
62 message: format!("invalid request body: {e}"),
63 }))
64 })?;
65 let validated = raw
66 .validate()
67 .map_err(|e| err(ApiError::Core(CoreError::from(e))))?;
68
69 let response = state
70 .searcher
71 .search(&kb, validated)
72 .await
73 .map_err(|e| err(ApiError::Core(CoreError::from(e))))?;
74
75 Ok((StatusCode::OK, Json(response)).into_response())
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use axum::{Router, body::Body, body::to_bytes, http::Request, routing::post};
82 use std::{collections::BTreeMap, sync::Arc};
83 use tower::util::ServiceExt;
84
85 const KB: &str = "notes";
86
87 fn app() -> Router {
88 let mut kbs = BTreeMap::new();
89 kbs.insert(KB.to_string(), KbSlug::try_new(KB).unwrap());
90 let (indexer_tx, _) = tokio::sync::mpsc::channel(1024);
91 let state = AppState {
92 storage: Arc::new(crate::testing::InMemoryStorage::default()),
93 declared_kbs: Arc::new(kbs),
94 bearer_token: Arc::new("token".to_string()),
95 max_body_size: 16 * 1024 * 1024,
96 max_patchable_size: 16 * 1024 * 1024,
97 indexer_tx,
98 searcher: Arc::new(crate::testing::NoopSearcher),
99 };
100
101 Router::new()
102 .route("/v1/knowledgebases/{kb_slug}/search", post(search_kb))
103 .with_state(state)
104 }
105
106 fn request(body: impl Into<Body>) -> Request<Body> {
107 Request::builder()
108 .method("POST")
109 .uri(format!("/v1/knowledgebases/{KB}/search"))
110 .header(header::CONTENT_TYPE, "application/json")
111 .body(body.into())
112 .unwrap()
113 }
114
115 async fn response_json(response: Response) -> serde_json::Value {
116 let bytes = to_bytes(response.into_body(), SEARCH_BODY_MAX_BYTES + 1024)
117 .await
118 .unwrap();
119 serde_json::from_slice(&bytes).unwrap()
120 }
121
122 #[tokio::test]
123 async fn valid_request_returns_200() {
124 let response = app()
125 .oneshot(request(r#"{"query":"install cargo"}"#))
126 .await
127 .unwrap();
128
129 assert_eq!(response.status(), StatusCode::OK);
130 let json = response_json(response).await;
131 assert_eq!(json, serde_json::json!({"hits": []}));
132 }
133
134 #[tokio::test]
135 async fn missing_content_type_returns_400() {
136 let response = app()
137 .oneshot(
138 Request::builder()
139 .method("POST")
140 .uri(format!("/v1/knowledgebases/{KB}/search"))
141 .body(Body::from(r#"{"query":"install cargo"}"#))
142 .unwrap(),
143 )
144 .await
145 .unwrap();
146
147 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
148 let json = response_json(response).await;
149 assert_eq!(json["error"], "invalid_request");
150 assert!(json["request_id"].is_string());
151 }
152
153 #[tokio::test]
154 async fn empty_query_returns_400() {
155 let response = app().oneshot(request(r#"{"query":""}"#)).await.unwrap();
156
157 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
158 let json = response_json(response).await;
159 assert_eq!(json["error"], "invalid_request");
160 assert!(json["message"].as_str().unwrap().contains("query"));
161 }
162
163 #[tokio::test]
164 async fn body_too_large_returns_413() {
165 let body = serde_json::json!({"query": "x".repeat(SEARCH_BODY_MAX_BYTES + 1)}).to_string();
166 let response = app().oneshot(request(body)).await.unwrap();
167
168 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
169 let json = response_json(response).await;
170 assert_eq!(json["error"], "payload_too_large");
171 assert!(json["request_id"].is_string());
172 }
173}