diff --git a/crates/meilisearch/src/routes/indexes/render.rs b/crates/meilisearch/src/routes/indexes/render.rs index 66e9cea28..041cb6791 100644 --- a/crates/meilisearch/src/routes/indexes/render.rs +++ b/crates/meilisearch/src/routes/indexes/render.rs @@ -8,9 +8,12 @@ use index_scheduler::IndexScheduler; use itertools::structs; use meilisearch_types::deserr::query_params::Param; use meilisearch_types::deserr::{DeserrJsonError, DeserrQueryParamError}; -use meilisearch_types::error::deserr_codes::{InvalidRenderInput, InvalidRenderInputDocumentId, InvalidRenderInputInline, InvalidRenderTemplate, InvalidRenderTemplateId, InvalidRenderTemplateInline}; +use meilisearch_types::error::deserr_codes::{ + InvalidRenderInput, InvalidRenderInputDocumentId, InvalidRenderInputInline, + InvalidRenderTemplate, InvalidRenderTemplateId, InvalidRenderTemplateInline, +}; +use meilisearch_types::error::Code; use meilisearch_types::error::ResponseError; -use meilisearch_types::error::{Code}; use meilisearch_types::index_uid::IndexUid; use meilisearch_types::keys::actions; use meilisearch_types::milli::vector::json_template::{self, JsonTemplate}; @@ -81,7 +84,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ) )] pub async fn render_post( - index_scheduler: GuardedData, Data>, + index_scheduler: GuardedData< + DoubleActionPolicy<{ actions::SETTINGS_GET }, { actions::DOCUMENTS_GET }>, + Data, + >, index_uid: web::Path, params: AwebJson, req: HttpRequest, @@ -147,7 +153,12 @@ enum RenderError { available_indexing_fragments: Vec, available_search_fragments: Vec, }, - UnknownTemplatePrefix(String), + UnknownTemplatePrefix { + embedder_name: String, + found: String, + available_indexing_fragments: Vec, + available_search_fragments: Vec, + }, ReponseError(ResponseError), MissingFragment { embedder_name: String, @@ -240,10 +251,23 @@ impl From for ResponseError { ) } }, - UnknownTemplatePrefix(prefix) => ResponseError::from_msg( - format!("Template ID must start with `embedders` or `chatCompletions`, but found `{prefix}`."), - Code::InvalidRenderTemplateId, - ), + UnknownTemplatePrefix { embedder_name, found, mut available_indexing_fragments, mut available_search_fragments } => { + if available_indexing_fragments.is_empty() && available_search_fragments.is_empty() { + ResponseError::from_msg( + format!("Wrong template `{found}` after embedder `{embedder_name}`.\n Hint: Available fragments: `documentTemplate`."), + Code::InvalidRenderTemplateId, + ) + } else { + available_indexing_fragments.sort_unstable(); + available_search_fragments.sort_unstable(); + ResponseError::from_msg( + format!("Wrong template `{found}` after embedder `{embedder_name}`.\n Hint: Available fragments are {}.", + available_indexing_fragments.iter().map(|s| format!("`indexingFragments.{s}`")).chain( + available_search_fragments.iter().map(|s| format!("`searchFragments.{s}`"))).collect::>().join(", ")), + Code::InvalidRenderTemplateId, + ) + } + }, ReponseError(response_error) => response_error, MissingFragment { embedder_name, kind, mut available } => { available.sort_unstable(); @@ -400,7 +424,18 @@ async fn render(index: Index, query: RenderQuery) -> Result return Err(UnknownTemplateRoot(root.to_owned())), + found => return Err(UnknownTemplatePrefix { + embedder_name: embedder_name.to_string(), + found: found.to_string(), + available_indexing_fragments: embedding_config + .config + .embedder_options + .indexing_fragments(), + available_search_fragments: embedding_config + .config + .embedder_options + .search_fragments(), + }), } } "chatCompletions" | "chatcompletions" => { @@ -414,8 +449,9 @@ async fn render(index: Index, query: RenderQuery) -> Result return Err(EmptyTemplateId), unknown => { - return Err(UnknownTemplatePrefix(unknown.to_string())); + return Err(UnknownTemplateRoot(unknown.to_string())); } }; @@ -429,32 +465,31 @@ async fn render(index: Index, query: RenderQuery) -> Result return Err(MissingTemplate), }; - let mut media = query.input.inline.unwrap_or_default(); - - if let Some(document_id) = query.input.document_id { - let internal_id = index - .external_documents_ids() - .get(&rtxn, &document_id)? - .ok_or_else(|| DocumentNotFound(document_id.to_string()))?; + let mut rendered = Value::Null; + if let Some(input) = query.input { + let mut media = input.inline.unwrap_or_default(); + if let Some(document_id) = input.document_id { + let internal_id = index + .external_documents_ids() + .get(&rtxn, &document_id)? + .ok_or_else(|| DocumentNotFound(document_id.to_string()))?; - let document = index.document(&rtxn, internal_id)?; + let document = index.document(&rtxn, internal_id)?; - let fields_ids_map = index.fields_ids_map(&rtxn)?; - let all_fields: Vec<_> = fields_ids_map.iter().map(|(id, _)| id).collect(); - let document = milli::obkv_to_json(&all_fields, &fields_ids_map, document)?; - let document = Value::Object(document); + let fields_ids_map = index.fields_ids_map(&rtxn)?; + let all_fields: Vec<_> = fields_ids_map.iter().map(|(id, _)| id).collect(); + let document = milli::obkv_to_json(&all_fields, &fields_ids_map, document)?; + let document = Value::Object(document); - if media.insert(String::from("doc"), document).is_some() { - return Err(BothInlineDocAndDocId); + if media.insert(String::from("doc"), document).is_some() { + return Err(BothInlineDocAndDocId); + } } - } - let json_template = JsonTemplate::new(template.clone()) - .map_err(TemplateParsing)?; - - let rendered = json_template - .render_serializable(&media) - .map_err(TemplateRendering)?; + let json_template = JsonTemplate::new(template.clone()).map_err(TemplateParsing)?; + + rendered = json_template.render_serializable(&media).map_err(TemplateRendering)?; + } Ok(RenderResult { template, rendered }) } @@ -464,8 +499,8 @@ async fn render(index: Index, query: RenderQuery) -> Result)] pub template: RenderQueryTemplate, - #[deserr(error = DeserrJsonError)] - pub input: RenderQueryInput, + #[deserr(default, error = DeserrJsonError)] + pub input: Option, } #[derive(Debug, Clone, PartialEq, Deserr, ToSchema)] @@ -477,7 +512,7 @@ pub struct RenderQueryTemplate { inline: Option, } -#[derive(Debug, Clone, PartialEq, Deserr, ToSchema)] +#[derive(Debug, Clone, Default, PartialEq, Deserr, ToSchema)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] pub struct RenderQueryInput { #[deserr(default, error = DeserrJsonError)] diff --git a/crates/meilisearch/tests/auth/tenant_token.rs b/crates/meilisearch/tests/auth/tenant_token.rs index a3f89e70b..6b0aa933e 100644 --- a/crates/meilisearch/tests/auth/tenant_token.rs +++ b/crates/meilisearch/tests/auth/tenant_token.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use ::time::format_description::well_known::Rfc3339; use maplit::hashmap; @@ -467,6 +467,7 @@ async fn error_access_forbidden_routes() { server.use_api_key(&web_token); for ((method, route), actions) in AUTHORIZATIONS.iter() { + let actions = actions.iter().flat_map(|s| s.iter()).copied().collect::>(); if !actions.contains("search") { let (mut response, code) = server.dummy_request(method, route).await; response["message"] = serde_json::json!(null); diff --git a/crates/meilisearch/tests/common/index.rs b/crates/meilisearch/tests/common/index.rs index e324d2ff5..3ff988540 100644 --- a/crates/meilisearch/tests/common/index.rs +++ b/crates/meilisearch/tests/common/index.rs @@ -457,6 +457,14 @@ impl Index<'_, State> { self.service.get(url).await } + pub async fn render( + &self, + query: Value + ) -> (Value, StatusCode) { + let url = format!("/indexes/{}/render", urlencode(self.uid.as_ref())); + self.service.post_encoded(url, query, self.encoder).await + } + pub async fn settings(&self) -> (Value, StatusCode) { let url = format!("/indexes/{}/settings", urlencode(self.uid.as_ref())); self.service.get(url).await diff --git a/crates/meilisearch/tests/documents/mod.rs b/crates/meilisearch/tests/documents/mod.rs index f6430b108..78861df12 100644 --- a/crates/meilisearch/tests/documents/mod.rs +++ b/crates/meilisearch/tests/documents/mod.rs @@ -3,3 +3,4 @@ mod delete_documents; mod errors; mod get_documents; mod update_documents; +mod render_documents; diff --git a/crates/meilisearch/tests/documents/render_documents.rs b/crates/meilisearch/tests/documents/render_documents.rs new file mode 100644 index 000000000..766d942cb --- /dev/null +++ b/crates/meilisearch/tests/documents/render_documents.rs @@ -0,0 +1,311 @@ +use crate::common::shared_index_for_fragments; +use crate::json; +use meili_snap::snapshot; + +#[actix_rt::test] +async fn empty_id() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "The template ID is empty.\n Hint: Valid prefixes are `embedders` or `chatCompletions`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn wrong_id_prefix() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "wrong.disregarded" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Template ID must start with `embedders` or `chatCompletions`, but found `wrong`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn missing_embedder() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Template ID configured with `embedders` but no embedder name provided.\n Hint: Available embedders are `rest`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn wrong_embedder() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.wrong.disregarded" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Embedder `wrong` does not exist.\n Hint: Available embedders are `rest`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn missing_template_kind() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Template ID configured with `embedders.rest` but no template kind provided.\n Hint: Available fragments are `indexingFragments.basic`, `indexingFragments.withBreed`, `searchFragments.justBreed`, `searchFragments.justName`, `searchFragments.query`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn wrong_template_kind() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.wrong.disregarded" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Wrong template `wrong` after embedder `rest`.\n Hint: Available fragments are `indexingFragments.basic`, `indexingFragments.withBreed`, `searchFragments.justBreed`, `searchFragments.justName`, `searchFragments.query`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn document_template_on_fragmented_index() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.documentTemplate" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Requested document template for embedder `rest` but it uses fragments.\n Hint: Use `indexingFragments` or `searchFragments` instead.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn missing_fragment_name() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.indexingFragments" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Indexing fragment name was not provided.\n Hint: Available indexing fragments for embedder `rest` are `basic`, `withBreed`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.searchFragments" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Search fragment name was not provided.\n Hint: Available search fragments for embedder `rest` are `justBreed`, `justName`, `query`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn wrong_fragment_name() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.indexingFragments.wrong" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Indexing fragment `wrong` does not exist for embedder `rest`.\n Hint: Available indexing fragments are `basic`, `withBreed`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.searchFragments.wrong" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Search fragment `wrong` does not exist for embedder `rest`.\n Hint: Available search fragments are `justBreed`, `justName`, `query`.", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn leftover_tokens() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.indexingFragments.withBreed.leftover" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Leftover token `leftover` after parsing template ID", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.searchFragments.justBreed.leftover" + } + }}) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Leftover token `leftover` after parsing template ID", + "code": "invalid_render_template_id", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_render_template_id" + } + "#); +} + +#[actix_rt::test] +async fn fragment_retrieval() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.indexingFragments.withBreed" + } + }}) + .await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "template": "{{ doc.name }} is a {{ doc.breed }}", + "rendered": null + } + "#); + + let (value, code) = index + .render(json! {{ + "template": { + "id": "embedders.rest.searchFragments.justBreed" + } + }}) + .await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "template": "It's a {{ media.breed }}", + "rendered": null + } + "#); +} + +// TODO chat completions