From 2ad370cbcb031ccec045f838ef0f403c95af3313 Mon Sep 17 00:00:00 2001 From: Benoit Orihuela Date: Tue, 31 Jan 2023 08:55:37 +0100 Subject: [PATCH] fix(common): update custom error handling - ProblemDetails support in SB is not totally finished (https://github.com/spring-projects/spring-boot/issues/33885, https://github.com/spring-projects/spring-boot/issues/19525), stick to semi-manual implementation --- .../resources/application-docker.properties | 2 + .../src/main/resources/application.properties | 2 + .../egm/stellio/shared/model/ErrorResponse.kt | 38 ++++++++++++------- .../stellio/shared/web/ExceptionHandler.kt | 20 +++++++--- .../resources/application-docker.properties | 2 + .../src/main/resources/application.properties | 2 + .../web/SubscriptionHandlerTests.kt | 15 ++++++++ 7 files changed, 62 insertions(+), 19 deletions(-) diff --git a/search-service/src/main/resources/application-docker.properties b/search-service/src/main/resources/application-docker.properties index 2f2761c71..5d9bcf205 100644 --- a/search-service/src/main/resources/application-docker.properties +++ b/search-service/src/main/resources/application-docker.properties @@ -3,6 +3,8 @@ spring.flyway.url = jdbc:postgresql://postgres/stellio_search spring.kafka.bootstrap-servers = kafka:9092 +server.error.include-stacktrace = never + spring.devtools.add-properties = false application.authentication.enabled = true diff --git a/search-service/src/main/resources/application.properties b/search-service/src/main/resources/application.properties index 0b23850d5..8bdf3cc99 100644 --- a/search-service/src/main/resources/application.properties +++ b/search-service/src/main/resources/application.properties @@ -15,6 +15,8 @@ spring.kafka.consumer.auto-offset-reset = earliest # By default, new matching topics are checked every 5 minutes but it can be configured by overriding the following prop # spring.kafka.consumer.properties.metadata.max.age.ms = 1000 +server.error.include-stacktrace = always + # cf https://docs.spring.io/spring-security/reference/reactive/oauth2/resource-server/jwt.html#_specifying_the_authorization_server spring.security.oauth2.resourceserver.jwt.issuer-uri = https://sso.eglobalmark.com/auth/realms/stellio # not required, but it avoids tying startup to authorization server's availability diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/ErrorResponse.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/ErrorResponse.kt index 38b66fad9..f41b4c356 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/ErrorResponse.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/ErrorResponse.kt @@ -1,7 +1,9 @@ package com.egm.stellio.shared.model +import java.net.URI + sealed class ErrorResponse( - val type: String, + val type: URI, open val title: String, open val detail: String ) @@ -67,26 +69,36 @@ data class JsonParseErrorResponse(override val detail: String) : ErrorResponse( data class AccessDeniedResponse(override val detail: String) : ErrorResponse( - "https://uri.etsi.org/ngsi-ld/errors/AccessDenied", + ErrorType.ACCESS_DENIED.type, "The request tried to access an unauthorized resource", detail ) data class NotImplementedResponse(override val detail: String) : ErrorResponse( - "https://uri.etsi.org/ngsi-ld/errors/NotImplemented", + ErrorType.NOT_IMPLEMENTED.type, "The requested functionality is not yet implemented", detail ) -enum class ErrorType(val type: String) { - INVALID_REQUEST("https://uri.etsi.org/ngsi-ld/errors/InvalidRequest"), - BAD_REQUEST_DATA("https://uri.etsi.org/ngsi-ld/errors/BadRequestData"), - ALREADY_EXISTS("https://uri.etsi.org/ngsi-ld/errors/AlreadyExists"), - OPERATION_NOT_SUPPORTED("https://uri.etsi.org/ngsi-ld/errors/OperationNotSupported"), - RESOURCE_NOT_FOUND("https://uri.etsi.org/ngsi-ld/errors/ResourceNotFound"), - INTERNAL_ERROR("https://uri.etsi.org/ngsi-ld/errors/InternalError"), - TOO_COMPLEX_QUERY("https://uri.etsi.org/ngsi-ld/errors/TooComplexQuery"), - TOO_MANY_RESULTS("https://uri.etsi.org/ngsi-ld/errors/TooManyResults"), - LD_CONTEXT_NOT_AVAILABLE("https://uri.etsi.org/ngsi-ld/errors/LdContextNotAvailable") +data class UnsupportedMediaTypeResponse(override val detail: String) : + ErrorResponse( + ErrorType.UNSUPPORTED_MEDIA_TYPE.type, + "The content type of the request is not supported", + detail + ) + +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")), + 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")), + TOO_COMPLEX_QUERY(URI("https://uri.etsi.org/ngsi-ld/errors/TooComplexQuery")), + TOO_MANY_RESULTS(URI("https://uri.etsi.org/ngsi-ld/errors/TooManyResults")), + LD_CONTEXT_NOT_AVAILABLE(URI("https://uri.etsi.org/ngsi-ld/errors/LdContextNotAvailable")), + ACCESS_DENIED(URI("https://uri.etsi.org/ngsi-ld/errors/AccessDenied")), + NOT_IMPLEMENTED(URI("https://uri.etsi.org/ngsi-ld/errors/NotImplemented")), + UNSUPPORTED_MEDIA_TYPE(URI("https://uri.etsi.org/ngsi-ld/errors/UnsupportedMediaType")) } diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/web/ExceptionHandler.kt b/shared/src/main/kotlin/com/egm/stellio/shared/web/ExceptionHandler.kt index a7f50ecbc..9271239f4 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/web/ExceptionHandler.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/web/ExceptionHandler.kt @@ -1,16 +1,16 @@ package com.egm.stellio.shared.web import com.egm.stellio.shared.model.* -import com.egm.stellio.shared.util.JsonUtils.serializeObject import com.fasterxml.jackson.core.JsonParseException import com.github.jsonldjava.core.JsonLdError import org.slf4j.LoggerFactory import org.springframework.core.codec.CodecException import org.springframework.http.HttpStatus -import org.springframework.http.MediaType +import org.springframework.http.ProblemDetail import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.server.UnsupportedMediaTypeStatusException @RestControllerAdvice class ExceptionHandler { @@ -18,7 +18,7 @@ class ExceptionHandler { private val logger = LoggerFactory.getLogger(javaClass) @ExceptionHandler - fun transformErrorResponse(throwable: Throwable): ResponseEntity = + fun transformErrorResponse(throwable: Throwable): ResponseEntity = when (val cause = throwable.cause ?: throwable) { is AlreadyExistsException -> generateErrorResponse( HttpStatus.CONFLICT, @@ -56,16 +56,24 @@ class ExceptionHandler { HttpStatus.SERVICE_UNAVAILABLE, LdContextNotAvailableResponse(cause.message) ) + is UnsupportedMediaTypeStatusException -> generateErrorResponse( + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + UnsupportedMediaTypeResponse(cause.message) + ) else -> generateErrorResponse( HttpStatus.INTERNAL_SERVER_ERROR, InternalErrorResponse("$cause") ) } - private fun generateErrorResponse(status: HttpStatus, exception: ErrorResponse): ResponseEntity { + private fun generateErrorResponse(status: HttpStatus, exception: ErrorResponse): ResponseEntity { logger.info("Returning error ${exception.type} (${exception.detail})") return ResponseEntity.status(status) - .contentType(MediaType.APPLICATION_JSON) - .body(serializeObject(exception)) + .body( + ProblemDetail.forStatusAndDetail(status, exception.detail).also { + it.title = exception.title + it.type = exception.type + } + ) } } diff --git a/subscription-service/src/main/resources/application-docker.properties b/subscription-service/src/main/resources/application-docker.properties index 758994661..04f865838 100644 --- a/subscription-service/src/main/resources/application-docker.properties +++ b/subscription-service/src/main/resources/application-docker.properties @@ -3,6 +3,8 @@ spring.flyway.url = jdbc:postgresql://postgres/stellio_subscription spring.kafka.bootstrap-servers = kafka:9092 +server.error.include-stacktrace = never + spring.devtools.add-properties = false application.authentication.enabled = true diff --git a/subscription-service/src/main/resources/application.properties b/subscription-service/src/main/resources/application.properties index 4146a6a31..7c4b89559 100644 --- a/subscription-service/src/main/resources/application.properties +++ b/subscription-service/src/main/resources/application.properties @@ -15,6 +15,8 @@ spring.kafka.consumer.auto-offset-reset = earliest # By default, new matching topics are checked every 5 minutes but it can be configured by overriding the following prop # spring.kafka.consumer.properties.metadata.max.age.ms = 1000 +server.error.include-stacktrace = always + # cf https://docs.spring.io/spring-security/reference/reactive/oauth2/resource-server/jwt.html#_specifying_the_authorization_server spring.security.oauth2.resourceserver.jwt.issuer-uri = https://sso.eglobalmark.com/auth/realms/stellio # not required, but it avoids tying startup to authorization server's availability diff --git a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/web/SubscriptionHandlerTests.kt b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/web/SubscriptionHandlerTests.kt index f161af28a..de0a0f888 100644 --- a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/web/SubscriptionHandlerTests.kt +++ b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/web/SubscriptionHandlerTests.kt @@ -25,6 +25,7 @@ import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest import org.springframework.context.annotation.Import import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.security.test.context.support.WithAnonymousUser import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.reactive.server.WebTestClient @@ -233,6 +234,20 @@ class SubscriptionHandlerTests { ) } + @Test + fun `create subscription should return a 415 if the content type is not correct`() { + val jsonLdFile = ClassPathResource("/ngsild/subscription.json") + + webClient.post() + .uri("/ngsi-ld/v1/subscriptions") + .contentType(MediaType.APPLICATION_PDF) + .bodyValue(jsonLdFile) + .exchange() + .expectStatus().isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + coVerify { subscriptionService.validateNewSubscription(any()) wasNot Called } + } + @Test fun `create subscription should return a 400 if validation of the subscription fails`() { val jsonLdFile = ClassPathResource("/ngsild/subscription_with_conflicting_timeInterval_watchedAttributes.json")