diff --git a/search-service/config/detekt/baseline.xml b/search-service/config/detekt/baseline.xml index 124c05513..751fa1b03 100644 --- a/search-service/config/detekt/baseline.xml +++ b/search-service/config/detekt/baseline.xml @@ -10,6 +10,8 @@ LongMethod:AttributeInstanceService.kt$AttributeInstanceService$@Transactional suspend fun create(attributeInstance: AttributeInstance): Either<APIException, Unit> LongMethod:EntityAccessControlHandler.kt$EntityAccessControlHandler$@PostMapping("/{subjectId}/attrs", consumes = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) suspend fun addRightsOnEntities( @RequestHeader httpHeaders: HttpHeaders, @PathVariable subjectId: String, @RequestBody requestBody: Mono<String> ): ResponseEntity<*> LongMethod:EntityOperationHandlerTests.kt$EntityOperationHandlerTests$@Test fun `create batch entity should return a 207 when some entities already exist`() + LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun mergePatchProvider(): Stream<Arguments> + LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun partialUpdatePatchProvider(): Stream<Arguments> LongMethod:QueryServiceTests.kt$QueryServiceTests$@Test fun `it should query temporal entities as requested by query params`() LongMethod:QueryServiceTests.kt$QueryServiceTests$@Test fun `it should return an empty list for an attribute if it has no temporal values`() LongMethod:TemporalEntityBuilderTests.kt$TemporalEntityBuilderTests$@Test fun `it should return a temporal entity with values aggregated`() diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/model/EntityTypeInfo.kt b/search-service/src/main/kotlin/com/egm/stellio/search/model/EntityTypeInfo.kt index 7655065e2..541990854 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/model/EntityTypeInfo.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/model/EntityTypeInfo.kt @@ -22,7 +22,8 @@ enum class AttributeType(val key: String) { Property("Property"), Relationship("Relationship"), GeoProperty("GeoProperty"), - JsonProperty("JsonProperty"); + JsonProperty("JsonProperty"), + LanguageProperty("LanguageProperty"); companion object { fun forKey(key: String): AttributeType = diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/model/Query.kt b/search-service/src/main/kotlin/com/egm/stellio/search/model/Query.kt index 0d6c1abb0..f9f524e76 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/model/Query.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/model/Query.kt @@ -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 = either { diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/model/TemporalEntityAttribute.kt b/search-service/src/main/kotlin/com/egm/stellio/search/model/TemporalEntityAttribute.kt index badf90d3a..b47ae52ce 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/model/TemporalEntityAttribute.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/model/TemporalEntityAttribute.kt @@ -5,6 +5,8 @@ import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_VALUES import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUES +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_TYPE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUES import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_VALUES import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_OBJECTS @@ -45,7 +47,8 @@ data class TemporalEntityAttribute( Property, Relationship, GeoProperty, - JsonProperty; + JsonProperty, + LanguageProperty; fun toExpandedName(): String = when (this) { @@ -53,6 +56,7 @@ data class TemporalEntityAttribute( Relationship -> NGSILD_RELATIONSHIP_TYPE.uri GeoProperty -> NGSILD_GEOPROPERTY_TYPE.uri JsonProperty -> NGSILD_JSONPROPERTY_TYPE.uri + LanguageProperty -> NGSILD_LANGUAGEPROPERTY_TYPE.uri } /** @@ -64,6 +68,7 @@ data class TemporalEntityAttribute( Relationship -> NGSILD_RELATIONSHIP_OBJECTS GeoProperty -> NGSILD_GEOPROPERTY_VALUES JsonProperty -> NGSILD_JSONPROPERTY_VALUES + LanguageProperty -> NGSILD_LANGUAGEPROPERTY_VALUES } } } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeService.kt index 33b490858..afc1d03c2 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeService.kt @@ -14,15 +14,14 @@ import com.egm.stellio.shared.util.* import com.egm.stellio.shared.util.AttributeType import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_OBSERVED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PREFIX import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_OBJECT import com.egm.stellio.shared.util.JsonLdUtils.buildNonReifiedTemporalValue import com.egm.stellio.shared.util.JsonLdUtils.expandJsonLdEntity import com.egm.stellio.shared.util.JsonUtils.serializeObject -import com.savvasdalkitsis.jsonmerger.JsonMerger import io.r2dbc.postgresql.codec.Json -import org.json.JSONObject import org.slf4j.LoggerFactory import org.springframework.r2dbc.core.DatabaseClient import org.springframework.r2dbc.core.bind @@ -270,9 +269,10 @@ class TemporalEntityAttributeService( attributeMetadata, observedAt ) - val (jsonTargetObject, updatedAttributeInstance) = mergeAttributePayload(tea, processedAttributePayload) + val (jsonTargetObject, updatedAttributeInstance) = + mergePatch(tea.payload.toExpandedAttributeInstance(), processedAttributePayload) val value = getValueFromPartialAttributePayload(tea, updatedAttributeInstance) - updateOnUpdate(tea.id, processedAttributeMetadata.valueType, mergedAt, jsonTargetObject.toString()).bind() + updateOnUpdate(tea.id, processedAttributeMetadata.valueType, mergedAt, jsonTargetObject).bind() val attributeInstance = createContextualAttributeInstance(tea, updatedAttributeInstance, value, mergedAt, sub) @@ -657,10 +657,11 @@ class TemporalEntityAttributeService( BadRequestDataException("The type of the attribute has to be the same as the existing one") } } - val (jsonTargetObject, updatedAttributeInstance) = mergeAttributePayload(tea, attributeValues) + val (jsonTargetObject, updatedAttributeInstance) = + partialUpdatePatch(tea.payload.toExpandedAttributeInstance(), attributeValues) val value = getValueFromPartialAttributePayload(tea, updatedAttributeInstance) val attributeValueType = guessAttributeValueType(tea.attributeType, attributeValues) - updateOnUpdate(tea.id, attributeValueType, modifiedAt, jsonTargetObject.toString()).bind() + updateOnUpdate(tea.id, attributeValueType, modifiedAt, jsonTargetObject).bind() // then update attribute instance val attributeInstance = createContextualAttributeInstance( @@ -863,33 +864,14 @@ class TemporalEntityAttributeService( null, null ) + TemporalEntityAttribute.AttributeType.LanguageProperty -> + Triple( + serializeObject(attributePayload.getMemberValue(NGSILD_LANGUAGEPROPERTY_VALUE)!!), + null, + null + ) } - suspend fun mergeAttributePayload( - tea: TemporalEntityAttribute, - expandedAttributeInstance: ExpandedAttributeInstance - ): Pair { - val jsonSourceObject = JSONObject(tea.payload.asString()) - val jsonUpdateObject = JSONObject(expandedAttributeInstance) - // if the attribute is a JsonProperty, preserve its JSON value to avoid it being merged - // (the whole JSON value shall be replaced) - val preservedJsonValue = if (tea.attributeType == TemporalEntityAttribute.AttributeType.JsonProperty) - expandedAttributeInstance[NGSILD_JSONPROPERTY_VALUE] - else null - val jsonMerger = JsonMerger( - arrayMergeMode = JsonMerger.ArrayMergeMode.REPLACE_ARRAY, - objectMergeMode = JsonMerger.ObjectMergeMode.MERGE_OBJECT - ) - val jsonTargetObject = jsonMerger.merge(jsonSourceObject, jsonUpdateObject) - .let { - if (preservedJsonValue != null) - it.put(NGSILD_JSONPROPERTY_VALUE, preservedJsonValue) - else it - } - val updatedAttributeInstance = jsonTargetObject.toMap() as ExpandedAttributeInstance - return Pair(jsonTargetObject, updatedAttributeInstance) - } - private fun createContextualAttributeInstance( tea: TemporalEntityAttribute, expandedAttributeInstance: ExpandedAttributeInstance, @@ -926,16 +908,15 @@ class TemporalEntityAttributeService( attributePayload: ExpandedAttributeInstance, attributeMetadata: AttributeMetadata, observedAt: ZonedDateTime? - ): Pair { - return if ( + ): Pair = + if ( observedAt != null && tea.payload.deserializeAsMap().containsKey(NGSILD_OBSERVED_AT_PROPERTY) && !attributePayload.containsKey(NGSILD_OBSERVED_AT_PROPERTY) - ) { + ) Pair( attributePayload.plus(NGSILD_OBSERVED_AT_PROPERTY to buildNonReifiedTemporalValue(observedAt)), attributeMetadata.copy(observedAt = observedAt) ) - } else Pair(attributePayload, attributeMetadata) - } + else Pair(attributePayload, attributeMetadata) } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/util/AttributeInstanceUtils.kt b/search-service/src/main/kotlin/com/egm/stellio/search/util/AttributeInstanceUtils.kt index 20a3cc80e..adaf2d293 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/util/AttributeInstanceUtils.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/util/AttributeInstanceUtils.kt @@ -8,8 +8,15 @@ import com.egm.stellio.search.model.AttributeMetadata import com.egm.stellio.search.model.TemporalEntityAttribute import com.egm.stellio.search.model.TemporalEntityAttribute.AttributeValueType import com.egm.stellio.shared.model.* +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_LANGUAGE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.logger +import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap import com.egm.stellio.shared.util.JsonUtils.serializeObject +import com.savvasdalkitsis.jsonmerger.JsonMerger +import io.r2dbc.postgresql.codec.Json import java.time.LocalDate import java.time.LocalTime import java.time.ZonedDateTime @@ -65,6 +72,12 @@ fun NgsiLdAttributeInstance.toTemporalAttributeMetadata(): Either + Triple( + TemporalEntityAttribute.AttributeType.LanguageProperty, + AttributeValueType.ARRAY, + Triple(serializeObject(this.languageMap), null, null) + ) } if (attributeValue == Triple(null, null, null)) { logger.warn("Unable to get a value from attribute: $this") @@ -92,6 +105,7 @@ fun guessAttributeValueType( TemporalEntityAttribute.AttributeType.Relationship -> AttributeValueType.URI TemporalEntityAttribute.AttributeType.GeoProperty -> AttributeValueType.GEOMETRY TemporalEntityAttribute.AttributeType.JsonProperty -> AttributeValueType.JSON + TemporalEntityAttribute.AttributeType.LanguageProperty -> AttributeValueType.ARRAY } fun guessPropertyValueType( @@ -114,3 +128,55 @@ fun guessPropertyValueType( is LocalTime -> Pair(AttributeValueType.TIME, Triple(value.toString(), null, null)) else -> Pair(AttributeValueType.STRING, Triple(value.toString(), null, null)) } + +fun Json.toExpandedAttributeInstance(): ExpandedAttributeInstance = + this.deserializeAsMap() as ExpandedAttributeInstance + +fun partialUpdatePatch( + source: ExpandedAttributeInstance, + update: ExpandedAttributeInstance +): Pair { + val target = source.plus(update) + return Pair(serializeObject(target), target) +} + +fun mergePatch( + source: ExpandedAttributeInstance, + update: ExpandedAttributeInstance +): Pair { + val target = source.toMutableMap() + update.forEach { (attrName, attrValue) -> + if (!source.containsKey(attrName)) { + target[attrName] = attrValue + } else if (listOf(NGSILD_JSONPROPERTY_VALUE, NGSILD_PROPERTY_VALUE).contains(attrName)) { + if (attrValue.size > 1) { + // a Property holding an array of value or a JsonPropery holding an array of JSON objects + // cannot be safely merged patch, so copy the whole value from the update + target[attrName] = attrValue + } else { + target[attrName] = listOf( + JsonMerger().merge( + serializeObject(source[attrName]!![0]), + serializeObject(attrValue[0]) + ).deserializeAsMap() + ) + } + } else if (listOf(NGSILD_LANGUAGEPROPERTY_VALUE).contains(attrName)) { + val sourceLangEntries = source[attrName] as List> + val targetLangEntries = sourceLangEntries.toMutableList() + (attrValue as List>).forEach { langEntry -> + // remove any previously existing entry for this language + targetLangEntries.removeIf { + it[JSONLD_LANGUAGE] == langEntry[JSONLD_LANGUAGE] + } + targetLangEntries.add(langEntry) + } + + target[attrName] = targetLangEntries + } else { + target[attrName] = attrValue + } + } + + return Pair(serializeObject(target), target) +} diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/util/EntitiesQueryUtils.kt b/search-service/src/main/kotlin/com/egm/stellio/search/util/EntitiesQueryUtils.kt index f4d86e7a7..99891b616 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/util/EntitiesQueryUtils.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/util/EntitiesQueryUtils.kt @@ -63,16 +63,6 @@ fun EntitiesQuery.validateMinimalQueryEntitiesParameters(): Either, - contexts: List -): Either = either { - val query = Query(requestBody).bind() - composeEntitiesQueryFromPostRequest(defaultPagination, query, requestParams, contexts).bind() -} - fun composeEntitiesQueryFromPostRequest( defaultPagination: ApplicationProperties.Pagination, query: Query, @@ -152,11 +142,10 @@ fun composeTemporalEntitiesQuery( fun composeTemporalEntitiesQueryFromPostRequest( defaultPagination: ApplicationProperties.Pagination, - requestBody: String, + query: Query, requestParams: MultiValueMap, contexts: List ): Either = either { - val query = Query(requestBody).bind() val entitiesQuery = composeEntitiesQueryFromPostRequest( defaultPagination, query, diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/util/TemporalEntityBuilder.kt b/search-service/src/main/kotlin/com/egm/stellio/search/util/TemporalEntityBuilder.kt index c64e3980f..0017e7515 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/util/TemporalEntityBuilder.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/util/TemporalEntityBuilder.kt @@ -12,6 +12,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PREFIX import com.egm.stellio.shared.util.JsonLdUtils.buildExpandedPropertyValue import com.egm.stellio.shared.util.JsonLdUtils.buildExpandedTemporalValue @@ -113,8 +114,8 @@ object TemporalEntityBuilder { */ private fun buildAttributesSimplifiedRepresentation( attributeAndResultsMap: TemporalEntityAttributeInstancesResult - ): Map { - return attributeAndResultsMap.mapValues { + ): Map = + attributeAndResultsMap.mapValues { val attributeInstance = mutableMapOf( JSONLD_TYPE to listOf(it.key.attributeType.toExpandedName()) ) @@ -142,6 +143,14 @@ object TemporalEntityBuilder { ), mapOf(JSONLD_VALUE to attributeInstanceResult.time) ) + } else if (it.key.attributeType == TemporalEntityAttribute.AttributeType.LanguageProperty) { + listOf( + mapOf( + NGSILD_LANGUAGEPROPERTY_VALUE to + deserializeListOfObjects(attributeInstanceResult.value as String) + ), + mapOf(JSONLD_VALUE to attributeInstanceResult.time) + ) } else { listOf( mapOf(JSONLD_VALUE to attributeInstanceResult.value), @@ -151,7 +160,6 @@ object TemporalEntityBuilder { } attributeInstance.toMap() } - } /** * Creates the aggregated representation for each temporal entity attribute in the input map. diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityOperationHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityOperationHandler.kt index 1a92bb2c9..87e687c8b 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityOperationHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityOperationHandler.kt @@ -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 @@ -272,10 +273,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() @@ -289,6 +291,8 @@ class EntityOperationHandler( val compactedEntities = compactEntities(filteredEntities, contexts) val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) + .copy(languageFilter = query.lang) + buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), count, diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/web/TemporalEntityOperationsHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/web/TemporalEntityOperationsHandler.kt index a3675e2e1..02bf29ba6 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/web/TemporalEntityOperationsHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/web/TemporalEntityOperationsHandler.kt @@ -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 @@ -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() @@ -55,6 +57,7 @@ class TemporalEntityOperationsHandler( val compactedEntities = compactEntities(temporalEntities, contexts) val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) + .copy(languageFilter = query.lang) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), diff --git a/search-service/src/main/kotlin/db/migration/V0_29__JsonLd_migration.kt b/search-service/src/main/kotlin/db/migration/V0_29__JsonLd_migration.kt index abca87bd7..f5fda8d2d 100644 --- a/search-service/src/main/kotlin/db/migration/V0_29__JsonLd_migration.kt +++ b/search-service/src/main/kotlin/db/migration/V0_29__JsonLd_migration.kt @@ -270,6 +270,7 @@ class V0_29__JsonLd_migration : BaseJavaMigration() { is NgsiLdRelationshipInstance -> TemporalEntityAttribute.AttributeValueType.URI is NgsiLdGeoPropertyInstance -> TemporalEntityAttribute.AttributeValueType.GEOMETRY is NgsiLdJsonPropertyInstance -> TemporalEntityAttribute.AttributeValueType.OBJECT + is NgsiLdLanguagePropertyInstance -> TemporalEntityAttribute.AttributeValueType.ARRAY } jdbcTemplate.execute( diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/scope/ScopeServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/scope/ScopeServiceTests.kt index 119e6f445..36372813d 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/scope/ScopeServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/scope/ScopeServiceTests.kt @@ -5,8 +5,7 @@ import com.egm.stellio.search.model.AttributeInstance.TemporalProperty import com.egm.stellio.search.service.EntityPayloadService import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer -import com.egm.stellio.search.util.deserializeAsMap -import com.egm.stellio.shared.model.ExpandedAttributeInstance +import com.egm.stellio.search.util.toExpandedAttributeInstance import com.egm.stellio.shared.model.PaginationQuery import com.egm.stellio.shared.model.getScopes import com.egm.stellio.shared.util.* @@ -111,7 +110,7 @@ class ScopeServiceTests : WithTimescaleContainer, WithKafkaContainer { entityPayloadService.retrieve(beehiveTestCId) .shouldSucceedWith { assertEquals(expectedScopes, it.scopes) - val scopesInEntity = (it.payload.deserializeAsMap() as ExpandedAttributeInstance).getScopes() + val scopesInEntity = it.payload.toExpandedAttributeInstance().getScopes() assertEquals(expectedScopes, scopesInEntity) } } @@ -296,7 +295,7 @@ class ScopeServiceTests : WithTimescaleContainer, WithKafkaContainer { scopeService.retrieve(beehiveTestCId) .shouldSucceedWith { assertNull(it.first) - assertNull((it.second.deserializeAsMap() as ExpandedAttributeInstance).getScopes()) + assertNull(it.second.toExpandedAttributeInstance().getScopes()) } val scopeHistoryEntries = scopeService.retrieveHistory( listOf(beehiveTestCId), diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/service/AttributeInstanceServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/service/AttributeInstanceServiceTests.kt index fb09f23c5..69f4cb661 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/service/AttributeInstanceServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/service/AttributeInstanceServiceTests.kt @@ -7,11 +7,13 @@ import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.model.addNonReifiedTemporalProperty import com.egm.stellio.shared.model.getSingleEntry import com.egm.stellio.shared.util.* +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_LANGUAGE import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATE_TIME_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_INSTANCE_ID_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_MODIFIED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_OBSERVED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_VALUE @@ -57,6 +59,7 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer private lateinit var incomingTemporalEntityAttribute: TemporalEntityAttribute private lateinit var outgoingTemporalEntityAttribute: TemporalEntityAttribute private lateinit var jsonTemporalEntityAttribute: TemporalEntityAttribute + private lateinit var languageTemporalEntityAttribute: TemporalEntityAttribute val entityId = "urn:ngsi-ld:BeeHive:TESTC".toUri() @@ -97,6 +100,18 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer runBlocking { temporalEntityAttributeService.create(jsonTemporalEntityAttribute) } + + languageTemporalEntityAttribute = TemporalEntityAttribute( + entityId = entityId, + attributeName = FRIENDLYNAME_LANGUAGEPROPERTY, + attributeValueType = TemporalEntityAttribute.AttributeValueType.ARRAY, + createdAt = now, + payload = SAMPLE_LANGUAGE_PROPERTY_PAYLOAD + ) + + runBlocking { + temporalEntityAttributeService.create(languageTemporalEntityAttribute) + } } @AfterEach @@ -630,6 +645,50 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer } } + @Test + fun `it should modify attribute instance for a LanguageProperty property`() = runTest { + val attributeInstance = gimmeLanguagePropertyAttributeInstance(languageTemporalEntityAttribute.id) + attributeInstanceService.create(attributeInstance) + + val instanceTemporalFragment = + loadSampleData("fragments/temporal_instance_language_fragment.jsonld") + val attributeInstancePayload = + mapOf(FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY to instanceTemporalFragment.deserializeAsMap()) + val jsonLdAttribute = JsonLdUtils.expandJsonLdFragment( + attributeInstancePayload, + APIC_COMPOUND_CONTEXTS + ) as ExpandedAttributes + + val temporalEntitiesQuery = gimmeTemporalEntitiesQuery( + TemporalQuery( + timerel = TemporalQuery.Timerel.AFTER, + timeAt = ZonedDateTime.parse("1970-01-01T00:00:00Z") + ) + ) + + attributeInstanceService.modifyAttributeInstance( + entityId, + FRIENDLYNAME_LANGUAGEPROPERTY, + attributeInstance.instanceId, + jsonLdAttribute.entries.first().value + ).shouldSucceed() + + attributeInstanceService.search(temporalEntitiesQuery, languageTemporalEntityAttribute) + .shouldSucceedWith { + (it as List).single { result -> + val deserializedPayload = result.payload.deserializeAsMap() + result.time == ZonedDateTime.parse("2023-03-13T12:33:06Z") && + deserializedPayload.containsKey(NGSILD_MODIFIED_AT_PROPERTY) && + deserializedPayload.containsKey(NGSILD_INSTANCE_ID_PROPERTY) && + deserializedPayload.containsKey(NGSILD_LANGUAGEPROPERTY_VALUE) && + (deserializedPayload[NGSILD_LANGUAGEPROPERTY_VALUE] as List>) + .all { langMap -> + langMap[JSONLD_LANGUAGE] == "fr" || langMap[JSONLD_LANGUAGE] == "it" + } + } + } + } + @Test fun `it should delete attribute instance`() = runTest { val attributeInstance = gimmeNumericPropertyAttributeInstance(incomingTemporalEntityAttribute.id) diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/service/EntityTypeServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/service/EntityTypeServiceTests.kt index 77fcdf6d0..7628e3002 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/service/EntityTypeServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/service/EntityTypeServiceTests.kt @@ -26,8 +26,6 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.data.r2dbc.core.R2dbcEntityTemplate import org.springframework.r2dbc.core.DatabaseClient import org.springframework.test.context.ActiveProfiles -import java.time.Instant -import java.time.ZoneOffset @SpringBootTest @ActiveProfiles("test") @@ -42,7 +40,7 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { @Autowired private lateinit var r2dbcEntityTemplate: R2dbcEntityTemplate - private val now = Instant.now().atZone(ZoneOffset.UTC) + private val now = ngsiLdDateTime() private val entityPayload1 = gimmeEntityPayload("urn:ngsi-ld:BeeHive:TESTA", listOf(BEEHIVE_TYPE, SENSOR_TYPE)) private val entityPayload2 = gimmeEntityPayload("urn:ngsi-ld:Sensor:TESTB", listOf(SENSOR_TYPE)) @@ -75,6 +73,12 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { "urn:ngsi-ld:Sensor:TESTB", LUMINOSITY_JSONPROPERTY, TemporalEntityAttribute.AttributeType.JsonProperty, + TemporalEntityAttribute.AttributeValueType.JSON + ) + private val friendlyNameLanguageProperty = newTemporalEntityAttribute( + "urn:ngsi-ld:BeeHive:TESTA", + FRIENDLYNAME_LANGUAGEPROPERTY, + TemporalEntityAttribute.AttributeType.LanguageProperty, TemporalEntityAttribute.AttributeValueType.OBJECT ) @@ -98,6 +102,7 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { createTemporalEntityAttribute(locationGeoProperty) createTemporalEntityAttribute(outgoingProperty) createTemporalEntityAttribute(luminosityJsonProperty) + createTemporalEntityAttribute(friendlyNameLanguageProperty) } @Test @@ -119,8 +124,8 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { fun `it should return all known entity types with details`() = runTest { val entityTypes = entityTypeService.getEntityTypes(APIC_COMPOUND_CONTEXTS) - assertEquals(3, entityTypes.size) assertThat(entityTypes) + .hasSize(3) .containsAll( listOf( EntityType( @@ -131,12 +136,17 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { EntityType( id = toUri(BEEHIVE_TYPE), typeName = BEEHIVE_COMPACT_TYPE, - attributeNames = listOf(INCOMING_COMPACT_PROPERTY, MANAGED_BY_COMPACT_RELATIONSHIP) + attributeNames = listOf( + FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY, + INCOMING_COMPACT_PROPERTY, + MANAGED_BY_COMPACT_RELATIONSHIP + ) ), EntityType( id = toUri(SENSOR_TYPE), typeName = SENSOR_COMPACT_TYPE, attributeNames = listOf( + FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY, INCOMING_COMPACT_PROPERTY, LUMINOSITY_COMPACT_JSONPROPERTY, MANAGED_BY_COMPACT_RELATIONSHIP, @@ -162,10 +172,15 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { entityTypeInfo.shouldSucceedWith { assertEquals( EntityTypeInfo( - id = toUri(SENSOR_TYPE), + id = SENSOR_TYPE.toUri(), typeName = SENSOR_COMPACT_TYPE, entityCount = 2, attributeDetails = listOf( + AttributeInfo( + id = toUri(FRIENDLYNAME_LANGUAGEPROPERTY), + attributeName = FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY, + attributeTypes = listOf(AttributeType.LanguageProperty) + ), AttributeInfo( id = toUri(INCOMING_PROPERTY), attributeName = INCOMING_COMPACT_PROPERTY, @@ -229,7 +244,7 @@ class EntityTypeServiceTests : WithTimescaleContainer, WithKafkaContainer { attributeValueType: TemporalEntityAttribute.AttributeValueType ): TemporalEntityAttribute = TemporalEntityAttribute( - entityId = toUri(id), + entityId = id.toUri(), attributeName = attributeName, attributeType = attributeType, attributeValueType = attributeValueType, diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeServiceTests.kt index bda5d024e..3cab9f8ca 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/service/TemporalEntityAttributeServiceTests.kt @@ -5,7 +5,6 @@ import com.egm.stellio.search.model.* import com.egm.stellio.search.support.EMPTY_JSON_PAYLOAD import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer -import com.egm.stellio.search.util.toJson import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.model.toNgsiLdAttribute import com.egm.stellio.shared.model.toNgsiLdAttributes @@ -281,65 +280,6 @@ class TemporalEntityAttributeServiceTests : WithTimescaleContainer, WithKafkaCon } } - @Test - fun `it should merge an attribute payload for a JsonProperty`() = runTest { - val initialJsonValue = """ - { - "incoming": { - "type": "JsonProperty", - "json": { "id": 1, "b": null, "c": 12.4 }, - "observedAt": "2022-12-24T14:01:22.066Z", - "subAttribute": { - "type": "Property", - "value": "subAttribute" - } - } - } - """.trimIndent() - val temporalEntityAttribute = TemporalEntityAttribute( - entityId = beehiveTestCId, - attributeName = INCOMING_PROPERTY, - attributeType = TemporalEntityAttribute.AttributeType.JsonProperty, - attributeValueType = TemporalEntityAttribute.AttributeValueType.JSON, - createdAt = ngsiLdDateTime(), - payload = expandAttribute(initialJsonValue, APIC_COMPOUND_CONTEXTS).second[0].toJson() - ) - - val newJsonValue = """ - { - "incoming": { - "type": "JsonProperty", - "json": { "id": 2, "b": "something" }, - "observedAt": "2023-12-24T14:01:22.066Z" - } - } - """.trimIndent() - val newJsonExpandedAttribute = expandAttribute(newJsonValue, APIC_COMPOUND_CONTEXTS).second[0] - val (_, expandedAttributeInstance) = temporalEntityAttributeService.mergeAttributePayload( - temporalEntityAttribute, - newJsonExpandedAttribute - ) - - val expectedJsonValue = """ - { - "incoming": { - "type": "JsonProperty", - "json": { "id": 2, "b": "something" }, - "observedAt": "2023-12-24T14:01:22.066Z", - "subAttribute": { - "type": "Property", - "value": "subAttribute" - } - } - } - """.trimIndent() - val expectedJsonExpandedAttribute = expandAttribute(expectedJsonValue, APIC_COMPOUND_CONTEXTS).second[0] - assertJsonPayloadsAreEqual( - serializeObject(expectedJsonExpandedAttribute), - serializeObject(expandedAttributeInstance) - ) - } - @Test fun `it should merge an entity attribute`() = runTest { val rawEntity = loadSampleData() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/support/BusinessObjectsFactory.kt b/search-service/src/test/kotlin/com/egm/stellio/search/support/BusinessObjectsFactory.kt index 80097011a..9c1496407 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/support/BusinessObjectsFactory.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/support/BusinessObjectsFactory.kt @@ -82,6 +82,32 @@ fun gimmeJsonPropertyAttributeInstance( ) } +fun gimmeLanguagePropertyAttributeInstance( + teaUuid: UUID, + timeProperty: AttributeInstance.TemporalProperty = AttributeInstance.TemporalProperty.OBSERVED_AT +): AttributeInstance { + val attributeMetadata = AttributeMetadata( + measuredValue = null, + value = SAMPLE_LANGUAGE_PROPERTY_PAYLOAD.asString(), + geoValue = null, + valueType = TemporalEntityAttribute.AttributeValueType.OBJECT, + datasetId = null, + type = TemporalEntityAttribute.AttributeType.LanguageProperty, + observedAt = ngsiLdDateTime() + ) + val payload = JsonLdUtils.buildExpandedPropertyValue(attributeMetadata.value!!) + .addNonReifiedTemporalProperty(JsonLdUtils.NGSILD_OBSERVED_AT_PROPERTY, attributeMetadata.observedAt!!) + .getSingleEntry() + + return AttributeInstance( + temporalEntityAttribute = teaUuid, + time = attributeMetadata.observedAt!!, + attributeMetadata = attributeMetadata, + timeProperty = timeProperty, + payload = payload + ) +} + fun gimmeTemporalEntitiesQuery( temporalQuery: TemporalQuery, withTemporalValues: Boolean = false, diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/support/TestUtils.kt b/search-service/src/test/kotlin/com/egm/stellio/search/support/TestUtils.kt index 7c31f6346..7d7ef1043 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/support/TestUtils.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/support/TestUtils.kt @@ -55,3 +55,22 @@ val SAMPLE_JSON_PROPERTY_PAYLOAD = Json.of( { "id": "123", "stringValue": "value", "nullValue": null } """.trimIndent() ) +val SAMPLE_LANGUAGE_PROPERTY_PAYLOAD = Json.of( + """ + { + "https://uri.etsi.org/ngsi-ld/hasLanguageMap": [ + { + "@value": "My beautiful beehive", + "@language": "en" + }, + { + "@value": "Ma belle ruche", + "@language": "fr" + } + ], + "@type": [ + "https://uri.etsi.org/ngsi-ld/LanguageProperty" + ] + } + """.trimIndent() +) diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/util/EntitiesQueryUtilsTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/util/EntitiesQueryUtilsTests.kt index 49698071e..5b3836cac 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/util/EntitiesQueryUtilsTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/util/EntitiesQueryUtilsTests.kt @@ -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.* @@ -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 @@ -222,6 +228,16 @@ class EntitiesQueryUtilsTests { } } + private fun composeEntitiesQueryFromPostRequest( + defaultPagination: ApplicationProperties.Pagination, + requestBody: String, + requestParams: MultiValueMap, + contexts: List + ): Either = 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() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/util/PatchAttributeTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/util/PatchAttributeTests.kt new file mode 100644 index 000000000..96c00ebd3 --- /dev/null +++ b/search-service/src/test/kotlin/com/egm/stellio/search/util/PatchAttributeTests.kt @@ -0,0 +1,304 @@ +package com.egm.stellio.search.util + +import com.egm.stellio.shared.util.JsonLdUtils.expandAttribute +import com.egm.stellio.shared.util.JsonUtils.serializeObject +import com.egm.stellio.shared.util.NGSILD_TEST_CORE_CONTEXTS +import com.egm.stellio.shared.util.assertJsonPayloadsAreEqual +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class PatchAttributeTests { + + companion object { + + @JvmStatic + fun partialUpdatePatchProvider(): Stream { + return Stream.of( + Arguments.of( + """ + { + "attribute": { + "type": "Property", + "value": 12.0, + "observedAt": "2024-04-14T12:34:56Z" + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": 12.2, + "unitCode": "GRM" + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": 12.2, + "unitCode": "GRM", + "observedAt": "2024-04-14T12:34:56Z" + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "id": 1, "b": null, "c": 12.4 }, + "observedAt": "2022-12-24T14:01:22.066Z", + "subAttribute": { + "type": "Property", + "value": "subAttribute" + } + } + } + """.trimIndent(), + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "id": 2, "b": "something" }, + "observedAt": "2023-12-24T14:01:22.066Z" + } + } + """.trimIndent(), + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "id": 2, "b": "something" }, + "observedAt": "2023-12-24T14:01:22.066Z", + "subAttribute": { + "type": "Property", + "value": "subAttribute" + } + } + } + """.trimIndent() + ) + ) + } + + @JvmStatic + fun mergePatchProvider(): Stream { + return Stream.of( + Arguments.of( + """ + { + "attribute": { + "type": "Property", + "value": 12.0, + "observedAt": "2024-04-14T12:34:56Z", + "subAttribute": { + "type": "Property", + "value": "subAttribute" + } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": 12.2, + "unitCode": "GRM", + "subAttribute": { + "type": "Property", + "value": "newSubAttributeValue" + } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": 12.2, + "unitCode": "GRM", + "observedAt": "2024-04-14T12:34:56Z", + "subAttribute": { + "type": "Property", + "value": "newSubAttributeValue" + } + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "attribute": { + "type": "Property", + "value": { "en": "car", "fr": "voiture" } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": { "fr": "vélo", "es": "bicicleta" } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": { "en": "car", "fr": "vélo", "es": "bicicleta" } + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "attribute": { + "type": "Property", + "value": [ "car", "voiture" ] + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": [ "vélo", "bicicleta" ] + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Property", + "value": [ "vélo", "bicicleta" ] + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "attribute": { + "type": "Relationship", + "object": "urn:ngsi-ld:Entity:01" + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Relationship", + "object": "urn:ngsi-ld:Entity:02" + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "Relationship", + "object": "urn:ngsi-ld:Entity:02" + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "attribute": { + "type": "LanguageProperty", + "languageMap": { "en": "train", "fr": "train" } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "LanguageProperty", + "languageMap": { "fr": "TGV", "es": "tren" } + } + } + """.trimIndent(), + """ + { + "attribute": { + "type": "LanguageProperty", + "languageMap": { "en": "train", "fr": "TGV", "es": "tren" } + } + } + """.trimIndent() + ), + Arguments.of( + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "a": 1, "b": "thing" } + } + } + """.trimIndent(), + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "a": 2, "c": "other thing" } + } + } + """.trimIndent(), + """ + { + "incoming": { + "type": "JsonProperty", + "json": { "a": 2, "b": "thing", "c": "other thing" } + } + } + """.trimIndent() + ) + ) + } + } + + @ParameterizedTest + @MethodSource("com.egm.stellio.search.util.PatchAttributeTests#partialUpdatePatchProvider") + fun `it should apply a partial update patch behavior to attribute instance`( + source: String, + target: String, + expected: String + ) = runTest { + val (mergeResult, _) = partialUpdatePatch( + expandAttribute(source, NGSILD_TEST_CORE_CONTEXTS).second[0], + expandAttribute(target, NGSILD_TEST_CORE_CONTEXTS).second[0] + ) + + assertJsonPayloadsAreEqual( + serializeObject(expandAttribute(expected, NGSILD_TEST_CORE_CONTEXTS).second[0]), + mergeResult + ) + } + + @ParameterizedTest + @MethodSource("com.egm.stellio.search.util.PatchAttributeTests#mergePatchProvider") + fun `it should apply a merge patch behavior to attribute instance`( + source: String, + target: String, + expected: String + ) = runTest { + val (mergeResult, _) = mergePatch( + expandAttribute(source, NGSILD_TEST_CORE_CONTEXTS).second[0], + expandAttribute(target, NGSILD_TEST_CORE_CONTEXTS).second[0] + ) + + assertJsonPayloadsAreEqual( + serializeObject(expandAttribute(expected, NGSILD_TEST_CORE_CONTEXTS).second[0]), + mergeResult + ) + } +} diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/util/TemporalEntityParameterizedSource.kt b/search-service/src/test/kotlin/com/egm/stellio/search/util/TemporalEntityParameterizedSource.kt index 3ec633e69..ecde867a3 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/util/TemporalEntityParameterizedSource.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/util/TemporalEntityParameterizedSource.kt @@ -6,6 +6,7 @@ import com.egm.stellio.search.scope.ScopeInstanceResult import com.egm.stellio.search.scope.SimplifiedScopeInstanceResult import com.egm.stellio.search.support.EMPTY_JSON_PAYLOAD import com.egm.stellio.search.support.SAMPLE_JSON_PROPERTY_PAYLOAD +import com.egm.stellio.search.support.SAMPLE_LANGUAGE_PROPERTY_PAYLOAD import com.egm.stellio.search.support.buildAttributeInstancePayload import com.egm.stellio.shared.util.JsonLdUtils import com.egm.stellio.shared.util.loadSampleData @@ -575,6 +576,55 @@ class TemporalEntityParameterizedSource { loadSampleData("expectations/beehive_json_property_temporal_values.jsonld") ) + private val beehiveLanguagePropertyTemporalValues = + Arguments.arguments( + emptyList(), + mapOf( + TemporalEntityAttribute( + entityId = entityId, + attributeName = "https://ontology.eglobalmark.com/apic#friendlyName", + attributeType = TemporalEntityAttribute.AttributeType.LanguageProperty, + attributeValueType = TemporalEntityAttribute.AttributeValueType.OBJECT, + datasetId = null, + createdAt = now, + payload = SAMPLE_LANGUAGE_PROPERTY_PAYLOAD + ) to + listOf( + SimplifiedAttributeInstanceResult( + temporalEntityAttribute = UUID.randomUUID(), + value = """ + [{ + "@value": "One beautiful beehive", + "@language": "en" + }, + { + "@value": "Une belle ruche", + "@language": "fr" + }] + """, + time = ZonedDateTime.parse("2020-03-25T08:29:17.965206Z") + ), + SimplifiedAttributeInstanceResult( + temporalEntityAttribute = UUID.randomUUID(), + value = """ + [{ + "@value": "My beautiful beehive", + "@language": "en" + }, + { + "@value": "Ma belle ruche", + "@language": "fr" + }] + """, + time = ZonedDateTime.parse("2020-03-25T08:33:17.965206Z") + ) + ) + ), + true, + false, + loadSampleData("expectations/beehive_language_property_temporal_values.jsonld") + ) + @JvmStatic fun rawResultsProvider(): Stream { return Stream.of( @@ -591,7 +641,8 @@ class TemporalEntityParameterizedSource { beehiveRelationshipMultiInstancesTemporalValues, beehiveScopeMultiInstancesTemporalValues, beehiveScopeMultiInstances, - beehiveJsonPropertyTemporalValues + beehiveJsonPropertyTemporalValues, + beehiveLanguagePropertyTemporalValues ) } } diff --git a/search-service/src/test/resources/ngsild/expectations/beehive_language_property_temporal_values.jsonld b/search-service/src/test/resources/ngsild/expectations/beehive_language_property_temporal_values.jsonld new file mode 100644 index 000000000..4f3939cc6 --- /dev/null +++ b/search-service/src/test/resources/ngsild/expectations/beehive_language_property_temporal_values.jsonld @@ -0,0 +1,57 @@ +{ + "@id": "urn:ngsi-ld:BeeHive:TESTC", + "@type": [ + "https://ontology.eglobalmark.com/apic#BeeHive" + ], + "https://ontology.eglobalmark.com/apic#friendlyName": [ + { + "@type": [ + "https://uri.etsi.org/ngsi-ld/LanguageProperty" + ], + "https://uri.etsi.org/ngsi-ld/hasLanguageMaps": [ + { + "@list": [ + { + "@list": [ + { + "https://uri.etsi.org/ngsi-ld/hasLanguageMap": [ + { + "@value": "One beautiful beehive", + "@language": "en" + }, + { + "@value": "Une belle ruche", + "@language": "fr" + } + ] + }, + { + "@value": "2020-03-25T08:29:17.965206Z" + } + ] + }, + { + "@list": [ + { + "https://uri.etsi.org/ngsi-ld/hasLanguageMap": [ + { + "@value": "My beautiful beehive", + "@language": "en" + }, + { + "@value": "Ma belle ruche", + "@language": "fr" + } + ] + }, + { + "@value": "2020-03-25T08:33:17.965206Z" + } + ] + } + ] + } + ] + } + ] +} diff --git a/search-service/src/test/resources/ngsild/fragments/temporal_instance_language_fragment.jsonld b/search-service/src/test/resources/ngsild/fragments/temporal_instance_language_fragment.jsonld new file mode 100644 index 000000000..e964936ea --- /dev/null +++ b/search-service/src/test/resources/ngsild/fragments/temporal_instance_language_fragment.jsonld @@ -0,0 +1,8 @@ +{ + "type": "LanguageProperty", + "languageMap": { + "fr": "Ma belle ruche", + "it": "Il mio bellissimo alveare" + }, + "observedAt": "2023-03-13T12:33:06Z" +} diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt index 781701e73..fc38b4fdf 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt @@ -1,55 +1,92 @@ package com.egm.stellio.shared.model +import com.egm.stellio.shared.model.AttributeCompactedType.* import com.egm.stellio.shared.util.* import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_CONTEXT import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID_TERM import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_JSON_TERM +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_LANGUAGEMAP_TERM import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_OBJECT 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 -fun CompactedEntity.toKeyValues(): Map = - 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) - // an attribute with multiple instances - is List<*> -> { - when (value.first()) { - is Map<*, *> -> simplifyMultiInstanceAttribute(value as List>) - // we keep @context value as it is (List) - else -> value - } - } - // keep id, type and other non-reified properties as they are (typically string or list) - else -> value +fun CompactedEntity.toSimplifiedAttributes(): Map = + this.mapValues { (_, value) -> + applyAttributeTransformation(value, null, ::simplifyAttribute, ::simplifyMultiInstanceAttribute) } -private fun simplifyMultiInstanceAttribute(value: List>): Map> { +private fun simplifyMultiInstanceAttribute( + value: List>, + transformationParameters: Map? +): Map> { 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): Any { +@SuppressWarnings("UnusedParameter") +private fun simplifyAttribute(value: Map, transformationParameters: Map?): Any { + val attributeCompactedType = AttributeCompactedType.forKey(value[JSONLD_TYPE_TERM] as String)!! + return when (attributeCompactedType) { + 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>, + transformationParameters: Map? +): Any = + value.map { + filterLanguageProperty(it, transformationParameters) + } + +private fun filterLanguageProperty(value: Map, transformationParameters: Map?): Any { val attributeCompactedType = AttributeCompactedType.forKey(value[JSONLD_TYPE_TERM] as String)!! return when (attributeCompactedType) { - AttributeCompactedType.PROPERTY, AttributeCompactedType.GEOPROPERTY -> { - value.getOrDefault(JSONLD_VALUE_TERM, value) + LANGUAGEPROPERTY -> { + val localeRanges = Locale.LanguageRange.parse(transformationParameters?.get(QUERY_PARAM_LANG)!!) + val propertyLocales = (value[JSONLD_LANGUAGEMAP_TERM] as Map).keys.sorted() + val bestLocaleMatch = Locale.filterTags(localeRanges, propertyLocales) + .getOrElse(0) { _ -> + // as the list is sorted, @none is the first in the list if it exists + propertyLocales.first() + } + mapOf( + JSONLD_TYPE_TERM to NGSILD_PROPERTY_TERM, + JSONLD_VALUE_TERM to (value[JSONLD_LANGUAGEMAP_TERM] as Map)[bestLocaleMatch], + NGSILD_LANG_TERM to bestLocaleMatch + ) } - AttributeCompactedType.JSONPROPERTY -> mapOf(JSONLD_JSON_TERM to value.getOrDefault(JSONLD_JSON_TERM, value)) - AttributeCompactedType.RELATIONSHIP -> value.getOrDefault(JSONLD_OBJECT, value) + else -> value } } @@ -100,7 +137,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) { @@ -133,13 +175,35 @@ fun List.toFinalRepresentation( } enum class AttributeCompactedType(val key: String) { - PROPERTY("Property"), - RELATIONSHIP("Relationship"), - GEOPROPERTY("GeoProperty"), - JSONPROPERTY("JsonProperty"); + 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?, + onSingleInstance: (Map, Map?) -> Any, + onMultiInstance: (List>, Map?) -> Any +): Any = + when (value) { + // an attribute with a single instance + is Map<*, *> -> onSingleInstance(value as Map, transformationParameters) + // an attribute with multiple instances + is List<*> -> { + when (value.first()) { + is Map<*, *> -> onMultiInstance(value as List>, transformationParameters) + // we keep @context value as it is (List) + else -> value + } + } + // keep id, type and other non-reified properties as they are (typically string or list) + else -> value + } diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdDataRepresentation.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdDataRepresentation.kt index 4e69b84fc..f1983d7b4 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdDataRepresentation.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdDataRepresentation.kt @@ -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, diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdEntity.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdEntity.kt index 16669b8fe..7931e49a8 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdEntity.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/NgsiLdEntity.kt @@ -9,13 +9,17 @@ import arrow.core.right import arrow.fx.coroutines.parMap import com.egm.stellio.shared.util.AttributeType import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_LANGUAGE import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CREATED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_VALUE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_TYPE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LANGUAGEPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_MODIFIED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_OBSERVED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_TYPE @@ -27,6 +31,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_UNIT_CODE_PROPERTY import com.egm.stellio.shared.util.toUri import java.net.URI import java.time.ZonedDateTime +import java.util.Locale class NgsiLdEntity private constructor( val id: URI, @@ -57,12 +62,13 @@ class NgsiLdEntity private constructor( } } - inline fun getAttributesOfType(): List = attributes.filterIsInstance() + private inline fun getAttributesOfType(): List = attributes.filterIsInstance() val properties = getAttributesOfType() val relationships = getAttributesOfType() val geoProperties = getAttributesOfType() val jsonProperties = getAttributesOfType() + val languageProperties = getAttributesOfType() } sealed class NgsiLdAttribute(val name: ExpandedTerm) { @@ -169,6 +175,31 @@ class NgsiLdJsonProperty private constructor( override fun getAttributeInstances(): List = instances } +class NgsiLdLanguageProperty private constructor( + name: ExpandedTerm, + val instances: List +) : NgsiLdAttribute(name) { + companion object { + suspend fun create( + name: ExpandedTerm, + instances: ExpandedAttributeInstances + ): Either = either { + checkInstancesAreOfSameType(name, instances, NGSILD_LANGUAGEPROPERTY_TYPE).bind() + + val ngsiLdLanguagePropertyInstances = instances.parMap { instance -> + NgsiLdLanguagePropertyInstance.create(name, instance).bind() + } + + checkAttributeDefaultInstance(name, ngsiLdLanguagePropertyInstances).bind() + checkAttributeDuplicateDatasetId(name, ngsiLdLanguagePropertyInstances).bind() + + NgsiLdLanguageProperty(name, ngsiLdLanguagePropertyInstances) + } + } + + override fun getAttributeInstances(): List = instances +} + sealed class NgsiLdAttributeInstance( val observedAt: ZonedDateTime?, val datasetId: URI?, @@ -294,11 +325,11 @@ class NgsiLdJsonPropertyInstance private constructor( ): Either = either { val json = values.getMemberValue(NGSILD_JSONPROPERTY_VALUE) ensureNotNull(json) { - BadRequestDataException("Property $name has an instance without a json member") + BadRequestDataException("JsonProperty $name has an instance without a json member") } ensure(json is Map<*, *> || (json is List<*> && json.all { it is Map<*, *> })) { BadRequestDataException( - "Property $name has a json member that is not a JSON object, nor an array of JSON objects" + "JsonProperty $name has a json member that is not a JSON object, nor an array of JSON objects" ) } @@ -322,12 +353,69 @@ class NgsiLdJsonPropertyInstance private constructor( override fun toString(): String = "NgsiLdJsonPropertyInstance(json=$json)" } +class NgsiLdLanguagePropertyInstance private constructor( + val languageMap: List>, + observedAt: ZonedDateTime?, + datasetId: URI?, + attributes: List +) : NgsiLdAttributeInstance(observedAt, datasetId, attributes) { + companion object { + suspend fun create( + name: ExpandedTerm, + values: ExpandedAttributeInstance + ): Either = either { + val languageMap = values[NGSILD_LANGUAGEPROPERTY_VALUE] + ensureNotNull(languageMap) { + BadRequestDataException("LanguageProperty $name has an instance without a languageMap member") + } + ensure(isValidLanguageMap(languageMap)) { + BadRequestDataException("LanguageProperty $name has an invalid languageMap member") + } + + val observedAt = values.getMemberValueAsDateTime(NGSILD_OBSERVED_AT_PROPERTY) + val datasetId = values.getDatasetId() + + checkAttributeHasNoForbiddenMembers(name, values, NGSILD_LANGUAGEPROPERTIES_FORBIDDEN_MEMBERS).bind() + + val rawAttributes = getNonCoreMembers(values, NGSILD_LANGUAGEPROPERTIES_CORE_MEMBERS) + val attributes = parseAttributes(rawAttributes).bind() + + NgsiLdLanguagePropertyInstance( + languageMap as List>, + observedAt, + datasetId, + attributes + ) + } + + private fun isValidLanguageMap(languageMap: List): Boolean = + languageMap.all { + it is Map<*, *> && + isValidStructure(it) && + isValidLangValue(it.values) && + isValidLanguageTag(it[JSONLD_LANGUAGE] as? String) + } + + private fun isValidStructure(langEntry: Map<*, *>): Boolean = + (langEntry.size == 2 && langEntry.containsKey(JSONLD_VALUE) && langEntry.containsKey(JSONLD_LANGUAGE)) || + (langEntry.size == 1 && langEntry.containsKey(JSONLD_VALUE)) + + private fun isValidLangValue(values: Collection): Boolean = + values.all { value -> value is String || value is List<*> } + + private fun isValidLanguageTag(tag: String?): Boolean = + tag == null || "und" != Locale.forLanguageTag(tag).toLanguageTag() + } + + override fun toString(): String = "NgsiLdLanguagePropertyInstance(languageMap=$languageMap)" +} + @JvmInline value class WKTCoordinates(val value: String) /** * Given an entity's attribute, returns whether it is of the given attribute type - * (i.e. property, geo property, json property or relationship) + * (i.e. property, geo property, json property, language property or relationship) */ fun isAttributeOfType(attributeInstance: ExpandedAttributeInstance, type: AttributeType): Boolean = attributeInstance.containsKey(JSONLD_TYPE) && @@ -347,6 +435,7 @@ private suspend fun parseAttributes( NGSILD_RELATIONSHIP_TYPE.uri -> NgsiLdRelationship.create(it.first, it.second) NGSILD_GEOPROPERTY_TYPE.uri -> NgsiLdGeoProperty.create(it.first, it.second) NGSILD_JSONPROPERTY_TYPE.uri -> NgsiLdJsonProperty.create(it.first, it.second) + NGSILD_LANGUAGEPROPERTY_TYPE.uri -> NgsiLdLanguageProperty.create(it.first, it.second) else -> BadRequestDataException("Attribute ${it.first} has an unknown type: $attributeType").left() } }.let { l -> @@ -413,18 +502,19 @@ suspend fun ExpandedAttribute.toNgsiLdAttribute(): Either = - when { - isAttributeOfType(this[0], NGSILD_PROPERTY_TYPE) -> - NgsiLdProperty.create(attributeName, this) - isAttributeOfType(this[0], NGSILD_RELATIONSHIP_TYPE) -> - NgsiLdRelationship.create(attributeName, this) - isAttributeOfType(this[0], NGSILD_GEOPROPERTY_TYPE) -> - NgsiLdGeoProperty.create(attributeName, this) - isAttributeOfType(this[0], NGSILD_JSONPROPERTY_TYPE) -> - NgsiLdJsonProperty.create(attributeName, this) - else -> BadRequestDataException("Unrecognized type for $attributeName").left() - } +): Either = when { + isAttributeOfType(this[0], NGSILD_PROPERTY_TYPE) -> + NgsiLdProperty.create(attributeName, this) + isAttributeOfType(this[0], NGSILD_RELATIONSHIP_TYPE) -> + NgsiLdRelationship.create(attributeName, this) + isAttributeOfType(this[0], NGSILD_GEOPROPERTY_TYPE) -> + NgsiLdGeoProperty.create(attributeName, this) + isAttributeOfType(this[0], NGSILD_JSONPROPERTY_TYPE) -> + NgsiLdJsonProperty.create(attributeName, this) + isAttributeOfType(this[0], NGSILD_LANGUAGEPROPERTY_TYPE) -> + NgsiLdLanguageProperty.create(attributeName, this) + else -> BadRequestDataException("Unrecognized type for $attributeName").left() +} suspend fun ExpandedEntity.toNgsiLdEntity(): Either = NgsiLdEntity.create(this.members) @@ -489,3 +579,13 @@ val NGSILD_JSONPROPERTIES_FORBIDDEN_MEMBERS = listOf( NGSILD_PROPERTY_VALUE, NGSILD_UNIT_CODE_PROPERTY ) + +val NGSILD_LANGUAGEPROPERTIES_CORE_MEMBERS = listOf( + NGSILD_LANGUAGEPROPERTY_VALUE +).plus(NGSILD_ATTRIBUTES_CORE_MEMBERS) + +val NGSILD_LANGUAGEPROPERTIES_FORBIDDEN_MEMBERS = listOf( + NGSILD_RELATIONSHIP_OBJECT, + NGSILD_PROPERTY_VALUE, + NGSILD_UNIT_CODE_PROPERTY +) diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiUtils.kt b/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiUtils.kt index 94583c720..a680285c7 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiUtils.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiUtils.kt @@ -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" @@ -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) @@ -229,6 +231,7 @@ fun parseRepresentations( entityRepresentation, attributeRepresentation, includeSysAttrs, + languageFilter, geometryProperty, timeproperty ) diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt b/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt index b4a9ca95a..c03f8b47f 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt @@ -44,11 +44,15 @@ object JsonLdUtils { const val NGSILD_JSONPROPERTY_TERM = "JsonProperty" val NGSILD_JSONPROPERTY_TYPE = AttributeType("https://uri.etsi.org/ngsi-ld/JsonProperty") const val NGSILD_JSONPROPERTY_VALUE = "https://uri.etsi.org/ngsi-ld/hasJSON" - const val NGSILD_JSONPROPERTY_VALUES = "https://uri.etsi.org/ngsi-ld/jsons" + const val NGSILD_LANGUAGEPROPERTY_TERM = "LanguageProperty" + val NGSILD_LANGUAGEPROPERTY_TYPE = AttributeType("https://uri.etsi.org/ngsi-ld/LanguageProperty") + const val NGSILD_LANGUAGEPROPERTY_VALUE = "https://uri.etsi.org/ngsi-ld/hasLanguageMap" const val NGSILD_PROPERTY_VALUES = "https://uri.etsi.org/ngsi-ld/hasValues" const val NGSILD_GEOPROPERTY_VALUES = "https://uri.etsi.org/ngsi-ld/hasValues" const val NGSILD_RELATIONSHIP_OBJECTS = "https://uri.etsi.org/ngsi-ld/hasObjects" + const val NGSILD_JSONPROPERTY_VALUES = "https://uri.etsi.org/ngsi-ld/jsons" + const val NGSILD_LANGUAGEPROPERTY_VALUES = "https://uri.etsi.org/ngsi-ld/hasLanguageMaps" private const val JSONLD_GRAPH = "@graph" const val JSONLD_ID_TERM = "id" @@ -60,10 +64,13 @@ object JsonLdUtils { const val JSONLD_OBJECT = "object" const val JSONLD_LIST = "@list" const val JSONLD_JSON_TERM = "json" + const val JSONLD_LANGUAGE = "@language" + const val JSONLD_LANGUAGEMAP_TERM = "languageMap" const val JSONLD_JSON = "@json" 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) diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/model/CompactedEntityTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/model/CompactedEntityTests.kt index 245bfebd4..c0fe63663 100644 --- a/shared/src/test/kotlin/com/egm/stellio/shared/model/CompactedEntityTests.kt +++ b/shared/src/test/kotlin/com/egm/stellio/shared/model/CompactedEntityTests.kt @@ -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) } @@ -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) } @@ -160,7 +160,7 @@ class CompactedEntityTests { """.trimIndent() .deserializeAsMap() - val simplifiedRepresentation = compactedEntity.toKeyValues() + val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes() val expectedSimplifiedRepresentation = """ { @@ -195,7 +195,7 @@ class CompactedEntityTests { """.trimIndent() .deserializeAsMap() - val simplifiedRepresentation = compactedEntity.toKeyValues() + val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes() val expectedSimplifiedRepresentation = """ { @@ -744,10 +744,45 @@ class CompactedEntityTests { EntityRepresentation.GEO_JSON, AttributeRepresentation.SIMPLIFIED, includeSysAttrs = false, - JsonLdUtils.NGSILD_LOCATION_TERM + geometryProperty = JsonLdUtils.NGSILD_LOCATION_TERM ) ) assertEquals(expectedEntities, actualEntities) } + + @Test + fun `it should return the simplified representation of a LanguageProperty`() { + val compactedEntity = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "fr": "Grand Place", + "nl": "Grote Markt" + } + } + } + """.trimIndent() + .deserializeAsMap() + + val simplifiedRepresentation = compactedEntity.toSimplifiedAttributes() + + val expectedSimplifiedRepresentation = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + "languageProperty": { + "languageMap": { + "fr": "Grand Place", + "nl": "Grote Markt" + } + } + } + """.trimIndent() + + assertJsonPayloadsAreEqual(expectedSimplifiedRepresentation, serializeObject(simplifiedRepresentation)) + } } diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/model/LanguageFilterTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/model/LanguageFilterTests.kt new file mode 100644 index 000000000..f54f06000 --- /dev/null +++ b/shared/src/test/kotlin/com/egm/stellio/shared/model/LanguageFilterTests.kt @@ -0,0 +1,167 @@ +package com.egm.stellio.shared.model + +import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap +import com.egm.stellio.shared.util.JsonUtils.serializeObject +import com.egm.stellio.shared.util.assertJsonPayloadsAreEqual +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class LanguageFilterTests { + + companion object { + + @JvmStatic + fun normalizedResultsProvider(): Stream { + return Stream.of( + Arguments.of( + "nl", + """ + "languageProperty": { + "type": "Property", + "value": "Grote Markt", + "lang": "nl" + } + """.trimIndent() + ), + Arguments.of( + "en", + """ + "languageProperty": { + "type": "Property", + "value": "Big Place", + "lang": "@none" + } + """.trimIndent() + ), + Arguments.of( + "*", + """ + "languageProperty": { + "type": "Property", + "value": "Big Place", + "lang": "@none" + } + """.trimIndent() + ), + Arguments.of( + "fr-CH,fr;q=0.9,en;q=0.8,*;q=0.5", + """ + "languageProperty": { + "type": "Property", + "value": "Grand Place", + "lang": "fr" + } + """.trimIndent() + ) + ) + } + + @JvmStatic + fun simplifiedResultsProvider(): Stream { + return Stream.of( + Arguments.of( + "nl", + """ + "languageProperty": "Grote Markt" + """.trimIndent() + ), + Arguments.of( + "en", + """ + "languageProperty": "Grand Place" + """.trimIndent() + ), + Arguments.of( + "*", + """ + "languageProperty": "Grand Place" + """.trimIndent() + ), + Arguments.of( + "fr-CH,fr;q=0.9,en;q=0.8,*;q=0.5", + """ + "languageProperty": "Grand Place" + """.trimIndent() + ) + ) + } + } + + @ParameterizedTest + @MethodSource("com.egm.stellio.shared.model.LanguageFilterTests#normalizedResultsProvider") + fun `it should return the normalized representation of a LanguageProperty with a language filter`( + languageFilter: String, + expectedAttribute: String + ) { + val compactedEntity = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "fr": "Grand Place", + "nl": "Grote Markt", + "@none": "Big Place" + } + } + } + """.trimIndent() + .deserializeAsMap() + + val filteredRepresentation = compactedEntity.toFilteredLanguageProperties(languageFilter) + + val expectedFilteredRepresentation = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + $expectedAttribute + } + """.trimIndent() + + assertJsonPayloadsAreEqual(expectedFilteredRepresentation, serializeObject(filteredRepresentation)) + } + + @ParameterizedTest + @MethodSource("com.egm.stellio.shared.model.LanguageFilterTests#simplifiedResultsProvider") + fun `it should return the simplfied representation of a LanguageProperty with a language filter`( + languageFilter: String, + expectedAttribute: String + ) { + val compactedEntity = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "fr": "Grand Place", + "nl": "Grote Markt" + } + } + } + """.trimIndent() + .deserializeAsMap() + + val filteredRepresentation = compactedEntity.toFinalRepresentation( + NgsiLdDataRepresentation( + EntityRepresentation.JSON, + AttributeRepresentation.SIMPLIFIED, + false, + languageFilter + ) + ) + + val expectedFilteredRepresentation = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + $expectedAttribute + } + """.trimIndent() + + assertJsonPayloadsAreEqual(expectedFilteredRepresentation, serializeObject(filteredRepresentation)) + } +} diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/model/NgsiLdEntityTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/model/NgsiLdEntityTests.kt index 62f401bb0..a9bfaf748 100644 --- a/shared/src/test/kotlin/com/egm/stellio/shared/model/NgsiLdEntityTests.kt +++ b/shared/src/test/kotlin/com/egm/stellio/shared/model/NgsiLdEntityTests.kt @@ -12,6 +12,7 @@ import com.egm.stellio.shared.util.toUri import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.time.ZonedDateTime class NgsiLdEntityTests { @@ -828,7 +829,7 @@ class NgsiLdEntityTests { .shouldFail { assertInstanceOf(BadRequestDataException::class.java, it) assertEquals( - "Property ${NGSILD_DEFAULT_VOCAB}jsonProperty has an instance without a json member", + "JsonProperty ${NGSILD_DEFAULT_VOCAB}jsonProperty has an instance without a json member", it.message ) } @@ -852,10 +853,161 @@ class NgsiLdEntityTests { .shouldFail { assertInstanceOf(BadRequestDataException::class.java, it) assertEquals( - "Property ${NGSILD_DEFAULT_VOCAB}jsonProperty has a json member that is not a JSON object, " + + "JsonProperty ${NGSILD_DEFAULT_VOCAB}jsonProperty has a json member that is not a JSON object, " + "nor an array of JSON objects", it.message ) } } + + @Test + fun `it should parse an entity with a LanguageProperty`() = runTest { + val rawEntity = + """ + { + "id":"urn:ngsi-ld:Device:01234", + "type":"Device", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "fr": ["Grand Place", "Grande Place"], + "nl": "Grote Markt", + "@none": "Big Place" + } + } + } + """.trimIndent() + + val ngsiLdEntity = expandJsonLdEntity(rawEntity, NGSILD_TEST_CORE_CONTEXTS).toNgsiLdEntity() + .shouldSucceedAndResult() + + val languageProperty = ngsiLdEntity.languageProperties.first() + assertNotNull(languageProperty) + assertEquals("${NGSILD_DEFAULT_VOCAB}languageProperty", languageProperty.name) + assertEquals(1, languageProperty.instances.size) + val languagePropertyInstance = languageProperty.instances[0] + assertEquals( + listOf( + mapOf( + "@language" to "fr", + "@value" to "Grand Place" + ), + mapOf( + "@language" to "fr", + "@value" to "Grande Place" + ), + mapOf( + "@language" to "nl", + "@value" to "Grote Markt" + ), + mapOf( + "@value" to "Big Place" + ) + ), + languagePropertyInstance.languageMap + ) + } + + @Test + fun `it should not parse an entity with a LanguageProperty without a languageMap member`() = runTest { + val rawEntity = + """ + { + "id":"urn:ngsi-ld:Device:01234", + "type":"Device", + "languageProperty": { + "type": "LanguageProperty", + "value": { + "id": "Parc-des-Princes" + } + } + } + """.trimIndent() + + expandJsonLdEntity(rawEntity, NGSILD_TEST_CORE_CONTEXTS).toNgsiLdEntity() + .shouldFail { + assertInstanceOf(BadRequestDataException::class.java, it) + assertEquals( + "LanguageProperty ${NGSILD_DEFAULT_VOCAB}languageProperty has an instance " + + "without a languageMap member", + it.message + ) + } + } + + @Test + fun `it should not parse an entity with a LanguageProperty with an invalid languageMap member`() = runTest { + val rawEntity = + """ + { + "id":"urn:ngsi-ld:Device:01234", + "type":"Device", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": [{ + "fr": "Grand Place", + "nl": "Grote Markt" + }] + } + } + """.trimIndent() + + expandJsonLdEntity(rawEntity, NGSILD_TEST_CORE_CONTEXTS).toNgsiLdEntity() + .shouldFail { + assertInstanceOf(BadRequestDataException::class.java, it) + assertEquals( + "LanguageProperty ${NGSILD_DEFAULT_VOCAB}languageProperty has an invalid languageMap member", + it.message + ) + } + } + + @Test + fun `it should not parse an entity with a LanguageProperty with an invalid language tag`() = runTest { + val rawEntity = + """ + { + "id":"urn:ngsi-ld:Device:01234", + "type":"Device", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "123": "Grand Place" + } + } + } + """.trimIndent() + + expandJsonLdEntity(rawEntity, NGSILD_TEST_CORE_CONTEXTS).toNgsiLdEntity() + .shouldFail { + assertInstanceOf(BadRequestDataException::class.java, it) + assertEquals( + "LanguageProperty ${NGSILD_DEFAULT_VOCAB}languageProperty has an invalid languageMap member", + it.message + ) + } + } + + @Test + fun `it should not parse an entity with a LanguageProperty with an invalid value for a language`() = runTest { + val rawEntity = + """ + { + "id":"urn:ngsi-ld:Device:01234", + "type":"Device", + "languageProperty": { + "type": "LanguageProperty", + "languageMap": { + "fr": { + "key": "Grand Place" + } + } + } + } + """.trimIndent() + + assertThrows { + expandJsonLdEntity(rawEntity, NGSILD_TEST_CORE_CONTEXTS) + } + } } diff --git a/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/JsonLdContextUtils.kt b/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/JsonLdContextUtils.kt index 14f1b8e86..b021d45ff 100644 --- a/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/JsonLdContextUtils.kt +++ b/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/JsonLdContextUtils.kt @@ -35,6 +35,8 @@ const val TEMPERATURE_COMPACT_PROPERTY = "temperature" const val TEMPERATURE_PROPERTY = "https://ontology.eglobalmark.com/apic#$TEMPERATURE_COMPACT_PROPERTY" const val LUMINOSITY_COMPACT_JSONPROPERTY = "luminosity" const val LUMINOSITY_JSONPROPERTY = "https://ontology.eglobalmark.com/apic#$LUMINOSITY_COMPACT_JSONPROPERTY" +const val FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY = "friendlyName" +const val FRIENDLYNAME_LANGUAGEPROPERTY = "https://ontology.eglobalmark.com/apic#$FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY" const val MANAGED_BY_COMPACT_RELATIONSHIP = "managedBy" const val MANAGED_BY_RELATIONSHIP = "https://ontology.eglobalmark.com/egm#$MANAGED_BY_COMPACT_RELATIONSHIP" diff --git a/shared/src/testFixtures/resources/jsonld-contexts/apic.jsonld b/shared/src/testFixtures/resources/jsonld-contexts/apic.jsonld index e20277ff6..0b79db69e 100644 --- a/shared/src/testFixtures/resources/jsonld-contexts/apic.jsonld +++ b/shared/src/testFixtures/resources/jsonld-contexts/apic.jsonld @@ -33,7 +33,7 @@ "resetCount": "https://ontology.eglobalmark.com/apic#resetCount", "movementCount": "https://ontology.eglobalmark.com/apic#movementCount", "hornetCount": "https://ontology.eglobalmark.com/apic#hornetCount", - "dateOfFirstBee": "https://ontology.eglobalmark.com/apic#dateOfFirstBee" + "dateOfFirstBee": "https://ontology.eglobalmark.com/apic#dateOfFirstBee", + "friendlyName": "https://ontology.eglobalmark.com/apic#friendlyName" } } - \ No newline at end of file diff --git a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/model/Subscription.kt b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/model/Subscription.kt index 8d7454140..305474a84 100644 --- a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/model/Subscription.kt +++ b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/model/Subscription.kt @@ -50,7 +50,8 @@ data class Subscription( // - used when needed to serve contexts in JSON notifications @JsonProperty(value = JSONLD_CONTEXT) val contexts: List, - val throttling: Int? = null + val throttling: Int? = null, + val lang: String? = null ) { @Transient diff --git a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt index 9d07ba653..22ab43996 100644 --- a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt +++ b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt @@ -50,7 +50,8 @@ class NotificationService( NgsiLdDataRepresentation( entityRepresentation, attributeRepresentation, - it.notification.sysAttrs + it.notification.sysAttrs, + it.lang ) ) callSubscriber(it, compactedEntity) diff --git a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/SubscriptionService.kt b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/SubscriptionService.kt index e64e12c9c..9921613e6 100644 --- a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/SubscriptionService.kt +++ b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/SubscriptionService.kt @@ -149,11 +149,11 @@ class SubscriptionService( INSERT INTO subscription(id, type, subscription_name, created_at, description, watched_attributes, notification_trigger, time_interval, q, scope_q, notif_attributes, notif_format, endpoint_uri, endpoint_accept, endpoint_receiver_info, endpoint_notifier_info, times_sent, is_active, - expires_at, sub, contexts, throttling, sys_attrs) + expires_at, sub, contexts, throttling, sys_attrs, lang) VALUES(:id, :type, :subscription_name, :created_at, :description, :watched_attributes, :notification_trigger, :time_interval, :q, :scope_q, :notif_attributes, :notif_format, :endpoint_uri, :endpoint_accept, :endpoint_receiver_info, :endpoint_notifier_info, :times_sent, :is_active, - :expires_at, :sub, :contexts, :throttling, :sys_attrs) + :expires_at, :sub, :contexts, :throttling, :sys_attrs, :lang) """.trimIndent() databaseClient.sql(insertStatement) @@ -180,6 +180,7 @@ class SubscriptionService( .bind("contexts", subscription.contexts.toTypedArray()) .bind("throttling", subscription.throttling) .bind("sys_attrs", subscription.notification.sysAttrs) + .bind("lang", subscription.lang) .execute().bind() geoQuery?.let { @@ -250,7 +251,7 @@ class SubscriptionService( notif_format, endpoint_uri, endpoint_accept, endpoint_receiver_info, endpoint_notifier_info, status, times_sent, is_active, last_notification, last_failure, last_success, entity_selector.id as entity_id, id_pattern, entity_selector.type_selection as type_selection, georel, geometry, coordinates, - pgis_geometry, geoproperty, scope_q, expires_at, contexts, throttling, sys_attrs + pgis_geometry, geoproperty, scope_q, expires_at, contexts, throttling, sys_attrs, lang FROM subscription LEFT JOIN entity_selector ON entity_selector.subscription_id = :id LEFT JOIN geometry_query ON geometry_query.subscription_id = :id @@ -355,7 +356,8 @@ class SubscriptionService( "scopeQ", "isActive", "modifiedAt", - "throttling" + "throttling", + "lang" ).contains(it.key) -> { val columnName = it.key.toSqlColumnName() val value = it.value.toSqlValue(it.key) @@ -498,7 +500,7 @@ class SubscriptionService( notif_format, endpoint_uri, endpoint_accept, endpoint_receiver_info, endpoint_notifier_info, status, times_sent, is_active, last_notification, last_failure, last_success, entity_selector.id as entity_id, id_pattern, entity_selector.type_selection as type_selection, georel, geometry, coordinates, - pgis_geometry, geoproperty, scope_q, expires_at, contexts, throttling, sys_attrs + pgis_geometry, geoproperty, scope_q, expires_at, contexts, throttling, sys_attrs, lang FROM subscription LEFT JOIN entity_selector ON entity_selector.subscription_id = subscription.id LEFT JOIN geometry_query ON geometry_query.subscription_id = subscription.id @@ -540,7 +542,7 @@ class SubscriptionService( entity_selector.id as entity_id, entity_selector.id_pattern as id_pattern, entity_selector.type_selection as type_selection, georel, geometry, coordinates, pgis_geometry, geoproperty, scope_q, notif_attributes, notif_format, endpoint_uri, endpoint_accept, times_sent, - endpoint_receiver_info, endpoint_notifier_info, contexts, throttling, sys_attrs + endpoint_receiver_info, endpoint_notifier_info, contexts, throttling, sys_attrs, lang FROM subscription LEFT JOIN entity_selector on subscription.id = entity_selector.subscription_id LEFT JOIN geometry_query on subscription.id = geometry_query.subscription_id @@ -693,7 +695,8 @@ class SubscriptionService( ), isActive = toBoolean(row["is_active"]), contexts = toList(row["contexts"]), - throttling = toNullableInt(row["throttling"]) + throttling = toNullableInt(row["throttling"]), + lang = row["lang"] as? String ) } @@ -724,7 +727,8 @@ class SubscriptionService( sysAttrs = row["sys_attrs"] as Boolean ), contexts = toList(row["contexts"]), - throttling = toNullableInt(row["throttling"]) + throttling = toNullableInt(row["throttling"]), + lang = row["lang"] as? String ) } @@ -760,7 +764,7 @@ class SubscriptionService( scope_q, notif_attributes, notif_format, endpoint_uri, endpoint_accept, endpoint_receiver_info, endpoint_notifier_info, status, times_sent, last_notification, last_failure, last_success, is_active, entity_selector.id as entity_id, id_pattern, entity_selector.type_selection as type_selection, georel, - geometry, coordinates, pgis_geometry, geoproperty, contexts, throttling, sys_attrs + geometry, coordinates, pgis_geometry, geoproperty, contexts, throttling, sys_attrs, lang FROM subscription LEFT JOIN entity_selector ON entity_selector.subscription_id = subscription.id LEFT JOIN geometry_query ON geometry_query.subscription_id = subscription.id diff --git a/subscription-service/src/main/resources/db/migration/V0_28__add_lang_column.sql b/subscription-service/src/main/resources/db/migration/V0_28__add_lang_column.sql new file mode 100644 index 000000000..727c7b9fd --- /dev/null +++ b/subscription-service/src/main/resources/db/migration/V0_28__add_lang_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE subscription + ADD lang text; diff --git a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt index 8ea143423..ef7276558 100644 --- a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt +++ b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt @@ -4,8 +4,12 @@ import arrow.core.filterIsInstance import arrow.core.right import com.egm.stellio.shared.util.* import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_COMPACTED_ENTITY_CORE_MEMBERS +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_LANG_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LOCATION_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LOCATION_TERM +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_TERM import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap import com.egm.stellio.shared.web.NGSILD_TENANT_HEADER import com.egm.stellio.subscription.model.Endpoint @@ -456,7 +460,7 @@ class NotificationServiceTests { } @Test - fun `it should notify the subscriber and return entities with sysAttrs if sysAttrs is true`() = runTest { + fun `it should notify the subscriber with sysAttrs if sysAttrs is true`() = runTest { val subscription = gimmeRawSubscription().copy( notification = NotificationParams( attributes = emptyList(), @@ -496,4 +500,62 @@ class NotificationServiceTests { } } } + + @Test + fun `it should notify the subscriber with language filter applied if lang is provided`() = runTest { + val subscription = gimmeRawSubscription().copy( + notification = NotificationParams( + attributes = emptyList(), + endpoint = Endpoint( + uri = "http://localhost:8089/notification".toUri(), + accept = Endpoint.AcceptType.JSONLD + ) + ), + lang = "fr", + contexts = APIC_COMPOUND_CONTEXTS + ) + + val expandedEntity = expandJsonLdEntity( + """ + { + "id":"$apiaryId", + "type":"Apiary", + "friendlyName": { + "type":"LanguageProperty", + "languageMap": { + "fr": "Le rucher de Nantes", + "en": "The apiary of Nantes" + } + }, + "@context":[ "$APIC_COMPOUND_CONTEXT" ] + } + """.trimIndent() + ) + + coEvery { + subscriptionService.getMatchingSubscriptions(any(), any(), any()) + } returns listOf(subscription).right() + coEvery { subscriptionService.updateSubscriptionNotification(any(), any(), any()) } returns 1 + + stubFor( + post(urlMatching("/notification")) + .willReturn(ok()) + ) + + notificationService.notifyMatchingSubscribers( + expandedEntity, + setOf(FRIENDLYNAME_LANGUAGEPROPERTY), + ATTRIBUTE_UPDATED + ).shouldSucceedWith { + val entity = it[0].second.data[0] + entity.filterKeys { key -> key == FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY } + .also { property -> + val propertyValues = property[FRIENDLYNAME_COMPACT_LANGUAGEPROPERTY] as Map + assertThat(propertyValues) + .containsEntry(JSONLD_TYPE_TERM, NGSILD_PROPERTY_TERM) + .containsEntry(JSONLD_VALUE_TERM, "Le rucher de Nantes") + .containsEntry(NGSILD_LANG_TERM, "fr") + } + } + } } diff --git a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt index 88b481be3..867c2726b 100644 --- a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt +++ b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt @@ -324,7 +324,8 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer { ) && it.notification.sysAttrs && it.expiresAt == ZonedDateTime.parse("2100-01-01T00:00:00Z") && - it.throttling == 60 + it.throttling == 60 && + it.lang == "fr,en" } } @@ -718,7 +719,8 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer { "coordinates" to "[100.0, 0.0]", "geoproperty" to "https://uri.etsi.org/ngsi-ld/observationSpace" ), - "throttling" to 50 + "throttling" to 50, + "lang" to "fr-CH,fr" ) subscriptionService.update(subscription.id, parsedInput, APIC_COMPOUND_CONTEXTS) @@ -735,7 +737,8 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer { it.geoQ!!.geometry == "Point" && it.geoQ!!.coordinates == "[100.0, 0.0]" && it.geoQ!!.geoproperty == "https://uri.etsi.org/ngsi-ld/observationSpace" && - it.throttling == 50 + it.throttling == 50 && + it.lang == "fr-CH,fr" } } diff --git a/subscription-service/src/test/resources/ngsild/subscription_full.json b/subscription-service/src/test/resources/ngsild/subscription_full.json index eda3bfc90..d77a991b6 100644 --- a/subscription-service/src/test/resources/ngsild/subscription_full.json +++ b/subscription-service/src/test/resources/ngsild/subscription_full.json @@ -37,5 +37,6 @@ "sysAttrs": true }, "expiresAt": "2100-01-01T00:00:00Z", - "throttling": 60 + "throttling": 60, + "lang": "fr,en" }