How to create mappings for parent-child index and search for children filtered by parent - spring

Goal:
I want to create a parent-child index with 2 entities. A profile and a comment. A profile (for simplicity) has a custom id (UUID converted to a string), age, and location (GeoPoint). A comment (for simplicity) has a custom id (UUID converted to a string). With this information, I want to be able to search for all comments given some filtering data against a profile. For example, I want to find all comments by profiles between the ages of 26 and 36 and is located within 100km of lat: 3.0, long 5.0.
Classes:
// Profile.kt
import org.elasticsearch.common.geo.GeoPoint
import org.springframework.data.annotation.Id
import org.springframework.data.elasticsearch.annotations.Document
import org.springframework.data.elasticsearch.annotations.Field
import org.springframework.data.elasticsearch.annotations.FieldType
import org.springframework.data.elasticsearch.annotations.GeoPointField
#Document(indexName = "message_board", createIndex = false, type = "profile")
data class Profile(
#Id
val profileId: String,
#Field(type = FieldType.Short, store = true)
val age: Short,
#GeoPointField
val location: GeoPoint
)
// Comment.kt
import org.springframework.data.annotation.Id
import org.springframework.data.elasticsearch.annotations.Document
import org.springframework.data.elasticsearch.annotations.Field
import org.springframework.data.elasticsearch.annotations.FieldType
import org.springframework.data.elasticsearch.annotations.Parent
#Document(indexName = "message_board", createIndex = false, type = "comment")
data class Comment(
#Id
val commentId: String,
#Field(type = FieldType.Text, store = true)
#Parent(type = "profile")
val parentId: String
)
// RestClientConfig.kt
import org.elasticsearch.client.RestHighLevelClient
import org.springframework.context.annotation.Configuration
import org.springframework.data.elasticsearch.client.ClientConfiguration
import org.springframework.data.elasticsearch.client.RestClients
import org.springframework.data.elasticsearch.config.AbstractElasticsearchConfiguration
#Configuration
class RestClientConfig(
private val elasticSearchConfig: ElasticSearchConfig
) : AbstractElasticsearchConfiguration() {
override fun elasticsearchClient(): RestHighLevelClient {
val clientConfiguration: ClientConfiguration = ClientConfiguration.builder()
.connectedTo("${elasticSearchConfig.endpoint}:${elasticSearchConfig.port}")
.build()
return RestClients.create(clientConfiguration).rest()
}
}
// Controller.kt
import org.springframework.web.bind.annotation.RestController
import org.springframework.data.elasticsearch.core.ElasticsearchOperations
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
#RestController
#RequestMapping("/", produces = [MediaType.APPLICATION_JSON_VALUE])
class Controller constructor(
private val elasticsearchOperations: ElasticsearchOperations
) {
init {
elasticsearchOperations.indexOps(IndexCoordinates.of("message_board")).let { indexOp ->
if (!indexOp.exists() && indexOp.create()) {
val profileMapping = indexOp.createMapping(Profile::class.java)
println("Profile Mapping: $profileMapping")
indexOp.putMapping(profileMapping)
val commentMapping = indexOp.createMapping(Comment::class.java)
println("Comment Mapping: $commentMapping")
indexOp.putMapping(commentMapping)
indexOp.refresh()
}
}
}
#GetMapping("comments")
fun getComments(): List<Comment> {
val searchQuery = NativeSearchQueryBuilder()
.withFilter(
HasParentQueryBuilder(
"profile",
QueryBuilders
.boolQuery()
.must(
QueryBuilders
.geoDistanceQuery("location")
.distance(100, DistanceUnit.KILOMETERS)
.point(3.0, 5.0)
)
.must(
QueryBuilders
.rangeQuery("age")
.gte(26)
.lte(36)
),
false
)
)
.build()
return elasticsearchOperations.search(searchQuery, Comment::class.java, IndexCoordinates.of("message_board")).toList().map(SearchHit<Comment>::getContent)
}
}
My Setup:
I have elasticsearch running in docker via:
docker run --name es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -d -v es_data:/usr/share/elasticsearch/data docker.elastic.co/elasticsearch/elasticsearch:7.4.2
Spring Boot: "2.3.2.RELEASE"
Spring Data Elasticsearch: "4.0.2.RELEASE"
Issues:
I'm failing to get past the init block of my controller with the following exception:
Profile Mapping: MapDocument#?#? {"properties":{"age":{"store":true,"type":"short"},"location":{"type":"geo_point"}}}
Comment Mapping: MapDocument#?#? {"_parent":{"type":"profile"},"properties":{"parentId":{"store":true,"type":"text"}}}
Suppressed: org.elasticsearch.client.ResponseException: method [PUT], host [http://localhost:9200], URI [/message_board/_mapping?master_timeout=30s&timeout=30s], status line [HTTP/1.1 400 Bad Request]
Caused by: org.elasticsearch.ElasticsearchStatusException: Elasticsearch exception [type=mapper_parsing_exception, reason=Root mapping definition has unsupported parameters: [_parent : {type=profile}]]
I need a solution that doesn't involve making a direct POST request to ES. Ideally, this is solved with the Elasticsearch client API. It feels like there's something missing with my annotations on the data classes however I couldn't find any documentation about this.

This is no problem of using the REST API. These calls are created by the Elasticsearch RestHighlevelClient.
Having multiple types in one index is not supported anymore by Elasticsearch since version 7.0.0. So you cannot model your data in this way.
Elasticsearch supports the join data type for this. We are currently working on a PR that will add the support for this for the next version of Spring Data Elasticsearch (4.1).

I was able to find a short-term solution with the following changes.
// Comment.kt
#Document(indexName = "message_board")
data class Comment(
#Id
val commentId: String,
val relationField: Map<String, String>
)
// Profile.kt
#Document(indexName = "message_board")
data class Profile(
#Id
val profileId: String,
#Field(type = FieldType.Short)
val age: Short,
#GeoPointField
val location: GeoPoint,
#Field(type = FieldType.Text)
val relationField: String = "profile"
)
// Controller.kt
class Controller constructor(
private val elasticsearchOperations: ElasticsearchOperations
) {
init {
elasticsearchOperations.indexOps(IndexCoordinates.of("message_board")).let { indexOp ->
if (!indexOp.exists() && indexOp.create()) {
val relationMap = Document.from(
mapOf(
"properties" to mapOf(
"relationField" to mapOf(
"type" to "join",
"relations" to mapOf(
"profile" to "comment"
)
),
"location" to mapOf(
"type" to "geo_point"
)
)
)
)
indexOp.putMapping(relationMap)
indexOp.refresh()
}
}
}
}
Note the relationField added to both data classes as well as the manually generated mapping document. Now, ES has a proper mapping on initialization:
{
"message_board" : {
"mappings" : {
"properties" : {
"location" : {
"type" : "geo_point"
},
"relationField" : {
"type" : "join",
"eager_global_ordinals" : true,
"relations" : {
"profile" : "comment"
}
}
}
}
}
}
Now, creating a profile is straightforward:
val profile = Profile(
profileId = UUID.randomUUID().toString(),
age = 27,
location = GeoPoint(3.0, 5.0)
)
val indexQuery = IndexQueryBuilder()
.withId(profile.profileId)
.withObject(profile)
.build()
elasticsearchOperations.index(indexQuery, IndexCoordinates.of("message_board"))
However, creating a comment is a little tricker because a routing ID is required:
val comment = Comment(
commentId = UUID.randomUUID().toString(),
relationField = mapOf(
"name" to "comment",
"parent" to profileId
)
)
val bulkOptions = BulkOptions.builder()
.withRoutingId(profileId)
.build()
val indexQuery = IndexQueryBuilder()
.withId(comment.commentId)
.withObject(comment)
.withParentId(profileId)
.build()
elasticsearchOperations.bulkIndex(listOf(indexQuery), bulkOptions, IndexCoordinates.of("message_board"))
This is how I was able to get a parent-child relation with the new JOIN relation type.

Related

Spring boot annotation #ConfigurationProperties doesn't work correctly classes with nested collection in Kotlin

There is the data class with #ConfigurationProperties and #ConstructorBinding. This class contains the field which is collection.
There are several property sources ( application.yml, application-dev1.yml) which initialize the first element of the collection.
Binding for this element doesn't work correctly. Values for initialazation pulls only from one property source.
Expected beahavior is the same as for field of type of some nested class: merging values from all property sources.
Kotlin properties class
#ConfigurationProperties("tpp.test.root")
#ConstructorBinding
data class RootPropperties(
var rootField1: String = "",
var rootField2: String = "",
var nested: NestedProperties = NestedProperties(),
var nestedList: List<NestedListProperties> = listOf()
) {
data class NestedProperties(
var nestedField1: String = "",
var nestedField2: String = ""
)
#ConstructorBinding
data class NestedListProperties(
var nestedListField1: String = "",
var nestedListField2: String = ""
)
}
application.yml
tpp:
test:
root:
root-field1: default
nested:
nested-field1: default
nested-list:
- nested-list-field1: default
application-dev1.yml
tpp:
test:
root:
root-field2: dev1
nested:
nested-field2: dev1
nested-list:
- nested-list-field2: dev1
Test
#ActiveProfiles("dev1")
#SpringBootTest
internal class ConfigurationPropertiesTest {
#Autowired
lateinit var environment: Environment
#Autowired
lateinit var rootPropperties: RootPropperties
#Test
fun `configuration properties binding`() {
Assertions.assertEquals("default", rootPropperties.rootField1)
Assertions.assertEquals("dev1", rootPropperties.rootField2)
Assertions.assertEquals("default", rootPropperties.nested.nestedField1)
Assertions.assertEquals("dev1", rootPropperties.nested.nestedField2)
Assertions.assertTrue(rootPropperties.nestedList.isNotEmpty())
//org.opentest4j.AssertionFailedError:
//Expected :default
//Actual :
Assertions.assertEquals("default", rootPropperties.nestedList[0].nestedListField1)
Assertions.assertEquals("dev1", rootPropperties.nestedList[0].nestedListField2)
}
#Test
fun `environment binding`() {
Assertions.assertEquals("default", environment.getProperty("tpp.test.root.root-field1"))
Assertions.assertEquals("dev1", environment.getProperty("tpp.test.root.root-field2"))
Assertions.assertEquals("default", environment.getProperty("tpp.test.root.nested.nested-field1"))
Assertions.assertEquals("dev1", environment.getProperty("tpp.test.root.nested.nested-field2"))
Assertions.assertEquals("default", environment.getProperty("tpp.test.root.nested-list[0].nested-list-field1"))
Assertions.assertEquals("dev1", environment.getProperty("tpp.test.root.nested-list[0].nested-list-field2"))
}
}
The test with RootProperties failed on assertEquals("default", rootPropperties.nestedList[0].nestedListField1) because rootPropperties.nestedList[0].nestedListField1 has empty value. All other assertions tests pass sucessfully. The binding doesn't work correctly just for collection.
At the same time the test with Environment passed successfully. And Environment.getProperty("tpp.test.root.nested-list[0].nested-list-field1") resolves corrected value: "default".
Spring boot version: 2.6.4
covered in this section of the reference documention
Possible workaround could be to switch List to a Map.
Properties class
#ConfigurationProperties("tpp.test.root-map")
#ConstructorBinding
data class RootMapPropperties(
var rootField1: String = "",
var rootField2: String = "",
var nested: NestedProperties = NestedProperties(),
var nestedMap: Map<String, NestedMapProperties> = mapOf()
) {
data class NestedProperties(
var nestedField1: String = "",
var nestedField2: String = ""
)
data class NestedMapProperties(
var nestedMapField1: String = "",
var nestedMapField2: String = ""
)
}
application.yml
tpp:
test:
root-map:
root-field1: default
nested:
nested-field1: default
nested-map:
1:
nested-map-field1: default
application-dev1.yml
tpp:
root-map:
root-field2: dev1
nested:
nested-field2: dev1
nested-map:
1:
nested-map-field2: dev1
Test
#ActiveProfiles("dev1")
#SpringBootTest
internal class ConfigurationPropertiesMapTest {
#Autowired
lateinit var environment: Environment
#Autowired
lateinit var rootPropperties: RootMapPropperties
#Test
fun `configuration properties binding`() {
Assertions.assertEquals("default", rootPropperties.rootField1)
Assertions.assertEquals("dev1", rootPropperties.rootField2)
Assertions.assertEquals("default", rootPropperties.nested.nestedField1)
Assertions.assertEquals("dev1", rootPropperties.nested.nestedField2)
Assertions.assertTrue(rootPropperties.nestedMap.isNotEmpty())
Assertions.assertEquals("default", rootPropperties.nestedMap["1"]!!.nestedMapField1)
Assertions.assertEquals("dev1", rootPropperties.nestedMap["1"]!!.nestedMapField2)
}
}

Spring boot serialize kotlin enum by custom property

I have an Enum and I would like to serialize it using custom property. It works in my tests but not when I make request.
Enum should be mapped using JsonValue
enum class PlantProtectionSortColumn(
#get:JsonValue val propertyName: String,
) {
NAME("name"),
REGISTRATION_NUMBER("registrationNumber");
}
In test the lowercase case works as expected.
class PlantProtectionSortColumnTest : ServiceSpec() {
#Autowired
lateinit var mapper: ObjectMapper
data class PlantProtectionSortColumnWrapper(
val sort: PlantProtectionSortColumn,
)
init {
// this works
test("Deserialize PlantProtectionSortColumn enum with custom name ") {
val json = """
{
"sort": "registrationNumber"
}
"""
val result = mapper.readValue(json, PlantProtectionSortColumnWrapper::class.java)
result.sort shouldBe PlantProtectionSortColumn.REGISTRATION_NUMBER
}
// this one fails
test("Deserialize PlantProtectionSortColumn enum with enum name ") {
val json = """
{
"sort": "REGISTRATION_NUMBER"
}
"""
val result = mapper.readValue(json, PlantProtectionSortColumnWrapper::class.java)
result.sort shouldBe PlantProtectionSortColumn.REGISTRATION_NUMBER
}
}
}
But in controller, when i send request with lowercase I get 400. But when the request matches the enum name It works, but response is returned with lowercase. So Spring is not using the objectMapper only for request, in response it is used.
private const val RESOURCE_PATH = "$API_PATH/plant-protection"
#RestController
#RequestMapping(RESOURCE_PATH, produces = [MediaType.APPLICATION_JSON_VALUE])
class PlantProtectionController() {
#GetMapping("/test")
fun get(
#RequestParam sortColumn: PlantProtectionSortColumn,
) = sortColumn
}
I believe kqr's answer is correct and you need to configure converter, not JSON deserializer.
It could look like:
#Component
class StringToPlantProtectionSortColumnConverter : Converter<String, PlantProtectionSortColumn> {
override fun convert(source: String): PlantProtectionSortColumn {
return PlantProtectionSortColumn.values().firstOrNull { it.propertyName == source }
?: throw NotFoundException(PlantProtectionSortColumn::class, source)
}}
In your endpoint you are not parsing json body but query parameters, which are not in json format.

spring type conversion not working - Giving Type Mismatch

I have tried creating a custom converter as specified in - https://www.baeldung.com/spring-type-conversions#creating-a-custom-converter but I am still getting type mismatch when I am carrying out the conversion.
I am creating a converter class (PageToApiResponse) which converts from Page<*> to ResponseList. This converter is then added to registry in the WebConfig class. However when I want to implicitly convert from a Page< ProductDemand > to ResponseList in the controller I get a type mismatch (The service returns Page< ProductDemand >). I have tried annotating the converter class with #Component but that doesn't work.
The error is as follows:
Required: ResponseList.
Found: Page< ProductDemand >
Any help would be greatly appreciated.
Here are the classes involved -
data class ResponseList(
val total: Int,
val limit: Int,
val offset: Int,
val results: List<*>
)
class PageToApiResponse : Converter<Page<*>, ResponseList> {
override fun convert(source: Page<*>) = ResponseList(source.numberOfElements, source.size, source.number, source.content)
}
#Configuration
class WebConfig : WebMvcConfigurer {
override fun addFormatters(registry: FormatterRegistry) {
registry.addConverter(PageToApiResponse())
}
}
#ApiOperation(value = "Find demand by GPI")
#ApiResponses(
ApiResponse(code = 200, message = "Demand results", response = ProductDemand::class, responseContainer = "List"),
ApiResponse(code = 400, message = "Bad input parameter"),
ApiResponse(code = 401, message = "Unauthorized"),
ApiResponse(code = 429, message = "Too Many Requests")
)
#PreAuthorize("hasAnyAuthority('${Authorities.EXT_USAGE_VIEW}')")
#GetMapping("")
fun getDemand(
#RequestParam(required = true) gpi: String,
#PageableDefault(
page = 0,
size = 50,
sort = ["molecule"],
direction = Sort.Direction.DESC
) pageable: Pageable
): ResponseList = service.getDemand(gpi, pageable)

