Skip to content

Commit

Permalink
feat: support for NGSI-LD Language Filter
Browse files Browse the repository at this point in the history
  • Loading branch information
bobeal committed Apr 7, 2024
1 parent 66535ea commit 7a28cf1
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ data class Query private constructor(
val q: String? = null,
val geoQ: UnparsedGeoQuery? = null,
val temporalQ: UnparsedTemporalQuery? = null,
val scopeQ: String? = null
val scopeQ: String? = null,
val lang: String? = null,
) {
companion object {
operator fun invoke(queryBody: String): Either<APIException, Query> = either {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,6 @@ fun EntitiesQuery.validateMinimalQueryEntitiesParameters(): Either<APIException,
this@validateMinimalQueryEntitiesParameters
}

fun composeEntitiesQueryFromPostRequest(
defaultPagination: ApplicationProperties.Pagination,
requestBody: String,
requestParams: MultiValueMap<String, String>,
contexts: List<String>
): Either<APIException, EntitiesQuery> = either {
val query = Query(requestBody).bind()
composeEntitiesQueryFromPostRequest(defaultPagination, query, requestParams, contexts).bind()
}

fun composeEntitiesQueryFromPostRequest(
defaultPagination: ApplicationProperties.Pagination,
query: Query,
Expand Down Expand Up @@ -152,11 +142,10 @@ fun composeTemporalEntitiesQuery(

fun composeTemporalEntitiesQueryFromPostRequest(
defaultPagination: ApplicationProperties.Pagination,
requestBody: String,
query: Query,
requestParams: MultiValueMap<String, String>,
contexts: List<String>
): Either<APIException, TemporalEntitiesQuery> = either {
val query = Query(requestBody).bind()
val entitiesQuery = composeEntitiesQueryFromPostRequest(
defaultPagination,
query,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.egm.stellio.search.web
import arrow.core.*
import arrow.core.raise.either
import com.egm.stellio.search.authorization.AuthorizationService
import com.egm.stellio.search.model.Query
import com.egm.stellio.search.service.EntityEventService
import com.egm.stellio.search.service.EntityOperationService
import com.egm.stellio.search.service.EntityPayloadService
Expand Down Expand Up @@ -273,10 +274,11 @@ class EntityOperationHandler(
val sub = getSubFromSecurityContext()
val contexts = getContextFromLinkHeaderOrDefault(httpHeaders).bind()
val mediaType = getApplicableMediaType(httpHeaders).bind()
val query = Query(requestBody.awaitFirst()).bind()

val entitiesQuery = composeEntitiesQueryFromPostRequest(
applicationProperties.pagination,
requestBody.awaitFirst(),
query,
params,
contexts
).bind()
Expand All @@ -290,6 +292,8 @@ class EntityOperationHandler(
val compactedEntities = compactEntities(filteredEntities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(params, mediaType)
.copy(languageFilter = query.lang)

buildQueryResponse(
compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
count,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.egm.stellio.search.web

import arrow.core.raise.either
import com.egm.stellio.search.authorization.AuthorizationService
import com.egm.stellio.search.model.Query
import com.egm.stellio.search.service.QueryService
import com.egm.stellio.search.util.composeTemporalEntitiesQueryFromPostRequest
import com.egm.stellio.shared.config.ApplicationProperties
Expand Down Expand Up @@ -36,11 +37,12 @@ class TemporalEntityOperationsHandler(
val sub = getSubFromSecurityContext()
val contexts = getContextFromLinkHeaderOrDefault(httpHeaders).bind()
val mediaType = getApplicableMediaType(httpHeaders).bind()
val query = Query(requestBody.awaitFirst()).bind()

val temporalEntitiesQuery =
composeTemporalEntitiesQueryFromPostRequest(
applicationProperties.pagination,
requestBody.awaitFirst(),
query,
params,
contexts
).bind()
Expand All @@ -55,6 +57,7 @@ class TemporalEntityOperationsHandler(
val compactedEntities = compactEntities(temporalEntities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(params, mediaType)
.copy(languageFilter = query.lang)

buildQueryResponse(
compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.egm.stellio.search.util

import arrow.core.Either
import arrow.core.raise.either
import com.egm.stellio.search.model.AttributeInstance
import com.egm.stellio.search.model.EntitiesQuery
import com.egm.stellio.search.model.Query
import com.egm.stellio.search.model.TemporalQuery
import com.egm.stellio.shared.config.ApplicationProperties
import com.egm.stellio.shared.model.APIException
import com.egm.stellio.shared.model.BadRequestDataException
import com.egm.stellio.shared.model.GeoQuery
import com.egm.stellio.shared.util.*
Expand All @@ -15,6 +20,7 @@ import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.springframework.test.context.ActiveProfiles
import org.springframework.util.LinkedMultiValueMap
import org.springframework.util.MultiValueMap
import java.net.URI
import java.time.ZonedDateTime

Expand Down Expand Up @@ -222,6 +228,16 @@ class EntitiesQueryUtilsTests {
}
}

private fun composeEntitiesQueryFromPostRequest(
defaultPagination: ApplicationProperties.Pagination,
requestBody: String,
requestParams: MultiValueMap<String, String>,
contexts: List<String>
): Either<APIException, EntitiesQuery> = either {
val query = Query(requestBody).bind()
composeEntitiesQueryFromPostRequest(defaultPagination, query, requestParams, contexts).bind()
}

@Test
fun `it should not validate the temporal query if type or attrs are not present`() = runTest {
val queryParams = LinkedMultiValueMap<String, String>()
Expand Down
115 changes: 86 additions & 29 deletions shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,82 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE_TERM
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANG_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_NONE_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_TERM
import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_SYSATTRS_TERMS
import java.util.Locale

typealias CompactedEntity = Map<String, Any>

fun CompactedEntity.toKeyValues(): Map<String, Any> =
this.mapValues { (_, value) -> simplifyRepresentation(value) }

private fun simplifyRepresentation(value: Any): Any =
when (value) {
// an attribute with a single instance
is Map<*, *> -> simplifyValue(value as Map<String, Any>)
// an attribute with multiple instances
is List<*> -> {
when (value.first()) {
is Map<*, *> -> simplifyMultiInstanceAttribute(value as List<Map<String, Any>>)
// we keep @context value as it is (List<String>)
else -> value
}
}
// keep id, type and other non-reified properties as they are (typically string or list)
else -> value
fun CompactedEntity.toSimplifiedAttributes(): Map<String, Any> =
this.mapValues { (_, value) ->
applyAttributeTransformation(value, null, ::simplifyAttribute, ::simplifyMultiInstanceAttribute)
}

private fun simplifyMultiInstanceAttribute(value: List<Map<String, Any>>): Map<String, Map<String, Any>> {
private fun simplifyMultiInstanceAttribute(
value: List<Map<String, Any>>,
transformationParameters: Map<String, String>?
): Map<String, Map<String, Any>> {
val datasetIds = value.map {
val datasetId = (it[NGSILD_DATASET_ID_TERM] as? String) ?: NGSILD_NONE_TERM
val datasetValue: Any = simplifyValue(it)
val datasetValue: Any = simplifyAttribute(it, transformationParameters)
Pair(datasetId, datasetValue)
}
return mapOf(NGSILD_DATASET_TERM to datasetIds.toMap())
}

private fun simplifyValue(value: Map<String, Any>): Any {
@SuppressWarnings("UnusedParameter")
private fun simplifyAttribute(value: Map<String, Any>, transformationParameters: Map<String, String>?): Any {
val attributeCompactedType = AttributeCompactedType.forKey(value[JSONLD_TYPE_TERM] as String)!!
return when (attributeCompactedType) {
PROPERTY, GEOPROPERTY -> {
value.getOrDefault(JSONLD_VALUE_TERM, value)
}
PROPERTY, GEOPROPERTY -> value.getOrDefault(JSONLD_VALUE_TERM, value)
RELATIONSHIP -> value.getOrDefault(JSONLD_OBJECT, value)
JSONPROPERTY -> mapOf(JSONLD_JSON_TERM to value.getOrDefault(JSONLD_JSON_TERM, value))
LANGUAGEPROPERTY -> mapOf(JSONLD_LANGUAGEMAP_TERM to value.getOrDefault(JSONLD_LANGUAGEMAP_TERM, value))
}
}

fun CompactedEntity.toFilteredLanguageProperties(languageFilter: String): CompactedEntity =
this.mapValues { (_, value) ->
applyAttributeTransformation(
value,
mapOf(QUERY_PARAM_LANG to languageFilter),
::filterLanguageProperty,
::filterMultiInstanceLanguageProperty
)
}

private fun filterMultiInstanceLanguageProperty(
value: List<Map<String, Any>>,
transformationParameters: Map<String, String>?
): Any =
value.map {
filterLanguageProperty(it, transformationParameters)
}

private fun filterLanguageProperty(value: Map<String, Any>, transformationParameters: Map<String, String>?): Any {
val attributeCompactedType = AttributeCompactedType.forKey(value[JSONLD_TYPE_TERM] as String)!!
return when (attributeCompactedType) {
LANGUAGEPROPERTY -> {
val localeRanges = Locale.LanguageRange.parse(transformationParameters?.get(QUERY_PARAM_LANG)!!)
val propertyLocales = (value[JSONLD_LANGUAGEMAP_TERM] as Map<String, Any>).keys.sorted()
val bestLocaleMatch = Locale.filterTags(localeRanges, propertyLocales)
.getOrElse(0) { _ -> propertyLocales.first() }
mapOf(
JSONLD_TYPE_TERM to NGSILD_PROPERTY_TERM,
JSONLD_VALUE_TERM to (value[JSONLD_LANGUAGEMAP_TERM] as Map<String, Any>)[bestLocaleMatch],
NGSILD_LANG_TERM to bestLocaleMatch
)
}
else -> value
}
}

fun CompactedEntity.toGeoJson(geometryProperty: String): Map<String, Any?> {
val geometryAttributeContent = this[geometryProperty] as? Map<String, Any>
val geometryPropertyValue = geometryAttributeContent?.let {
Expand Down Expand Up @@ -103,7 +134,12 @@ fun CompactedEntity.toFinalRepresentation(
if (!ngsiLdDataRepresentation.includeSysAttrs) it.withoutSysAttrs(ngsiLdDataRepresentation.timeproperty)
else it
}.let {
if (ngsiLdDataRepresentation.attributeRepresentation == AttributeRepresentation.SIMPLIFIED) it.toKeyValues()
if (ngsiLdDataRepresentation.languageFilter != null)
it.toFilteredLanguageProperties(ngsiLdDataRepresentation.languageFilter)
else it
}.let {
if (ngsiLdDataRepresentation.attributeRepresentation == AttributeRepresentation.SIMPLIFIED)
it.toSimplifiedAttributes()
else it
}.let {
when (ngsiLdDataRepresentation.entityRepresentation) {
Expand Down Expand Up @@ -136,14 +172,35 @@ fun List<CompactedEntity>.toFinalRepresentation(
}

enum class AttributeCompactedType(val key: String) {
PROPERTY("Property"),
RELATIONSHIP("Relationship"),
GEOPROPERTY("GeoProperty"),
JSONPROPERTY("JsonProperty"),
LANGUAGEPROPERTY("LanguageProperty");
PROPERTY(NGSILD_PROPERTY_TERM),
RELATIONSHIP(NGSILD_RELATIONSHIP_TERM),
GEOPROPERTY(NGSILD_GEOPROPERTY_TERM),
JSONPROPERTY(NGSILD_JSONPROPERTY_TERM),
LANGUAGEPROPERTY(NGSILD_LANGUAGEPROPERTY_TERM);

companion object {
fun forKey(key: String): AttributeCompactedType? =
entries.find { it.key == key }
}
}

private fun applyAttributeTransformation(
value: Any,
transformationParameters: Map<String, String>?,
onSingleInstance: (Map<String, Any>, Map<String, String>?) -> Any,
onMultiInstance: (List<Map<String, Any>>, Map<String, String>?) -> Any
): Any =
when (value) {
// an attribute with a single instance
is Map<*, *> -> onSingleInstance(value as Map<String, Any>, transformationParameters)
// an attribute with multiple instances
is List<*> -> {
when (value.first()) {
is Map<*, *> -> onMultiInstance(value as List<Map<String, Any>>, transformationParameters)
// we keep @context value as it is (List<String>)
else -> value
}
}
// keep id, type and other non-reified properties as they are (typically string or list)
else -> value
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class NgsiLdDataRepresentation(
val entityRepresentation: EntityRepresentation,
val attributeRepresentation: AttributeRepresentation,
val includeSysAttrs: Boolean,
val languageFilter: String? = null,
// In the case of GeoJSON Entity representation,
// this parameter indicates which GeoProperty to use for the toplevel geometry field
val geometryProperty: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const val QUERY_PARAM_ATTRS: String = "attrs"
const val QUERY_PARAM_Q: String = "q"
const val QUERY_PARAM_SCOPEQ: String = "scopeQ"
const val QUERY_PARAM_GEOMETRY_PROPERTY: String = "geometryProperty"
const val QUERY_PARAM_LANG: String = "lang"
const val QUERY_PARAM_OPTIONS: String = "options"
const val QUERY_PARAM_OPTIONS_SYSATTRS_VALUE: String = "sysAttrs"
const val QUERY_PARAM_OPTIONS_KEYVALUES_VALUE: String = "keyValues"
Expand Down Expand Up @@ -218,6 +219,7 @@ fun parseRepresentations(
val includeSysAttrs = optionsParam.contains(QUERY_PARAM_OPTIONS_SYSATTRS_VALUE)
val attributeRepresentation = optionsParam.contains(QUERY_PARAM_OPTIONS_KEYVALUES_VALUE)
.let { if (it) AttributeRepresentation.SIMPLIFIED else AttributeRepresentation.NORMALIZED }
val languageFilter = requestParams.getFirst(QUERY_PARAM_LANG)
val entityRepresentation = EntityRepresentation.forMediaType(acceptMediaType)
val geometryProperty =
if (entityRepresentation == EntityRepresentation.GEO_JSON)
Expand All @@ -229,6 +231,7 @@ fun parseRepresentations(
entityRepresentation,
attributeRepresentation,
includeSysAttrs,
languageFilter,
geometryProperty,
timeproperty
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ object JsonLdUtils {
const val JSONLD_CONTEXT = "@context"
const val NGSILD_SCOPE_TERM = "scope"
const val NGSILD_SCOPE_PROPERTY = "https://uri.etsi.org/ngsi-ld/$NGSILD_SCOPE_TERM"
const val NGSILD_LANG_TERM = "lang"
const val NGSILD_NONE_TERM = "@none"
const val NGSILD_DATASET_TERM = "dataset"
val JSONLD_EXPANDED_ENTITY_SPECIFIC_MEMBERS = setOf(JSONLD_TYPE, NGSILD_SCOPE_PROPERTY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class CompactedEntityTests {
val normalizedMap = normalizedEntity.deserializeAsMap()
val simplifiedMap = simplifiedEntity.deserializeAsMap()

val resultMap = normalizedMap.toKeyValues()
val resultMap = normalizedMap.toSimplifiedAttributes()

assertEquals(simplifiedMap, resultMap)
}
Expand All @@ -137,7 +137,7 @@ class CompactedEntityTests {
val normalizedMap = normalizedMultiAttributeEntity.deserializeAsMap()
val simplifiedMap = simplifiedMultiAttributeEntity.deserializeAsMap()

val resultMap = normalizedMap.toKeyValues()
val resultMap = normalizedMap.toSimplifiedAttributes()

assertEquals(simplifiedMap, resultMap)
}
Expand All @@ -160,7 +160,7 @@ class CompactedEntityTests {
""".trimIndent()
.deserializeAsMap()

val simplifiedRepresentation = compactedEntity.toKeyValues()
val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes()

val expectedSimplifiedRepresentation = """
{
Expand Down Expand Up @@ -195,7 +195,7 @@ class CompactedEntityTests {
""".trimIndent()
.deserializeAsMap()

val simplifiedRepresentation = compactedEntity.toKeyValues()
val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes()

val expectedSimplifiedRepresentation = """
{
Expand Down Expand Up @@ -744,7 +744,7 @@ class CompactedEntityTests {
EntityRepresentation.GEO_JSON,
AttributeRepresentation.SIMPLIFIED,
includeSysAttrs = false,
JsonLdUtils.NGSILD_LOCATION_TERM
geometryProperty = JsonLdUtils.NGSILD_LOCATION_TERM
)
)

Expand All @@ -768,7 +768,7 @@ class CompactedEntityTests {
""".trimIndent()
.deserializeAsMap()

val simplifiedRepresentation = compactedEntity.toKeyValues()
val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes()

val expectedSimplifiedRepresentation = """
{
Expand Down
Loading

0 comments on commit 7a28cf1

Please sign in to comment.