From 1a2a7113601120978138d820a67125915acd4737 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Fri, 27 Jun 2025 21:28:51 +0200 Subject: [PATCH 1/3] feat: Add endpoint to retrieve index fields This commit introduces a new API endpoint `/indexes/{indexUid}/fields` that allows users to fetch all field names in an index, including nested fields. It also includes corresponding tests to validate the functionality, ensuring that the endpoint returns the correct fields for both empty and populated indexes, as well as handling errors for non-existent indexes. --- crates/meilisearch/src/routes/indexes/mod.rs | 45 ++++ crates/meilisearch/tests/common/index.rs | 5 + crates/meilisearch/tests/index/stats.rs | 262 +++++++++++++++++++ 3 files changed, 312 insertions(+) diff --git a/crates/meilisearch/src/routes/indexes/mod.rs b/crates/meilisearch/src/routes/indexes/mod.rs index 04b3e12c4..f30798022 100644 --- a/crates/meilisearch/src/routes/indexes/mod.rs +++ b/crates/meilisearch/src/routes/indexes/mod.rs @@ -73,6 +73,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route(web::delete().to(SeqHandler(delete_index))), ) .service(web::resource("/stats").route(web::get().to(SeqHandler(get_index_stats)))) + .service(web::resource("/fields").route(web::get().to(SeqHandler(get_index_fields)))) .service(web::scope("/documents").configure(documents::configure)) .service(web::scope("/search").configure(search::configure)) .service(web::scope("/facet-search").configure(facet_search::configure)) @@ -580,3 +581,47 @@ pub async fn get_index_stats( debug!(returns = ?stats, "Get index stats"); Ok(HttpResponse::Ok().json(stats)) } + +/// Get fields of index +/// +/// Get all field names in the index (including nested fields). +#[utoipa::path( + get, + path = "/{indexUid}/fields", + tag = "Indexes", + security(("Bearer" = ["indexes.get", "indexes.*", "*"])), + params(("indexUid", example = "movies", description = "Index Unique Identifier", nullable = false)), + responses( + (status = OK, description = "The fields of the index", body = Vec, content_type = "application/json", example = json!( + ["id", "title", "user.name", "user.email", "metadata.category"] + )), + (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 get_index_fields( + index_scheduler: GuardedData, Data>, + index_uid: web::Path, +) -> Result { + let index_uid = IndexUid::try_from(index_uid.into_inner())?; + let index = index_scheduler.index(&index_uid)?; + let rtxn = index.read_txn()?; + let fields: Vec = index.fields_ids_map(&rtxn)?.names().map(|s| s.to_string()).collect(); + + debug!(returns = ?fields, "Get index fields"); + Ok(HttpResponse::Ok().json(fields)) +} diff --git a/crates/meilisearch/tests/common/index.rs b/crates/meilisearch/tests/common/index.rs index e324d2ff5..6a212740b 100644 --- a/crates/meilisearch/tests/common/index.rs +++ b/crates/meilisearch/tests/common/index.rs @@ -467,6 +467,11 @@ impl Index<'_, State> { self.service.get(url).await } + pub async fn fields(&self) -> (Value, StatusCode) { + let url = format!("/indexes/{}/fields", urlencode(self.uid.as_ref())); + self.service.get(url).await + } + /// Performs both GET and POST search queries pub async fn search( &self, diff --git a/crates/meilisearch/tests/index/stats.rs b/crates/meilisearch/tests/index/stats.rs index 610601318..58b465b7e 100644 --- a/crates/meilisearch/tests/index/stats.rs +++ b/crates/meilisearch/tests/index/stats.rs @@ -60,3 +60,265 @@ async fn error_get_stats_unexisting_index() { assert_eq!(response, expected_response); assert_eq!(code, 404); } + +#[actix_rt::test] +async fn fields() { + let server = Server::new_shared(); + let index = server.unique_index(); + let (task, code) = index.create(Some("id")).await; + + assert_eq!(code, 202); + server.wait_task(task.uid()).await.succeeded(); + + // Test empty index + let (response, code) = index.fields().await; + assert_eq!(code, 200); + assert!(response.as_array().unwrap().is_empty()); + + // Test with documents containing nested fields + let documents = json!([ + { + "id": 1, + "name": "John", + "user": { + "email": "john@example.com", + "profile": { + "age": 30, + "location": "Paris" + } + }, + "tags": ["developer", "rust"] + }, + { + "id": 2, + "title": "Article", + "metadata": { + "category": "tech", + "author": { + "name": "Jane", + "id": 123 + } + } + } + ]); + + let (response, code) = index.add_documents(documents, None).await; + assert_eq!(code, 202); + server.wait_task(response.uid()).await.succeeded(); + + // Test fields including nested fields + let (response, code) = index.fields().await; + assert_eq!(code, 200); + + let fields = response.as_array().unwrap(); + let field_names: Vec<&str> = fields.iter() + .map(|f| f.as_str().unwrap()) + .collect(); + + // Check that all expected fields are present (including nested fields) + assert!(field_names.contains(&"id")); + assert!(field_names.contains(&"name")); + assert!(field_names.contains(&"title")); + assert!(field_names.contains(&"user.email")); + assert!(field_names.contains(&"user.profile.age")); + assert!(field_names.contains(&"user.profile.location")); + assert!(field_names.contains(&"tags")); + assert!(field_names.contains(&"metadata.category")); + assert!(field_names.contains(&"metadata.author.name")); + assert!(field_names.contains(&"metadata.author.id")); + + // Verify the response is a simple array of strings + for field in fields { + assert!(field.is_string()); + } +} + +#[actix_rt::test] +async fn fields_nested_complex() { + let server = Server::new_shared(); + let index = server.unique_index(); + let (task, code) = index.create(Some("id")).await; + + assert_eq!(code, 202); + server.wait_task(task.uid()).await.succeeded(); + + // Test with complex deeply nested structures + let documents = json!([ + { + "id": 1, + "product": { + "name": "Laptop", + "specifications": { + "hardware": { + "cpu": { + "brand": "Intel", + "model": "i7-12700K", + "cores": { + "physical": 12, + "logical": 20 + } + }, + "memory": { + "ram": 16, + "type": "DDR4" + }, + "storage": { + "primary": { + "type": "SSD", + "capacity": 512 + }, + "secondary": { + "type": "HDD", + "capacity": 1000 + } + } + }, + "software": { + "os": "Windows 11", + "applications": ["Chrome", "VS Code", "Docker"] + } + }, + "pricing": { + "base": 1299.99, + "currency": "USD", + "discounts": { + "student": 0.1, + "bulk": 0.05 + } + } + }, + "customer": { + "info": { + "name": "Alice", + "contact": { + "email": "alice@example.com", + "phone": { + "country": "+1", + "number": "555-1234" + } + } + }, + "preferences": { + "notifications": { + "email": true, + "sms": false, + "push": { + "enabled": true, + "frequency": "daily" + } + } + } + } + }, + { + "id": 2, + "order": { + "items": [ + { + "product_id": "ABC123", + "quantity": 2 + }, + { + "product_id": "DEF456", + "quantity": 1 + } + ], + "shipping": { + "address": { + "street": "123 Main St", + "city": "New York", + "state": "NY", + "zip": "10001", + "country": "USA" + }, + "method": "express", + "tracking": { + "number": "1Z999AA1234567890", + "carrier": "UPS" + } + } + } + } + ]); + + let (response, code) = index.add_documents(documents, None).await; + assert_eq!(code, 202); + server.wait_task(response.uid()).await.succeeded(); + + // Test fields with complex nested structures + let (response, code) = index.fields().await; + assert_eq!(code, 200); + + let fields = response.as_array().unwrap(); + let field_names: Vec<&str> = fields.iter() + .map(|f| f.as_str().unwrap()) + .collect(); + + // Test deeply nested fields from the first document + assert!(field_names.contains(&"product.name")); + assert!(field_names.contains(&"product.specifications.hardware.cpu.brand")); + assert!(field_names.contains(&"product.specifications.hardware.cpu.model")); + assert!(field_names.contains(&"product.specifications.hardware.cpu.cores.physical")); + assert!(field_names.contains(&"product.specifications.hardware.cpu.cores.logical")); + assert!(field_names.contains(&"product.specifications.hardware.memory.ram")); + assert!(field_names.contains(&"product.specifications.hardware.memory.type")); + assert!(field_names.contains(&"product.specifications.hardware.storage.primary.type")); + assert!(field_names.contains(&"product.specifications.hardware.storage.primary.capacity")); + assert!(field_names.contains(&"product.specifications.hardware.storage.secondary.type")); + assert!(field_names.contains(&"product.specifications.hardware.storage.secondary.capacity")); + assert!(field_names.contains(&"product.specifications.software.os")); + assert!(field_names.contains(&"product.specifications.software.applications")); + assert!(field_names.contains(&"product.pricing.base")); + assert!(field_names.contains(&"product.pricing.currency")); + assert!(field_names.contains(&"product.pricing.discounts.student")); + assert!(field_names.contains(&"product.pricing.discounts.bulk")); + + // Test deeply nested fields from the second document + assert!(field_names.contains(&"order.items")); + assert!(field_names.contains(&"order.shipping.address.street")); + assert!(field_names.contains(&"order.shipping.address.city")); + assert!(field_names.contains(&"order.shipping.address.state")); + assert!(field_names.contains(&"order.shipping.address.zip")); + assert!(field_names.contains(&"order.shipping.address.country")); + assert!(field_names.contains(&"order.shipping.method")); + assert!(field_names.contains(&"order.shipping.tracking.number")); + assert!(field_names.contains(&"order.shipping.tracking.carrier")); + + // Test customer fields + assert!(field_names.contains(&"customer.info.name")); + assert!(field_names.contains(&"customer.info.contact.email")); + assert!(field_names.contains(&"customer.info.contact.phone.country")); + assert!(field_names.contains(&"customer.info.contact.phone.number")); + assert!(field_names.contains(&"customer.preferences.notifications.email")); + assert!(field_names.contains(&"customer.preferences.notifications.sms")); + assert!(field_names.contains(&"customer.preferences.notifications.push.enabled")); + assert!(field_names.contains(&"customer.preferences.notifications.push.frequency")); + + // Verify all fields are strings and no duplicates + let unique_fields: std::collections::HashSet<&str> = field_names.iter().cloned().collect(); + assert_eq!(unique_fields.len(), field_names.len(), "No duplicate fields should exist"); + + // Verify the response is a simple array of strings + for field in fields { + assert!(field.is_string()); + } + + // Test that we have a reasonable number of fields (should be more than just top-level) + assert!(field_names.len() > 10, "Should have more than 10 fields including nested ones"); +} + +#[actix_rt::test] +async fn error_get_fields_unexisting_index() { + let index = shared_does_not_exists_index().await; + let (response, code) = index.fields().await; + + let expected_response = json!({ + "message": format!("Index `{}` not found.", index.uid), + "code": "index_not_found", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#index_not_found" + }); + + assert_eq!(response, expected_response); + assert_eq!(code, 404); +} From e34021c31525cf7879f73feaa627455ccb8f40f1 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Fri, 27 Jun 2025 21:37:00 +0200 Subject: [PATCH 2/3] cargo format --- crates/meilisearch/tests/index/stats.rs | 28 +++++++++++-------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/crates/meilisearch/tests/index/stats.rs b/crates/meilisearch/tests/index/stats.rs index 58b465b7e..b31b13466 100644 --- a/crates/meilisearch/tests/index/stats.rs +++ b/crates/meilisearch/tests/index/stats.rs @@ -109,12 +109,10 @@ async fn fields() { // Test fields including nested fields let (response, code) = index.fields().await; assert_eq!(code, 200); - + let fields = response.as_array().unwrap(); - let field_names: Vec<&str> = fields.iter() - .map(|f| f.as_str().unwrap()) - .collect(); - + let field_names: Vec<&str> = fields.iter().map(|f| f.as_str().unwrap()).collect(); + // Check that all expected fields are present (including nested fields) assert!(field_names.contains(&"id")); assert!(field_names.contains(&"name")); @@ -126,7 +124,7 @@ async fn fields() { assert!(field_names.contains(&"metadata.category")); assert!(field_names.contains(&"metadata.author.name")); assert!(field_names.contains(&"metadata.author.id")); - + // Verify the response is a simple array of strings for field in fields { assert!(field.is_string()); @@ -248,12 +246,10 @@ async fn fields_nested_complex() { // Test fields with complex nested structures let (response, code) = index.fields().await; assert_eq!(code, 200); - + let fields = response.as_array().unwrap(); - let field_names: Vec<&str> = fields.iter() - .map(|f| f.as_str().unwrap()) - .collect(); - + let field_names: Vec<&str> = fields.iter().map(|f| f.as_str().unwrap()).collect(); + // Test deeply nested fields from the first document assert!(field_names.contains(&"product.name")); assert!(field_names.contains(&"product.specifications.hardware.cpu.brand")); @@ -272,7 +268,7 @@ async fn fields_nested_complex() { assert!(field_names.contains(&"product.pricing.currency")); assert!(field_names.contains(&"product.pricing.discounts.student")); assert!(field_names.contains(&"product.pricing.discounts.bulk")); - + // Test deeply nested fields from the second document assert!(field_names.contains(&"order.items")); assert!(field_names.contains(&"order.shipping.address.street")); @@ -283,7 +279,7 @@ async fn fields_nested_complex() { assert!(field_names.contains(&"order.shipping.method")); assert!(field_names.contains(&"order.shipping.tracking.number")); assert!(field_names.contains(&"order.shipping.tracking.carrier")); - + // Test customer fields assert!(field_names.contains(&"customer.info.name")); assert!(field_names.contains(&"customer.info.contact.email")); @@ -293,16 +289,16 @@ async fn fields_nested_complex() { assert!(field_names.contains(&"customer.preferences.notifications.sms")); assert!(field_names.contains(&"customer.preferences.notifications.push.enabled")); assert!(field_names.contains(&"customer.preferences.notifications.push.frequency")); - + // Verify all fields are strings and no duplicates let unique_fields: std::collections::HashSet<&str> = field_names.iter().cloned().collect(); assert_eq!(unique_fields.len(), field_names.len(), "No duplicate fields should exist"); - + // Verify the response is a simple array of strings for field in fields { assert!(field.is_string()); } - + // Test that we have a reasonable number of fields (should be more than just top-level) assert!(field_names.len() > 10, "Should have more than 10 fields including nested ones"); } From b53f7eb79f45eb1298c60a7b4b8e7c8b257fada7 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 29 Jun 2025 01:12:42 +0200 Subject: [PATCH 3/3] fix: Update test for fields route, index creation to handle None value --- crates/meilisearch/tests/index/stats.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch/tests/index/stats.rs b/crates/meilisearch/tests/index/stats.rs index b31b13466..c3a62222e 100644 --- a/crates/meilisearch/tests/index/stats.rs +++ b/crates/meilisearch/tests/index/stats.rs @@ -65,7 +65,7 @@ async fn error_get_stats_unexisting_index() { async fn fields() { let server = Server::new_shared(); let index = server.unique_index(); - let (task, code) = index.create(Some("id")).await; + let (task, code) = index.create(None).await; assert_eq!(code, 202); server.wait_task(task.uid()).await.succeeded();