diff --git a/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/DynamicTemplateBuilderFn.scala b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/DynamicTemplateBuilderFn.scala new file mode 100644 index 0000000000..2611e9e31a --- /dev/null +++ b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/DynamicTemplateBuilderFn.scala @@ -0,0 +1,62 @@ +package com.sksamuel.elastic4s.handlers.index + +import com.sksamuel.elastic4s.handlers.fields.ElasticFieldBuilderFn +import com.sksamuel.elastic4s.requests.mappings.dynamictemplate.DynamicTemplateRequest + +object DynamicTemplateBuilderFn { + + /** Deserializes a single dynamic template from the ES "array of single-key objects" format. + * + * ES encodes dynamic_templates as: [ { "template_name": { "match": "...", "mapping": {...} } }, ... ] + * + * @param name the template name (the single key in the outer object) + * @param values the inner object containing match conditions and the "mapping" sub-object + */ + def fromMap(name: String, values: Map[String, Any]): DynamicTemplateRequest = { + val mappingMap = values.get("mapping") match { + case Some(m: Map[_, _]) => m.asInstanceOf[Map[String, Any]] + case _ => Map.empty[String, Any] + } + val field = ElasticFieldBuilderFn.construct(name, mappingMap) + + DynamicTemplateRequest( + name = name, + mapping = field, + `match` = values.get("match").map(_.toString), + unmatch = values.get("unmatch").map(_.toString), + pathMatch = values.get("path_match").map(_.toString), + pathUnmatch = values.get("path_unmatch").map(_.toString), + MatchPattern = values.get("match_pattern").map(_.toString), + matchMappingType = values.get("match_mapping_type").map(_.toString) + ) + } + + /** Converts the raw mappings map (from Jackson untyped deserialization) into a typed [[TemplateMappings]]. + * + * Handles the ES dynamic_templates encoding (array of single-key objects) and reconstructs + * [[com.sksamuel.elastic4s.requests.mappings.dynamictemplate.DynamicTemplateRequest]] instances. + */ + def fromMappingsMap(raw: Map[String, Any]): TemplateMappings = { + val dynamicTemplates: Seq[DynamicTemplateRequest] = raw.get("dynamic_templates") match { + case Some(list: List[_]) => + list.flatMap { + case entry: Map[_, _] => + entry.asInstanceOf[Map[String, Any]].map { case (templateName, value) => + fromMap(templateName, value.asInstanceOf[Map[String, Any]]) + } + case _ => Seq.empty + } + case _ => Seq.empty + } + + val properties = raw.get("properties") match { + case Some(props: Map[_, _]) => + props.asInstanceOf[Map[String, Any]].map { case (fieldName, value) => + ElasticFieldBuilderFn.construct(fieldName, value.asInstanceOf[Map[String, Any]]) + }.toSeq + case _ => Seq.empty + } + + TemplateMappings(dynamicTemplates, properties) + } +} diff --git a/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/IndexTemplateHandlers.scala b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/IndexTemplateHandlers.scala index 4e8a5c7002..b050f8ef8d 100644 --- a/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/IndexTemplateHandlers.scala +++ b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/IndexTemplateHandlers.scala @@ -26,11 +26,38 @@ case class IndexTemplate( order: Int, @JsonProperty("index_patterns") indexPatterns: Seq[String], settings: Map[String, Any], - mappings: Map[String, Any], + mappings: TemplateMappings, aliases: Map[String, Any], version: Option[Int] ) +// Raw Jackson-deserialized intermediate types — not part of the public API. +// Composable index templates nest settings/mappings/aliases under a "template" sub-object. +private[index] case class RawGetIndexTemplatesResponse( + @JsonProperty("index_templates") indexTemplates: List[RawTemplateEntry] +) +private[index] case class RawTemplateEntry(name: String, @JsonProperty("index_template") template: RawIndexTemplate) +private[index] case class RawTemplateBody( + settings: Option[Map[String, Any]], + mappings: Option[Map[String, Any]], + aliases: Option[Map[String, Any]] +) +private[index] case class RawIndexTemplate( + order: Int, + @JsonProperty("index_patterns") indexPatterns: Seq[String], + template: Option[RawTemplateBody], + version: Option[Int] +) { + def toIndexTemplate: IndexTemplate = IndexTemplate( + order = order, + indexPatterns = indexPatterns, + settings = template.flatMap(_.settings).getOrElse(Map.empty), + mappings = template.flatMap(_.mappings).fold(TemplateMappings())(DynamicTemplateBuilderFn.fromMappingsMap), + aliases = template.flatMap(_.aliases).getOrElse(Map.empty), + version = version + ) +} + trait IndexTemplateHandlers { implicit object IndexTemplateExistsHandler extends Handler[IndexTemplateExistsRequest, IndexTemplateExists] { @@ -70,8 +97,11 @@ trait IndexTemplateHandlers { override def handle(response: HttpResponse): Either[ElasticError, GetIndexTemplatesResponse] = response.statusCode match { case 200 => - val templates = ResponseHandler.fromResponse[GetIndexTemplatesResponse](response) - Right(templates) + val raw = ResponseHandler.fromResponse[RawGetIndexTemplatesResponse](response) + val typed = GetIndexTemplatesResponse( + raw.indexTemplates.map(t => Templates(t.name, t.template.toIndexTemplate)) + ) + Right(typed) case _ => Left(ElasticErrorParser.parse(response)) } } diff --git a/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/TemplateMappings.scala b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/TemplateMappings.scala new file mode 100644 index 0000000000..43d5afca55 --- /dev/null +++ b/elastic4s-handlers/src/main/scala/com/sksamuel/elastic4s/handlers/index/TemplateMappings.scala @@ -0,0 +1,9 @@ +package com.sksamuel.elastic4s.handlers.index + +import com.sksamuel.elastic4s.fields.ElasticField +import com.sksamuel.elastic4s.requests.mappings.dynamictemplate.DynamicTemplateRequest + +case class TemplateMappings( + dynamicTemplates: Seq[DynamicTemplateRequest] = Seq.empty, + properties: Seq[ElasticField] = Seq.empty +) diff --git a/elastic4s-tests/src/test/resources/json/index/get_index_template_response.json b/elastic4s-tests/src/test/resources/json/index/get_index_template_response.json new file mode 100644 index 0000000000..52f4a8a298 --- /dev/null +++ b/elastic4s-tests/src/test/resources/json/index/get_index_template_response.json @@ -0,0 +1,54 @@ +{ + "index_templates": [ + { + "name": "my_template", + "index_template": { + "index_patterns": ["my_*", "bar_*"], + "template": { + "settings": { + "number_of_shards": 1 + }, + "mappings": { + "dynamic_templates": [ + { + "es": { + "match": "*_es", + "match_pattern": "regex", + "match_mapping_type": "string", + "mapping": { + "type": "text", + "analyzer": "spanish" + } + } + }, + { + "en": { + "match": "*", + "match_mapping_type": "string", + "mapping": { + "type": "text", + "analyzer": "english" + } + } + } + ], + "properties": { + "title": { + "type": "keyword" + }, + "created_at": { + "type": "date" + } + } + }, + "aliases": { + "my_alias": {} + } + }, + "priority": 500, + "version": 3, + "composed_of": [] + } + } + ] +} diff --git a/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/handlers/index/GetIndexTemplateResponseTest.scala b/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/handlers/index/GetIndexTemplateResponseTest.scala new file mode 100644 index 0000000000..15402f34b0 --- /dev/null +++ b/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/handlers/index/GetIndexTemplateResponseTest.scala @@ -0,0 +1,131 @@ +package com.sksamuel.elastic4s.handlers.index + +import com.sksamuel.elastic4s.HttpEntity.StringEntity +import com.sksamuel.elastic4s.HttpResponse +import com.sksamuel.elastic4s.fields.{KeywordField, TextField} +import com.sksamuel.elastic4s.requests.mappings.dynamictemplate.DynamicTemplateRequest +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.io.Source + +class GetIndexTemplateResponseTest extends AnyFlatSpec with IndexTemplateHandlers with Matchers with EitherValues { + + private def fixture(path: String): String = + Source.fromInputStream(getClass.getResourceAsStream(path)).mkString + + "GetIndexTemplateHandler" should "deserialize dynamic_templates from the array-of-single-key-objects format" in { + val json = fixture("/json/index/get_index_template_response.json") + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val result = GetIndexTemplateHandler.responseHandler.handle(response).value + + result.indexTemplates should have size 1 + val tmpl = result.indexTemplates.head + tmpl.name shouldBe "my_template" + + val mappings = tmpl.template.mappings + mappings.dynamicTemplates should have size 2 + } + + it should "correctly parse the first dynamic template (es)" in { + val json = fixture("/json/index/get_index_template_response.json") + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val mappings = GetIndexTemplateHandler.responseHandler.handle(response).value + .indexTemplates.head.template.mappings + + val es = mappings.dynamicTemplates.find(_.name == "es").get + es.`match` shouldBe Some("*_es") + es.MatchPattern shouldBe Some("regex") + es.matchMappingType shouldBe Some("string") + es.mapping shouldBe a[TextField] + es.mapping.asInstanceOf[TextField].analyzer shouldBe Some("spanish") + } + + it should "correctly parse the second dynamic template (en)" in { + val json = fixture("/json/index/get_index_template_response.json") + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val mappings = GetIndexTemplateHandler.responseHandler.handle(response).value + .indexTemplates.head.template.mappings + + val en = mappings.dynamicTemplates.find(_.name == "en").get + en.`match` shouldBe Some("*") + en.matchMappingType shouldBe Some("string") + en.MatchPattern shouldBe None + en.mapping shouldBe a[TextField] + en.mapping.asInstanceOf[TextField].analyzer shouldBe Some("english") + } + + it should "deserialize properties from mappings" in { + val json = fixture("/json/index/get_index_template_response.json") + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val mappings = GetIndexTemplateHandler.responseHandler.handle(response).value + .indexTemplates.head.template.mappings + + mappings.properties should have size 2 + val fieldNames = mappings.properties.map(_.name).toSet + fieldNames shouldBe Set("title", "created_at") + + val title = mappings.properties.find(_.name == "title").get + title shouldBe a[KeywordField] + } + + it should "return empty TemplateMappings when mappings is absent" in { + val json = + """{ + | "index_templates": [ + | { + | "name": "empty_template", + | "index_template": { + | "index_patterns": ["empty_*"], + | "priority": 1, + | "version": 1, + | "composed_of": [] + | } + | } + | ] + |}""".stripMargin + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val mappings = GetIndexTemplateHandler.responseHandler.handle(response).value + .indexTemplates.head.template.mappings + + mappings.dynamicTemplates shouldBe empty + mappings.properties shouldBe empty + } + + it should "return empty dynamic_templates when mappings has only properties" in { + val json = + """{ + | "index_templates": [ + | { + | "name": "props_only", + | "index_template": { + | "index_patterns": ["props_*"], + | "template": { + | "mappings": { + | "properties": { + | "name": { "type": "keyword" } + | } + | } + | }, + | "priority": 1, + | "version": 1, + | "composed_of": [] + | } + | } + | ] + |}""".stripMargin + val response = HttpResponse(200, Some(StringEntity(json, None)), Map.empty) + + val mappings = GetIndexTemplateHandler.responseHandler.handle(response).value + .indexTemplates.head.template.mappings + + mappings.dynamicTemplates shouldBe empty + mappings.properties should have size 1 + } +} diff --git a/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/requests/admin/IndexTemplateHttpTest.scala b/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/requests/admin/IndexTemplateHttpTest.scala index 728cb9cede..98b0b769e3 100644 --- a/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/requests/admin/IndexTemplateHttpTest.scala +++ b/elastic4s-tests/src/test/scala/com/sksamuel/elastic4s/requests/admin/IndexTemplateHttpTest.scala @@ -1,7 +1,9 @@ package com.sksamuel.elastic4s.requests.admin +import com.sksamuel.elastic4s.fields.TextField import com.sksamuel.elastic4s.requests.common.RefreshPolicy import com.sksamuel.elastic4s.requests.indexes.CreateIndexTemplateRequest +import com.sksamuel.elastic4s.requests.mappings.dynamictemplate.DynamicTemplateRequest import com.sksamuel.elastic4s.testkit.DockerTests import org.scalatest.concurrent.Eventually import org.scalatest.matchers.should.Matchers @@ -29,6 +31,13 @@ class IndexTemplateHttpTest Thread.sleep(2000) } + Try { + client.execute { + deleteIndexTemplate("dyntemplate_template") + }.await + Thread.sleep(2000) + } + "create template" should { "create template" in { @@ -50,6 +59,33 @@ class IndexTemplateHttpTest resp.result.indexTemplates.find(_.name == "brewery_template").get.template.order shouldBe 0 } } + "deserialize dynamic_templates from get template response" in { + + val dynTemplate = DynamicTemplateRequest("es_fields", TextField("").analyzer("spanish")) + .matchMappingType("string") + .matching("*_es") + + client.execute { + CreateIndexTemplateRequest("dyntemplate_template", Seq("dyntemplate*")).mappings( + properties(keywordField("id")).dynamicTemplates(dynTemplate) + ) + }.await.result.acknowledged shouldBe true + + eventually { + val resp = client.execute { + getIndexTemplate("dyntemplate_template") + }.await + + val mappings = resp.result.indexTemplates.find(_.name == "dyntemplate_template").get.template.mappings + mappings.dynamicTemplates should have size 1 + + val tmpl = mappings.dynamicTemplates.head + tmpl.name shouldBe "es_fields" + tmpl.`match` shouldBe Some("*_es") + tmpl.matchMappingType shouldBe Some("string") + tmpl.mapping shouldBe a[TextField] + } + } "apply template to new indexes that match the pattern" in { // this should match the earlier template of brew*