diff --git a/crates/meilisearch/src/routes/indexes/search.rs b/crates/meilisearch/src/routes/indexes/search.rs index ccc3b5b9a..91fef74fc 100644 --- a/crates/meilisearch/src/routes/indexes/search.rs +++ b/crates/meilisearch/src/routes/indexes/search.rs @@ -346,7 +346,12 @@ pub async fn search_with_url_query( search_kind(&query, index_scheduler.get_ref(), index_uid.to_string(), &index)?; let retrieve_vector = RetrieveVectors::new(query.retrieve_vectors); let permit = search_queue.try_get_search_permit().await?; - let include_metadata = req.headers().get(INCLUDE_METADATA_HEADER).is_some(); + let include_metadata = req + .headers() + .get(INCLUDE_METADATA_HEADER) + .and_then(|h| h.to_str().ok()) + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1")) + .unwrap_or(false); let search_result = tokio::task::spawn_blocking(move || { perform_search( @@ -459,7 +464,12 @@ pub async fn search_with_post( search_kind(&query, index_scheduler.get_ref(), index_uid.to_string(), &index)?; let retrieve_vectors = RetrieveVectors::new(query.retrieve_vectors); - let include_metadata = req.headers().get(INCLUDE_METADATA_HEADER).is_some(); + let include_metadata = req + .headers() + .get(INCLUDE_METADATA_HEADER) + .and_then(|h| h.to_str().ok()) + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1")) + .unwrap_or(false); let permit = search_queue.try_get_search_permit().await?; let search_result = tokio::task::spawn_blocking(move || { diff --git a/crates/meilisearch/src/routes/multi_search.rs b/crates/meilisearch/src/routes/multi_search.rs index 29cca8945..30938298d 100644 --- a/crates/meilisearch/src/routes/multi_search.rs +++ b/crates/meilisearch/src/routes/multi_search.rs @@ -202,7 +202,12 @@ pub async fn multi_search_with_post( .headers() .get(PROXY_SEARCH_HEADER) .is_some_and(|value| value.as_bytes() == PROXY_SEARCH_HEADER_VALUE.as_bytes()); - let include_metadata = req.headers().get(INCLUDE_METADATA_HEADER).is_some(); + let include_metadata = req + .headers() + .get(INCLUDE_METADATA_HEADER) + .and_then(|h| h.to_str().ok()) + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1")) + .unwrap_or(false); let search_result = perform_federated_search( &index_scheduler, queries, @@ -230,7 +235,12 @@ pub async fn multi_search_with_post( HttpResponse::Ok().json(search_result?) } None => { - let include_metadata = req.headers().get(INCLUDE_METADATA_HEADER).is_some(); + let include_metadata = req + .headers() + .get(INCLUDE_METADATA_HEADER) + .and_then(|h| h.to_str().ok()) + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1")) + .unwrap_or(false); // Explicitly expect a `(ResponseError, usize)` for the error type rather than `ResponseError` only, // so that `?` doesn't work if it doesn't use `with_index`, ensuring that it is not forgotten in case of code diff --git a/crates/meilisearch/tests/common/index.rs b/crates/meilisearch/tests/common/index.rs index f8ff5ced9..81415d3b8 100644 --- a/crates/meilisearch/tests/common/index.rs +++ b/crates/meilisearch/tests/common/index.rs @@ -516,6 +516,33 @@ impl Index<'_, State> { self.service.post_encoded(url, query, self.encoder).await } + pub async fn search_with_headers( + &self, + query: Value, + headers: Vec<(&str, &str)>, + ) -> (Value, StatusCode) { + let url = format!("/indexes/{}/search", urlencode(self.uid.as_ref())); + let body = serde_json::to_string(&query).unwrap(); + let mut all_headers = vec![("content-type", "application/json")]; + all_headers.extend(headers); + self.service.post_str(url, body, all_headers).await + } + + pub async fn multi_search_post(&self, queries: Value) -> (Value, StatusCode) { + self.service.post("/multi-search", queries).await + } + + pub async fn multi_search_post_with_headers( + &self, + queries: Value, + headers: Vec<(&str, &str)>, + ) -> (Value, StatusCode) { + let body = serde_json::to_string(&queries).unwrap(); + let mut all_headers = vec![("content-type", "application/json")]; + all_headers.extend(headers); + self.service.post_str("/multi-search", body, all_headers).await + } + pub async fn search_get(&self, query: &str) -> (Value, StatusCode) { let url = format!("/indexes/{}/search{}", urlencode(self.uid.as_ref()), query); self.service.get(url).await diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index b87dbe0ad..53c137644 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -390,6 +390,15 @@ impl Server { self.service.post("/multi-search", queries).await } + pub async fn multi_search_post_with_headers( + &self, + queries: Value, + headers: Vec<(&str, &str)>, + ) -> (Value, StatusCode) { + let body = serde_json::to_string(&queries).unwrap(); + self.service.post_str("/multi-search", body, headers).await + } + pub async fn list_indexes_raw(&self, parameters: &str) -> (Value, StatusCode) { self.service.get(format!("/indexes{parameters}")).await } diff --git a/crates/meilisearch/tests/search/metadata.rs b/crates/meilisearch/tests/search/metadata.rs new file mode 100644 index 000000000..c4c70ea63 --- /dev/null +++ b/crates/meilisearch/tests/search/metadata.rs @@ -0,0 +1,387 @@ +use meili_snap::{json_string, snapshot}; + +use crate::common::{shared_index_with_documents, Server, DOCUMENTS}; +use crate::json; + +#[actix_rt::test] +async fn search_without_metadata_header() { + let index = shared_index_with_documents().await; + + // Test that metadata is not included by default + index + .search(json!({"q": "glass"}), |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]" + } + "###); + }) + .await; +} + +#[actix_rt::test] +async fn search_with_metadata_header() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, None).await; + server.wait_task(task.uid()).await.succeeded(); + + // Test with Meili-Include-Metadata header + let (response, code) = index + .search_with_headers(json!({"q": "glass"}), vec![("Meili-Include-Metadata", "true")]) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]", ".metadata.queryUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + } + "###); +} + +#[actix_rt::test] +async fn search_with_metadata_header_and_primary_key() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, Some("id")).await; + server.wait_task(task.uid()).await.succeeded(); + + // Test with Meili-Include-Metadata header + let (response, code) = index + .search_with_headers(json!({"q": "glass"}), vec![("Meili-Include-Metadata", "true")]) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]", ".metadata.queryUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "id": "450465", + "title": "Gläss", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + } + "###); +} + +#[actix_rt::test] +async fn multi_search_without_metadata_header() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, None).await; + server.wait_task(task.uid()).await.succeeded(); + + // Test multi-search without metadata header + let (response, code) = index + .multi_search_post(json!({ + "queries": [ + {"indexUid": index.uid, "q": "glass"}, + {"indexUid": index.uid, "q": "dragon"} + ] + })) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".results[0].processingTimeMs" => "[duration]", ".results[0].requestUid" => "[uuid]", ".results[1].processingTimeMs" => "[duration]", ".results[1].requestUid" => "[uuid]" }), @r###" + { + "results": [ + { + "indexUid": "[uuid]", + "hits": [ + { + "title": "Gläss", + "id": "450465", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]" + }, + { + "indexUid": "[uuid]", + "hits": [ + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "color": [ + "green", + "red" + ] + } + ], + "query": "dragon", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]" + } + ] + } + "###); +} + +#[actix_rt::test] +async fn multi_search_with_metadata_header() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, Some("id")).await; + server.wait_task(task.uid()).await.succeeded(); + + // Test multi-search with metadata header + let (response, code) = index + .multi_search_post_with_headers( + json!({ + "queries": [ + {"indexUid": index.uid, "q": "glass"}, + {"indexUid": index.uid, "q": "dragon"} + ] + }), + vec![("Meili-Include-Metadata", "true")], + ) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".results[0].processingTimeMs" => "[duration]", ".results[0].requestUid" => "[uuid]", ".results[0].metadata.queryUid" => "[uuid]", ".results[1].processingTimeMs" => "[duration]", ".results[1].requestUid" => "[uuid]", ".results[1].metadata.queryUid" => "[uuid]" }), @r###" + { + "results": [ + { + "indexUid": "[uuid]", + "hits": [ + { + "id": "450465", + "title": "Gläss", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + }, + { + "indexUid": "[uuid]", + "hits": [ + { + "id": "166428", + "title": "How to Train Your Dragon: The Hidden World", + "color": [ + "green", + "red" + ] + } + ], + "query": "dragon", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + } + ] + } + "###); +} + +#[actix_rt::test] +async fn search_metadata_header_false_value() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, None).await; + server.wait_task(task.uid()).await.succeeded(); + + // Test with header set to false + let (response, code) = index + .search_with_headers(json!({"q": "glass"}), vec![("Meili-Include-Metadata", "false")]) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]" + } + "###); +} + +#[actix_rt::test] +async fn search_metadata_uuid_format() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, None).await; + server.wait_task(task.uid()).await.succeeded(); + + let (response, code) = index + .search_with_headers(json!({"q": "glass"}), vec![("Meili-Include-Metadata", "true")]) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]", ".metadata.queryUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + } + "###); +} + +#[actix_rt::test] +async fn search_metadata_consistency_across_requests() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = DOCUMENTS.clone(); + let (task, _code) = index.add_documents(documents, Some("id")).await; + server.wait_task(task.uid()).await.succeeded(); + + // Make multiple requests and check that metadata is consistent + for _i in 0..3 { + let (response, code) = index + .search_with_headers(json!({"q": "glass"}), vec![("Meili-Include-Metadata", "true")]) + .await; + + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, { ".processingTimeMs" => "[duration]", ".requestUid" => "[uuid]", ".metadata.queryUid" => "[uuid]" }), @r###" + { + "hits": [ + { + "id": "450465", + "title": "Gläss", + "color": [ + "blue", + "red" + ] + } + ], + "query": "glass", + "processingTimeMs": "[duration]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1, + "requestUid": "[uuid]", + "metadata": { + "queryUid": "[uuid]", + "indexUid": "[uuid]", + "primaryKey": "id" + } + } + "###); + } +} diff --git a/crates/meilisearch/tests/search/mod.rs b/crates/meilisearch/tests/search/mod.rs index 1f14a380e..69a69dee6 100644 --- a/crates/meilisearch/tests/search/mod.rs +++ b/crates/meilisearch/tests/search/mod.rs @@ -11,6 +11,7 @@ mod hybrid; #[cfg(not(feature = "chinese-pinyin"))] mod locales; mod matching_strategy; +mod metadata; mod multi; mod pagination; mod restrict_searchable;