Swagger shows Mongo ObjectId as complex JSON instead of String

Project setup
I have a Kotlin Spring Boot 2.0 project that exposes a #RestController API that returns MongoDB models. For example, this model and controller:
#RestController
#RequestMapping("/api/accounts")
class AccountsController() {
#GetMapping
fun list(): List<Account> {
return listOf(Account(ObjectId(), "Account 1"), Account(ObjectId(), "Account 2"), Account(ObjectId(), "Account 3"))
}
}
#Document
data class Account(
#Id val id: ObjectId? = null,
val name: String
)
These models have ObjectId identifiers, but in the API I want them to be treated as plain String (i.e. instead of a complex JSON, the default behaviour).
To achieve this, I created these components to configure Spring Boot parameter binding and JSON parsing:
#JsonComponent
class ObjectIdJsonSerializer : JsonSerializer<ObjectId>() {
override fun serialize(value: ObjectId?, gen: JsonGenerator?, serializers: SerializerProvider?) {
if (value == null || gen == null) return
gen.writeString(value.toHexString())
}
}
#JsonComponent
class ObjectIdJsonDeserializer : JsonDeserializer<ObjectId>() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): ObjectId? {
if (p == null) return null
val text = p.getCodec().readTree<TextNode>(p).textValue()
return ObjectId(text)
}
}
#Component
class StringToObjectIdConverter : Converter<String, ObjectId> {
override fun convert(source: String): ObjectId? {
return ObjectId(source)
}
}
So far this works as intended, calls to the API return this JSON:
[
{
"id": "5da454f4307b0a8b30838839",
"name": "Account 1"
},
{
"id": "5da454f4307b0a8b3083883a",
"name": "Account 2"
},
{
"id": "5da454f4307b0a8b3083883b",
"name": "Account 3"
}
]
Issue
The problem comes when integrating Swagger into the project, the documentation shows that calling this method returns a complex JSON instead of a plain String as the id property:
Adding #ApiModelProperty(dataType = "string") to the id field made no difference, and I can't find a way to solve it without changing all the id fields in the project to String. Any help would be appreciated.
I couldn't get #ApiModelProperty(dataType = "") to work, but I found a more convenient way configuring a direct substitute in the Swagger configuration using directModelSubstitute method of the Docket instance in this response.
#Configuration
#EnableSwagger2
class SwaggerConfig() {
#Bean
fun api(): Docket {
return Docket(DocumentationType.SWAGGER_2)
.directModelSubstitute(ObjectId::class.java, String::class.java)
}
}
Java equivalent:
#Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.directModelSubstitute(ObjectId.class, String.class);
}
For OpenApi (Swagger 3.0) and SpringDoc the following global configuration could be used.
static {
SpringDocUtils.getConfig().replaceWithSchema(ObjectId.class, new StringSchema());
}

