diff --git a/crates/meilisearch-types/src/deserr/query_params.rs b/crates/meilisearch-types/src/deserr/query_params.rs index 58113567e..dded0ea5c 100644 --- a/crates/meilisearch-types/src/deserr/query_params.rs +++ b/crates/meilisearch-types/src/deserr/query_params.rs @@ -16,7 +16,6 @@ use std::ops::Deref; use std::str::FromStr; use deserr::{DeserializeError, Deserr, MergeWithError, ValueKind}; -use utoipa::{PartialSchema, ToSchema}; use super::{DeserrParseBoolError, DeserrParseIntError}; use crate::index_uid::IndexUid; @@ -30,18 +29,6 @@ use crate::tasks::{Kind, Status}; #[derive(Default, Debug, Clone, Copy)] pub struct Param(pub T); -impl ToSchema for Param { - fn name() -> std::borrow::Cow<'static, str> { - T::name() - } -} - -impl PartialSchema for Param { - fn schema() -> utoipa::openapi::RefOr { - T::schema() - } -} - impl Deref for Param { type Target = T; diff --git a/crates/meilisearch-types/src/star_or.rs b/crates/meilisearch-types/src/star_or.rs index 6af833ed8..1070b99ff 100644 --- a/crates/meilisearch-types/src/star_or.rs +++ b/crates/meilisearch-types/src/star_or.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use deserr::{DeserializeError, Deserr, MergeWithError, ValueKind}; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use utoipa::{IntoParams, PartialSchema, ToSchema}; +use utoipa::PartialSchema; use crate::deserr::query_params::FromQueryParameter; diff --git a/crates/meilisearch/src/routes/api_key.rs b/crates/meilisearch/src/routes/api_key.rs index 3309c1f6e..2a08448db 100644 --- a/crates/meilisearch/src/routes/api_key.rs +++ b/crates/meilisearch/src/routes/api_key.rs @@ -16,7 +16,7 @@ use time::OffsetDateTime; use utoipa::{IntoParams, OpenApi, ToSchema}; use uuid::Uuid; -use super::PAGINATION_DEFAULT_LIMIT; +use super::{PAGINATION_DEFAULT_LIMIT, PAGINATION_DEFAULT_LIMIT_FN}; use crate::extractors::authentication::policies::*; use crate::extractors::authentication::GuardedData; use crate::extractors::sequential_extractor::SeqHandler; @@ -116,11 +116,11 @@ pub async fn create_api_key( #[deserr(error = DeserrQueryParamError, rename_all = camelCase, deny_unknown_fields)] #[into_params(rename_all = "camelCase", parameter_in = Query)] pub struct ListApiKeys { - #[into_params(value_type = usize, default = 0)] #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = usize, default = 0)] pub offset: Param, - #[into_params(value_type = usize, default = PAGINATION_DEFAULT_LIMIT)] #[deserr(default = Param(PAGINATION_DEFAULT_LIMIT), error = DeserrQueryParamError)] + #[param(value_type = usize, default = PAGINATION_DEFAULT_LIMIT_FN)] pub limit: Param, } diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index ca73ac8b3..dee46f2be 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -205,7 +205,7 @@ impl Aggregate for DocumentsFetchAggregator { GetDocument, ), responses( - (status = 200, description = "The documents are returned", body = serde_json::Value, content_type = "application/json", example = json!( + (status = 200, description = "The document is returned", body = serde_json::Value, content_type = "application/json", example = json!( { "id": 25684, "title": "American Ninja 5", diff --git a/crates/meilisearch/src/routes/indexes/search.rs b/crates/meilisearch/src/routes/indexes/search.rs index 291193c4e..ca3c61753 100644 --- a/crates/meilisearch/src/routes/indexes/search.rs +++ b/crates/meilisearch/src/routes/indexes/search.rs @@ -12,6 +12,7 @@ use meilisearch_types::milli; use meilisearch_types::serde_cs::vec::CS; use serde_json::Value; use tracing::debug; +use utoipa::{IntoParams, OpenApi}; use crate::analytics::Analytics; use crate::error::MeilisearchHttpError; @@ -22,12 +23,28 @@ use crate::metrics::MEILISEARCH_DEGRADED_SEARCH_REQUESTS; use crate::routes::indexes::search_analytics::{SearchAggregator, SearchGET, SearchPOST}; use crate::search::{ add_search_rules, perform_search, HybridQuery, MatchingStrategy, RankingScoreThreshold, - RetrieveVectors, SearchKind, SearchQuery, SemanticRatio, DEFAULT_CROP_LENGTH, + RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio, DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG, DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO, }; use crate::search_queue::SearchQueue; +#[derive(OpenApi)] +#[openapi( + paths(search_with_url_query, search_with_post), + tags( + ( + name = "Search", + description = "Meilisearch exposes two routes to perform searches: + +- A POST route: this is the preferred route when using API authentication, as it allows [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) caching and better performance. +- A GET route: the usage of this route is discouraged, unless you have good reason to do otherwise (specific caching abilities for example)", + external_docs(url = "https://www.meilisearch.com/docs/reference/api/search"), + ), + ), +)] +pub struct SearchApi; + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("") @@ -36,30 +53,41 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ); } -#[derive(Debug, deserr::Deserr)] +#[derive(Debug, deserr::Deserr, IntoParams)] #[deserr(error = DeserrQueryParamError, rename_all = camelCase, deny_unknown_fields)] +#[into_params(rename_all = "camelCase", parameter_in = Query)] pub struct SearchQueryGet { #[deserr(default, error = DeserrQueryParamError)] q: Option, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] vector: Option>, #[deserr(default = Param(DEFAULT_SEARCH_OFFSET()), error = DeserrQueryParamError)] + #[param(value_type = usize, default = DEFAULT_SEARCH_OFFSET)] offset: Param, #[deserr(default = Param(DEFAULT_SEARCH_LIMIT()), error = DeserrQueryParamError)] + #[param(value_type = usize, default = DEFAULT_SEARCH_LIMIT)] limit: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Option)] page: Option>, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Option)] hits_per_page: Option>, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] attributes_to_retrieve: Option>, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = bool, default)] retrieve_vectors: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] attributes_to_crop: Option>, #[deserr(default = Param(DEFAULT_CROP_LENGTH()), error = DeserrQueryParamError)] + #[param(value_type = usize, default = DEFAULT_CROP_LENGTH)] crop_length: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] attributes_to_highlight: Option>, #[deserr(default, error = DeserrQueryParamError)] filter: Option, @@ -68,30 +96,41 @@ pub struct SearchQueryGet { #[deserr(default, error = DeserrQueryParamError)] distinct: Option, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = bool)] show_matches_position: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = bool)] show_ranking_score: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = bool)] show_ranking_score_details: Param, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] facets: Option>, - #[deserr( default = DEFAULT_HIGHLIGHT_PRE_TAG(), error = DeserrQueryParamError)] + #[deserr(default = DEFAULT_HIGHLIGHT_PRE_TAG(), error = DeserrQueryParamError)] + #[param(default = DEFAULT_HIGHLIGHT_PRE_TAG)] highlight_pre_tag: String, - #[deserr( default = DEFAULT_HIGHLIGHT_POST_TAG(), error = DeserrQueryParamError)] + #[deserr(default = DEFAULT_HIGHLIGHT_POST_TAG(), error = DeserrQueryParamError)] + #[param(default = DEFAULT_HIGHLIGHT_POST_TAG)] highlight_post_tag: String, #[deserr(default = DEFAULT_CROP_MARKER(), error = DeserrQueryParamError)] + #[param(default = DEFAULT_CROP_MARKER)] crop_marker: String, #[deserr(default, error = DeserrQueryParamError)] matching_strategy: MatchingStrategy, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] pub attributes_to_search_on: Option>, #[deserr(default, error = DeserrQueryParamError)] pub hybrid_embedder: Option, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = f32)] pub hybrid_semantic_ratio: Option, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = f32)] pub ranking_score_threshold: Option, #[deserr(default, error = DeserrQueryParamError)] + #[param(value_type = Vec, explode = false)] pub locales: Option>, } @@ -220,6 +259,62 @@ pub fn fix_sort_query_parameters(sort_query: &str) -> Vec { sort_parameters } +/// Search an index with GET +/// +/// Search for documents matching a specific query in the given index. +#[utoipa::path( + get, + path = "/{indexUid}/search", + tags = ["Indexes", "Search"], + security(("Bearer" = ["search", "*"])), + params( + ("indexUid" = String, Path, example = "movies", description = "Index Unique Identifier", nullable = false), + SearchQueryGet + ), + responses( + (status = 200, description = "The documents are returned", body = SearchResult, content_type = "application/json", example = json!( + { + "hits": [ + { + "id": 2770, + "title": "American Pie 2", + "poster": "https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg", + "overview": "The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest…", + "release_date": 997405200 + }, + { + "id": 190859, + "title": "American Sniper", + "poster": "https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg", + "overview": "U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime…", + "release_date": 1418256000 + } + ], + "offset": 0, + "limit": 2, + "estimatedTotalHits": 976, + "processingTimeMs": 35, + "query": "american " + } + )), + (status = 404, description = "Index not found", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "Index `movies` not found.", + "code": "index_not_found", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#index_not_found" + } + )), + (status = 401, description = "The authorization header is missing", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + } + )), + ) +)] pub async fn search_with_url_query( index_scheduler: GuardedData, Data>, search_queue: web::Data, @@ -271,6 +366,62 @@ pub async fn search_with_url_query( Ok(HttpResponse::Ok().json(search_result)) } +/// Search with POST +/// +/// Search for documents matching a specific query in the given index. +#[utoipa::path( + post, + path = "/{indexUid}/search", + tags = ["Indexes", "Search"], + security(("Bearer" = ["search", "*"])), + params( + ("indexUid", example = "movies", description = "Index Unique Identifier", nullable = false), + ), + request_body = SearchQuery, + responses( + (status = 200, description = "The documents are returned", body = SearchResult, content_type = "application/json", example = json!( + { + "hits": [ + { + "id": 2770, + "title": "American Pie 2", + "poster": "https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg", + "overview": "The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest…", + "release_date": 997405200 + }, + { + "id": 190859, + "title": "American Sniper", + "poster": "https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg", + "overview": "U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime…", + "release_date": 1418256000 + } + ], + "offset": 0, + "limit": 2, + "estimatedTotalHits": 976, + "processingTimeMs": 35, + "query": "american " + } + )), + (status = 404, description = "Index not found", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "Index `movies` not found.", + "code": "index_not_found", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#index_not_found" + } + )), + (status = 401, description = "The authorization header is missing", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + } + )), + ) +)] pub async fn search_with_post( index_scheduler: GuardedData, Data>, search_queue: web::Data, diff --git a/crates/meilisearch/src/routes/mod.rs b/crates/meilisearch/src/routes/mod.rs index ddd6b6139..9a40f216a 100644 --- a/crates/meilisearch/src/routes/mod.rs +++ b/crates/meilisearch/src/routes/mod.rs @@ -35,6 +35,7 @@ use self::open_api_utils::OpenApiAuth; use self::tasks::AllTasks; const PAGINATION_DEFAULT_LIMIT: usize = 20; +const PAGINATION_DEFAULT_LIMIT_FN: fn() -> usize = || 20; mod api_key; pub mod batches; @@ -55,6 +56,8 @@ pub mod tasks; nest( (path = "/tasks", api = tasks::TaskApi), (path = "/indexes", api = indexes::IndexesApi), + // We must stop the search path here because the rest must be configured by each route individually + (path = "/indexes", api = indexes::search::SearchApi), (path = "/snapshots", api = snapshot::SnapshotApi), (path = "/dumps", api = dump::DumpApi), (path = "/keys", api = api_key::ApiKeyApi), diff --git a/crates/meilisearch/src/search/mod.rs b/crates/meilisearch/src/search/mod.rs index b48266b6a..6e73e794c 100644 --- a/crates/meilisearch/src/search/mod.rs +++ b/crates/meilisearch/src/search/mod.rs @@ -34,6 +34,7 @@ use serde::Serialize; use serde_json::{json, Value}; #[cfg(test)] mod mod_test; +use utoipa::ToSchema; use crate::error::MeilisearchHttpError; @@ -52,7 +53,7 @@ pub const DEFAULT_HIGHLIGHT_PRE_TAG: fn() -> String = || "".to_string(); pub const DEFAULT_HIGHLIGHT_POST_TAG: fn() -> String = || "".to_string(); pub const DEFAULT_SEMANTIC_RATIO: fn() -> SemanticRatio = || SemanticRatio(0.5); -#[derive(Clone, Default, PartialEq, Deserr)] +#[derive(Clone, Default, PartialEq, Deserr, ToSchema)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] pub struct SearchQuery { #[deserr(default, error = DeserrJsonError)] @@ -62,8 +63,10 @@ pub struct SearchQuery { #[deserr(default, error = DeserrJsonError)] pub hybrid: Option, #[deserr(default = DEFAULT_SEARCH_OFFSET(), error = DeserrJsonError)] + #[schema(default = DEFAULT_SEARCH_OFFSET)] pub offset: usize, #[deserr(default = DEFAULT_SEARCH_LIMIT(), error = DeserrJsonError)] + #[schema(default = DEFAULT_SEARCH_LIMIT)] pub limit: usize, #[deserr(default, error = DeserrJsonError)] pub page: Option, @@ -75,15 +78,16 @@ pub struct SearchQuery { pub retrieve_vectors: bool, #[deserr(default, error = DeserrJsonError)] pub attributes_to_crop: Option>, - #[deserr(default, error = DeserrJsonError, default = DEFAULT_CROP_LENGTH())] + #[deserr(error = DeserrJsonError, default = DEFAULT_CROP_LENGTH())] + #[schema(default = DEFAULT_CROP_LENGTH)] pub crop_length: usize, #[deserr(default, error = DeserrJsonError)] pub attributes_to_highlight: Option>, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub show_matches_position: bool, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub show_ranking_score: bool, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub show_ranking_score_details: bool, #[deserr(default, error = DeserrJsonError)] pub filter: Option, @@ -93,26 +97,28 @@ pub struct SearchQuery { pub distinct: Option, #[deserr(default, error = DeserrJsonError)] pub facets: Option>, - #[deserr(default, error = DeserrJsonError, default = DEFAULT_HIGHLIGHT_PRE_TAG())] + #[deserr(error = DeserrJsonError, default = DEFAULT_HIGHLIGHT_PRE_TAG())] + #[schema(default = DEFAULT_HIGHLIGHT_PRE_TAG)] pub highlight_pre_tag: String, - #[deserr(default, error = DeserrJsonError, default = DEFAULT_HIGHLIGHT_POST_TAG())] + #[deserr(error = DeserrJsonError, default = DEFAULT_HIGHLIGHT_POST_TAG())] + #[schema(default = DEFAULT_HIGHLIGHT_POST_TAG)] pub highlight_post_tag: String, - #[deserr(default, error = DeserrJsonError, default = DEFAULT_CROP_MARKER())] + #[deserr(error = DeserrJsonError, default = DEFAULT_CROP_MARKER())] + #[schema(default = DEFAULT_CROP_MARKER)] pub crop_marker: String, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub matching_strategy: MatchingStrategy, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub attributes_to_search_on: Option>, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub ranking_score_threshold: Option, - #[deserr(default, error = DeserrJsonError, default)] + #[deserr(default, error = DeserrJsonError)] pub locales: Option>, } -#[derive(Debug, Clone, Copy, PartialEq, Deserr)] +#[derive(Debug, Clone, Copy, PartialEq, Deserr, ToSchema)] #[deserr(try_from(f64) = TryFrom::try_from -> InvalidSearchRankingScoreThreshold)] pub struct RankingScoreThreshold(f64); - impl std::convert::TryFrom for RankingScoreThreshold { type Error = InvalidSearchRankingScoreThreshold; @@ -266,10 +272,11 @@ impl fmt::Debug for SearchQuery { } } -#[derive(Debug, Clone, Default, PartialEq, Deserr)] +#[derive(Debug, Clone, Default, PartialEq, Deserr, ToSchema)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] pub struct HybridQuery { #[deserr(default, error = DeserrJsonError, default)] + #[schema(value_type = f32, default)] pub semantic_ratio: SemanticRatio, #[deserr(error = DeserrJsonError)] pub embedder: String, @@ -587,7 +594,7 @@ impl TryFrom for ExternalDocumentId { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserr)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserr, ToSchema)] #[deserr(rename_all = camelCase)] pub enum MatchingStrategy { /// Remove query words from last to first @@ -634,11 +641,13 @@ impl From for OrderBy { } } -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq, ToSchema)] pub struct SearchHit { #[serde(flatten)] + #[schema(additional_properties, inline, value_type = HashMap)] pub document: Document, #[serde(rename = "_formatted", skip_serializing_if = "Document::is_empty")] + #[schema(additional_properties, value_type = HashMap)] pub formatted: Document, #[serde(rename = "_matchesPosition", skip_serializing_if = "Option::is_none")] pub matches_position: Option, @@ -648,8 +657,9 @@ pub struct SearchHit { pub ranking_score_details: Option>, } -#[derive(Serialize, Clone, PartialEq)] +#[derive(Serialize, Clone, PartialEq, ToSchema)] #[serde(rename_all = "camelCase")] +#[schema(rename_all = "camelCase")] pub struct SearchResult { pub hits: Vec, pub query: String, @@ -657,6 +667,7 @@ pub struct SearchResult { #[serde(flatten)] pub hits_info: HitsInfo, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = HashMap)] pub facet_distribution: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub facet_stats: Option>, @@ -729,7 +740,7 @@ pub struct SearchResultWithIndex { pub result: SearchResult, } -#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, ToSchema)] #[serde(untagged)] pub enum HitsInfo { #[serde(rename_all = "camelCase")] @@ -738,7 +749,7 @@ pub enum HitsInfo { OffsetLimit { limit: usize, offset: usize, estimated_total_hits: usize }, } -#[derive(Serialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Debug, Clone, PartialEq, ToSchema)] pub struct FacetStats { pub min: f64, pub max: f64, diff --git a/crates/milli/src/search/new/matches/mod.rs b/crates/milli/src/search/new/matches/mod.rs index 19c1127cd..83d00caf0 100644 --- a/crates/milli/src/search/new/matches/mod.rs +++ b/crates/milli/src/search/new/matches/mod.rs @@ -13,6 +13,7 @@ use matching_words::{MatchType, PartialMatch}; use r#match::{Match, MatchPosition}; use serde::Serialize; use simple_token_kind::SimpleTokenKind; +use utoipa::ToSchema; const DEFAULT_CROP_MARKER: &str = "…"; const DEFAULT_HIGHLIGHT_PREFIX: &str = ""; @@ -100,7 +101,7 @@ impl FormatOptions { } } -#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, ToSchema)] pub struct MatchBounds { pub start: usize, pub length: usize,