Skip to main content

notedthat_api_http/
search_route.rs

1//! Handler for `POST /v1/knowledgebases/{kb_slug}/search`.
2
3use 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
17/// Maximum request body size for the search endpoint (64 KiB).
18///
19/// Smaller than the global PUT limit — covers the max 8 KiB query plus a
20/// reasonable filter payload.
21pub const SEARCH_BODY_MAX_BYTES: usize = 64 * 1024;
22
23/// Handle `POST /v1/knowledgebases/{kb_slug}/search`.
24pub 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    // Validate slug format before declaration lookup so malformed slugs return
36    // 400 `invalid_request` instead of leaking as a 404.
37    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}