Partial update REST in Spring Boot and Kotlin

I have a project with Spring Boot + Kotlin + Morphia.
I need add partial update of my entities. My actual post method:
#PostMapping("update/")
fun updateStudent(#RequestBody #Valid student: Student, results: BindingResult): ResponseData<Student> {
if (results.hasErrors())
return ResponseData(errors = results.errors)
if (!student.canEdit(login.user))
return ResponseData()
student.save()
return ResponseData(data = student)
}
I need read student from database and update only the sended fields
This is my solution:
import org.springframework.beans.BeanWrapperImpl
import java.util.HashSet
fun getNullPropertyNames(source: Any): Array<String> {
val src = BeanWrapperImpl(source)
val pds = src.propertyDescriptors
val emptyNames = HashSet<String>()
for (pd in pds) {
if (src.getPropertyValue(pd.name) == null) emptyNames.add(pd.name)
}
return emptyNames.toTypedArray()
}
And in controller
import org.springframework.beans.BeanUtils
#RestController
class GateController {
#Autowired
private val modelRepository: MyRepository? = null
// allow both 'full' and 'partial' update
#PutMapping("/somemodel/{Id}")
fun updateModel(
#PathVariable Id: Long,
#RequestBody requestBody: SomeModel
): SomeModel {
var objFromDb = modelRepository!!.findById(Id).orElseThrow { ResourceNotFoundException("Object not found with id: " + Id) }
BeanUtils.copyProperties(requestBody, objFromDb, *getNullPropertyNames(requestBody))
return modelRepository.save(objFromDb)
}
...
}
There are 2 things to implement. Reading Student from DB and copying properties from the student from request.
I post java code but it's no problem to convert to kotlin
Morphia morphia = new Morphia();
db = new Mongo();
Datastore ds = morphia.createDatastore(db, appname, user, pass.toCharArray());
morphia.map(Student.class);
Student existing= ds.find(Student.class).field("id").equal(student.id).get();
Then you can use Apache BeanUtils
http://commons.apache.org/proper/commons-beanutils/javadocs/v1.8.3/apidocs/org/apache/commons/beanutils/BeanUtils.html#copyProperties%28java.lang.Object,%20java.lang.Object%29
BeanUtils.copyProperties(existing, student);
then
existing.save();

Resources