diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/CSRFilters.kt b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/CSRFilters.kt index 3b44a57ac..0993bdfba 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/CSRFilters.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/CSRFilters.kt @@ -3,7 +3,7 @@ package com.egm.stellio.search.csr.model import com.egm.stellio.shared.model.EntityTypeSelection import java.net.URI -data class CSRFilters( // we should use a combination of EntitiesQuery TemporalQuery (when we implement all operations) +open class CSRFilters( // we should use a combination of EntitiesQuery TemporalQuery (when we implement all operations) val ids: Set = emptySet(), val typeSelection: EntityTypeSelection? = null, val idPattern: String? = null, @@ -13,12 +13,24 @@ data class CSRFilters( // we should use a combination of EntitiesQuery TemporalQ ids: Set = emptySet(), typeSelection: EntityTypeSelection? = null, idPattern: String? = null, - operations: List + operations: List? ) : this( ids = ids, typeSelection = typeSelection, idPattern = idPattern, - csf = operations.joinToString("|") { "${ContextSourceRegistration::operations.name}==${it.key}" } + csf = operations?.joinToString("|") { "${ContextSourceRegistration::operations.name}==${it.key}" } ) + + constructor( + ids: Set = emptySet(), + types: Set, + idPattern: String? = null, + operations: List? = null + ) : this( + ids = ids, + typeSelection = types.joinToString("|"), + idPattern = idPattern, + operations = operations + ) } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistration.kt b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistration.kt index 47cbe2c9f..407773f45 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistration.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistration.kt @@ -6,11 +6,15 @@ import arrow.core.raise.either import arrow.core.right import com.egm.stellio.shared.model.APIException import com.egm.stellio.shared.model.BadRequestDataException +import com.egm.stellio.shared.model.ExpandedEntity +import com.egm.stellio.shared.model.ExpandedTerm import com.egm.stellio.shared.model.toAPIException import com.egm.stellio.shared.util.DataTypes import com.egm.stellio.shared.util.JSON_LD_MEDIA_TYPE import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_CONTEXT +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CSR_TERM +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_TYPE import com.egm.stellio.shared.util.JsonLdUtils.compactTerm import com.egm.stellio.shared.util.JsonLdUtils.expandJsonLdTerm import com.egm.stellio.shared.util.JsonUtils.deserializeAs @@ -154,6 +158,46 @@ data class ContextSourceRegistration( if (!id.isAbsolute) BadRequestDataException(invalidUriMessage("$id")).left() else Unit.right() + + fun getAssociatedAttributes( + registrationInfoFilter: RegistrationInfoFilter, + entity: ExpandedEntity, + ): Set { + val matchingRegistrationsInfo = getMatchingInformation(registrationInfoFilter) + + val properties = + if (matchingRegistrationsInfo.any { it.propertyNames == null }) null + else matchingRegistrationsInfo.flatMap { it.propertyNames!! }.toSet() + + val relationships = + if (matchingRegistrationsInfo.any { it.relationshipNames == null }) null + else matchingRegistrationsInfo.flatMap { it.relationshipNames!! }.toSet() + + return entity.getAttributes().filter { (term, attribute) -> + val attributeType = attribute.first()[JSONLD_TYPE]?.first() + if (NGSILD_RELATIONSHIP_TYPE.uri == attributeType) { + relationships == null || term in relationships + } else { + properties == null || term in properties + } + }.keys + } + + private fun getMatchingInformation(registrationInfoFilter: RegistrationInfoFilter): List = + information.filter { info -> + info.entities?.any { entityInfo -> + entityInfo.id?.let { registrationInfoFilter.ids.contains(it) } ?: true && + entityInfo.types.let { types -> + types.any { + registrationInfoFilter.types?.contains(it) ?: true + } + } && + entityInfo.idPattern?.let { pattern -> + registrationInfoFilter.ids.any { pattern.toRegex().matches(it.toString()) } + } ?: true + } ?: true + } + companion object { fun deserialize( diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/RegistrationInfoFilter.kt b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/RegistrationInfoFilter.kt new file mode 100644 index 000000000..5156ec24a --- /dev/null +++ b/search-service/src/main/kotlin/com/egm/stellio/search/csr/model/RegistrationInfoFilter.kt @@ -0,0 +1,15 @@ +package com.egm.stellio.search.csr.model + +import java.net.URI + +class RegistrationInfoFilter( + ids: Set = emptySet(), + val types: Set? = null, + idPattern: String? = null, + operations: List? = null +) : CSRFilters( + ids = ids, + typeSelection = types?.joinToString("|"), + idPattern = idPattern, + operations = operations +) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionService.kt new file mode 100644 index 000000000..904dd4f46 --- /dev/null +++ b/search-service/src/main/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionService.kt @@ -0,0 +1,196 @@ +package com.egm.stellio.search.csr.service + +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import com.egm.stellio.search.csr.model.CSRFilters +import com.egm.stellio.search.csr.model.ContextSourceRegistration +import com.egm.stellio.search.csr.model.Mode +import com.egm.stellio.search.csr.model.Operation +import com.egm.stellio.search.csr.model.RegistrationInfoFilter +import com.egm.stellio.search.entity.web.BatchEntityError +import com.egm.stellio.search.entity.web.BatchEntitySuccess +import com.egm.stellio.search.entity.web.BatchOperationResult +import com.egm.stellio.shared.model.APIException +import com.egm.stellio.shared.model.BadGatewayException +import com.egm.stellio.shared.model.CompactedEntity +import com.egm.stellio.shared.model.ConflictException +import com.egm.stellio.shared.model.ContextSourceException +import com.egm.stellio.shared.model.ExpandedEntity +import com.egm.stellio.shared.model.ExpandedTerm +import com.egm.stellio.shared.model.GatewayTimeoutException +import com.egm.stellio.shared.util.JSON_LD_CONTENT_TYPE +import com.egm.stellio.shared.util.JsonLdUtils.compactEntity +import com.egm.stellio.shared.util.toUri +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBodyOrNull +import org.springframework.web.reactive.function.client.awaitExchange +import java.net.URI + +@Service +class DistributedEntityProvisionService( + private val contextSourceRegistrationService: ContextSourceRegistrationService, +) { + val createPath = "/ngsi-ld/v1/entities" + + val logger: Logger = LoggerFactory.getLogger(javaClass) + + suspend fun distributeCreateEntity( + entity: ExpandedEntity, + contexts: List, + ): Pair { + val csrFilters = + CSRFilters( + ids = setOf(entity.id.toUri()), + types = entity.types.toSet() + ) + val result = BatchOperationResult() + val registrationInfoFilter = + RegistrationInfoFilter(ids = setOf(entity.id.toUri()), types = entity.types.toSet()) + + val matchingCSR = contextSourceRegistrationService.getContextSourceRegistrations( + filters = csrFilters, + ).groupBy { it.mode } + + val entityAfterExclusive = distributeCreateEntityForContextSources( + matchingCSR[Mode.EXCLUSIVE], // can only be one + registrationInfoFilter, + entity, + contexts, + result + ) + if (entityAfterExclusive == null) return result to null + + val entityAfterRedirect = distributeCreateEntityForContextSources( + matchingCSR[Mode.REDIRECT], + registrationInfoFilter, + entityAfterExclusive, + contexts, + result + ) + if (entityAfterRedirect == null) return result to null + + distributeCreateEntityForContextSources( + matchingCSR[Mode.INCLUSIVE], + registrationInfoFilter, + entityAfterRedirect, + contexts, + result + ) + return result to entityAfterRedirect + } + + internal suspend fun distributeCreateEntityForContextSources( + csrs: List?, + registrationInfoFilter: RegistrationInfoFilter, + entity: ExpandedEntity, + contexts: List, + resultToUpdate: BatchOperationResult + ): ExpandedEntity? { + val allProcessedAttrs = mutableSetOf() + csrs?.forEach { csr -> + csr.getAssociatedAttributes(registrationInfoFilter, entity) + .let { attrs -> + allProcessedAttrs.addAll(attrs) + if (attrs.isEmpty()) Unit + else if (csr.operations.any { + it == Operation.CREATE_ENTITY || + it == Operation.UPDATE_OPS || + it == Operation.REDIRECTION_OPS + } + ) { + postDistributedInformation( + compactEntity(entity.filterAttributes(attrs, emptySet()), contexts), + csr, + createPath + ).fold( + { + resultToUpdate.errors.add( + BatchEntityError( + entityId = entity.id.toUri(), + registrationId = csr.id, + error = it.toProblemDetail() + ) + ) + }, + { resultToUpdate.success.add(BatchEntitySuccess(csr.id)) } + ) + } else if (csr.mode != Mode.INCLUSIVE) { + resultToUpdate.errors.add( + BatchEntityError( + entityId = entity.id.toUri(), + registrationId = csr.id, + error = ConflictException( + "The csr: ${csr.id} does not support the creation of entities" + ).toProblemDetail() + ) + ) + } + } + } + return if (allProcessedAttrs.isNotEmpty()) { + val remainingEntity = entity.omitAttributes(allProcessedAttrs) + if (remainingEntity.hasNonCoreAttributes()) remainingEntity else null + } else entity + } + + internal suspend fun postDistributedInformation( + entity: CompactedEntity, + csr: ContextSourceRegistration, + path: String, + ): Either = either { + val uri = URI("${csr.endpoint}$path") + + val request = WebClient.create() + .method(HttpMethod.POST) + .uri { uriBuilder -> + uriBuilder.scheme(uri.scheme) + .host(uri.host) + .port(uri.port) + .path(uri.path) + .build() + }.headers { newHeaders -> + newHeaders[HttpHeaders.CONTENT_TYPE] = JSON_LD_CONTENT_TYPE + }.bodyValue(entity) + + return runCatching { + val (statusCode, response, _) = request.awaitExchange { response -> + Triple(response.statusCode(), response.awaitBodyOrNull(), response.headers()) + } + if (statusCode.value() == HttpStatus.MULTI_STATUS.value()) { + ContextSourceException( + type = URI("https://uri.etsi.org/ngsi-ld/errors/MultiStatus"), + status = HttpStatus.MULTI_STATUS, + title = "Context source returned 207", + detail = response ?: "no message" + ).left() + } else if (statusCode.is2xxSuccessful) { + logger.info("Successfully post data to CSR ${csr.id} at $uri") + Unit.right() + } else if (response == null) { + val message = "No error message received from CSR ${csr.id} at $uri" + logger.warn(message) + BadGatewayException(message).left() + } else { + logger.warn("Error creating an entity for CSR at $uri: $response") + ContextSourceException.fromResponse(response).left() + } + }.fold( + onSuccess = { it }, + onFailure = { e -> + logger.warn("Error contacting CSR at $uri: ${e.message}") + logger.warn(e.stackTraceToString()) + GatewayTimeoutException( + "Error connecting to CSR at $uri: \"${e.cause}:${e.message}\"" + ).left() + } + ) + } +} diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityOperationService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityOperationService.kt index 833b90c2e..efa5b2f5c 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityOperationService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityOperationService.kt @@ -13,6 +13,7 @@ import com.egm.stellio.search.entity.web.JsonLdNgsiLdEntity import com.egm.stellio.search.entity.web.entityId import com.egm.stellio.shared.model.APIException import com.egm.stellio.shared.model.BadRequestDataException +import com.egm.stellio.shared.model.toAPIException import com.egm.stellio.shared.util.Sub import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @@ -90,7 +91,7 @@ class EntityOperationService( entityService.createEntity(jsonLdNgsiLdEntity.second, jsonLdNgsiLdEntity.first, sub).map { BatchEntitySuccess(jsonLdNgsiLdEntity.entityId()) }.mapLeft { apiException -> - BatchEntityError(jsonLdNgsiLdEntity.entityId(), arrayListOf(apiException.message)) + BatchEntityError(jsonLdNgsiLdEntity.entityId(), apiException.toProblemDetail()) }.bind() } }.fold( @@ -114,7 +115,7 @@ class EntityOperationService( BatchEntitySuccess(id) } .mapLeft { apiException -> - BatchEntityError(id, arrayListOf(apiException.message)) + BatchEntityError(id, apiException.toProblemDetail()) }.bind() } }.fold( @@ -242,10 +243,10 @@ class EntityOperationService( }.map { BatchEntitySuccess(entity.entityId(), it) }.mapLeft { - BatchEntityError(entity.entityId(), arrayListOf(it.message)) + BatchEntityError(entity.entityId(), it.toProblemDetail()) } }.fold( - onFailure = { BatchEntityError(entity.entityId(), arrayListOf(it.message!!)).left() }, + onFailure = { BatchEntityError(entity.entityId(), it.toAPIException().toProblemDetail()).left() }, onSuccess = { it } ) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/BatchAPIResponses.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/BatchAPIResponses.kt index 64c0ac380..2afa9ad83 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/BatchAPIResponses.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/BatchAPIResponses.kt @@ -4,11 +4,18 @@ import com.egm.stellio.search.entity.model.UpdateResult import com.egm.stellio.shared.model.APIException import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.NgsiLdEntity +import com.egm.stellio.shared.model.toErrorResponse import com.egm.stellio.shared.util.toUri import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonValue +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity import java.net.URI +/** + * BatchOperationResult type as defined in 5.2.16 + */ data class BatchOperationResult( val success: MutableList = mutableListOf(), val errors: MutableList = mutableListOf() @@ -25,8 +32,28 @@ data class BatchOperationResult( @JsonIgnore fun addEntitiesToErrors(entities: List>) = entities.forEach { - errors.add(BatchEntityError(it.first.toUri(), arrayListOf(it.second.message))) + errors.add(BatchEntityError(it.first.toUri(), it.second.toProblemDetail())) } + + // the BatchOperationResult is also used for distributed provision operation + // for those endpoint you return a single error if the all operation failed at once + fun toNonBatchEndpointResponse(entityId: URI): ResponseEntity<*> { + val location = URI("/ngsi-ld/v1/entities/$entityId") + return when { + this.success.size == 1 && this.errors.isEmpty() -> + ResponseEntity.status(HttpStatus.CREATED) + .location(location) + .build() + + this.success.isEmpty() && this.errors.size == 1 -> + this.errors.first().error.toErrorResponse() + + else -> + ResponseEntity.status(HttpStatus.MULTI_STATUS) + .location(location) + .body(this) + } + } } data class BatchEntitySuccess( @@ -36,9 +63,13 @@ data class BatchEntitySuccess( val updateResult: UpdateResult? = null ) +/** + * BatchEntityError type as defined in 5.2.17 + */ data class BatchEntityError( val entityId: URI, - val error: MutableList + val error: ProblemDetail, + val registrationId: URI? = null ) typealias JsonLdNgsiLdEntity = Pair diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/EntityHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/EntityHandler.kt index f9b1a0f9d..efcb4ed7f 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/EntityHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/web/EntityHandler.kt @@ -7,6 +7,7 @@ import arrow.core.right import com.egm.stellio.search.csr.model.addWarnings import com.egm.stellio.search.csr.service.ContextSourceUtils import com.egm.stellio.search.csr.service.DistributedEntityConsumptionService +import com.egm.stellio.search.csr.service.DistributedEntityProvisionService import com.egm.stellio.search.entity.service.EntityQueryService import com.egm.stellio.search.entity.service.EntityService import com.egm.stellio.search.entity.service.LinkedEntityService @@ -71,6 +72,7 @@ class EntityHandler( private val entityService: EntityService, private val entityQueryService: EntityQueryService, private val distributedEntityConsumptionService: DistributedEntityConsumptionService, + private val distributedEntityProvisionService: DistributedEntityProvisionService, private val linkedEntityService: LinkedEntityService ) : BaseHandler() { @@ -87,14 +89,25 @@ class EntityHandler( val sub = getSubFromSecurityContext() val (body, contexts) = extractPayloadAndContexts(requestBody, httpHeaders, applicationProperties.contexts.core).bind() - val expandedEntity = expandJsonLdEntity(body, contexts) - val ngsiLdEntity = expandedEntity.toNgsiLdEntity().bind() - entityService.createEntity(ngsiLdEntity, expandedEntity, sub.getOrNull()).bind() + val expandedEntity = expandJsonLdEntity(body, contexts) + expandedEntity.toNgsiLdEntity().bind() + val entityId = expandedEntity.id.toUri() + + val (result, remainingEntity) = distributedEntityProvisionService + .distributeCreateEntity(expandedEntity, contexts) + + if (remainingEntity != null) { + either { + val ngsiLdEntity = remainingEntity.toNgsiLdEntity().bind() + entityService.createEntity(ngsiLdEntity, remainingEntity, sub.getOrNull()).bind() + }.fold( + { result.errors.add(BatchEntityError(entityId, it.toProblemDetail())) }, + { result.success.add(BatchEntitySuccess(entityId)) } // todo what uri here? + ) + } - ResponseEntity.status(HttpStatus.CREATED) - .location(URI("/ngsi-ld/v1/entities/${ngsiLdEntity.id}")) - .build() + result.toNonBatchEndpointResponse(entityId) }.fold( { it.toErrorResponse() }, { it } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/authorization/web/AnonymousUserHandlerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/authorization/web/AnonymousUserHandlerTests.kt index 822c29066..aa87f1719 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/authorization/web/AnonymousUserHandlerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/authorization/web/AnonymousUserHandlerTests.kt @@ -3,6 +3,7 @@ package com.egm.stellio.search.authorization.web import com.egm.stellio.search.authorization.service.AuthorizationService import com.egm.stellio.search.common.config.SearchProperties import com.egm.stellio.search.csr.service.DistributedEntityConsumptionService +import com.egm.stellio.search.csr.service.DistributedEntityProvisionService import com.egm.stellio.search.entity.service.EntityEventService import com.egm.stellio.search.entity.service.EntityQueryService import com.egm.stellio.search.entity.service.EntityService @@ -44,6 +45,9 @@ class AnonymousUserHandlerTests { @MockkBean private lateinit var distributedEntityConsumptionService: DistributedEntityConsumptionService + @MockkBean + private lateinit var distributedEntityProvisionService: DistributedEntityProvisionService + @MockkBean(relaxed = true) private lateinit var linkedEntityService: LinkedEntityService diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/csr/CsrUtils.kt b/search-service/src/test/kotlin/com/egm/stellio/search/csr/CsrUtils.kt index b06c1d9f5..82ee20e9b 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/csr/CsrUtils.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/csr/CsrUtils.kt @@ -1,16 +1,25 @@ package com.egm.stellio.search.csr import com.egm.stellio.search.csr.model.ContextSourceRegistration +import com.egm.stellio.search.csr.model.Mode import com.egm.stellio.search.csr.model.Operation import com.egm.stellio.shared.util.ngsiLdDateTime import com.egm.stellio.shared.util.toUri +import java.net.URI object CsrUtils { - fun gimmeRawCSR() = ContextSourceRegistration( - id = "urn:ngsi-ld:ContextSourceRegistration:test".toUri(), - endpoint = "http://localhost:8089".toUri(), - information = emptyList(), - operations = listOf(Operation.FEDERATION_OPS), + fun gimmeRawCSR( + id: URI = "urn:ngsi-ld:ContextSourceRegistration:test".toUri(), + endpoint: URI = "http://localhost:8089".toUri(), + information: List = emptyList(), + operations: List = listOf(Operation.FEDERATION_OPS), + mode: Mode = Mode.INCLUSIVE + ) = ContextSourceRegistration( + id = id, + endpoint = endpoint, + information = information, + operations = operations, createdAt = ngsiLdDateTime(), + mode = mode ) } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistrationTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistrationTests.kt index 5ae760140..131d83f81 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistrationTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/csr/model/ContextSourceRegistrationTests.kt @@ -2,13 +2,18 @@ package com.egm.stellio.search.csr.model import com.egm.stellio.search.csr.model.ContextSourceRegistration.RegistrationInfo import com.egm.stellio.shared.model.BadRequestDataException +import com.egm.stellio.shared.util.APIC_COMPOUND_CONTEXT import com.egm.stellio.shared.util.BEEHIVE_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CSR_TERM +import com.egm.stellio.shared.util.MANAGED_BY_RELATIONSHIP +import com.egm.stellio.shared.util.NGSILD_NAME_PROPERTY +import com.egm.stellio.shared.util.expandJsonLdEntity import com.egm.stellio.shared.util.shouldFailWith import com.egm.stellio.shared.util.shouldSucceed import com.egm.stellio.shared.util.shouldSucceedAndResult import com.egm.stellio.shared.util.toUri import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test class ContextSourceRegistrationTests { @@ -111,4 +116,82 @@ class ContextSourceRegistrationTests { contextSourceRegistration.validate() .shouldSucceed() } + + private val entityPayload = """ + { + "id": "urn:ngsi-ld:Entity:01", + "type": "Entity", + "name": { + "type": "Property", + "value": "An entity" + }, + "managedBy": { + "type": "Relationship", + "datasetId": "urn:ngsi-ld:Dataset:french-name", + "object": "urn:ngsi-ld:Apiculteur:1230" + }, + "@context": [ "$APIC_COMPOUND_CONTEXT" ] + } + """.trimIndent() + + @Test + fun `getAssociatedAttributes should check properties and relationship separately`() = runTest { + val entity = expandJsonLdEntity(entityPayload) + val registrationInfoFilter = RegistrationInfoFilter( + ids = setOf(entity.id.toUri()), + types = entity.types.toSet() + ) + val entityInfo = ContextSourceRegistration.EntityInfo(entity.id.toUri(), types = entity.types) + val information = RegistrationInfo( + entities = listOf(entityInfo), + propertyNames = listOf(NGSILD_NAME_PROPERTY), + relationshipNames = listOf(MANAGED_BY_RELATIONSHIP) + ) + + val csr = ContextSourceRegistration( + endpoint = "http://my:csr".toUri(), + information = listOf(information) + ) + + val attrs = csr.getAssociatedAttributes(registrationInfoFilter, entity) + assertThat(attrs).contains(NGSILD_NAME_PROPERTY, MANAGED_BY_RELATIONSHIP) + + val invertedCsr = ContextSourceRegistration( + endpoint = "http://my:csr".toUri(), + information = listOf( + RegistrationInfo( + entities = listOf(entityInfo), + propertyNames = listOf(MANAGED_BY_RELATIONSHIP), + relationshipNames = listOf(NGSILD_NAME_PROPERTY) + ) + ) + ) + val inversedAttrs = invertedCsr.getAssociatedAttributes(registrationInfoFilter, entity) + + assertThat(inversedAttrs).doesNotContain(NGSILD_NAME_PROPERTY, MANAGED_BY_RELATIONSHIP) + } + + @Test + fun `getAssociatedAttributes should not get Attributes for non matching registrationInfo`() = runTest { + val entity = expandJsonLdEntity(entityPayload) + + val registrationInfoFilter = RegistrationInfoFilter( + ids = setOf(entity.id.toUri()), + types = entity.types.toSet() + ) + val nonMatchingEntityInfo = ContextSourceRegistration.EntityInfo(types = listOf(BEEHIVE_TYPE)) + val nonMatchingInformation = RegistrationInfo( + entities = listOf(nonMatchingEntityInfo), + propertyNames = listOf(NGSILD_NAME_PROPERTY), + relationshipNames = listOf(MANAGED_BY_RELATIONSHIP) + ) + + val csr = ContextSourceRegistration( + endpoint = "http://my:csr".toUri(), + information = listOf(nonMatchingInformation) + ) + + val attrs = csr.getAssociatedAttributes(registrationInfoFilter, entity) + assertThat(attrs).doesNotContain(NGSILD_NAME_PROPERTY, MANAGED_BY_RELATIONSHIP) + } } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionServiceTests.kt new file mode 100644 index 000000000..91e76eb93 --- /dev/null +++ b/search-service/src/test/kotlin/com/egm/stellio/search/csr/service/DistributedEntityProvisionServiceTests.kt @@ -0,0 +1,305 @@ +package com.egm.stellio.search.csr.service + +import arrow.core.left +import arrow.core.right +import com.egm.stellio.search.csr.CsrUtils.gimmeRawCSR +import com.egm.stellio.search.csr.model.Mode +import com.egm.stellio.search.csr.model.Operation +import com.egm.stellio.search.csr.model.RegistrationInfoFilter +import com.egm.stellio.search.entity.web.BatchEntitySuccess +import com.egm.stellio.search.entity.web.BatchOperationResult +import com.egm.stellio.search.support.WithKafkaContainer +import com.egm.stellio.search.support.WithTimescaleContainer +import com.egm.stellio.shared.model.ContextSourceException +import com.egm.stellio.shared.model.ErrorType +import com.egm.stellio.shared.model.GatewayTimeoutException +import com.egm.stellio.shared.util.APIC_COMPOUND_CONTEXT +import com.egm.stellio.shared.util.JsonLdUtils.compactEntity +import com.egm.stellio.shared.util.NGSILD_NAME_PROPERTY +import com.egm.stellio.shared.util.TEMPERATURE_PROPERTY +import com.egm.stellio.shared.util.expandJsonLdEntity +import com.egm.stellio.shared.util.toUri +import com.github.tomakehurst.wiremock.client.WireMock.badRequest +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import com.github.tomakehurst.wiremock.client.WireMock.urlMatching +import com.github.tomakehurst.wiremock.junit5.WireMockTest +import com.ninjasquad.springmockk.MockkBean +import com.ninjasquad.springmockk.SpykBean +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles +import java.net.URI + +@SpringBootTest +@WireMockTest(httpPort = 8089) +@ActiveProfiles("test") +class DistributedEntityProvisionServiceTests : WithTimescaleContainer, WithKafkaContainer() { + + @SpykBean + private lateinit var distributedEntityProvisionService: DistributedEntityProvisionService + + @MockkBean + private lateinit var contextSourceRegistrationService: ContextSourceRegistrationService + + private val apiaryId = "urn:ngsi-ld:Apiary:TEST" + + private val entity = + """ + { + "id":"$apiaryId", + "type":"Apiary", + "name": { + "type":"Property", + "value":"ApiarySophia" + }, + "name": { + "type":"Property", + "value":"ApiarySophia" + }, + "temperature":{ + "type":"Property", + "value":"19", + "unitCode":"Cel" + }, + + "@context":[ "$APIC_COMPOUND_CONTEXT" ] + } + """.trimIndent() + + private val validErrorResponse = + """ + { + "type":"${ErrorType.BAD_REQUEST_DATA.type}", + "status": ${HttpStatus.BAD_REQUEST.value()}, + "title": "A valid error", + "detail":"The detail of the valid Error" + } + """.trimIndent() + + private val invalidErrorResponse = "error message not respecting specification" + + private val contextSourceException = ContextSourceException( + type = ErrorType.BAD_REQUEST_DATA.type, + status = HttpStatus.MULTI_STATUS, + title = "A valid error", + detail = "The detail of the valid Error" + ) + private val contexts = listOf(APIC_COMPOUND_CONTEXT) + + @Test + fun `distributeCreateEntity should return the remainingEntity`() = runTest { + val firstExclusiveCsr = gimmeRawCSR(id = "id:exclusive:1".toUri(), mode = Mode.EXCLUSIVE) + val firstRedirectCsr = gimmeRawCSR(id = "id:redirect:1".toUri(), mode = Mode.REDIRECT) + val firstInclusiveCsr = gimmeRawCSR(id = "id:inclusive:1".toUri(), mode = Mode.INCLUSIVE) + val secondRedirectCsr = gimmeRawCSR(id = "id:redirect:2".toUri(), mode = Mode.REDIRECT) + val secondInclusiveCsr = gimmeRawCSR(id = "id:inclusive:2;".toUri(), mode = Mode.INCLUSIVE) + + val entryEntity = expandJsonLdEntity(entity) + val entityWithIgnoredTemperature = entryEntity.omitAttributes(setOf(TEMPERATURE_PROPERTY)) + val entityWithIgnoredTemperatureAndName = entryEntity.omitAttributes(setOf(NGSILD_NAME_PROPERTY)) + + coEvery { + distributedEntityProvisionService.distributeCreateEntityForContextSources(any(), any(), any(), any(), any()) + } returns + entityWithIgnoredTemperature andThen entityWithIgnoredTemperatureAndName andThen null + + coEvery { + contextSourceRegistrationService.getContextSourceRegistrations(any(), any(), any()) + } returns listOf(firstInclusiveCsr, firstExclusiveCsr, firstRedirectCsr, secondInclusiveCsr, secondRedirectCsr) + + val (_, remainingEntity) = distributedEntityProvisionService.distributeCreateEntity( + entryEntity, + contexts + ) + + assertEquals(entityWithIgnoredTemperatureAndName, remainingEntity) + + coVerifyOrder { + distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(firstExclusiveCsr), + any(), + entryEntity, + any(), + any() + ) + distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(firstRedirectCsr, secondRedirectCsr), + any(), + entityWithIgnoredTemperature, + any(), + any() + ) + distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(firstInclusiveCsr, secondInclusiveCsr), + any(), + entityWithIgnoredTemperatureAndName, + any(), + any() + ) + } + } + + @Test + fun `distributeCreateEntityForContextSources should update the result`() = runTest { + val csr = spyk(gimmeRawCSR(operations = listOf(Operation.UPDATE_OPS))) + val firstURI = URI("id:1") + val secondURI = URI("id:2") + coEvery { + distributedEntityProvisionService.postDistributedInformation(any(), any(), any()) + } returns contextSourceException.left() andThen Unit.right() + + coEvery { + csr.getAssociatedAttributes(any(), any()) + } returns setOf(NGSILD_NAME_PROPERTY) andThen setOf(TEMPERATURE_PROPERTY) + coEvery { + csr.id + } returns firstURI andThen secondURI + + val result = BatchOperationResult() + + distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(csr, csr), + RegistrationInfoFilter(), + expandJsonLdEntity(entity), + contexts, + result + ) + + assertThat(result.success).hasSize(1) + assertThat(result.success).contains(BatchEntitySuccess(secondURI)) + assertThat(result.errors).hasSize(1) + assertEquals(firstURI, result.errors.first().registrationId) + assertEquals(contextSourceException.message, result.errors.first().error.title) + } + + @Test + fun `distributeCreateEntityForContextSources should return null if all the entity have been processed`() = runTest { + val csr = spyk(gimmeRawCSR()) + coEvery { + csr.getAssociatedAttributes(any(), any()) + } returns setOf(NGSILD_NAME_PROPERTY) andThen setOf(TEMPERATURE_PROPERTY) + + coEvery { + distributedEntityProvisionService.postDistributedInformation(any(), any(), any()) + } returns contextSourceException.left() andThen Unit.right() + + val entity = distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(csr, csr), + RegistrationInfoFilter(), + expandJsonLdEntity(entity), + contexts, + BatchOperationResult() + ) + + assertNull(entity) + } + + @Test + fun `distributeCreateEntityForContextSources should return only non processed attributes`() = runTest { + val csr = spyk(gimmeRawCSR()) + coEvery { + csr.getAssociatedAttributes(any(), any()) + } returns setOf(NGSILD_NAME_PROPERTY) + + coEvery { + distributedEntityProvisionService.postDistributedInformation(any(), any(), any()) + } returns Unit.right() + + val successEntity = distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(csr), + RegistrationInfoFilter(), + expandJsonLdEntity(entity), + contexts, + BatchOperationResult() + ) + + assertNull(successEntity?.getAttributes()?.get(NGSILD_NAME_PROPERTY)) + assertNotNull(successEntity?.getAttributes()?.get(TEMPERATURE_PROPERTY)) + } + + @Test + fun `distributeCreateEntityForContextSources should remove the attrs even when the csr is in error`() = runTest { + val csr = spyk(gimmeRawCSR()) + + coEvery { + csr.getAssociatedAttributes(any(), any()) + } returns setOf(NGSILD_NAME_PROPERTY) + coEvery { + distributedEntityProvisionService.postDistributedInformation(any(), any(), any()) + } returns contextSourceException.left() + + val errorEntity = distributedEntityProvisionService.distributeCreateEntityForContextSources( + listOf(csr), + RegistrationInfoFilter(), + expandJsonLdEntity(entity), + contexts, + BatchOperationResult() + ) + + assertNull(errorEntity?.getAttributes()?.get(NGSILD_NAME_PROPERTY)) + assertNotNull(errorEntity?.getAttributes()?.get(TEMPERATURE_PROPERTY)) + } + + @Test + fun `postDistributedInformation should process badly formed errors`() = runTest { + val csr = gimmeRawCSR() + val path = "/ngsi-ld/v1/entities" + + stubFor( + post(urlMatching(path)) + .willReturn(badRequest().withBody(invalidErrorResponse)) + ) + + val entity = compactEntity(expandJsonLdEntity(entity), listOf(APIC_COMPOUND_CONTEXT)) + val response = distributedEntityProvisionService.postDistributedInformation(entity, csr, path) + + assertTrue(response.isLeft()) + assertEquals(response.leftOrNull()?.type, ErrorType.BAD_GATEWAY.type) + assertEquals(response.leftOrNull()?.status, HttpStatus.BAD_GATEWAY) + assertEquals(response.leftOrNull()?.detail, invalidErrorResponse) + } + + @Test + fun `postDistributedInformation should return the received error`() = runTest { + val csr = gimmeRawCSR() + val path = "/ngsi-ld/v1/entities" + + stubFor( + post(urlMatching(path)) + .willReturn(badRequest().withBody(validErrorResponse)) + ) + + val entity = compactEntity(expandJsonLdEntity(entity), listOf(APIC_COMPOUND_CONTEXT)) + val response = distributedEntityProvisionService.postDistributedInformation(entity, csr, path) + + assertTrue(response.isLeft()) + assertInstanceOf(ContextSourceException::class.java, response.leftOrNull()) + assertEquals(response.leftOrNull()?.type, ErrorType.BAD_REQUEST_DATA.type) + assertEquals(response.leftOrNull()?.status, HttpStatus.BAD_REQUEST) + assertEquals(response.leftOrNull()?.detail, "The detail of the valid Error") + assertEquals(response.leftOrNull()?.message, "A valid error") + } + + @Test + fun `postDistributedInformation should return a GateWayTimeOut if it receives no answer`() = runTest { + val csr = gimmeRawCSR().copy(endpoint = "http://localhost:invalid".toUri()) + val path = "/ngsi-ld/v1/entities" + val entity = compactEntity(expandJsonLdEntity(entity), listOf(APIC_COMPOUND_CONTEXT)) + val response = distributedEntityProvisionService.postDistributedInformation(entity, csr, path) + + assertTrue(response.isLeft()) + assertInstanceOf(GatewayTimeoutException::class.java, response.leftOrNull()) + } +} diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityOperationServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityOperationServiceTests.kt index 004a15c6f..28e57cfd5 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityOperationServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityOperationServiceTests.kt @@ -9,6 +9,7 @@ import com.egm.stellio.search.entity.web.BatchEntityError import com.egm.stellio.search.entity.web.BatchEntitySuccess import com.egm.stellio.search.entity.web.BatchOperationResult import com.egm.stellio.search.entity.web.JsonLdNgsiLdEntity +import com.egm.stellio.shared.model.AccessDeniedException import com.egm.stellio.shared.model.BadRequestDataException import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.InternalErrorException @@ -126,12 +127,14 @@ class EntityOperationServiceTests { @Test fun `processEntities should count as error a process which raises a BadRequestDataException`() = runTest { + val error = BadRequestDataException("error") + coEvery { entityService.appendAttributes(firstEntityURI, any(), any(), any()) } returns EMPTY_UPDATE_RESULT.right() coEvery { entityService.appendAttributes(secondEntityURI, any(), any(), any()) - } returns BadRequestDataException("error").left() + } returns error.left() val batchOperationResult = entityOperationService.processEntities( @@ -149,7 +152,7 @@ class EntityOperationServiceTests { batchOperationResult.success ) assertEquals( - listOf(BatchEntityError(secondEntityURI, arrayListOf("error"))), + listOf(BatchEntityError(secondEntityURI, error.toProblemDetail())), batchOperationResult.errors ) } @@ -188,7 +191,7 @@ class EntityOperationServiceTests { listOf( BatchEntityError( secondEntityURI, - arrayListOf("attribute#1 : reason, attribute#2 : reason") + BadRequestDataException("attribute#1 : reason, attribute#2 : reason").toProblemDetail() ) ), batchOperationResult.errors @@ -223,10 +226,11 @@ class EntityOperationServiceTests { @Test fun `batch create should ask to create entities and transmit back any error`() = runTest { + val badRequestException = BadRequestDataException("Invalid entity") coEvery { entityService.createEntity(firstEntity, any(), any()) } returns Unit.right() coEvery { entityService.createEntity(secondEntity, any(), any()) - } returns BadRequestDataException("Invalid entity").left() + } returns badRequestException.left() val batchOperationResult = entityOperationService.create( listOf( @@ -239,7 +243,7 @@ class EntityOperationServiceTests { assertEquals(arrayListOf(BatchEntitySuccess(firstEntityURI)), batchOperationResult.success) assertEquals( arrayListOf( - BatchEntityError(secondEntityURI, arrayListOf("Invalid entity")) + BatchEntityError(secondEntityURI, badRequestException.toProblemDetail()) ), batchOperationResult.errors ) @@ -329,10 +333,11 @@ class EntityOperationServiceTests { @Test fun `batch delete should return deleted entity ids and in errors when deletion is partially successful`() = runTest { + val internalError = InternalErrorException("Something went wrong during deletion") coEvery { entityService.deleteEntity(firstEntityURI, sub) } returns Unit.right() coEvery { entityService.deleteEntity(secondEntityURI, sub) - } returns InternalErrorException("Something went wrong during deletion").left() + } returns internalError.left() val batchOperationResult = entityOperationService.delete( listOf( @@ -350,7 +355,7 @@ class EntityOperationServiceTests { listOf( BatchEntityError( secondEntityURI, - mutableListOf("Something went wrong during deletion") + internalError.toProblemDetail() ) ), batchOperationResult.errors @@ -359,11 +364,11 @@ class EntityOperationServiceTests { @Test fun `batch delete should return error messages when deletion in DB has failed`() = runTest { - val deleteEntityErrorMessage = "Something went wrong with deletion request" + val deleteEntityError = InternalErrorException("Something went wrong with deletion request") coEvery { entityService.deleteEntity(any(), any()) - } returns InternalErrorException(deleteEntityErrorMessage).left() + } returns deleteEntityError.left() val batchOperationResult = entityOperationService.delete( listOf( @@ -378,11 +383,11 @@ class EntityOperationServiceTests { listOf( BatchEntityError( firstEntityURI, - mutableListOf(deleteEntityErrorMessage) + deleteEntityError.toProblemDetail() ), BatchEntityError( secondEntityURI, - mutableListOf(deleteEntityErrorMessage) + deleteEntityError.toProblemDetail() ) ), batchOperationResult.errors @@ -548,11 +553,21 @@ class EntityOperationServiceTests { coEvery { entityOperationService.create(any(), any()) } returns BatchOperationResult( emptyList().toMutableList(), - arrayListOf(BatchEntityError(firstEntity.id, mutableListOf(ENTITIY_CREATION_FORBIDDEN_MESSAGE))) + arrayListOf( + BatchEntityError( + firstEntity.id, + AccessDeniedException(ENTITIY_CREATION_FORBIDDEN_MESSAGE).toProblemDetail() + ) + ) ) coEvery { entityOperationService.replace(any(), any()) } returns BatchOperationResult( emptyList().toMutableList(), - arrayListOf(BatchEntityError(secondEntity.id, mutableListOf(ENTITY_ADMIN_FORBIDDEN_MESSAGE))) + arrayListOf( + BatchEntityError( + secondEntity.id, + AccessDeniedException(ENTITY_ADMIN_FORBIDDEN_MESSAGE).toProblemDetail() + ) + ) ) val (batchOperationResult, createdIds) = entityOperationService.upsert( diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt index 6202d8652..456870d4b 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt @@ -7,6 +7,7 @@ import com.egm.stellio.search.csr.CsrUtils.gimmeRawCSR import com.egm.stellio.search.csr.model.MiscellaneousWarning import com.egm.stellio.search.csr.model.NGSILDWarning import com.egm.stellio.search.csr.service.DistributedEntityConsumptionService +import com.egm.stellio.search.csr.service.DistributedEntityProvisionService import com.egm.stellio.search.entity.model.EntitiesQueryFromGet import com.egm.stellio.search.entity.model.NotUpdatedDetails import com.egm.stellio.search.entity.model.UpdateResult @@ -18,7 +19,9 @@ import com.egm.stellio.shared.model.AccessDeniedException import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.model.BadRequestDataException import com.egm.stellio.shared.model.CompactedEntity +import com.egm.stellio.shared.model.ConflictException import com.egm.stellio.shared.model.DEFAULT_DETAIL +import com.egm.stellio.shared.model.ErrorType import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.InternalErrorException import com.egm.stellio.shared.model.NgsiLdEntity @@ -110,6 +113,9 @@ class EntityHandlerTests { @MockkBean private lateinit var distributedEntityConsumptionService: DistributedEntityConsumptionService + @MockkBean + private lateinit var distributedEntityProvisionService: DistributedEntityProvisionService + @MockkBean(relaxed = true) private lateinit var linkedEntityService: LinkedEntityService @@ -126,7 +132,7 @@ class EntityHandlerTests { } @BeforeEach - fun mockCSR() { + fun mockNoCSR() { coEvery { distributedEntityConsumptionService .distributeRetrieveEntityOperation(any(), any(), any()) @@ -135,6 +141,11 @@ class EntityHandlerTests { distributedEntityConsumptionService .distributeQueryEntitiesOperation(any(), any(), any()) } returns Triple(emptyList(), emptyList(), emptyList()) + val capturedExpandedEntity = slot() + coEvery { + distributedEntityProvisionService + .distributeCreateEntity(capture(capturedExpandedEntity), any()) + } answers { BatchOperationResult() to capturedExpandedEntity.captured } } private val beehiveId = "urn:ngsi-ld:BeeHive:TESTC".toUri() @@ -351,6 +362,98 @@ class EntityHandlerTests { ) } + @Test + fun `create entity should return a 207 if some contextSources failed`() { + val jsonLdFile = ClassPathResource("/ngsild/aquac/breedingService.jsonld") + val capturedExpandedEntity = slot() + + val error = BatchEntityError( + entityId = "urn:ngsi-ld:BreedingService:0214".toUri(), + error = ConflictException("error").toProblemDetail(), + registrationId = "id:2".toUri() + ) + val firstResult = BatchOperationResult( + mutableListOf(BatchEntitySuccess("id:1".toUri())), + mutableListOf(error) + ) + coEvery { + distributedEntityProvisionService + .distributeCreateEntity(capture(capturedExpandedEntity), any()) + } answers { + firstResult to capturedExpandedEntity.captured + } + coEvery { + entityService.createEntity(any(), any(), sub.getOrNull()) + } returns AccessDeniedException("User forbidden to create entities").left() + + val expectedJson = """ + { + "success": ["id:1"], + "errors":[ + { + "entityId":"urn:ngsi-ld:BreedingService:0214", + "error":{ + "type": "https://uri.etsi.org/ngsi-ld/errors/Conflict", + "title": "error" + }, + "registrationId": "id:2" + }, + { + "entityId":"urn:ngsi-ld:BreedingService:0214", + "error":{ + "type": "https://uri.etsi.org/ngsi-ld/errors/AccessDenied", + "title": "User forbidden to create entities" + }, + "registrationId":null + } + ] + } + """.trimIndent() + + webClient.post() + .uri("/ngsi-ld/v1/entities") + .bodyValue(jsonLdFile) + .exchange() + .expectStatus().isEqualTo(207) + .expectBody().json(expectedJson) + } + + @Test + fun `create entity should return a 409 if a context source containing all the information return a conflict`() { + val jsonLdFile = ClassPathResource("/ngsild/aquac/breedingService.jsonld") + val capturedExpandedEntity = slot() + + val error = BatchEntityError( + entityId = "entity:1".toUri(), + error = ConflictException("my test message").toProblemDetail(), + registrationId = "i:2".toUri() + ) + val result = BatchOperationResult( + mutableListOf(), + mutableListOf(error) + ) + coEvery { + distributedEntityProvisionService + .distributeCreateEntity(capture(capturedExpandedEntity), any()) + } answers { + result to null + } + webClient.post() + .uri("/ngsi-ld/v1/entities") + .bodyValue(jsonLdFile) + .exchange() + .expectStatus().isEqualTo(409) + .expectBody().json( + """ + { + "type": "${ErrorType.CONFLICT.type}", + "title": "my test message", + "detail": "$DEFAULT_DETAIL" + } + """.trimIndent() + ) + } + fun initializeRetrieveEntityMocks() { val compactedEntity = slot() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityOperationHandlerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityOperationHandlerTests.kt index 085563554..c4cd4d563 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityOperationHandlerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityOperationHandlerTests.kt @@ -8,9 +8,13 @@ import com.egm.stellio.search.entity.model.UpdateResult import com.egm.stellio.search.entity.service.EntityOperationService import com.egm.stellio.search.entity.service.EntityQueryService import com.egm.stellio.shared.config.ApplicationProperties +import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.model.DEFAULT_DETAIL +import com.egm.stellio.shared.model.ErrorType import com.egm.stellio.shared.model.ExpandedEntity +import com.egm.stellio.shared.model.InternalErrorException import com.egm.stellio.shared.model.NgsiLdEntity +import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.util.BEEHIVE_TYPE import com.egm.stellio.shared.util.DEVICE_TYPE import com.egm.stellio.shared.util.ENTITY_ALREADY_EXISTS_MESSAGE @@ -151,8 +155,14 @@ class EntityOperationHandlerTests { fun `update batch entity should return a 207 if JSON-LD payload contains update errors`() = runTest { val jsonLdFile = validJsonFile val errors = arrayListOf( - BatchEntityError(temperatureSensorUri, arrayListOf("Update unexpectedly failed.")), - BatchEntityError(dissolvedOxygenSensorUri, arrayListOf("Update unexpectedly failed.")) + BatchEntityError( + temperatureSensorUri, + InternalErrorException("Update unexpectedly failed.").toProblemDetail() + ), + BatchEntityError( + dissolvedOxygenSensorUri, + InternalErrorException("Update unexpectedly failed.").toProblemDetail() + ) ) coEvery { entityOperationService.update(any(), any(), any()) } returns BatchOperationResult( @@ -171,11 +181,17 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"https://uri.etsi.org/ngsi-ld/errors/InternalError", + "title":"Update unexpectedly failed." + } }, { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1dissolvedOxygen", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"https://uri.etsi.org/ngsi-ld/errors/InternalError", + "title":"Update unexpectedly failed." + } } ], "success": [] @@ -204,7 +220,10 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX2temperature", - "error": [ "Unable to expand input payload" ] + "error": { + "type":"${ErrorType.BAD_REQUEST_DATA.type}", + "title":"Unable to expand input payload" + } } ], "success": [ @@ -265,7 +284,12 @@ class EntityOperationHandlerTests { coEvery { entityOperationService.create(any(), any()) } returns BatchOperationResult( createdEntitiesIds.map { BatchEntitySuccess(it) }.toMutableList(), - mutableListOf(BatchEntityError(temperatureSensorUri, mutableListOf(ENTITY_ALREADY_EXISTS_MESSAGE))) + mutableListOf( + BatchEntityError( + temperatureSensorUri, + AlreadyExistsException(ENTITY_ALREADY_EXISTS_MESSAGE).toProblemDetail() + ) + ) ) webClient.post() @@ -279,7 +303,10 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature", - "error": [ "Entity already exists" ] + "error": { + "type":"${ErrorType.ALREADY_EXISTS.type}", + "title":"Entity already exists" + } } ], "success": [ @@ -357,9 +384,16 @@ class EntityOperationHandlerTests { @Test fun `upsert batch entity should return a 207 if JSON-LD payload contains update errors`() = runTest { val jsonLdFile = validJsonFile + val errorMessage = "Update unexpectedly failed." val errors = arrayListOf( - BatchEntityError(temperatureSensorUri, arrayListOf("Update unexpectedly failed.")), - BatchEntityError(dissolvedOxygenSensorUri, arrayListOf("Update unexpectedly failed.")) + BatchEntityError( + temperatureSensorUri, + InternalErrorException(errorMessage).toProblemDetail() + ), + BatchEntityError( + dissolvedOxygenSensorUri, + InternalErrorException(errorMessage).toProblemDetail() + ) ) coEvery { entityOperationService.upsert(any(), any(), any(), any()) } returns ( @@ -380,11 +414,17 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"${ErrorType.INTERNAL_ERROR.type}", + "title":"$errorMessage" + } }, { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1dissolvedOxygen", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"${ErrorType.INTERNAL_ERROR.type}", + "title":"$errorMessage" + } } ], "success": [ "urn:ngsi-ld:Device:HCMR-AQUABOX1" ] @@ -459,7 +499,10 @@ class EntityOperationHandlerTests { BatchOperationResult( mutableListOf(BatchEntitySuccess(temperatureSensorUri), BatchEntitySuccess(dissolvedOxygenSensorUri)), mutableListOf( - BatchEntityError(deviceUri, mutableListOf(ENTITY_DOES_NOT_EXIST_MESSAGE)) + BatchEntityError( + deviceUri, + ResourceNotFoundException(ENTITY_DOES_NOT_EXIST_MESSAGE).toProblemDetail() + ) ) ) @@ -475,8 +518,13 @@ class EntityOperationHandlerTests { { "success": ["urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature","urn:ngsi-ld:Sensor:HCMR-AQUABOX1dissolvedOxygen"], "errors": [ - {"entityId":"urn:ngsi-ld:Device:HCMR-AQUABOX1", - "error":["$ENTITY_DOES_NOT_EXIST_MESSAGE"]} + { + "entityId":"urn:ngsi-ld:Device:HCMR-AQUABOX1", + "error": { + "type":"${ErrorType.RESOURCE_NOT_FOUND.type}", + "title":"$ENTITY_DOES_NOT_EXIST_MESSAGE" + } + } ] } """.trimIndent() @@ -542,9 +590,10 @@ class EntityOperationHandlerTests { @Test fun `merge batch entity should return a 207 if JSON-LD payload contains update errors`() = runTest { val jsonLdFile = validJsonFile + val internalError = InternalErrorException("Update unexpectedly failed.").toProblemDetail() val errors = arrayListOf( - BatchEntityError(temperatureSensorUri, arrayListOf("Update unexpectedly failed.")), - BatchEntityError(dissolvedOxygenSensorUri, arrayListOf("Update unexpectedly failed.")) + BatchEntityError(temperatureSensorUri, internalError), + BatchEntityError(dissolvedOxygenSensorUri, internalError) ) coEvery { entityOperationService.merge(any(), any()) } returns BatchOperationResult( @@ -563,11 +612,17 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"${ErrorType.INTERNAL_ERROR.type}", + "title":"Update unexpectedly failed." + } }, { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX1dissolvedOxygen", - "error": [ "Update unexpectedly failed." ] + "error": { + "type":"${ErrorType.INTERNAL_ERROR.type}", + "title":"Update unexpectedly failed." + } } ], "success": [] @@ -596,7 +651,10 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Sensor:HCMR-AQUABOX2temperature", - "error": [ "Unable to expand input payload" ] + "error": { + "type":"${ErrorType.BAD_REQUEST_DATA.type}", + "title":"Unable to expand input payload" + } } ], "success": [ @@ -618,7 +676,12 @@ class EntityOperationHandlerTests { coEvery { entityOperationService.merge(any(), any()) } returns BatchOperationResult( success = mutableListOf(BatchEntitySuccess(temperatureSensorUri, mockkClass(UpdateResult::class))), - errors = mutableListOf(BatchEntityError(deviceUri, mutableListOf("Entity does not exist"))) + errors = mutableListOf( + BatchEntityError( + deviceUri, + ResourceNotFoundException("Entity does not exist").toProblemDetail() + ) + ) ) webClient.post() @@ -632,7 +695,10 @@ class EntityOperationHandlerTests { "errors": [ { "entityId": "urn:ngsi-ld:Device:HCMR-AQUABOX1", - "error": [ "Entity does not exist" ] + "error": { + "type":"${ErrorType.RESOURCE_NOT_FOUND.type}", + "title":"Entity does not exist" + } } ], "success": [ "urn:ngsi-ld:Sensor:HCMR-AQUABOX1temperature" ] diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/ApiExceptions.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/ApiExceptions.kt index efbbcce5c..8116fd72b 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/ApiExceptions.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/ApiExceptions.kt @@ -2,6 +2,9 @@ package com.egm.stellio.shared.model import com.apicatalog.jsonld.JsonLdError import com.apicatalog.jsonld.JsonLdErrorCode +import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap +import com.egm.stellio.shared.util.toUri +import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -13,27 +16,96 @@ const val DEFAULT_DETAIL = "If you have difficulty identifying the exact cause o "please check the list of some usual causes on https://stellio.readthedocs.io/en/latest/TROUBLESHOOT.html . " + "If the error is still not clear or if you think it is a bug, feel free to open an issue on " + "https://github.com/stellio-hub/stellio-context-broker" +const val TYPE_PROPERTY = "type" +const val TITLE_PROPERTY = "title" +const val STATUS_PROPERTY = "status" +const val DETAIL_PROPERTY = "detail" sealed class APIException( - val type: URI, - val status: HttpStatus, + open val type: URI, + open val status: HttpStatus, override val message: String, open val detail: String = DEFAULT_DETAIL ) : Exception(message) { - private val logger = LoggerFactory.getLogger(javaClass) fun toProblemDetail(): ProblemDetail = ProblemDetail.forStatusAndDetail(status, this.detail).also { it.title = this.message it.type = this.type } fun toErrorResponse(): ResponseEntity { - logger.info("Returning error ${this.type} (${this.message})") - return ResponseEntity.status(status) - .contentType(MediaType.APPLICATION_JSON) - .body(toProblemDetail()) + return toProblemDetail().toErrorResponse() + } + + companion object { + val logger: Logger = LoggerFactory.getLogger(APIException::class.java) + } +} + +fun ProblemDetail.toErrorResponse(): ResponseEntity { + APIException.logger.info("Returning error ${this.type} (${this.title})") + return ResponseEntity.status(this.status) + .contentType(MediaType.APPLICATION_JSON) + .body(this) +} + +data class ContextSourceException( + override val type: URI, + override val status: HttpStatus, + val title: String, + override val detail: String +) : APIException( + type = type, + status = status, + message = title, + detail = detail, +) { + companion object { + fun fromResponse(response: String): ContextSourceException = + kotlin.runCatching { + val responseMap = response.deserializeAsMap() + // mandatory + val type = responseMap[TYPE_PROPERTY].toString().toUri() + val title = responseMap[TITLE_PROPERTY]!!.toString() + + // optional + val status = responseMap[STATUS_PROPERTY]?.let { HttpStatus.valueOf(it as Int) } + val detail = responseMap[DETAIL_PROPERTY]?.toString() + + ContextSourceException( + type = type, + status = status ?: HttpStatus.BAD_GATEWAY, + title = title, + detail = detail ?: "The context source provided no additional detail about the error" + ) + }.fold({ it }, { + ContextSourceException( + ErrorType.BAD_GATEWAY.type, + HttpStatus.BAD_GATEWAY, + "The context source sent a badly formed error", + response + ) + }) } } +data class ConflictException(override val message: String) : APIException( + ErrorType.CONFLICT.type, + HttpStatus.CONFLICT, + message +) + +data class GatewayTimeoutException(override val message: String) : APIException( + ErrorType.GATEWAY_TIMEOUT.type, + HttpStatus.GATEWAY_TIMEOUT, + message +) + +data class BadGatewayException(override val message: String) : APIException( + ErrorType.BAD_GATEWAY.type, + HttpStatus.BAD_GATEWAY, + message +) + data class AlreadyExistsException(override val message: String) : APIException( ErrorType.ALREADY_EXISTS.type, HttpStatus.CONFLICT, @@ -138,6 +210,9 @@ enum class ErrorType(val type: URI) { INVALID_REQUEST(URI("https://uri.etsi.org/ngsi-ld/errors/InvalidRequest")), BAD_REQUEST_DATA(URI("https://uri.etsi.org/ngsi-ld/errors/BadRequestData")), ALREADY_EXISTS(URI("https://uri.etsi.org/ngsi-ld/errors/AlreadyExists")), + CONFLICT(URI("https://uri.etsi.org/ngsi-ld/errors/Conflict")), // defined only in 6.3.17 + BAD_GATEWAY(URI("https://uri.etsi.org/ngsi-ld/errors/BadGateway")), // defined only in 6.3.17 + GATEWAY_TIMEOUT(URI("https://uri.etsi.org/ngsi-ld/errors/GatewayTimeout")), // defined only in 6.3.17 OPERATION_NOT_SUPPORTED(URI("https://uri.etsi.org/ngsi-ld/errors/OperationNotSupported")), RESOURCE_NOT_FOUND(URI("https://uri.etsi.org/ngsi-ld/errors/ResourceNotFound")), INTERNAL_ERROR(URI("https://uri.etsi.org/ngsi-ld/errors/InternalError")), diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedEntity.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedEntity.kt index 943b434f8..5de6ea1af 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedEntity.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedEntity.kt @@ -26,6 +26,11 @@ data class ExpandedEntity( Unit.right() else ResourceNotFoundException(entityOrAttrsNotFoundMessage(id, expandedAttributes)).left() + fun hasNonCoreAttributes(): Boolean = + members.keys.any { + !JSONLD_EXPANDED_ENTITY_CORE_MEMBERS.contains(it) + } + fun getAttributes(): ExpandedAttributes = members.filter { !JSONLD_EXPANDED_ENTITY_CORE_MEMBERS.contains(it.key) } .mapValues { castAttributeValue(it.value) } @@ -110,6 +115,10 @@ data class ExpandedEntity( }.ifEmpty { null } } ) + + fun omitAttributes(attributes: Set): ExpandedEntity = ExpandedEntity( + members.filterKeys { it !in attributes } + ) } fun List.filterAttributes( diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt index 08242e58f..46710c8ee 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt @@ -54,7 +54,7 @@ fun ExpandedAttributes.getAttributeFromExpandedAttributes( } fun ExpandedAttribute.toExpandedAttributes(): ExpandedAttributes = - mapOf(this.first to this.second) + mapOf(this) fun ExpandedAttributeInstances.addSubAttribute( subAttributeName: ExpandedTerm,