refactor(search): normalize answer to /resources format

This commit is contained in:
Edouard Vanbelle
2026-07-26 14:42:04 +02:00
parent 14da27db10
commit c22741bc7f
15 changed files with 950 additions and 346 deletions
+59 -149
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use tracing::{error, info};
use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
SearchResourcesDto, SearchResourcesQuery, SearchSuggestionsDto,
};
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
@@ -39,12 +39,32 @@ impl SearchHandler {
// so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP
// verb or annotation content. All route handlers are free functions below.
// TODO: collapse after utoipa upgrade.
pub(super) async fn search_files_get_impl(
/// `GET /api/search` — wire-normalised search endpoint.
///
/// Returns the same `items[] { resource_type, resource, meta }`
/// envelope shape as every other `/*/resources` listing endpoint
/// (folders, favorites, recent, trash, shared) so the SPA's
/// `ResourceList` component consumes it as-is. Search-specific
/// enrichment (`meta.score` + optional `snippet` + `via`) sits
/// inline on each item.
///
/// Phase 1-plus wire adapter: the internal `SearchService` still
/// speaks `SearchCriteriaDto`/`SearchResultsDto`. The query is
/// translated at this boundary; the result envelope is composed
/// via `SearchResourcesDto::from_service_result`. The `is_favorite`
/// / `is_shared` fields on each `FileDto`/`FolderDto` come from
/// per-row EXISTS subqueries in the search SQL (see
/// `search_files_paginated` and `search_folders`).
///
/// The old `POST /api/search/advanced` variant was deleted in
/// the same PR — every field it accepted fits cleanly as a query
/// param.
pub(super) async fn search_resources_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<SearchParams>,
Query(query): Query<SearchResourcesQuery>,
) -> impl IntoResponse {
info!("API: File search with parameters: {:?}", params);
info!("API: File search (normalized envelope)");
let search_service = match &state.applications.search_service {
Some(service) => service,
@@ -58,25 +78,13 @@ impl SearchHandler {
}
};
let search_criteria = SearchCriteriaDto {
name_contains: params.query,
file_types: params
.type_filter
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()),
created_after: params.created_after,
created_before: params.created_before,
modified_after: params.modified_after,
modified_before: params.modified_before,
min_size: params.min_size,
max_size: params.max_size,
folder_id: params.folder_id,
recursive: params.recursive.unwrap_or(true),
limit: params.limit.unwrap_or(100).min(MAX_SEARCH_LIMIT),
offset: params.offset.unwrap_or(0),
sort_by: params.sort_by.unwrap_or_else(|| "relevance".to_string()),
};
// Cap page size — `SearchResourcesQuery::limit_clamped` already
// hits `[1, 200]`, but re-clamp against MAX_SEARCH_LIMIT for
// defence-in-depth if the constant is ever raised above 200.
let mut criteria = query.to_criteria();
criteria.limit = criteria.limit.min(MAX_SEARCH_LIMIT);
match search_service.search(search_criteria, auth_user.id).await {
match search_service.search(criteria, auth_user.id).await {
Ok(results) => {
info!(
"Search completed in {}ms — {} files, {} folders",
@@ -84,62 +92,18 @@ impl SearchHandler {
results.files.len(),
results.folders.len()
);
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Search error" })),
// Unwrap the Arc — the service caches `Arc<SearchResultsDto>`
// so consumers share the allocation. `from_service_result`
// consumes the DTO to move enriched rows into the envelope's
// `resource` slot without cloning; the Arc's shared clone
// pays one deep copy here but avoids allocating during the
// hot cache-hit path elsewhere.
let dto = SearchResourcesDto::from_service_result((*results).clone(), &query);
let rows = dto.items.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&dto,
)
.into_response()
}
}
}
/// Advanced search with full criteria in the request body.
pub(super) async fn search_files_post_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(criteria): Json<SearchCriteriaDto>,
) -> impl IntoResponse {
info!("API: Advanced file search");
let search_service = match &state.applications.search_service {
Some(service) => service,
None => {
error!("Search service not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response();
}
};
match search_service.search(criteria, auth_user.id).await {
Ok(results) => {
info!(
"Advanced search completed in {}ms — {} files, {} folders",
results.query_time_ms,
results.files.len(),
results.folders.len()
);
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
@@ -258,50 +222,6 @@ impl SearchHandler {
}
}
/// Search parameters for the GET /search endpoint
#[derive(Debug, serde::Deserialize)]
pub struct SearchParams {
/// Text to search in file and folder names
pub query: Option<String>,
/// Filter by file types (comma-separated extensions)
#[serde(rename = "type")]
pub type_filter: Option<String>,
/// Created after this timestamp
pub created_after: Option<u64>,
/// Created before this timestamp
pub created_before: Option<u64>,
/// Modified after this timestamp
pub modified_after: Option<u64>,
/// Modified before this timestamp
pub modified_before: Option<u64>,
/// Minimum file size in bytes
pub min_size: Option<u64>,
/// Maximum file size in bytes
pub max_size: Option<u64>,
/// Folder ID to limit the search scope
pub folder_id: Option<String>,
/// Recursive search in subfolders (default: true)
pub recursive: Option<bool>,
/// Result limit for pagination
pub limit: Option<usize>,
/// Offset for pagination
pub offset: Option<usize>,
/// Sort order: relevance | name | name_desc | date | date_desc | size | size_desc
pub sort_by: Option<String>,
}
/// Parameters for the GET /search/suggest endpoint
#[derive(Debug, serde::Deserialize)]
pub struct SuggestParams {
@@ -334,45 +254,35 @@ pub struct SuggestParams {
get,
path = "/api/search",
params(
("query" = Option<String>, Query, description = "Text to search in names"),
("type" = Option<String>, Query, description = "Comma-separated MIME type filter"),
("query" = Option<String>, Query, description = "Text to search in names / content"),
("limit" = Option<u32>, Query, description = "Max items per page (1–200, default 50)"),
("cursor" = Option<String>, Query, description = "Opaque cursor from a previous response"),
("order_by" = Option<String>, Query, description = "Sort dimension: relevance (default) | name | name_desc | date | date_desc | size | size_desc"),
("resource_types" = Option<String>, Query, description = "Comma-separated: file, folder (both by default)"),
("reverse" = Option<bool>, Query, description = "Reverse the sort order"),
("type" = Option<String>, Query, description = "Filter by file extensions (comma-separated)"),
("folder_id" = Option<String>, Query, description = "Restrict search to this folder"),
("recursive" = Option<bool>, Query, description = "Include sub-folders"),
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Pagination offset"),
("recursive" = Option<bool>, Query, description = "Recurse into subfolders (default true)"),
("created_after" = Option<u64>, Query, description = "Minimum creation timestamp (unix seconds)"),
("created_before" = Option<u64>, Query, description = "Maximum creation timestamp"),
("modified_after" = Option<u64>, Query, description = "Minimum modification timestamp"),
("modified_before" = Option<u64>, Query, description = "Maximum modification timestamp"),
("min_size" = Option<u64>, Query, description = "Minimum file size (bytes)"),
("max_size" = Option<u64>, Query, description = "Maximum file size (bytes)"),
),
responses(
(status = 200, description = "Search results", body = SearchResultsDto),
(status = 200, description = "Search results (cursor-paginated envelope shared with /*/resources)", body = SearchResourcesDto),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
)]
pub async fn search_files_get(
pub async fn search_resources(
state: State<Arc<AppState>>,
auth_user: AuthUser,
query: Query<SearchParams>,
query: Query<SearchResourcesQuery>,
) -> impl IntoResponse {
SearchHandler::search_files_get_impl(state, auth_user, query).await
}
#[utoipa::path(
post,
path = "/api/search/advanced",
request_body(content = SearchCriteriaDto, content_type = "application/json", description = "Search criteria"),
responses(
(status = 200, description = "Search results", body = SearchResultsDto),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
)]
pub async fn search_files_post(
state: State<Arc<AppState>>,
auth_user: AuthUser,
json: Json<SearchCriteriaDto>,
) -> impl IntoResponse {
SearchHandler::search_files_post_impl(state, auth_user, json).await
SearchHandler::search_resources_impl(state, auth_user, query).await
}
#[utoipa::path(
+12 -5
View File
@@ -34,8 +34,8 @@ use crate::application::dtos::i18n_dto::{
use crate::application::dtos::pagination::{PaginationDto, PaginationRequestDto};
use crate::application::dtos::recent_dto::{RecentItemDto, RecentResourceItemDto};
use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
SearchSuggestionItem, SearchSuggestionsDto,
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchMeta, SearchResourceItem,
SearchResourcesDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto,
};
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
use crate::application::dtos::trash_dto::{
@@ -103,8 +103,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::folder_handler::delete_folder_with_trash,
handlers::folder_handler::download_folder_zip,
// Search handlers (free functions — see search_handler.rs for why)
handlers::search_handler::search_files_get,
handlers::search_handler::search_files_post,
handlers::search_handler::search_resources,
handlers::search_handler::suggest_files,
handlers::search_handler::clear_search_cache,
// i18n handlers (free functions — see i18n_handler.rs for why)
@@ -302,7 +301,15 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
MoveToTrashRequest,
RestoreFromTrashRequest,
DeletePermanentlyRequest,
// Search schemas
// Search schemas — wire envelope shares the /*/resources
// shape (SearchResourcesDto → items[] { resource_type,
// resource, meta }). The internal SearchCriteriaDto /
// SearchResultsDto types are still emitted so external
// consumers browsing the OpenAPI doc can see the service-
// layer shape referenced by other docs.
SearchResourcesDto,
SearchResourceItem,
SearchMeta,
SearchCriteriaDto,
SearchResultsDto,
SearchFileResultDto,
+7 -6
View File
@@ -88,9 +88,7 @@ use crate::interfaces::api::handlers::folder_handler::{
use crate::interfaces::api::handlers::i18n_handler::{
get_locales, get_translations_by_locale, translate,
};
use crate::interfaces::api::handlers::search_handler::{
search_files_get, search_files_post, suggest_files,
};
use crate::interfaces::api::handlers::search_handler::{search_resources, suggest_files};
use crate::interfaces::api::handlers::trash_handler;
/// Creates root-level health check routes — mounted directly at `/`, not under `/api/`.
@@ -298,11 +296,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let search_router = if search_service.is_some() {
Router::new()
// Simple search with query parameters
.route("/", get(search_files_get))
// Cursor-paginated search with the `/*/resources` envelope
// (items + next_cursor + meta). `POST /search/advanced` was
// deleted alongside this normalization — every field it
// accepted fits fine as a query param, and it shared 100%
// of the service call with GET (no fast-vs-deep semantics).
.route("/", get(search_resources))
// Lightweight autocomplete suggestions
.route("/suggest", get(suggest_files))
// Advanced search with full criteria object
.route("/advanced", post(search_files_post))
// `DELETE /api/search/cache` used to live here as a per-user-
// reachable endpoint. It's an operator-only debug lever
// (moka `invalidate_all()` — nukes every tenant), so it