From fbef632efb6770fe1d4fa738ad8344b49fa126f5 Mon Sep 17 00:00:00 2001 From: Benoit Orihuela Date: Sat, 25 Nov 2023 15:10:10 +0100 Subject: [PATCH] feat(core): add support for GeoJSON representations --- .../search/web/EntityAccessControlHandler.kt | 15 +- .../egm/stellio/search/web/EntityHandler.kt | 12 +- .../search/web/EntityOperationHandler.kt | 7 +- shared/config/detekt/baseline.xml | 3 +- .../egm/stellio/shared/model/JsonLdEntity.kt | 34 ++- .../shared/model/NgsiLdDataRepresentation.kt | 5 +- .../egm/stellio/shared/util/ApiResponses.kt | 2 +- .../com/egm/stellio/shared/util/ApiUtils.kt | 17 +- .../com/egm/stellio/shared/util/GeoUtils.kt | 6 + .../stellio/shared/model/JsonLdEntityTests.kt | 208 ++++++++++++++++++ 10 files changed, 274 insertions(+), 35 deletions(-) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityAccessControlHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityAccessControlHandler.kt index 3e97bb9e6..22973ec26 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityAccessControlHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityAccessControlHandler.kt @@ -75,10 +75,7 @@ class EntityAccessControlHandler( mediaType ) - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), countAndAuthorizedEntities.first, @@ -126,10 +123,7 @@ class EntityAccessControlHandler( mediaType ) - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), countAndGroupEntities.first, @@ -178,10 +172,7 @@ class EntityAccessControlHandler( mediaType ) - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), countAndUserEntities.first, diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityHandler.kt index 3adeed884..fc15e68d9 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/web/EntityHandler.kt @@ -181,7 +181,7 @@ class EntityHandler( /** * Implements 6.4.3.2 - Query Entities */ - @GetMapping(produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) + @GetMapping(produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun getEntities( @RequestHeader httpHeaders: HttpHeaders, @RequestParam params: MultiValueMap @@ -208,10 +208,7 @@ class EntityHandler( mediaType ) - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), countAndEntities.second, @@ -259,10 +256,7 @@ class EntityHandler( ) val compactedEntity = JsonLdUtils.compactEntity(filteredJsonLdEntity, contextLink, mediaType).toMutableMap() - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) prepareGetSuccessResponse(mediaType, contextLink) .body(serializeObject(compactedEntity.toFinalRepresentation(ngsiLdDataRepresentation))) }.fold( 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 02bcc39da..cb8a05aed 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 @@ -252,7 +252,7 @@ class EntityOperationHandler( /** * Implements 6.23.3.1 - Query Entities via POST */ - @PostMapping("/query", produces = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) + @PostMapping("/query", produces = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun queryEntitiesViaPost( @RequestHeader httpHeaders: HttpHeaders, @RequestBody requestBody: Mono, @@ -281,10 +281,7 @@ class EntityOperationHandler( mediaType ) - val ngsiLdDataRepresentation = parseRepresentations( - params.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()), - mediaType - ) + val ngsiLdDataRepresentation = parseRepresentations(params, mediaType) buildQueryResponse( compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation), countAndEntities.second, diff --git a/shared/config/detekt/baseline.xml b/shared/config/detekt/baseline.xml index 2faea8f5c..aa4e5a0a6 100644 --- a/shared/config/detekt/baseline.xml +++ b/shared/config/detekt/baseline.xml @@ -3,9 +3,10 @@ CyclomaticComplexMethod:ExceptionHandler.kt$ExceptionHandler$@ExceptionHandler fun transformErrorResponse(throwable: Throwable): ResponseEntity<ProblemDetail> + LongMethod:JsonLdEntityTests.kt$JsonLdEntityTests$@Test fun `it should return simplified GeoJSON entities`() LongMethod:QueryUtils.kt$private fun transformQQueryToSqlJsonPath( mainAttributePath: List<ExpandedTerm>, trailingAttributePath: List<ExpandedTerm>, operator: String, value: String ) LongParameterList:ApiResponses.kt$( body: String, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, requestParams: MultiValueMap<String, String>, mediaType: MediaType, contextLink: String ) - LongParameterList:ApiResponses.kt$( entities: List<CompactedJsonLdEntity>, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, requestParams: MultiValueMap<String, String>, mediaType: MediaType, contextLink: String ) + LongParameterList:ApiResponses.kt$( entities: Any, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, requestParams: MultiValueMap<String, String>, mediaType: MediaType, contextLink: String ) LongParameterList:NgsiLdEntity.kt$NgsiLdEntity$( val id: URI, val types: List<ExpandedTerm>, val scopes: List<String>?, val relationships: List<NgsiLdRelationship>, val properties: List<NgsiLdProperty>, val geoProperties: List<NgsiLdGeoProperty>, val contexts: List<String> ) LongParameterList:NgsiLdEntity.kt$NgsiLdGeoPropertyInstance$( val coordinates: WKTCoordinates, createdAt: ZonedDateTime?, modifiedAt: ZonedDateTime?, observedAt: ZonedDateTime?, datasetId: URI?, properties: List<NgsiLdProperty>, relationships: List<NgsiLdRelationship> ) LongParameterList:NgsiLdEntity.kt$NgsiLdPropertyInstance$( val value: Any, val unitCode: String?, createdAt: ZonedDateTime?, modifiedAt: ZonedDateTime?, observedAt: ZonedDateTime?, datasetId: URI?, properties: List<NgsiLdProperty>, relationships: List<NgsiLdRelationship> ) diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/JsonLdEntity.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/JsonLdEntity.kt index c8cade787..556a10037 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/JsonLdEntity.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/JsonLdEntity.kt @@ -5,7 +5,10 @@ import arrow.core.left import arrow.core.right import com.egm.stellio.shared.util.* import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID +import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID_TERM import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE +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_CREATED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_MODIFIED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.castAttributeValue @@ -114,6 +117,21 @@ private fun simplifyValue(value: Map): Any { } } +fun CompactedJsonLdEntity.toGeoJson(geometryProperty: String): Any { + val geometryAttributeContent = this[geometryProperty] as? Map + val geometryPropertyValue = geometryAttributeContent?.let { + if (it.containsKey(JSONLD_VALUE_TERM)) it[JSONLD_VALUE_TERM] + else it + } + + return mapOf( + JSONLD_ID_TERM to this[JSONLD_ID_TERM]!!, + JSONLD_TYPE_TERM to FEATURE_TYPE, + GEOMETRY_PROPERTY_TERM to geometryPropertyValue, + PROPERTIES_PROPERTY_TERM to this.filter { it.key != JSONLD_ID_TERM } + ) +} + fun CompactedJsonLdEntity.withoutSysAttrs(): Map = this.filter { !JsonLdUtils.NGSILD_SYSATTRS_TERMS.contains(it.key) @@ -133,18 +151,30 @@ fun CompactedJsonLdEntity.withoutSysAttrs(): Map = fun CompactedJsonLdEntity.toFinalRepresentation( ngsiLdDataRepresentation: NgsiLdDataRepresentation -): CompactedJsonLdEntity = +): Any = this.let { if (!ngsiLdDataRepresentation.includeSysAttrs) it.withoutSysAttrs() else it }.let { if (ngsiLdDataRepresentation.attributeRepresentation == AttributeRepresentation.SIMPLIFIED) it.toKeyValues() else it + }.let { + if (ngsiLdDataRepresentation.entityRepresentation == EntityRepresentation.GEO_JSON) + // geometryProperty is not null when GeoJSON representation is asked (defaults to location) + it.toGeoJson(ngsiLdDataRepresentation.geometryProperty!!) + else it } fun List.toFinalRepresentation( ngsiLdDataRepresentation: NgsiLdDataRepresentation -): List = +): Any = this.map { it.toFinalRepresentation(ngsiLdDataRepresentation) + }.let { + if (ngsiLdDataRepresentation.entityRepresentation == EntityRepresentation.GEO_JSON) { + mapOf( + JSONLD_TYPE_TERM to FEATURE_COLLECTION_TYPE, + FEATURES_PROPERTY_TERM to it + ) + } else it } 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 b679681d8..8eb1a5619 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 @@ -10,7 +10,10 @@ import org.springframework.http.MediaType data class NgsiLdDataRepresentation( val entityRepresentation: EntityRepresentation, val attributeRepresentation: AttributeRepresentation, - val includeSysAttrs: Boolean + val includeSysAttrs: Boolean, + // In the case of GeoJSON Entity representation, + // this parameter indicates which GeoProperty to use for the toplevel geometry field + val geometryProperty: String? = null ) enum class AttributeRepresentation { diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiResponses.kt b/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiResponses.kt index 1d553164f..09612a8dd 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiResponses.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/util/ApiResponses.kt @@ -98,7 +98,7 @@ fun missingPathErrorResponse(errorMessage: String): ResponseEntity<*> { } fun buildQueryResponse( - entities: List, + entities: Any, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, 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 f895afa95..9c1cdb66e 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 @@ -8,6 +8,7 @@ import arrow.core.right import arrow.fx.coroutines.parMap import com.egm.stellio.shared.model.* import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_CONTEXT +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LOCATION_TERM import com.egm.stellio.shared.util.JsonLdUtils.extractContextFromInput import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap import kotlinx.coroutines.reactive.awaitFirst @@ -34,6 +35,7 @@ const val QUERY_PARAM_ID_PATTERN: String = "idPattern" 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_OPTIONS: String = "options" const val QUERY_PARAM_OPTIONS_SYSATTRS_VALUE: String = "sysAttrs" const val QUERY_PARAM_OPTIONS_KEYVALUES_VALUE: String = "keyValues" @@ -171,17 +173,24 @@ fun parseAndExpandRequestParameter(requestParam: String?, contextLink: String): }.toSet() fun parseRepresentations( - optionsParam: List, + requestParams: MultiValueMap, acceptMediaType: MediaType ): NgsiLdDataRepresentation { + val optionsParam = requestParams.getOrDefault(QUERY_PARAM_OPTIONS, emptyList()) 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 entityRepresentation = EntityRepresentation.forMediaType(acceptMediaType) + val geometryProperty = + if (entityRepresentation == EntityRepresentation.GEO_JSON) + requestParams.getFirst(QUERY_PARAM_GEOMETRY_PROPERTY) ?: NGSILD_LOCATION_TERM + else null return NgsiLdDataRepresentation( - EntityRepresentation.forMediaType(acceptMediaType), + entityRepresentation, attributeRepresentation, - includeSysAttrs + includeSysAttrs, + geometryProperty ) } @@ -233,7 +242,7 @@ fun List.getApplicable(): Either { return if (mediaType.includes(MediaType.APPLICATION_JSON)) MediaType.APPLICATION_JSON.right() else - JSON_LD_MEDIA_TYPE.right() + mediaType.right() } fun String.parseTimeParameter(errorMsg: String): Either = diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/util/GeoUtils.kt b/shared/src/main/kotlin/com/egm/stellio/shared/util/GeoUtils.kt index 2d2814483..fa917c702 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/util/GeoUtils.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/util/GeoUtils.kt @@ -14,6 +14,12 @@ import org.locationtech.jts.io.WKTWriter import org.locationtech.jts.io.geojson.GeoJsonReader import org.locationtech.jts.io.geojson.GeoJsonWriter +const val FEATURE_TYPE = "Feature" +const val FEATURE_COLLECTION_TYPE = "FeatureCollection" +const val GEOMETRY_PROPERTY_TERM = "geometry" +const val PROPERTIES_PROPERTY_TERM = "properties" +const val FEATURES_PROPERTY_TERM = "features" + fun geoJsonToWkt(geometryType: GeoQuery.GeometryType, coordinates: String): Either = geoJsonToWkt( """ diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/model/JsonLdEntityTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/model/JsonLdEntityTests.kt index e636a8d74..5021a4c27 100644 --- a/shared/src/test/kotlin/com/egm/stellio/shared/model/JsonLdEntityTests.kt +++ b/shared/src/test/kotlin/com/egm/stellio/shared/model/JsonLdEntityTests.kt @@ -3,6 +3,7 @@ package com.egm.stellio.shared.model import com.egm.stellio.shared.util.* import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CREATED_AT_PROPERTY +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_LOCATION_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_NAME_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.expandJsonLdEntity import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap @@ -302,4 +303,211 @@ class JsonLdEntityTests { assertEquals(expectedEntity, simplifiedEntity) } + + @Test + fun `it should return a normalized GeoJSON entity on location attribute`() { + val inputEntity = normalizedJson.deserializeAsMap() + + val expectedEntity = + """ + { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + }, + "properties": { + "type": "Vehicle", + "brandName": { + "type": "Property", + "value": "Mercedes" + }, + "isParked": { + "type": "Relationship", + "object": "urn:ngsi-ld:OffStreetParking:Downtown1", + "observedAt": "2017-07-29T12:00:04Z", + "providedBy": { + "type": "Relationship", + "object": "urn:ngsi-ld:Person:Bob" + } + }, + "location": { + "type": "GeoProperty", + "value": { + "type": "Point", + "coordinates": [ + 24.30623, + 60.07966 + ] + } + }, + "@context": [ + "https://example.org/ngsi-ld/latest/commonTerms.jsonld", + "https://example.org/ngsi-ld/latest/vehicle.jsonld", + "https://example.org/ngsi-ld/latest/parking.jsonld", + "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.7.jsonld" + ] + } + } + """.trimIndent().deserializeAsMap() + + val actualEntity = inputEntity.toFinalRepresentation( + NgsiLdDataRepresentation( + EntityRepresentation.GEO_JSON, + AttributeRepresentation.NORMALIZED, + includeSysAttrs = false, + geometryProperty = NGSILD_LOCATION_TERM + ) + ) + + assertEquals(expectedEntity, actualEntity) + } + + @Test + fun `it should return a simplified GeoJSON entity on location attribute`() { + val inputEntity = normalizedJson.deserializeAsMap() + + val expectedEntity = + """ + { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + }, + "properties": { + "type": "Vehicle", + "brandName": "Mercedes", + "isParked": "urn:ngsi-ld:OffStreetParking:Downtown1", + "location": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + }, + "@context": [ + "https://example.org/ngsi-ld/latest/commonTerms.jsonld", + "https://example.org/ngsi-ld/latest/vehicle.jsonld", + "https://example.org/ngsi-ld/latest/parking.jsonld", + "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.7.jsonld" + ] + } + } + """.trimIndent().deserializeAsMap() + + val simplifiedEntity = inputEntity.toFinalRepresentation( + NgsiLdDataRepresentation( + EntityRepresentation.GEO_JSON, + AttributeRepresentation.SIMPLIFIED, + includeSysAttrs = false, + geometryProperty = NGSILD_LOCATION_TERM + ) + ) + + assertEquals(expectedEntity, simplifiedEntity) + } + + @Test + fun `it should return a GeoJSON entity with a null geometry if the GeoProperty does not exist`() { + val inputEntity = + """ + { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Vehicle", + "brandName": { + "type": "Property", + "value": "Mercedes" + } + } + """.trimIndent().deserializeAsMap() + + val expectedEntity = + """ + { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Feature", + "geometry": null, + "properties": { + "type": "Vehicle", + "brandName": "Mercedes" + } + } + """.trimIndent().deserializeAsMap() + + val simplifiedEntity = inputEntity.toFinalRepresentation( + NgsiLdDataRepresentation( + EntityRepresentation.GEO_JSON, + AttributeRepresentation.SIMPLIFIED, + includeSysAttrs = false, + geometryProperty = NGSILD_LOCATION_TERM + ) + ) + + assertEquals(expectedEntity, simplifiedEntity) + } + + @Test + fun `it should return simplified GeoJSON entities`() { + val inputEntity = + """ + { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Vehicle", + "location": { + "type": "GeoProperty", + "value": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + } + } + } + """.trimIndent().deserializeAsMap() + + val expectedEntities = + """ + { + "type": "FeatureCollection", + "features": [{ + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + }, + "properties": { + "type": "Vehicle", + "location": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + } + } + }, { + "id": "urn:ngsi-ld:Vehicle:A4567", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + }, + "properties": { + "type": "Vehicle", + "location": { + "type": "Point", + "coordinates": [ 24.30623, 60.07966 ] + } + } + }] + } + """.trimIndent().deserializeAsMap() + + val actualEntities = listOf(inputEntity, inputEntity).toFinalRepresentation( + NgsiLdDataRepresentation( + EntityRepresentation.GEO_JSON, + AttributeRepresentation.SIMPLIFIED, + includeSysAttrs = false, + NGSILD_LOCATION_TERM + ) + ) + + assertEquals(expectedEntities, actualEntities) + } }