From 627acbd85f6d5816b36a75ffa02d5c99c91cd095 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 29 Jun 2026 22:47:09 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Split API search route validation into a child module after strict validation.","authority":"manual"} --- apps/elf-api/src/routes/search.rs | 139 +++---------------- apps/elf-api/src/routes/search/validation.rs | 108 ++++++++++++++ 2 files changed, 129 insertions(+), 118 deletions(-) create mode 100644 apps/elf-api/src/routes/search/validation.rs diff --git a/apps/elf-api/src/routes/search.rs b/apps/elf-api/src/routes/search.rs index 4e6ccb2a..7fb62688 100644 --- a/apps/elf-api/src/routes/search.rs +++ b/apps/elf-api/src/routes/search.rs @@ -1,10 +1,11 @@ +mod validation; + use crate::routes::{ - self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, MAX_CANDIDATE_K, - MAX_NOTE_IDS_PER_DETAILS, MAX_QUERY_CHARS, MAX_TOP_K, Path, Query, QueryRejection, - RequestContext, SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, + QueryRejection, RequestContext, SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, SearchDetailsRequest, SearchDetailsResponseV2, SearchIndexResponseV2, SearchMode, SearchRequest, SearchResponse, SearchSessionGetQuery, SearchSessionGetRequest, - SearchTimelineQuery, SearchTimelineRequest, SearchTimelineResponseV2, State, StatusCode, Uuid, + SearchTimelineQuery, SearchTimelineRequest, SearchTimelineResponseV2, State, Uuid, }; #[utoipa::path( @@ -28,49 +29,13 @@ pub(super) async fn searches_create( ) -> Result, ApiError> { let ctx = RequestContext::from_headers(&headers)?; let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; + let Json(payload) = payload.map_err(validation::invalid_json_payload)?; - if payload.query.chars().count() > MAX_QUERY_CHARS { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Query is too long.", - Some(vec!["$.query".to_string()]), - )); - } - if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "top_k is too large.", - Some(vec!["$.top_k".to_string()]), - )); - } - if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "candidate_k is too large.", - Some(vec!["$.candidate_k".to_string()]), - )); - } - if payload.ranking.is_some() { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Ranking overrides are only supported on admin endpoints.".to_string(), - None, - )); - } + validation::validate_search_create_payload( + &payload, + state.service.cfg.memory.top_k, + state.service.cfg.memory.candidate_k, + )?; let mode = payload.mode; let token_id = @@ -147,16 +112,7 @@ pub(super) async fn searches_get( query: Result, QueryRejection>, ) -> Result, ApiError> { let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; + let Query(query) = query.map_err(validation::invalid_query_parameters)?; let response = state .service .search_session_get(SearchSessionGetRequest { @@ -211,16 +167,7 @@ pub(super) async fn searches_timeline( query: Result, QueryRejection>, ) -> Result, ApiError> { let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; + let Query(query) = query.map_err(validation::invalid_query_parameters)?; let response = state .service .search_timeline(SearchTimelineRequest { @@ -262,25 +209,9 @@ pub(super) async fn searches_notes( payload: Result, JsonRejection>, ) -> Result, ApiError> { let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; + let Json(payload) = payload.map_err(validation::invalid_json_payload)?; - if payload.note_ids.len() > MAX_NOTE_IDS_PER_DETAILS { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "note_ids list is too large.", - Some(vec!["$.note_ids".to_string()]), - )); - } + validation::validate_search_details_payload(&payload)?; let response = state .service @@ -323,41 +254,13 @@ pub(super) async fn searches_raw( ) -> Result, ApiError> { let ctx = RequestContext::from_headers(&headers)?; let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; + let Json(payload) = payload.map_err(validation::invalid_json_payload)?; - if payload.query.chars().count() > MAX_QUERY_CHARS { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Query is too long.", - Some(vec!["$.query".to_string()]), - )); - } - if payload.top_k.unwrap_or(state.service.cfg.memory.top_k) > MAX_TOP_K { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "top_k is too large.", - Some(vec!["$.top_k".to_string()]), - )); - } - if payload.candidate_k.unwrap_or(state.service.cfg.memory.candidate_k) > MAX_CANDIDATE_K { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "candidate_k is too large.", - Some(vec!["$.candidate_k".to_string()]), - )); - } + validation::validate_search_raw_payload( + &payload, + state.service.cfg.memory.top_k, + state.service.cfg.memory.candidate_k, + )?; let request = SearchRequest { tenant_id: ctx.tenant_id, diff --git a/apps/elf-api/src/routes/search/validation.rs b/apps/elf-api/src/routes/search/validation.rs new file mode 100644 index 00000000..f45c2f8b --- /dev/null +++ b/apps/elf-api/src/routes/search/validation.rs @@ -0,0 +1,108 @@ +use crate::routes::{ + self, ApiError, JsonRejection, MAX_CANDIDATE_K, MAX_NOTE_IDS_PER_DETAILS, MAX_QUERY_CHARS, + MAX_TOP_K, QueryRejection, SearchCreateRequest, SearchDetailsBody, StatusCode, +}; + +pub(super) fn invalid_json_payload(err: JsonRejection) -> ApiError { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) +} + +pub(super) fn invalid_query_parameters(err: QueryRejection) -> ApiError { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.", + None, + ) +} + +pub(super) fn validate_search_create_payload( + payload: &SearchCreateRequest, + default_top_k: u32, + default_candidate_k: u32, +) -> Result<(), ApiError> { + validate_search_limits( + payload.query.as_str(), + payload.top_k, + payload.candidate_k, + default_top_k, + default_candidate_k, + )?; + + if payload.ranking.is_some() { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Ranking overrides are only supported on admin endpoints.", + None, + )); + } + + Ok(()) +} + +pub(super) fn validate_search_raw_payload( + payload: &SearchCreateRequest, + default_top_k: u32, + default_candidate_k: u32, +) -> Result<(), ApiError> { + validate_search_limits( + payload.query.as_str(), + payload.top_k, + payload.candidate_k, + default_top_k, + default_candidate_k, + ) +} + +pub(super) fn validate_search_details_payload(payload: &SearchDetailsBody) -> Result<(), ApiError> { + if payload.note_ids.len() > MAX_NOTE_IDS_PER_DETAILS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "note_ids list is too large.", + Some(vec!["$.note_ids".to_string()]), + )); + } + + Ok(()) +} + +fn validate_search_limits( + query: &str, + top_k: Option, + candidate_k: Option, + default_top_k: u32, + default_candidate_k: u32, +) -> Result<(), ApiError> { + if query.chars().count() > MAX_QUERY_CHARS { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Query is too long.", + Some(vec!["$.query".to_string()]), + )); + } + if top_k.unwrap_or(default_top_k) > MAX_TOP_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "top_k is too large.", + Some(vec!["$.top_k".to_string()]), + )); + } + if candidate_k.unwrap_or(default_candidate_k) > MAX_CANDIDATE_K { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "candidate_k is too large.", + Some(vec!["$.candidate_k".to_string()]), + )); + } + + Ok(()) +}