From fe19e7dda498aab67dd54203b008b2cfe936e1df Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Tue, 22 Oct 2024 08:22:44 +0200 Subject: [PATCH] feat: use non-reactive stack (#40) --- .../aam-backend-service/build.gradle.kts | 27 +- .../aambackendservice/Application.kt | 2 + .../auth/core/AuthProvider.kt | 4 +- .../auth/core/KeycloakAuthProvider.kt | 59 ++- .../auth/di/AuthConfiguration.kt | 36 +- .../couchdb/core/CouchDbClient.kt | 35 +- .../couchdb/core/DefaultCouchDbClient.kt | 413 +++++++++++------- .../couchdb/di/CouchDbConfiguration.kt | 35 +- .../aambackendservice/domain/DomainUseCase.kt | 90 +++- .../aambackendservice/error/AamException.kt | 24 +- .../aambackendservice/error/HttpErrorDto.kt | 6 + .../controller/TemplateExportController.kt | 292 +++++++++---- .../export/core/CreateTemplateUseCase.kt | 12 +- .../export/core/FetchTemplateUseCase.kt | 12 +- .../export/core/RenderTemplateUseCase.kt | 14 +- .../export/core/TemplateStorage.kt | 11 +- .../export/di/AamRenderApiConfiguration.kt | 56 +-- .../export/di/UseCaseConfiguration.kt | 14 +- .../export/storage/DefaultTemplateStorage.kt | 39 +- .../usecase/DefaultCreateTemplateUseCase.kt | 85 ++-- .../usecase/DefaultFetchTemplateUseCase.kt | 162 +++---- .../usecase/DefaultRenderTemplateUseCase.kt | 216 +++++---- .../http/AamReadTimeoutHandler.kt | 28 -- .../queue/core/DefaultQueueMessageParser.kt | 19 +- .../core/CouchDbDatabaseChangeDetection.kt | 99 ++--- .../core/CreateDocumentChangeUseCase.kt | 3 +- .../changes/core/DatabaseChangeDetection.kt | 4 +- .../core/DatabaseChangeEventConsumer.kt | 3 +- .../DefaultCreateDocumentChangeUseCase.kt | 74 ++-- .../core/NoopDatabaseChangeDetection.kt | 10 +- .../changes/di/RepositoryConfiguration.kt | 29 -- .../changes/jobs/CouchDbChangeDetectionJob.kt | 17 +- .../queue/DefaultChangeEventPublisher.kt | 9 +- .../DefaultDatabaseChangeEventConsumer.kt | 10 +- .../changes/repository/SyncRepository.kt | 19 +- .../controller/WebhookController.kt | 124 ++++-- .../core/AddWebhookSubscriptionUseCase.kt | 3 +- .../DefaultAddWebhookSubscriptionUseCase.kt | 28 +- .../core/DefaultNotificationEventConsumer.kt | 18 +- .../core/DefaultNotificationEventPublisher.kt | 7 +- .../core/DefaultTriggerWebhookUseCase.kt | 85 ++-- .../core/NotificationEventConsumer.kt | 3 +- .../notification/core/NotificationService.kt | 42 +- .../notification/core/NotificationStorage.kt | 11 +- .../core/TriggerWebhookUseCase.kt | 3 +- .../di/NotificationConfiguration.kt | 14 +- .../storage/DefaultNotificationStorage.kt | 57 +-- .../notification/storage/WebhookRepository.kt | 44 +- .../report/controller/ReportController.kt | 136 ++++-- .../DefaultIdentifyAffectedReportsUseCase.kt | 31 +- .../core/DefaultReportCalculationProcessor.kt | 93 ++-- ...efaultReportDocumentChangeEventConsumer.kt | 59 ++- .../core/IdentifyAffectedReportsUseCase.kt | 3 +- .../core/NoopReportCalculationProcessor.kt | 9 +- .../reporting/report/core/QueryStorage.kt | 5 +- .../report/core/ReportCalculationProcessor.kt | 4 +- .../core/ReportDocumentChangeEventConsumer.kt | 3 +- .../reporting/report/core/ReportingStorage.kt | 18 +- .../reporting/report/di/SqsConfiguration.kt | 31 +- .../report/jobs/ReportCalculationJob.kt | 11 +- .../reporting/report/sqs/SqsQueryStorage.kt | 66 ++- .../reporting/report/sqs/SqsSchemaService.kt | 78 ++-- .../controller/ReportCalculationController.kt | 273 ++++++++---- .../core/CreateReportCalculationUseCase.kt | 3 +- .../DefaultCreateReportCalculationUseCase.kt | 51 +-- .../DefaultReportCalculationChangeUseCase.kt | 55 ++- .../core/DefaultReportCalculator.kt | 64 +-- .../core/ReportCalculationChangeUseCase.kt | 3 +- .../core/ReportCalculator.kt | 3 +- .../reporting/storage/DefaultReportStorage.kt | 87 ++-- .../storage/DefaultReportingStorage.kt | 83 ++-- .../storage/ReportCalculationRepository.kt | 35 +- .../rest/AamErrorAttributes.kt | 8 +- .../security/AamAuthenticationEntryPoint.kt | 43 ++ .../security/SecurityConfiguration.kt | 85 ++-- .../src/main/resources/application.yaml | 27 +- .../sql/embedded_h2_database_init_script.sql | 9 - .../common/WebClientTestBase.kt | 9 +- .../domain/BasicDomainUseCaseTest.kt | 34 +- .../e2e/CucumberIntegrationTest.kt | 9 + .../DefaultCreateTemplateUseCaseTest.kt | 93 ++-- .../DefaultFetchTemplateUseCaseTest.kt | 163 +++---- .../DefaultRenderTemplateUseCaseTest.kt | 260 +++++------ ...ltReportDocumentChangeEventConsumerTest.kt | 42 +- ...faultCreateReportCalculationUseCaseTest.kt | 42 +- .../core/DefaultReportCalculatorTest.kt | 97 ++-- .../src/test/resources/application-e2e.yaml | 19 +- .../cucumber/features/export/export.feature | 1 + 88 files changed, 2424 insertions(+), 2128 deletions(-) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/HttpErrorDto.kt delete mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/http/AamReadTimeoutHandler.kt delete mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationEntryPoint.kt delete mode 100644 application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 9f3ed1f..6700ab0 100644 --- a/application/aam-backend-service/build.gradle.kts +++ b/application/aam-backend-service/build.gradle.kts @@ -2,12 +2,13 @@ plugins { application distribution jacoco + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" + kotlin("plugin.jpa") version "1.9.25" id("org.springframework.boot") version "3.3.4" id("io.spring.dependency-management") version "1.1.6" - id("io.sentry.jvm.gradle") version "4.11.0" id("org.jetbrains.kotlin.kapt") version "1.9.25" - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.22" + id("io.sentry.jvm.gradle") version "4.11.0" } group = "com.aam-digital" @@ -35,26 +36,22 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-amqp") - implementation("org.springframework.boot:spring-boot-starter-cache") - implementation("org.springframework.boot:spring-boot-starter-security") +// implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-webflux") - - implementation("org.springframework.data:spring-data-r2dbc") - - implementation("org.springframework.security:spring-security-oauth2-resource-server") - implementation("org.springframework.security:spring-security-oauth2-jose") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.apache.commons:commons-lang3") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") - + implementation("io.micrometer:micrometer-tracing-bridge-brave") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") - implementation("io.r2dbc:r2dbc-h2") + runtimeOnly("org.postgresql:postgresql:42.7.4") + runtimeOnly("com.h2database:h2") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt index dcc464d..12475fe 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt @@ -3,6 +3,7 @@ package com.aamdigital.aambackendservice import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @@ -11,6 +12,7 @@ import java.util.* @SpringBootApplication @ConfigurationPropertiesScan @EnableScheduling +@EnableJpaRepositories class Application fun main(args: Array) { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt index 4b0c7d6..be9077f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt @@ -1,7 +1,5 @@ package com.aamdigital.aambackendservice.auth.core -import reactor.core.publisher.Mono - data class TokenResponse(val token: String) @@ -28,5 +26,5 @@ data class AuthConfig( * Used for fetching access tokens for third party systems. */ interface AuthProvider { - fun fetchToken(authClientConfig: AuthConfig): Mono + fun fetchToken(authClientConfig: AuthConfig): TokenResponse } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt index c27e084..8339d0d 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt @@ -1,14 +1,13 @@ package com.aamdigital.aambackendservice.auth.core +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.ExternalSystemException import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.util.LinkedMultiValueMap -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono +import org.springframework.web.client.RestClient data class KeycloakTokenResponse( @JsonProperty("access_token") val accessToken: String, @@ -21,17 +20,22 @@ data class KeycloakTokenResponse( * * Not related to authentication mechanics related to endpoints we provide to others. * - * @property webClient The WebClient used for making HTTP requests. + * @property httpClient The RestClient used for making HTTP requests. * @property objectMapper The ObjectMapper used for parsing JSON responses. */ class KeycloakAuthProvider( - val webClient: WebClient, + val httpClient: RestClient, val objectMapper: ObjectMapper, ) : AuthProvider { + enum class KeycloakAuthProviderError : AamErrorCode { + EMPTY_RESPONSE, + RESPONSE_PARSING_ERROR, + } + private val logger = LoggerFactory.getLogger(javaClass) - override fun fetchToken(authClientConfig: AuthConfig): Mono { + override fun fetchToken(authClientConfig: AuthConfig): TokenResponse { val formData = LinkedMultiValueMap( mutableMapOf( "client_id" to listOf(authClientConfig.clientId), @@ -44,30 +48,43 @@ class KeycloakAuthProvider( } ) - return webClient.post() - .uri(authClientConfig.tokenEndpoint) - .headers { - it.contentType = MediaType.APPLICATION_FORM_URLENCODED - } - .body( - BodyInserters.fromFormData( + return try { + val response = httpClient.post() + .uri(authClientConfig.tokenEndpoint) + .headers { + it.contentType = MediaType.APPLICATION_FORM_URLENCODED + } + .body( formData ) - ).exchangeToMono { - it.bodyToMono(String::class.java) - }.map { - parseResponse(it) - }.doOnError { logger.error(it.message, it) } + .retrieve() + .body(String::class.java) + parseResponse(response) + } catch (ex: ExternalSystemException) { + logger.error(ex.message, ex) + throw ex + } } - private fun parseResponse(raw: String): TokenResponse { + private fun parseResponse(raw: String?): TokenResponse { + if (raw.isNullOrEmpty()) { + throw ExternalSystemException( + message = "Could not parse access token from KeycloakAuthProvider.", + code = KeycloakAuthProviderError.EMPTY_RESPONSE + ) + } + try { val keycloakTokenResponse = objectMapper.readValue(raw, KeycloakTokenResponse::class.java) return TokenResponse( token = keycloakTokenResponse.accessToken ) - } catch (e: Exception) { - throw ExternalSystemException("Could not parse access token from KeycloakAuthProvider", e) + } catch (ex: Exception) { + throw ExternalSystemException( + message = "Could not parse access token from KeycloakAuthProvider", + cause = ex, + code = KeycloakAuthProviderError.RESPONSE_PARSING_ERROR + ) } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/di/AuthConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/di/AuthConfiguration.kt index d95439e..a48e7df 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/di/AuthConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/di/AuthConfiguration.kt @@ -4,44 +4,24 @@ import com.aamdigital.aambackendservice.auth.core.AuthProvider import com.aamdigital.aambackendservice.auth.core.KeycloakAuthProvider import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient - - -@ConfigurationProperties("aam-keycloak-client-configuration") -data class KeycloakConfiguration( - val maxInMemorySizeInMegaBytes: Int = 16, -) +import org.springframework.web.client.RestClient @Configuration class AuthConfiguration { - companion object { - const val MEGA_BYTES_MULTIPLIER = 1024 * 1024 - } - + @Bean(name = ["aam-keycloak-client"]) - fun aamKeycloakWebClient( - configuration: KeycloakConfiguration, - ): WebClient { - val clientBuilder = - WebClient.builder() - .codecs { - it.defaultCodecs() - .maxInMemorySize(configuration.maxInMemorySizeInMegaBytes * MEGA_BYTES_MULTIPLIER) - } - - return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + fun aamKeycloakRestClient( + ): RestClient { + val clientBuilder = RestClient.builder() + return clientBuilder.build() } @Bean(name = ["aam-keycloak"]) fun aamKeycloakAuthProvider( - @Qualifier("aam-keycloak-client") webClient: WebClient, + @Qualifier("aam-keycloak-client") webClient: RestClient, objectMapper: ObjectMapper, ): AuthProvider = - KeycloakAuthProvider(webClient = webClient, objectMapper = objectMapper) - + KeycloakAuthProvider(httpClient = webClient, objectMapper = objectMapper) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt index 24c98cb..04f2692 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt @@ -3,65 +3,72 @@ package com.aamdigital.aambackendservice.couchdb.core import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChangesResponse import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess import com.aamdigital.aambackendservice.couchdb.dto.FindResponse -import org.springframework.core.io.buffer.DataBuffer +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.error.NotFoundException import org.springframework.http.HttpHeaders import org.springframework.util.MultiValueMap -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import java.io.InputStream +import java.io.InterruptedIOException +import java.util.* import kotlin.reflect.KClass interface CouchDbClient { - fun allDatabases(): Mono> - fun changes(database: String, queryParams: MultiValueMap): Mono + fun allDatabases(): List + fun changes(database: String, queryParams: MultiValueMap): CouchDbChangesResponse fun find( database: String, body: Map, queryParams: MultiValueMap = getEmptyQueryParams(), kClass: KClass - ): Mono> + ): FindResponse fun headDatabaseDocument( database: String, documentId: String, - ): Mono + ): HttpHeaders + @Throws( + NotFoundException::class, + ExternalSystemException::class, + InterruptedIOException::class + ) fun getDatabaseDocument( database: String, documentId: String, queryParams: MultiValueMap = getEmptyQueryParams(), kClass: KClass, - ): Mono + ): T fun putDatabaseDocument( database: String, documentId: String, body: Any - ): Mono + ): DocSuccess fun getPreviousDocRev( database: String, documentId: String, rev: String, kClass: KClass, - ): Mono + ): Optional fun headAttachment( database: String, documentId: String, attachmentId: String, - ): Mono + ): HttpHeaders fun getAttachment( database: String, documentId: String, attachmentId: String, - ): Flux + ): InputStream fun putAttachment( database: String, documentId: String, attachmentId: String, - file: Flux - ): Mono + file: InputStream + ): DocSuccess } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt index 7afd075..fc2c9cf 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/DefaultCouchDbClient.kt @@ -3,121 +3,180 @@ package com.aamdigital.aambackendservice.couchdb.core import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChangesResponse import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess import com.aamdigital.aambackendservice.couchdb.dto.FindResponse +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.ExternalSystemException -import com.aamdigital.aambackendservice.error.InternalServerException -import com.aamdigital.aambackendservice.error.InvalidArgumentException import com.aamdigital.aambackendservice.error.NotFoundException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import org.slf4j.LoggerFactory import org.springframework.core.ParameterizedTypeReference -import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.Resource import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.util.MultiValueMap -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.ClientResponse -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import org.springframework.web.client.RestClient +import java.io.InputStream +import java.io.InterruptedIOException +import java.util.* import kotlin.reflect.KClass class DefaultCouchDbClient( - private val webClient: WebClient, + private val httpClient: RestClient, private val objectMapper: ObjectMapper ) : CouchDbClient { private val logger = LoggerFactory.getLogger(javaClass) + + enum class DefaultCouchDbClientErrorCode : AamErrorCode { + INVALID_RESPONSE, + PARSING_ERROR, + EMPTY_RESPONSE, + NOT_FOUND, + } + companion object { private const val CHANGES_URL = "/_changes" private const val FIND_URL = "/_find" + private const val BYTE_ARRAY_BUFFER_LENGTH = 4096 } - override fun allDatabases(): Mono> { - return webClient + override fun allDatabases(): List { + val response = httpClient .get() .uri("/_all_dbs") .accept(MediaType.APPLICATION_JSON) - .exchangeToMono { response -> - response.bodyToMono(object : ParameterizedTypeReference>() {}) - } + .retrieve() + .body(object : ParameterizedTypeReference>() {}) + + if (response.isNullOrEmpty()) { + throw ExternalSystemException( + message = "Could not parse response to List", + code = DefaultCouchDbClientErrorCode.EMPTY_RESPONSE + ) + } + + return response } override fun changes( database: String, queryParams: MultiValueMap - ): Mono { - return webClient.get().uri { - it.path("/$database/$CHANGES_URL") - it.queryParams(queryParams) - it.build() - }.accept(MediaType.APPLICATION_JSON).exchangeToMono { response -> - response.bodyToMono(CouchDbChangesResponse::class.java).mapNotNull { - it + ): CouchDbChangesResponse { + val response = httpClient + .get() + .uri { + it.path("/$database/$CHANGES_URL") + it.queryParams(queryParams) + it.build() } + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(CouchDbChangesResponse::class.java) + + if (response == null) { + throw ExternalSystemException( + message = "Could not parse response to CouchDbChangesResponse", + code = DefaultCouchDbClientErrorCode.EMPTY_RESPONSE, + ) } + + return response } override fun find( database: String, body: Map, queryParams: MultiValueMap, kClass: KClass - ): Mono> { - return webClient.post().uri { - it.path("/$database/$FIND_URL") - it.queryParams(queryParams) - it.build() - }.contentType(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(body)).exchangeToMono { - it.bodyToMono(ObjectNode::class.java).map { objectNode -> - val data = - (objectMapper.convertValue(objectNode, Map::class.java)["docs"] as Iterable<*>).map { entry -> - objectMapper.convertValue(entry, kClass.java) - } - - FindResponse(docs = data) + ): FindResponse { + val response = httpClient + .post() + .uri { + it.path("/$database/$FIND_URL") + it.queryParams(queryParams) + it.build() } + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(ObjectNode::class.java) + + if (response == null) { + throw ExternalSystemException( + message = "Could not parse response to ObjectNode", + code = DefaultCouchDbClientErrorCode.EMPTY_RESPONSE + ) } + + // todo refactor to string parsing + val data = + (objectMapper.convertValue(response, Map::class.java)["docs"] as Iterable<*>).map { entry -> + objectMapper.convertValue(entry, kClass.java) + } + + return FindResponse(docs = data) } override fun headDatabaseDocument( database: String, documentId: String, - ): Mono { - return webClient + ): HttpHeaders { + return httpClient .head() .uri { it.path("/$database/$documentId") it.build() } - .accept(MediaType.APPLICATION_JSON).exchangeToMono { - if (it.statusCode().is2xxSuccessful) { - Mono.just(it.headers().asHttpHeaders()) - } else if (it.statusCode().is4xxClientError) { - Mono.just(HttpHeaders()) + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + if (clientResponse.statusCode.is2xxSuccessful) { + clientResponse.headers + } else if (clientResponse.statusCode.is4xxClientError) { + HttpHeaders() } else { - throw InternalServerException() + throw ExternalSystemException( + message = "Retrieved HTTP 500 from CouchDb, ${clientResponse.bodyTo(String::class.java)}", + code = DefaultCouchDbClientErrorCode.INVALID_RESPONSE + ) } } } + /** + * Fetch a document from the couchdb and return a parsed instance of given kClass. + * + * @param database couchdb target database + * @param documentId couchdb document _id + * @param queryParams List of query params forwarded to couchdb request + * @param kClass response will be parsed with objectMapper to this class reference + * + * @throws NotFoundException Document is not available in Database + * @throws ExternalSystemException Response could not be processed or parsed to requested kClass + * @throws InterruptedIOException NetworkTimeout, SocketTimeout or similar + */ + @Throws( + NotFoundException::class, + ExternalSystemException::class, + InterruptedIOException::class + ) override fun getDatabaseDocument( database: String, documentId: String, queryParams: MultiValueMap, kClass: KClass, - ): Mono { - return webClient.get() + ): T { + return httpClient.get() .uri { it.path("/$database/$documentId") it.queryParams(queryParams) it.build() } .accept(MediaType.APPLICATION_JSON) - .exchangeToMono { response: ClientResponse -> - if (response.statusCode().is4xxClientError) { - throw InvalidArgumentException( - message = "Document \"$documentId\" could not be found in database \"$database\"" + .exchange { _, clientResponse -> + if (clientResponse.statusCode.is4xxClientError) { + throw NotFoundException( + message = "Document \"$documentId\" could not be found in database \"$database\"", + code = DefaultCouchDbClientErrorCode.NOT_FOUND ) } - handleResponse(response, kClass) + handleResponse(clientResponse, kClass) } } @@ -125,28 +184,29 @@ class DefaultCouchDbClient( database: String, documentId: String, body: Any, - ): Mono { - return headDatabaseDocument( + ): DocSuccess { + val documentHeaders = headDatabaseDocument( database = database, documentId = documentId - ).flatMap { httpHeaders -> - val etag = httpHeaders.eTag?.replace("\"", "") + ) - webClient.put() - .uri { - it.path("/$database/$documentId") - it.build() - } - .body(BodyInserters.fromValue(body)) - .headers { - if (etag.isNullOrBlank().not()) { - it.set("If-Match", etag) - } - } - .accept(MediaType.APPLICATION_JSON).exchangeToMono { - handleResponse(it, DocSuccess::class) + val etag = documentHeaders.eTag?.replace("\"", "") + + return httpClient.put() + .uri { + it.path("/$database/$documentId") + it.build() + } + .body(body) + .headers { + if (etag.isNullOrBlank().not()) { + it.set("If-Match", etag) } - } + } + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + handleResponse(clientResponse, DocSuccess::class) + } } override fun getPreviousDocRev( @@ -154,86 +214,109 @@ class DefaultCouchDbClient( documentId: String, rev: String, kClass: KClass, - ): Mono { + ): Optional { val allRevsInfoQueryParams = getEmptyQueryParams() allRevsInfoQueryParams.set("revs_info", "true") - return getDatabaseDocument( - database = database, - documentId = documentId, - queryParams = allRevsInfoQueryParams, - kClass = ObjectNode::class - ).flatMap { currentDoc -> - val revInfo = currentDoc.get("_revs_info") ?: return@flatMap Mono.empty() + val currentDoc = try { + getDatabaseDocument( + database = database, + documentId = documentId, + queryParams = allRevsInfoQueryParams, + kClass = ObjectNode::class + ) + } catch (ex: NotFoundException) { + return Optional.empty() + } - if (!revInfo.isArray) { - return@flatMap Mono.empty() - } + val revInfo = currentDoc.get("_revs_info") ?: return Optional.empty() - val revIndex = revInfo.indexOfFirst { jsonNode -> jsonNode.get("rev").textValue().equals(rev) } + if (!revInfo.isArray) { + return Optional.empty() + } - if (revIndex == -1) { - return@flatMap Mono.empty() - } + val revIndex = revInfo.indexOfFirst { jsonNode -> jsonNode.get("rev").textValue().equals(rev) } - if (revIndex + 1 >= revInfo.size()) { - return@flatMap Mono.empty() - } + if (revIndex == -1) { + return Optional.empty() + } - val previousRef = revInfo.get(revIndex + 1).get("rev").textValue() + if (revIndex + 1 >= revInfo.size()) { + return Optional.empty() + } - val previousRevQueryParams = getEmptyQueryParams() - previousRevQueryParams.set("rev", previousRef) + val previousRef = revInfo.get(revIndex + 1).get("rev").textValue() - getDatabaseDocument( - database = database, - documentId = documentId, - queryParams = previousRevQueryParams, - kClass = ObjectNode::class - ).map { previousDoc -> - objectMapper.convertValue(previousDoc, kClass.java) - } - } + val previousRevQueryParams = getEmptyQueryParams() + previousRevQueryParams.set("rev", previousRef) + + val previousDoc = getDatabaseDocument( + database = database, + documentId = documentId, + queryParams = previousRevQueryParams, + kClass = ObjectNode::class + ) + + return Optional.of(objectMapper.convertValue(previousDoc, kClass.java)) } override fun getAttachment( database: String, documentId: String, attachmentId: String, - ): Flux { - return webClient.get() + ): InputStream { + val response = httpClient.get() .uri { it.path("$database/$documentId/$attachmentId") it.build() } .accept(MediaType.APPLICATION_OCTET_STREAM) .retrieve() - .onStatus({ it.is4xxClientError }, { - Mono.error(NotFoundException("Could not find attachment: $database/$documentId/$attachmentId")) + .onStatus({ it.is4xxClientError }, { _, _ -> + throw NotFoundException( + message = "Could not find attachment: $database/$documentId/$attachmentId", + code = DefaultCouchDbClientErrorCode.NOT_FOUND + ) }) - .bodyToFlux(DataBuffer::class.java) - .doOnError { - logger.warn(it.localizedMessage, it) - } + .onStatus({ it.is5xxServerError }, { _, response -> + logger.warn( + "[DefaultCouchDbClient.getAttachment] CouchDb responses with ${response.statusCode.value()} error" + ) + }) + .body(Resource::class.java) + + + if (response == null) { + throw ExternalSystemException( + message = "Could not parse response to Resource", + code = DefaultCouchDbClientErrorCode.EMPTY_RESPONSE + ) + } + + return response.inputStream } override fun headAttachment( database: String, documentId: String, attachmentId: String, - ): Mono { - return webClient.head() + ): HttpHeaders { + return httpClient.head() .uri { it.path("$database/$documentId/$attachmentId") it.build() } - .accept(MediaType.APPLICATION_JSON).exchangeToMono { - if (it.statusCode().is2xxSuccessful) { - Mono.just(it.headers().asHttpHeaders()) - } else if (it.statusCode().is4xxClientError) { - Mono.just(HttpHeaders()) + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + if (clientResponse.statusCode.is2xxSuccessful) { + clientResponse.headers + } else if (clientResponse.statusCode.is4xxClientError) { + HttpHeaders() } else { - throw InternalServerException() + throw ExternalSystemException( + message = "Retrieved HTTP 500 from CouchDb, ${clientResponse.bodyTo(String::class.java)}", + code = DefaultCouchDbClientErrorCode.INVALID_RESPONSE + ) } } } @@ -242,52 +325,76 @@ class DefaultCouchDbClient( database: String, documentId: String, attachmentId: String, - file: Flux - ): Mono { - return headDatabaseDocument( + file: InputStream + ): DocSuccess { + val httpHeaders = headDatabaseDocument( database = database, documentId = documentId, ) - .flatMap { httpHeaders -> - val etag = httpHeaders.eTag?.replace("\"", "") - webClient.put() - .uri { - it.path("$database/$documentId/$attachmentId") - it.build() - } - .body(BodyInserters.fromDataBuffers(file)) - .headers { - if (etag.isNullOrBlank().not()) { - it.set("If-Match", etag) - } - it.contentType = MediaType.APPLICATION_JSON - } - .retrieve() - .bodyToMono(DocSuccess::class.java) - .doOnSuccess { - logger.trace("[CouchDbClient] PUT Attachment response: {}", it) - } - .doOnError { - logger.error("[CouchDbClient] PUT Attachment failed: {}", it.localizedMessage) - } + val etag = httpHeaders.eTag?.replace("\"", "") + + val response = httpClient.put() + .uri { + it.path("$database/$documentId/$attachmentId") + it.build() + } + .headers { + if (etag.isNullOrBlank().not()) { + it.set("If-Match", etag) + } + it.contentType = MediaType.APPLICATION_JSON } + .body { outputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + while ((file.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + .retrieve() + .body(DocSuccess::class.java) + + if (response == null) { + throw ExternalSystemException( + message = "[CouchDbClient] PUT Attachment failed", + code = DefaultCouchDbClientErrorCode.EMPTY_RESPONSE + ) + } + + logger.trace("[CouchDbClient] PUT Attachment response: {}", response.id) + + return response } + @Throws(ExternalSystemException::class) private fun handleResponse( - response: ClientResponse, + response: RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse, typeReference: KClass - ): Mono { - return response.bodyToMono(String::class.java) - .mapNotNull { - try { - val renderApiClientResponse = objectMapper.readValue(it, typeReference.java) - renderApiClientResponse - } catch (ex: Exception) { - throw ExternalSystemException( - message = ex.localizedMessage, - cause = ex, - ) - } - } + ): T { + val rawResponse = try { + response.bodyTo(String::class.java) + } catch (ex: Exception) { + logger.error( + "[DefaultCouchDbClient] Invalid response from couchdb. Could not parse response to String.", + ex + ) + throw ExternalSystemException( + message = ex.localizedMessage, + cause = ex, + code = DefaultCouchDbClientErrorCode.INVALID_RESPONSE + ) + } + + try { + val renderApiClientResponse = objectMapper.readValue(rawResponse, typeReference.java) + return renderApiClientResponse + } catch (ex: Exception) { + logger.error("[DefaultCouchDbClient] Could not parse response to ${typeReference.java.canonicalName}", ex) + throw ExternalSystemException( + message = ex.localizedMessage, + cause = ex, + code = DefaultCouchDbClientErrorCode.PARSING_ERROR + ) + } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt index 90221df..80d65d0 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt @@ -7,35 +7,29 @@ import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient +import org.springframework.web.client.RestClient @Configuration class CouchDbConfiguration { @Bean fun defaultCouchDbStorage( - @Qualifier("couch-db-client") webClient: WebClient, + @Qualifier("couch-db-client") restClient: RestClient, objectMapper: ObjectMapper, - ): CouchDbClient = DefaultCouchDbClient(webClient, objectMapper) + ): CouchDbClient = DefaultCouchDbClient(restClient, objectMapper) @Bean(name = ["couch-db-client"]) - fun couchDbWebClient(configuration: CouchDbClientConfiguration): WebClient { - val clientBuilder = - WebClient.builder() - .codecs { - it.defaultCodecs() - .maxInMemorySize(configuration.maxInMemorySizeInMegaBytes * 1024 * 1024) - } - .baseUrl(configuration.basePath) - .defaultHeaders { - it.setBasicAuth( - configuration.basicAuthUsername, - configuration.basicAuthPassword, - ) - } - return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + fun couchDbWebClient(configuration: CouchDbClientConfiguration): RestClient { + val clientBuilder = RestClient.builder() + .baseUrl(configuration.basePath) + .defaultHeaders { + it.setBasicAuth( + configuration.basicAuthUsername, + configuration.basicAuthPassword, + ) + } + + return clientBuilder.build() } } @@ -44,5 +38,4 @@ class CouchDbClientConfiguration( val basePath: String, val basicAuthUsername: String, val basicAuthPassword: String, - val maxInMemorySizeInMegaBytes: Int = 16, ) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainUseCase.kt index 52cdfe6..0b8a562 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainUseCase.kt @@ -1,36 +1,88 @@ package com.aamdigital.aambackendservice.domain -import reactor.core.publisher.Mono - +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.AamException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +/** + * Represents the input data needed, to fulfill the use case. + * Usually implemented as data class. + */ interface UseCaseRequest + +/** + * Represents the data outcome, if the use case was applied successfully. + * Usually implemented as data class. + */ interface UseCaseData -interface UseCaseErrorCode -sealed interface UseCaseOutcome { - data class Success( - val outcome: D - ) : UseCaseOutcome +/** + * The UseCaseOutcome will represent the result of a use case run. + * It's always `Success` or `Failure` and will provide either `data` or `error details` + */ +sealed interface UseCaseOutcome { + data class Success( + val data: D + ) : UseCaseOutcome - data class Failure( - val errorCode: E, - val errorMessage: String? = "An unspecific error occurred, while executing this use case", + data class Failure( + val errorCode: AamErrorCode, + val errorMessage: String = "An unexpected error occurred while executing this use case.", val cause: Throwable? = null - ) : UseCaseOutcome + ) : UseCaseOutcome } -interface DomainUseCase { - fun apply(request: R): Mono> - fun handleError(it: Throwable): Mono> +abstract class DomainUseCase { + + protected val logger: Logger = LoggerFactory.getLogger(javaClass) + + enum class DomainError : AamErrorCode { + UNHANDLED_EXCEPTION_IN_USE_CASE + } + + /** + * Implement your business use case here + * + * @throws AamException + */ + protected abstract fun apply(request: R): UseCaseOutcome + + /** + * optional extend default error handling + */ + protected open fun errorHandler(it: Throwable): UseCaseOutcome = baseErrorHandler(it) + + private fun baseErrorHandler( + it: Throwable + ): UseCaseOutcome { + val errorCode: AamErrorCode = when (it) { + is AamException -> { + it.code + } + + else -> { + DomainError.UNHANDLED_EXCEPTION_IN_USE_CASE + } + } + + logger.debug("[{}] {}", errorCode, it.localizedMessage, it.cause) + + return UseCaseOutcome.Failure( + errorMessage = it.localizedMessage, + errorCode = errorCode, + cause = it + ) + } - fun execute(request: R): Mono> { + /** + * Execute the use case with errorHandler() in place. + */ + fun run(request: R): UseCaseOutcome { return try { apply(request) - .onErrorResume { - handleError(it) - } } catch (ex: Exception) { - handleError(ex) + errorHandler(ex) } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt index 58e944d..3cfbe84 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt @@ -1,53 +1,51 @@ package com.aamdigital.aambackendservice.error +interface AamErrorCode + sealed class AamException( message: String, cause: Throwable? = null, - val code: String = DEFAULT_ERROR_CODE -) : Exception(message, cause) { - companion object { - private const val DEFAULT_ERROR_CODE = "AAM-GENERAL" - } -} + val code: AamErrorCode +) : Exception(message, cause) class InternalServerException( message: String = "Unspecific InternalServerException", cause: Throwable? = null, - code: String = "INTERNAL_SERVER_ERROR" + code: AamErrorCode, ) : AamException(message, cause, code) class ExternalSystemException( message: String = "Unspecific ExternalSystemException", cause: Throwable? = null, - code: String = "EXTERNAL_SYSTEM_ERROR" + code: AamErrorCode, ) : AamException(message, cause, code) class NetworkException( message: String = "Unspecific NetworkException", cause: Throwable? = null, - code: String = "NETWORK_EXCEPTION" + code: AamErrorCode, ) : AamException(message, cause, code) class InvalidArgumentException( message: String = "Unspecific InvalidArgumentException", cause: Throwable? = null, - code: String = "BAD_REQUEST" + code: AamErrorCode, ) : AamException(message, cause, code) class UnauthorizedAccessException( message: String = "Unspecific UnauthorizedAccessException", cause: Throwable? = null, - code: String = "UNAUTHORIZED" + code: AamErrorCode, ) : AamException(message, cause, code) class ForbiddenAccessException( message: String = "Unspecific ForbiddenAccessException", cause: Throwable? = null, - code: String = "FORBIDDEN" + code: AamErrorCode, ) : AamException(message, cause, code) class NotFoundException( message: String = "Unspecific NotFoundException", cause: Throwable? = null, - code: String = "NOT_FOUND" + code: AamErrorCode, ) : AamException(message, cause, code) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/HttpErrorDto.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/HttpErrorDto.kt new file mode 100644 index 0000000..057e877 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/HttpErrorDto.kt @@ -0,0 +1,6 @@ +package com.aamdigital.aambackendservice.error + +open class HttpErrorDto( + val errorCode: String, + val errorMessage: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt index 8587076..c96f597 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt @@ -3,24 +3,23 @@ package com.aamdigital.aambackendservice.export.controller import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.UseCaseOutcome.Failure import com.aamdigital.aambackendservice.domain.UseCaseOutcome.Success -import com.aamdigital.aambackendservice.error.ExternalSystemException -import com.aamdigital.aambackendservice.error.InternalServerException -import com.aamdigital.aambackendservice.error.NotFoundException -import com.aamdigital.aambackendservice.export.core.CreateTemplateErrorCode +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.aamdigital.aambackendservice.export.core.CreateTemplateError import com.aamdigital.aambackendservice.export.core.CreateTemplateRequest import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase -import com.aamdigital.aambackendservice.export.core.FetchTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.FetchTemplateError import com.aamdigital.aambackendservice.export.core.FetchTemplateRequest import com.aamdigital.aambackendservice.export.core.FetchTemplateUseCase -import com.aamdigital.aambackendservice.export.core.RenderTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.RenderTemplateError import com.aamdigital.aambackendservice.export.core.RenderTemplateRequest import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase import com.fasterxml.jackson.databind.JsonNode -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.core.io.buffer.DataBuffer +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.http.codec.multipart.FilePart import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -29,16 +28,38 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.OutputStream -/** - * @param templateId The external identifier of the implementing TemplateEngine - */ -data class CreateTemplateResponseDto( - val templateId: String, -) +sealed interface TemplateExportControllerResponse { + + /** + * @param templateId The external identifier of the implementing TemplateEngine + */ + data class CreateTemplateControllerResponse( + val templateId: String, + ) : TemplateExportControllerResponse + + /** + * StreamingResponse of the template binary file + */ + fun interface FetchTemplateControllerResponse : StreamingResponseBody, TemplateExportControllerResponse + + /** + * StreamingResponse of the template, rendered with passed data as binary file + */ + fun interface RenderTemplateControllerResponse : StreamingResponseBody, TemplateExportControllerResponse + + class ErrorControllerResponse( + errorCode: String, + errorMessage: String + ) : HttpErrorDto( + errorCode, + errorMessage + ), TemplateExportControllerResponse +} /** * REST controller responsible for handling export operations related to templates. @@ -46,7 +67,6 @@ data class CreateTemplateResponseDto( * * In Aam, this API is especially used for generating PDFs for an entity. * - * @param webClient The WebClient used to interact with external services. * @param createTemplateUseCase Use case for creating a new template. * @param fetchTemplateUseCase Use case for fetching an existing template (file). * @param renderTemplateUseCase Use case for rendering an existing template. @@ -55,58 +75,146 @@ data class CreateTemplateResponseDto( @RequestMapping("/v1/export") @Validated class TemplateExportController( - @Qualifier("aam-render-api-client") val webClient: WebClient, - val createTemplateUseCase: CreateTemplateUseCase, - val fetchTemplateUseCase: FetchTemplateUseCase, - val renderTemplateUseCase: RenderTemplateUseCase, + private val createTemplateUseCase: CreateTemplateUseCase, + private val fetchTemplateUseCase: FetchTemplateUseCase, + private val renderTemplateUseCase: RenderTemplateUseCase, + private val objectMapper: ObjectMapper, ) { + companion object { + private const val BYTE_ARRAY_BUFFER_LENGTH = 4096 + } + + private val logger = LoggerFactory.getLogger(javaClass) + + private fun getErrorEntity( + errorCode: String, + errorMessage: String, + status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR + ): ResponseEntity = ResponseEntity + .status(status) + .body( + TemplateExportControllerResponse.ErrorControllerResponse( + errorMessage = errorMessage, + errorCode = errorCode, + ) + ) + + /* + * Needed so be able to return "ResponseEntity" without the need to write a converter. + */ + private fun getErrorStreamingBody(result: Failure<*>) = + StreamingResponseBody { outputStream: OutputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + + val bodyStream = objectMapper.writeValueAsString( + TemplateExportControllerResponse.ErrorControllerResponse( + errorCode = result.errorCode.toString(), + errorMessage = result.errorMessage, + ) + ).byteInputStream() + + while ((bodyStream.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + @PostMapping("/template") fun postTemplate( - @RequestPart("template") file: FilePart - ): Mono { - return createTemplateUseCase - .execute( + @RequestPart("template") file: MultipartFile + ): ResponseEntity { + val result = createTemplateUseCase + .run( CreateTemplateRequest( file = file ) - ).handle { result, sink -> - when (result) { - is Success -> - sink.next( - CreateTemplateResponseDto( - templateId = result.outcome.templateRef.id - ) - ) - - is Failure -> sink.error( - result.cause ?: getError(result.errorCode) + ) + + return when (result) { + is Success -> { + val response = TemplateExportControllerResponse.CreateTemplateControllerResponse( + templateId = result.data.templateRef.id + ) + + logger.trace( + "[TemplateExportController.postTemplate()] success response: {}", + response.toString() + ) + + ResponseEntity.ok(response) + } + + is Failure -> { + val responseEntity = when (result.errorCode as CreateTemplateError) { + else -> getErrorEntity( + errorCode = result.errorCode.toString(), + errorMessage = result.errorMessage ) } + + logger.trace( + "[TemplateExportController.postTemplate()] failure response: {}", + responseEntity.body.toString() + ) + + return responseEntity } + } } @GetMapping("/template/{templateId}") fun fetchTemplate( @PathVariable templateId: String, - ): Mono> { - return fetchTemplateUseCase.execute( + ): ResponseEntity { + val result = fetchTemplateUseCase.run( FetchTemplateRequest( templateRef = DomainReference(templateId), ) - ).handle { result, sink -> - when (result) { - is Success -> { - sink.next(ResponseEntity(result.outcome.file, result.outcome.responseHeaders, HttpStatus.OK)) - } + ) + + return when (result) { + is Success -> { + val responseBody = + TemplateExportControllerResponse.FetchTemplateControllerResponse { outputStream: OutputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + while ((result.data.file.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + + logger.trace( + "[TemplateExportController.fetchTemplate()] success response: (FetchTemplateControllerResponse)", + ) + + ResponseEntity( + responseBody, + result.data.responseHeaders, + HttpStatus.OK + ) + } + + is Failure -> { + val errorStreamingBody = getErrorStreamingBody(result) + val headers = HttpHeaders() + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - is Failure -> - sink.error( - getError( - result.errorCode, - "[${result.errorCode}] ${result.errorMessage}".trimIndent() - ) + val responseEntity = when (result.errorCode as FetchTemplateError) { + FetchTemplateError.NOT_FOUND_ERROR -> ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.NOT_FOUND, ) + + else -> ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.INTERNAL_SERVER_ERROR + ) + } + + return responseEntity } } } @@ -115,55 +223,57 @@ class TemplateExportController( fun renderTemplate( @PathVariable templateId: String, @RequestBody templateData: JsonNode, - ): Mono> { - return renderTemplateUseCase.execute( + ): ResponseEntity { + val result = renderTemplateUseCase.run( RenderTemplateRequest( templateRef = DomainReference(templateId), bodyData = templateData ) - ).handle { result, sink -> - when (result) { - is Success -> { - sink.next( - ResponseEntity( - result.outcome.file, - result.outcome.responseHeaders, HttpStatus.OK - ) - ) - } + ) - is Failure -> - sink.error( - getError( - result.errorCode, - "[${result.errorCode}] ${result.errorMessage}".trimIndent() - ) - ) + return when (result) { + is Success -> { + val responseBody = + TemplateExportControllerResponse.RenderTemplateControllerResponse { outputStream: OutputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + while ((result.data.file.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + + logger.trace( + "[TemplateExportController.renderTemplate()] success response: (RenderTemplateControllerResponse)", + ) + + ResponseEntity( + responseBody, + result.data.responseHeaders, + HttpStatus.OK + ) } - } - } - private fun getError(errorCode: FetchTemplateErrorCode, message: String): Throwable = - when (errorCode) { - FetchTemplateErrorCode.INTERNAL_SERVER_ERROR -> throw InternalServerException(message) - FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR -> throw ExternalSystemException(message) - FetchTemplateErrorCode.NOT_FOUND_ERROR -> throw NotFoundException(message) - } + is Failure -> { + val errorStreamingBody = getErrorStreamingBody(result) + val headers = HttpHeaders() + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - private fun getError(errorCode: RenderTemplateErrorCode, message: String): Throwable = - when (errorCode) { - RenderTemplateErrorCode.INTERNAL_SERVER_ERROR -> throw InternalServerException(message) - RenderTemplateErrorCode.FETCH_TEMPLATE_FAILED_ERROR -> throw ExternalSystemException(message) - RenderTemplateErrorCode.CREATE_RENDER_REQUEST_FAILED_ERROR -> throw ExternalSystemException(message) - RenderTemplateErrorCode.FETCH_RENDER_ID_REQUEST_FAILED_ERROR -> throw ExternalSystemException(message) - RenderTemplateErrorCode.PARSE_RESPONSE_ERROR -> throw ExternalSystemException(message) - RenderTemplateErrorCode.NOT_FOUND_ERROR -> throw NotFoundException(message) - } + val responseEntity = when (result.errorCode as RenderTemplateError) { + RenderTemplateError.NOT_FOUND_ERROR -> ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.NOT_FOUND + ) - private fun getError(errorCode: CreateTemplateErrorCode): Throwable = - when (errorCode) { - CreateTemplateErrorCode.INTERNAL_SERVER_ERROR -> throw InternalServerException() - CreateTemplateErrorCode.PARSE_RESPONSE_ERROR -> throw ExternalSystemException() - CreateTemplateErrorCode.CREATE_TEMPLATE_REQUEST_FAILED_ERROR -> throw ExternalSystemException() + else -> ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.INTERNAL_SERVER_ERROR + ) + } + + return responseEntity + } } + } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt index d4529bb..b0878b5 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt @@ -3,12 +3,12 @@ package com.aamdigital.aambackendservice.export.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.DomainUseCase import com.aamdigital.aambackendservice.domain.UseCaseData -import com.aamdigital.aambackendservice.domain.UseCaseErrorCode import com.aamdigital.aambackendservice.domain.UseCaseRequest -import org.springframework.http.codec.multipart.FilePart +import com.aamdigital.aambackendservice.error.AamErrorCode +import org.springframework.web.multipart.MultipartFile data class CreateTemplateRequest( - val file: FilePart, + val file: MultipartFile, ) : UseCaseRequest data class CreateTemplateData( @@ -16,11 +16,11 @@ data class CreateTemplateData( ) : UseCaseData -enum class CreateTemplateErrorCode : UseCaseErrorCode { +enum class CreateTemplateError : AamErrorCode { INTERNAL_SERVER_ERROR, PARSE_RESPONSE_ERROR, CREATE_TEMPLATE_REQUEST_FAILED_ERROR } -interface CreateTemplateUseCase : - DomainUseCase +abstract class CreateTemplateUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/FetchTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/FetchTemplateUseCase.kt index e2ea24b..ed258c7 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/FetchTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/FetchTemplateUseCase.kt @@ -3,26 +3,26 @@ package com.aamdigital.aambackendservice.export.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.DomainUseCase import com.aamdigital.aambackendservice.domain.UseCaseData -import com.aamdigital.aambackendservice.domain.UseCaseErrorCode import com.aamdigital.aambackendservice.domain.UseCaseRequest -import org.springframework.core.io.buffer.DataBuffer +import com.aamdigital.aambackendservice.error.AamErrorCode import org.springframework.http.HttpHeaders +import java.io.InputStream data class FetchTemplateRequest( val templateRef: DomainReference, ) : UseCaseRequest data class FetchTemplateData( - val file: DataBuffer, + val file: InputStream, val responseHeaders: HttpHeaders, ) : UseCaseData -enum class FetchTemplateErrorCode : UseCaseErrorCode { +enum class FetchTemplateError : AamErrorCode { INTERNAL_SERVER_ERROR, FETCH_TEMPLATE_REQUEST_FAILED_ERROR, NOT_FOUND_ERROR } -interface FetchTemplateUseCase : - DomainUseCase +abstract class FetchTemplateUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt index db71edc..3620973 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt @@ -3,11 +3,11 @@ package com.aamdigital.aambackendservice.export.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.DomainUseCase import com.aamdigital.aambackendservice.domain.UseCaseData -import com.aamdigital.aambackendservice.domain.UseCaseErrorCode import com.aamdigital.aambackendservice.domain.UseCaseRequest +import com.aamdigital.aambackendservice.error.AamErrorCode import com.fasterxml.jackson.databind.JsonNode -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders +import java.io.InputStream data class RenderTemplateRequest( val templateRef: DomainReference, @@ -15,18 +15,18 @@ data class RenderTemplateRequest( ) : UseCaseRequest data class RenderTemplateData( - val file: DataBuffer, + val file: InputStream, val responseHeaders: HttpHeaders, ) : UseCaseData -enum class RenderTemplateErrorCode : UseCaseErrorCode { +enum class RenderTemplateError : AamErrorCode { INTERNAL_SERVER_ERROR, FETCH_TEMPLATE_FAILED_ERROR, CREATE_RENDER_REQUEST_FAILED_ERROR, FETCH_RENDER_ID_REQUEST_FAILED_ERROR, PARSE_RESPONSE_ERROR, - NOT_FOUND_ERROR + NOT_FOUND_ERROR; } -interface RenderTemplateUseCase : - DomainUseCase +abstract class RenderTemplateUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateStorage.kt index 448c7bd..1ef71fc 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateStorage.kt @@ -1,8 +1,15 @@ package com.aamdigital.aambackendservice.export.core import com.aamdigital.aambackendservice.domain.DomainReference -import reactor.core.publisher.Mono +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.error.NetworkException +import com.aamdigital.aambackendservice.error.NotFoundException interface TemplateStorage { - fun fetchTemplate(template: DomainReference): Mono + @Throws( + NotFoundException::class, + ExternalSystemException::class, + NetworkException::class + ) + fun fetchTemplate(template: DomainReference): TemplateExport } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt index 59a1b21..05de92f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt @@ -2,62 +2,48 @@ package com.aamdigital.aambackendservice.export.di import com.aamdigital.aambackendservice.auth.core.AuthConfig import com.aamdigital.aambackendservice.auth.core.AuthProvider -import com.aamdigital.aambackendservice.http.AamReadTimeoutHandler import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpHeaders -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.ClientRequest -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.web.client.RestClient @ConfigurationProperties("aam-render-api-client-configuration") class AamRenderApiClientConfiguration( val basePath: String, val authConfig: AuthConfig? = null, - val responseTimeoutInSeconds: Long = 30L, - val maxInMemorySizeInMegaBytes: Int = 16, + val responseTimeoutInSeconds: Int = 30, ) @Configuration class AamRenderApiConfiguration { - companion object { - const val MEGA_BYTES_MULTIPLIER = 1024 * 1024 - } - + @Bean(name = ["aam-render-api-client"]) fun aamRenderApiClient( @Qualifier("aam-keycloak") authProvider: AuthProvider, configuration: AamRenderApiClientConfiguration - ): WebClient { - val clientBuilder = - WebClient.builder() - .codecs { - it.defaultCodecs() - .maxInMemorySize(configuration.maxInMemorySizeInMegaBytes * MEGA_BYTES_MULTIPLIER) - } - .baseUrl(configuration.basePath) - .filter { request, next -> - if (configuration.authConfig == null) return@filter next.exchange(request) + ): RestClient { + val clientBuilder = RestClient.builder() + .baseUrl(configuration.basePath) - authProvider.fetchToken(configuration.authConfig).map { - ClientRequest.from(request).header(HttpHeaders.AUTHORIZATION, "Bearer ${it.token}").build() - }.flatMap { - next.exchange(it) - } + if (configuration.authConfig != null) { + clientBuilder.defaultRequest { request -> + val token = + authProvider.fetchToken(configuration.authConfig) + request.headers { + it.set(HttpHeaders.AUTHORIZATION, "Bearer ${token.token}") } + } + } + + clientBuilder.requestFactory(SimpleClientHttpRequestFactory().apply { + setReadTimeout(configuration.responseTimeoutInSeconds * 1000) + setConnectTimeout(configuration.responseTimeoutInSeconds * 1000) + }) - return clientBuilder.clientConnector( - ReactorClientHttpConnector( - HttpClient - .create() - .doOnConnected { - it.addHandlerLast(AamReadTimeoutHandler(configuration.responseTimeoutInSeconds)) - } - ) - ).build() + return clientBuilder.build() } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt index 7e86c98..c0c58f3 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt @@ -13,7 +13,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.client.RestClient @Configuration class UseCaseConfiguration { @@ -24,21 +24,21 @@ class UseCaseConfiguration { @Bean(name = ["default-create-template-use-case"]) fun defaultCreateTemplateUseCase( - @Qualifier("aam-render-api-client") webClient: WebClient, + @Qualifier("aam-render-api-client") restClient: RestClient, objectMapper: ObjectMapper - ): CreateTemplateUseCase = DefaultCreateTemplateUseCase(webClient, objectMapper) + ): CreateTemplateUseCase = DefaultCreateTemplateUseCase(restClient, objectMapper) @Bean(name = ["default-fetch-template-use-case"]) fun defaultFetchTemplateUseCase( - @Qualifier("aam-render-api-client") webClient: WebClient, + @Qualifier("aam-render-api-client") restClient: RestClient, templateStorage: TemplateStorage - ): FetchTemplateUseCase = DefaultFetchTemplateUseCase(webClient, templateStorage) + ): FetchTemplateUseCase = DefaultFetchTemplateUseCase(restClient, templateStorage) @Bean(name = ["default-render-template-use-case"]) fun defaultRenderTemplateUseCase( - @Qualifier("aam-render-api-client") webClient: WebClient, + @Qualifier("aam-render-api-client") restClient: RestClient, objectMapper: ObjectMapper, templateStorage: TemplateStorage - ): RenderTemplateUseCase = DefaultRenderTemplateUseCase(webClient, objectMapper, templateStorage) + ): RenderTemplateUseCase = DefaultRenderTemplateUseCase(restClient, objectMapper, templateStorage) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt index bae921a..6021f08 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt @@ -2,11 +2,15 @@ package com.aamdigital.aambackendservice.export.storage import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.error.NetworkException +import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.export.core.TemplateExport import com.aamdigital.aambackendservice.export.core.TemplateStorage import com.fasterxml.jackson.annotation.JsonProperty import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Mono +import java.io.InterruptedIOException data class TemplateExportDto( @JsonProperty("_id") @@ -25,15 +29,32 @@ class DefaultTemplateStorage( private const val TARGET_COUCH_DB = "app" } - override fun fetchTemplate(template: DomainReference): Mono { - return couchDbClient.getDatabaseDocument( - database = TARGET_COUCH_DB, - documentId = template.id, - queryParams = LinkedMultiValueMap(mapOf()), - kClass = TemplateExportDto::class - ).map { - toEntity(it) + enum class DefaultTemplateStorageErrorCode : AamErrorCode { + IO_NETWORK_ERROR + } + + @Throws( + NotFoundException::class, + ExternalSystemException::class, + NetworkException::class + ) + override fun fetchTemplate(template: DomainReference): TemplateExport { + val document = try { + couchDbClient.getDatabaseDocument( + database = TARGET_COUCH_DB, + documentId = template.id, + queryParams = LinkedMultiValueMap(mapOf()), + kClass = TemplateExportDto::class + ) + } catch (ex: InterruptedIOException) { + throw NetworkException( + cause = ex, + message = ex.localizedMessage, + code = DefaultTemplateStorageErrorCode.IO_NETWORK_ERROR + ) } + + return toEntity(document) } private fun toEntity(dto: TemplateExportDto): TemplateExport = TemplateExport( diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt index b3190b2..c622dea 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt @@ -2,21 +2,17 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.UseCaseOutcome -import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.ExternalSystemException import com.aamdigital.aambackendservice.export.core.CreateTemplateData -import com.aamdigital.aambackendservice.export.core.CreateTemplateErrorCode -import com.aamdigital.aambackendservice.export.core.CreateTemplateErrorCode.CREATE_TEMPLATE_REQUEST_FAILED_ERROR -import com.aamdigital.aambackendservice.export.core.CreateTemplateErrorCode.PARSE_RESPONSE_ERROR +import com.aamdigital.aambackendservice.export.core.CreateTemplateError.CREATE_TEMPLATE_REQUEST_FAILED_ERROR +import com.aamdigital.aambackendservice.export.core.CreateTemplateError.PARSE_RESPONSE_ERROR import com.aamdigital.aambackendservice.export.core.CreateTemplateRequest import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase import com.fasterxml.jackson.databind.ObjectMapper -import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.http.client.MultipartBodyBuilder -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono +import org.springframework.web.client.RestClient + data class CreateTemplateResponseDto( val success: Boolean, @@ -34,61 +30,48 @@ data class CreateTemplateResponseDataDto( * This use case is responsible for registering a template file by making a POST request to a specified * template creation endpoint. The TemplateExport entity is then created in the frontend. * - * @property webClient The WebClient used to make HTTP requests to the template engine. + * @property restClient The RestClient used to make HTTP requests to the template engine. * @property objectMapper The ObjectMapper used to parse JSON responses. */ class DefaultCreateTemplateUseCase( - private val webClient: WebClient, + private val restClient: RestClient, private val objectMapper: ObjectMapper, -) : CreateTemplateUseCase { - - private val logger = LoggerFactory.getLogger(javaClass) +) : CreateTemplateUseCase() { override fun apply( request: CreateTemplateRequest - ): Mono> { + ): UseCaseOutcome { val builder = MultipartBodyBuilder() builder - .part("template", request.file) - .filename(request.file.filename()) + .part("template", request.file.resource) + .filename(request.file.name) - return webClient.post() - .uri("/template") - .contentType(MediaType.MULTIPART_FORM_DATA) - .accept(MediaType.APPLICATION_JSON) - .body(BodyInserters.fromMultipartData(builder.build())) - .exchangeToMono { - it.bodyToMono(String::class.java) - } - .onErrorMap { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = CREATE_TEMPLATE_REQUEST_FAILED_ERROR.toString() - ) - } - .map { - UseCaseOutcome.Success( - outcome = CreateTemplateData( - templateRef = DomainReference(parseResponse(it)) - ) - ) - } - } - - override fun handleError(it: Throwable): Mono> { - val errorCode: CreateTemplateErrorCode = runCatching { - CreateTemplateErrorCode.valueOf((it as AamException).code) - }.getOrDefault(CreateTemplateErrorCode.INTERNAL_SERVER_ERROR) + val response = try { + restClient.post() + .uri("/template") + .accept(MediaType.APPLICATION_JSON) + .body(builder.build()) + .retrieve() + .body(String::class.java) + } catch (it: Exception) { + throw ExternalSystemException( + cause = it, + message = it.localizedMessage, + code = CREATE_TEMPLATE_REQUEST_FAILED_ERROR + ) + } - logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + if (response.isNullOrEmpty()) { + return UseCaseOutcome.Failure( + errorMessage = "Response from template service was null or empty.", + errorCode = CREATE_TEMPLATE_REQUEST_FAILED_ERROR, + ) + } - return Mono.just( - UseCaseOutcome.Failure( - errorMessage = it.localizedMessage, - errorCode = errorCode, - cause = it.cause + return UseCaseOutcome.Success( + data = CreateTemplateData( + templateRef = DomainReference(parseResponse(response)) ) ) } @@ -102,7 +85,7 @@ class DefaultCreateTemplateUseCase( throw ExternalSystemException( cause = ex, message = ex.localizedMessage, - code = PARSE_RESPONSE_ERROR.toString() + code = PARSE_RESPONSE_ERROR, ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCase.kt index e6f3c54..7330b32 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCase.kt @@ -3,23 +3,21 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.UseCaseOutcome import com.aamdigital.aambackendservice.domain.UseCaseOutcome.Success -import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.ExternalSystemException -import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.NetworkException import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.export.core.FetchTemplateData -import com.aamdigital.aambackendservice.export.core.FetchTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.FetchTemplateError import com.aamdigital.aambackendservice.export.core.FetchTemplateRequest import com.aamdigital.aambackendservice.export.core.FetchTemplateUseCase import com.aamdigital.aambackendservice.export.core.TemplateExport import com.aamdigital.aambackendservice.export.core.TemplateStorage -import org.slf4j.LoggerFactory -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders import org.springframework.http.MediaType -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono -import reactor.kotlin.core.publisher.switchIfEmpty +import org.springframework.web.client.ResourceAccessException +import org.springframework.web.client.RestClient +import java.io.ByteArrayInputStream +import java.io.InputStream /** * Default implementation of the [FetchTemplateUseCase]. @@ -27,111 +25,93 @@ import reactor.kotlin.core.publisher.switchIfEmpty * This use case is responsible for fetching a template based on a given request by making a GET request * to a specified template fetch endpoint. * - * @property webClient The WebClient used to make HTTP requests to the template engine. + * @property restClient The RestClient used to make HTTP requests to the template engine. * @property templateStorage The TemplateStorage instance used to fetch template metadata. */ class DefaultFetchTemplateUseCase( - private val webClient: WebClient, + private val restClient: RestClient, private val templateStorage: TemplateStorage, -) : FetchTemplateUseCase { - - private val logger = LoggerFactory.getLogger(javaClass) +) : FetchTemplateUseCase() { private data class FileResponse( - val file: DataBuffer, + val file: InputStream, val headers: HttpHeaders, ) override fun apply( request: FetchTemplateRequest - ): Mono> { - return try { - fetchTemplateRequest(request.templateRef) - .flatMap { template -> - webClient.get() - .uri("/template/${template.templateId}") - .accept(MediaType.APPLICATION_JSON) - .exchangeToMono { exchange -> - exchange.bodyToMono(DataBuffer::class.java).map { dataBuffer -> - val responseHeaders = exchange.headers().asHttpHeaders() + ): UseCaseOutcome { + val template = fetchTemplateRequest(request.templateRef) + + var responseHeaders = HttpHeaders() + + val fileStream = try { + restClient.get() + .uri("/template/${template.templateId}") + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> - val forwardHeaders = HttpHeaders() - forwardHeaders.contentType = responseHeaders.contentType + if (clientResponse.statusCode.is4xxClientError) { + throw NotFoundException( + code = FetchTemplateError.NOT_FOUND_ERROR, + message = "Template not found in template engine. Please re-upload" + + " the template and try again." + ) + } - if (!responseHeaders["Content-Disposition"].isNullOrEmpty()) { - forwardHeaders["Content-Disposition"] = responseHeaders["Content-Disposition"] - } + responseHeaders = clientResponse.headers - FileResponse( - file = dataBuffer, - headers = forwardHeaders - ) - } - } - .onErrorMap { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR.toString() - ) - } - .flatMap { fileResponse: FileResponse -> - Mono.just( - Success( - outcome = FetchTemplateData( - file = fileResponse.file, - responseHeaders = fileResponse.headers - ) - ) - ) - } + clientResponse.bodyTo(ByteArray::class.java) + ?: throw ExternalSystemException( + code = FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, + message = "Could not fetch the template file from the template engine." + ) } - } catch (it: Exception) { - handleError(it) + } catch (ex: ResourceAccessException) { + throw NetworkException( + cause = ex, + message = ex.localizedMessage, + code = FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR + ) } - } - override fun handleError(it: Throwable): Mono> { - val errorCode: FetchTemplateErrorCode = runCatching { - FetchTemplateErrorCode.valueOf((it as AamException).code) - }.getOrDefault(FetchTemplateErrorCode.INTERNAL_SERVER_ERROR) + val forwardHeaders = HttpHeaders() + forwardHeaders.contentType = responseHeaders.contentType + + if (!responseHeaders["Content-Disposition"].isNullOrEmpty()) { + forwardHeaders["Content-Disposition"] = responseHeaders["Content-Disposition"] + } - logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + val inputStream = ByteArrayInputStream(fileStream) - return Mono.just( - UseCaseOutcome.Failure( - errorMessage = it.localizedMessage, - errorCode = errorCode, - cause = it.cause + val fileResponse = FileResponse( + file = inputStream, + headers = forwardHeaders + ) + + return Success( + data = FetchTemplateData( + file = fileResponse.file, + responseHeaders = fileResponse.headers ) ) } - private fun fetchTemplateRequest(templateRef: DomainReference): Mono { - return templateStorage.fetchTemplate(templateRef) - .switchIfEmpty { - Mono.error( - ExternalSystemException( - cause = null, - message = "fetchTemplate() returned empty Mono", - code = FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR.toString() - ) - ) - } - .onErrorMap { - if (it is InvalidArgumentException) { - NotFoundException( - cause = it, - message = it.localizedMessage, - code = FetchTemplateErrorCode.NOT_FOUND_ERROR.toString() - ) - } else { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR.toString() - ) - } - } + private fun fetchTemplateRequest(templateRef: DomainReference): TemplateExport { + return try { + templateStorage.fetchTemplate(templateRef) + } catch (ex: NotFoundException) { + throw NotFoundException( + cause = ex, + message = ex.localizedMessage, + code = FetchTemplateError.NOT_FOUND_ERROR + ) + } catch (ex: Exception) { + throw ExternalSystemException( + cause = ex, + message = ex.localizedMessage, + code = FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR + ) + } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt index 9b719bb..e9d7e7f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt @@ -3,13 +3,12 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.domain.UseCaseOutcome import com.aamdigital.aambackendservice.domain.UseCaseOutcome.Success -import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.ExternalSystemException -import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.NetworkException import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.export.core.RenderTemplateData -import com.aamdigital.aambackendservice.export.core.RenderTemplateErrorCode -import com.aamdigital.aambackendservice.export.core.RenderTemplateErrorCode.FETCH_RENDER_ID_REQUEST_FAILED_ERROR +import com.aamdigital.aambackendservice.export.core.RenderTemplateError +import com.aamdigital.aambackendservice.export.core.RenderTemplateError.CREATE_RENDER_REQUEST_FAILED_ERROR import com.aamdigital.aambackendservice.export.core.RenderTemplateRequest import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase import com.aamdigital.aambackendservice.export.core.TemplateExport @@ -17,14 +16,11 @@ import com.aamdigital.aambackendservice.export.core.TemplateStorage import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode -import org.slf4j.LoggerFactory -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders import org.springframework.http.MediaType -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono -import reactor.kotlin.core.publisher.switchIfEmpty +import org.springframework.web.client.ResourceAccessException +import org.springframework.web.client.RestClient +import java.io.InputStream data class RenderRequestResponseDto( val success: Boolean, @@ -48,130 +44,109 @@ data class RenderRequestResponseDataDto( * * The file metadata is forwarded to the client. * - * @property webClient The WebClient used to make HTTP requests. + * @property renderClient The RestClient used to make HTTP requests. * @property objectMapper The ObjectMapper used for JSON processing. * @property templateStorage The TemplateStorage used to fetch templates. */ class DefaultRenderTemplateUseCase( - private val webClient: WebClient, + private val renderClient: RestClient, private val objectMapper: ObjectMapper, private val templateStorage: TemplateStorage, -) : RenderTemplateUseCase { - - private val logger = LoggerFactory.getLogger(javaClass) +) : RenderTemplateUseCase() { private data class FileResponse( - val file: DataBuffer, + val file: InputStream, val headers: HttpHeaders, ) override fun apply( request: RenderTemplateRequest - ): Mono> { - return try { - return fetchTemplateRequest(request.templateRef) - .flatMap { template: TemplateExport -> - val fileName = template.targetFileName + ): UseCaseOutcome { - (request.bodyData as ObjectNode).put( - "reportName", fileName - .replace(Regex("[\\\\/*?\"<>|]"), "_") - ) + val template = fetchTemplate(request.templateRef) - createRenderRequest(template.templateId, request.bodyData) - .map { templateId: String -> - parseRenderRequestResponse(templateId) - } - } - .flatMap { renderId: String -> - fetchRenderIdRequest(renderId) - } - .flatMap { fileResponse: FileResponse -> - Mono.just( - Success( - outcome = RenderTemplateData( - file = fileResponse.file, - responseHeaders = fileResponse.headers - ) - ) - ) - } - } catch (it: Exception) { - handleError(it) - } - } + val targetFileName = template.targetFileName + .replace(Regex("[\\\\/*?\"<>|]"), "_") - override fun handleError( - it: Throwable - ): Mono> { - val errorCode: RenderTemplateErrorCode = runCatching { - RenderTemplateErrorCode.valueOf((it as AamException).code) - }.getOrDefault(RenderTemplateErrorCode.INTERNAL_SERVER_ERROR) + (request.bodyData as ObjectNode).put( + "reportName", + targetFileName.replace(Regex("[\\\\/*?\"<>|]"), "_") + ) - logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + val templateId = createRenderRequest(template.templateId, request.bodyData) + val renderId = parseRenderRequestResponse(templateId) + val fileResponse = fetchRenderIdRequest(renderId) - return Mono.just( - UseCaseOutcome.Failure( - errorMessage = it.localizedMessage, - errorCode = errorCode, - cause = it.cause + return Success( + data = RenderTemplateData( + file = fileResponse.file, + responseHeaders = fileResponse.headers ) ) } - private fun fetchTemplateRequest(templateRef: DomainReference): Mono { - return templateStorage.fetchTemplate(templateRef) - .switchIfEmpty { - Mono.error( - ExternalSystemException( - cause = null, - message = "fetchTemplate() returned empty Mono", - code = RenderTemplateErrorCode.FETCH_TEMPLATE_FAILED_ERROR.toString() - ) + private fun fetchTemplate(templateRef: DomainReference): TemplateExport { + return try { + templateStorage.fetchTemplate(templateRef) + } catch (ex: Exception) { + throw when (ex) { + is NotFoundException -> NotFoundException( + cause = ex.cause ?: ex, + message = ex.localizedMessage, + code = RenderTemplateError.NOT_FOUND_ERROR ) - } - .onErrorMap { - if (it is InvalidArgumentException) { - NotFoundException( - cause = it, - message = it.localizedMessage, - code = RenderTemplateErrorCode.NOT_FOUND_ERROR.toString() - ) - } else { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = RenderTemplateErrorCode.FETCH_TEMPLATE_FAILED_ERROR.toString() - ) - } - } - } - private fun createRenderRequest(templateId: String, bodyData: JsonNode): Mono { - return webClient.post() - .uri("/render/$templateId") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .body(BodyInserters.fromValue(bodyData)) - .exchangeToMono { - it.bodyToMono(String::class.java) - } - .onErrorMap { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = RenderTemplateErrorCode.CREATE_RENDER_REQUEST_FAILED_ERROR.toString() + is NetworkException -> NetworkException( + cause = ex.cause ?: ex, + message = ex.localizedMessage, + code = RenderTemplateError.FETCH_TEMPLATE_FAILED_ERROR + ) + + else -> ExternalSystemException( + cause = ex.cause ?: ex, + message = "Could not create render request to template engine.", + code = RenderTemplateError.FETCH_TEMPLATE_FAILED_ERROR ) + } + } } - private fun fetchRenderIdRequest(renderId: String): Mono { - return webClient.get() - .uri("/render/$renderId") - .accept(MediaType.APPLICATION_JSON) - .exchangeToMono { exchange -> - exchange.bodyToMono(DataBuffer::class.java).map { dataBuffer -> - val responseHeaders = exchange.headers().asHttpHeaders() + private fun createRenderRequest(templateId: String, bodyData: JsonNode): String { + val response = try { + renderClient.post() + .uri("/render/$templateId") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(bodyData) + .retrieve() + .body(String::class.java) + } catch (ex: Exception) { + throw ExternalSystemException( + cause = ex, + message = ex.localizedMessage, + code = CREATE_RENDER_REQUEST_FAILED_ERROR + ) + } + + if (response.isNullOrEmpty()) { + throw ExternalSystemException( + cause = null, + message = "Null or empty response from renderClient.", + code = CREATE_RENDER_REQUEST_FAILED_ERROR + ) + } + + return response + } + + private fun fetchRenderIdRequest(renderId: String): FileResponse { + return try { + renderClient.get() + .uri("/render/$renderId") + .accept(MediaType.APPLICATION_JSON) + .exchange { _, clientResponse -> + val responseHeaders = clientResponse.headers val forwardHeaders = HttpHeaders() forwardHeaders.contentType = responseHeaders.contentType @@ -180,19 +155,32 @@ class DefaultRenderTemplateUseCase( forwardHeaders["Content-Disposition"] = responseHeaders["Content-Disposition"] } + val buffer = clientResponse.bodyTo(ByteArray::class.java) ?: throw ExternalSystemException( + cause = null, + message = "Could not convert body to Resource.", + code = RenderTemplateError.FETCH_RENDER_ID_REQUEST_FAILED_ERROR + ) + FileResponse( - file = dataBuffer, + file = buffer.inputStream(), headers = forwardHeaders ) } - } - .onErrorMap { - ExternalSystemException( - cause = it, - message = it.localizedMessage, - code = FETCH_RENDER_ID_REQUEST_FAILED_ERROR.toString() + } catch (ex: Exception) { + throw when (ex) { + is ResourceAccessException -> NetworkException( + cause = ex.cause ?: ex, + message = ex.localizedMessage, + code = RenderTemplateError.FETCH_RENDER_ID_REQUEST_FAILED_ERROR + ) + + else -> ExternalSystemException( + cause = ex.cause ?: ex, + message = "Could not create render request to template engine.", + code = RenderTemplateError.FETCH_RENDER_ID_REQUEST_FAILED_ERROR ) } + } } private fun parseRenderRequestResponse(raw: String): String { @@ -210,7 +198,7 @@ class DefaultRenderTemplateUseCase( throw ExternalSystemException( cause = ex, message = renderApiClientResponse, - code = RenderTemplateErrorCode.PARSE_RESPONSE_ERROR.toString() + code = RenderTemplateError.PARSE_RESPONSE_ERROR ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/http/AamReadTimeoutHandler.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/http/AamReadTimeoutHandler.kt deleted file mode 100644 index 6445e59..0000000 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/http/AamReadTimeoutHandler.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.aamdigital.aambackendservice.http - -import com.aamdigital.aambackendservice.error.AamException -import com.aamdigital.aambackendservice.error.NetworkException -import io.netty.channel.ChannelHandlerContext -import io.netty.handler.timeout.ReadTimeoutHandler -import java.util.concurrent.TimeUnit - -class AamReadTimeoutHandler( - private val timeout: Long = 30, - private val timeUnit: TimeUnit = TimeUnit.SECONDS, -) : ReadTimeoutHandler(timeout, timeUnit) { - private var closed = false - - @Throws(AamException::class) - override fun readTimedOut(ctx: ChannelHandlerContext) { - if (!this.closed) { - ctx.fireExceptionCaught( - NetworkException( - message = "The connection has not responded within $timeout ${timeUnit.toString().lowercase()}", - code = "READ_TIMEOUT_EXCEPTION", - ) - ) - ctx.close() - this.closed = true - } - } -} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt index c804e46..1297758 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt @@ -1,5 +1,6 @@ package com.aamdigital.aambackendservice.queue.core +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.InvalidArgumentException import com.fasterxml.jackson.core.JacksonException import com.fasterxml.jackson.databind.JsonNode @@ -11,6 +12,14 @@ class DefaultQueueMessageParser( private val objectMapper: ObjectMapper, ) : QueueMessageParser { + enum class DefaultQueueMessageParserErrorCode : AamErrorCode { + INVALID_JSON, + MISSING_TYPE_FIELD, + INVALID_CLASS_NAME, + MISSING_PAYLOAD_FIELD, + INVALID_PAYLOAD + } + private val logger = LoggerFactory.getLogger(javaClass) companion object { @@ -24,7 +33,7 @@ class DefaultQueueMessageParser( } catch (ex: JacksonException) { throw InvalidArgumentException( message = "Could not parse message.", - code = "INVALID_JSON", + code = DefaultQueueMessageParserErrorCode.INVALID_JSON, cause = ex, ) } @@ -37,7 +46,7 @@ class DefaultQueueMessageParser( if (!jsonNode.has(TYPE_FIELD)) { throw InvalidArgumentException( message = "Could not extract type from message.", - code = "MISSING_TYPE_FIELD", + code = DefaultQueueMessageParserErrorCode.MISSING_TYPE_FIELD, ) } @@ -54,7 +63,7 @@ class DefaultQueueMessageParser( logger.debug("INVALID_CLASS_NAME_DEBUG_EX", ex) throw InvalidArgumentException( message = "Could not find Class for this type.", - code = "INVALID_CLASS_NAME", + code = DefaultQueueMessageParserErrorCode.INVALID_CLASS_NAME, ) } } @@ -66,7 +75,7 @@ class DefaultQueueMessageParser( if (!jsonNode.has(PAYLOAD_FIELD)) { throw InvalidArgumentException( message = "Could not extract payload from message.", - code = "MISSING_PAYLOAD_FIELD", + code = DefaultQueueMessageParserErrorCode.MISSING_PAYLOAD_FIELD, ) } @@ -75,7 +84,7 @@ class DefaultQueueMessageParser( } catch (ex: JacksonException) { throw InvalidArgumentException( message = "Could not parse payload object from message.", - code = "INVALID_PAYLOAD", + code = DefaultQueueMessageParserErrorCode.INVALID_PAYLOAD, cause = ex, ) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt index 858f6b3..d54ff05 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt @@ -7,8 +7,8 @@ import com.aamdigital.aambackendservice.reporting.changes.repository.SyncEntry import com.aamdigital.aambackendservice.reporting.changes.repository.SyncRepository import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent import org.slf4j.LoggerFactory -import reactor.core.publisher.Mono import java.util.* +import kotlin.jvm.optionals.getOrDefault class CouchDbDatabaseChangeDetection( private val couchDbClient: CouchDbClient, @@ -25,70 +25,63 @@ class CouchDbDatabaseChangeDetection( /** * Will reach out to CouchDb and convert _changes to Domain.DocumentChangeEvent's */ - override fun checkForChanges(): Mono { + override fun checkForChanges() { logger.trace("[CouchDatabaseChangeDetection] start couchdb change detection...") - return couchDbClient.allDatabases().flatMap { databases -> - val requests = databases.filter { !it.startsWith("_") }.map { database -> + couchDbClient + .allDatabases() + .filter { !it.startsWith("_") }.map { database -> fetchChangesForDatabase(database) } - Mono.zip(requests) { - it.map { } - } - }.map { - logger.trace("[CouchDatabaseChangeDetection] ...completed couchdb change detection.") - } + logger.trace("[CouchDatabaseChangeDetection] ...completed couchdb change detection.") } - private fun fetchChangesForDatabase(database: String): Mono { + private fun fetchChangesForDatabase(database: String) { logger.trace("[CouchDatabaseChangeDetection] check changes for database \"{}\"...", database) - return syncRepository.findByDatabase(database).defaultIfEmpty(SyncEntry(database = database, latestRef = "")) - .flatMap { - LATEST_REFS[database] = it.latestRef + var syncEntry = + syncRepository.findByDatabase(database).getOrDefault(SyncEntry(database = database, latestRef = "")) + + LATEST_REFS[database] = syncEntry.latestRef + + val queryParams = getEmptyQueryParams() - val queryParams = getEmptyQueryParams() + if (LATEST_REFS.containsKey(database) && LATEST_REFS.getValue(database).isNotEmpty()) { + queryParams.set("last-event-id", LATEST_REFS.getValue(database)) + } + + queryParams.set("limit", CHANGES_LIMIT.toString()) + queryParams.set("include_docs", "true") - if (LATEST_REFS.containsKey(database) && LATEST_REFS.getValue(database).isNotEmpty()) { - queryParams.set("last-event-id", LATEST_REFS.getValue(database)) - } + val changes = couchDbClient.changes( + database = database, queryParams = queryParams + ) - queryParams.set("limit", CHANGES_LIMIT.toString()) - queryParams.set("include_docs", "true") + changes.results.forEachIndexed { index, couchDbChangeResult -> + logger.trace("$database $index: {}", couchDbChangeResult.toString()) - couchDbClient.changes( - database = database, queryParams = queryParams + val rev = couchDbChangeResult.doc?.get("_rev")?.textValue() + + if (!couchDbChangeResult.id.startsWith("_design")) { + documentChangeEventPublisher.publish( + channel = DB_CHANGES_QUEUE, + DatabaseChangeEvent( + documentId = couchDbChangeResult.id, + database = database, + rev = rev, + deleted = couchDbChangeResult.deleted == true + ) ) } - .map { changes -> - changes.results.forEachIndexed { index, couchDbChangeResult -> - logger.trace("$database $index: {}", couchDbChangeResult.toString()) - - val rev = couchDbChangeResult.doc?.get("_rev")?.textValue() - - if (!couchDbChangeResult.id.startsWith("_design")) { - documentChangeEventPublisher.publish( - channel = DB_CHANGES_QUEUE, - DatabaseChangeEvent( - documentId = couchDbChangeResult.id, - database = database, - rev = rev, - deleted = couchDbChangeResult.deleted == true - ) - ) - } - - LATEST_REFS[database] = couchDbChangeResult.seq - } - } - .flatMap { - syncRepository.findByDatabase(database).defaultIfEmpty(SyncEntry(database = database, latestRef = "")) - } - .flatMap { - it.latestRef = LATEST_REFS[database].orEmpty() - syncRepository.save(it) - } - .map { - logger.trace("[CouchDatabaseChangeDetection] ...completed changes check for database \"{}\".", database) - } + + LATEST_REFS[database] = couchDbChangeResult.seq + } + + syncEntry = + syncRepository.findByDatabase(database).getOrDefault(SyncEntry(database = database, latestRef = "")) + + syncEntry.latestRef = LATEST_REFS[database].orEmpty() + syncRepository.save(syncEntry) + + logger.trace("[CouchDatabaseChangeDetection] ...completed changes check for database \"{}\".", database) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt index 2bc7d00..fc26cdc 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt @@ -1,8 +1,7 @@ package com.aamdigital.aambackendservice.reporting.changes.core import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent -import reactor.core.publisher.Mono interface CreateDocumentChangeUseCase { - fun createEvent(event: DatabaseChangeEvent): Mono + fun createEvent(event: DatabaseChangeEvent) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt index ef8ee01..566470a 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt @@ -1,7 +1,5 @@ package com.aamdigital.aambackendservice.reporting.changes.core -import reactor.core.publisher.Mono - interface DatabaseChangeDetection { - fun checkForChanges(): Mono + fun checkForChanges() } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt index 37a163f..cdc734f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt @@ -2,8 +2,7 @@ package com.aamdigital.aambackendservice.reporting.changes.core import com.rabbitmq.client.Channel import org.springframework.amqp.core.Message -import reactor.core.publisher.Mono interface DatabaseChangeEventConsumer { - fun consume(rawMessage: String, message: Message, channel: Channel): Mono + fun consume(rawMessage: String, message: Message, channel: Channel) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt index 0cf377a..5d8b7ca 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt @@ -2,13 +2,14 @@ package com.aamdigital.aambackendservice.reporting.changes.core import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient import com.aamdigital.aambackendservice.couchdb.core.getEmptyQueryParams +import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DOCUMENT_CHANGES_EXCHANGE import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import org.slf4j.LoggerFactory -import reactor.core.publisher.Mono +import kotlin.jvm.optionals.getOrDefault /** * Use case is called if a change on any database document is detected. @@ -20,46 +21,48 @@ class DefaultCreateDocumentChangeUseCase( ) : CreateDocumentChangeUseCase { private val logger = LoggerFactory.getLogger(javaClass) - override fun createEvent(event: DatabaseChangeEvent): Mono { + override fun createEvent(event: DatabaseChangeEvent) { val queryParams = getEmptyQueryParams() queryParams.set("rev", event.rev) - return couchDbClient.getDatabaseDocument( + val currentDoc = couchDbClient.getDatabaseDocument( database = event.database, documentId = event.documentId, queryParams = queryParams, kClass = ObjectNode::class - ).zipWith( - if (event.rev.isNullOrBlank()) { - return Mono.empty() - } else { - couchDbClient.getPreviousDocRev( - database = event.database, - documentId = event.documentId, - rev = event.rev, - kClass = ObjectNode::class - ).defaultIfEmpty( - objectMapper.createObjectNode() - ) - } - ).map { - val currentDoc = it.t1 - val previousDoc = it.t2 + ) - if (currentDoc.has("_deleted") - && currentDoc.get("_deleted").isBoolean - && currentDoc.get("_deleted").booleanValue() - ) { - DocumentChangeEvent( - database = event.database, - documentId = event.documentId, - rev = event.rev, - currentVersion = emptyMap(), - previousVersion = emptyMap(), - deleted = event.deleted - ) - } + if (event.rev.isNullOrBlank()) { + return + } + + val previousDoc: ObjectNode = try { + couchDbClient.getPreviousDocRev( + database = event.database, + documentId = event.documentId, + rev = event.rev, + kClass = ObjectNode::class + ).getOrDefault( + objectMapper.createObjectNode() + ) + } catch (ex: AamException) { + logger.debug(ex.message, ex) + objectMapper.createObjectNode() + } + val changeEvent = if (currentDoc.has("_deleted") + && currentDoc.get("_deleted").isBoolean + && currentDoc.get("_deleted").booleanValue() + ) { + DocumentChangeEvent( + database = event.database, + documentId = event.documentId, + rev = event.rev, + currentVersion = emptyMap(), + previousVersion = emptyMap(), + deleted = event.deleted + ) + } else { DocumentChangeEvent( database = event.database, documentId = event.documentId, @@ -68,9 +71,10 @@ class DefaultCreateDocumentChangeUseCase( previousVersion = objectMapper.convertValue(previousDoc, Map::class.java), deleted = event.deleted ) - }.map { - logger.debug("[{}]: send event: {}", DOCUMENT_CHANGES_EXCHANGE, it) - documentChangeEventPublisher.publish(DOCUMENT_CHANGES_EXCHANGE, it) } + + logger.debug("[{}]: send event: {}", DOCUMENT_CHANGES_EXCHANGE, changeEvent) + + documentChangeEventPublisher.publish(DOCUMENT_CHANGES_EXCHANGE, changeEvent) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt index c6caae1..d418f1b 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt @@ -1,16 +1,12 @@ package com.aamdigital.aambackendservice.reporting.changes.core import org.slf4j.LoggerFactory -import reactor.core.publisher.Mono class NoopDatabaseChangeDetection : DatabaseChangeDetection { + private val logger = LoggerFactory.getLogger(javaClass) - override fun checkForChanges(): Mono { - logger.trace("[NoopDatabaseChangeDetection] start couchdb change detection...") - return Mono.just(Unit) - .map { - logger.trace("[NoopDatabaseChangeDetection] ...completed couchdb change detection.") - } + override fun checkForChanges() { + logger.trace("[NoopDatabaseChangeDetection] checkForChanges() called.") } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt deleted file mode 100644 index 183e248..0000000 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.aamdigital.aambackendservice.reporting.changes.di - -import io.r2dbc.spi.ConnectionFactory -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.io.ClassPathResource -import org.springframework.r2dbc.connection.init.CompositeDatabasePopulator -import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer -import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator - -@Configuration -class RepositoryConfiguration { - - @Bean - fun connectionFactoryInitializer(connectionFactory: ConnectionFactory): ConnectionFactoryInitializer { - val initializer = ConnectionFactoryInitializer() - initializer.setConnectionFactory(connectionFactory) - - val populator = CompositeDatabasePopulator() - populator.addPopulators( - ResourceDatabasePopulator( - ClassPathResource("sql/embedded_h2_database_init_script.sql"), - ) - ) - initializer.setDatabasePopulator(populator) - - return initializer - } -} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt index 0dbb6f6..89487a5 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt @@ -20,28 +20,21 @@ class CouchDbChangeDetectionJob( @Scheduled(fixedDelay = 8000) fun checkForCouchDbChanges() { if (ERROR_COUNTER >= MAX_ERROR_COUNT) { + logger.trace("[CouchDbChangeDetectionJob]: MAX_ERROR_COUNT reached. Not starting job again.") return } logger.trace("[CouchDbChangeDetectionJob] Starting job...") try { databaseChangeDetection.checkForChanges() - .doOnError { - logger.error( - "[CouchDbChangeDetectionJob] An error occurred (count: $ERROR_COUNTER): {}", - it.localizedMessage - ) - logger.debug("[CouchDbChangeDetectionJob] Debug information", it) - ERROR_COUNTER += 1 - } - .subscribe { - logger.trace("[CouchDbChangeDetectionJob]: ...job completed.") - } } catch (ex: Exception) { logger.error( - "[CouchDbChangeDetectionJob] An error occurred {}", + "[CouchDbChangeDetectionJob] An error occurred (count: $ERROR_COUNTER): {}", ex.localizedMessage ) logger.debug("[CouchDbChangeDetectionJob] Debug information", ex) + ERROR_COUNTER += 1 } + + logger.trace("[CouchDbChangeDetectionJob]: ...job completed.") } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt index 6a3569c..b336ff8 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt @@ -1,5 +1,6 @@ package com.aamdigital.aambackendservice.reporting.changes.queue +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.queue.core.QueueMessage @@ -20,6 +21,10 @@ class DefaultChangeEventPublisher( private val rabbitTemplate: RabbitTemplate, ) : ChangeEventPublisher { + enum class DefaultChangeEventPublisherErrorCode : AamErrorCode { + EVENT_PUBLISH_ERROR + } + private val logger = LoggerFactory.getLogger(javaClass) @Throws(AamException::class) @@ -41,7 +46,7 @@ class DefaultChangeEventPublisher( } catch (ex: AmqpException) { throw InternalServerException( message = "Could not publish DatabaseChangeEvent: $event", - code = "EVENT_PUBLISH_ERROR", + code = DefaultChangeEventPublisherErrorCode.EVENT_PUBLISH_ERROR, cause = ex ) } @@ -73,7 +78,7 @@ class DefaultChangeEventPublisher( } catch (ex: AmqpException) { throw InternalServerException( message = "Could not publish DocumentChangeEvent: $event", - code = "EVENT_PUBLISH_ERROR", + code = DefaultChangeEventPublisherErrorCode.EVENT_PUBLISH_ERROR, cause = ex ) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt index 7406686..3f75eee 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt @@ -11,7 +11,6 @@ import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpRejectAndDontRequeueException import org.springframework.amqp.core.Message import org.springframework.amqp.rabbit.annotation.RabbitListener -import reactor.core.publisher.Mono class DefaultDatabaseChangeEventConsumer( private val messageParser: QueueMessageParser, @@ -22,13 +21,12 @@ class DefaultDatabaseChangeEventConsumer( @RabbitListener( queues = [DB_CHANGES_QUEUE], - ackMode = "MANUAL" ) - override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + override fun consume(rawMessage: String, message: Message, channel: Channel) { val type = try { messageParser.getTypeKClass(rawMessage.toByteArray()) } catch (ex: AamException) { - return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } when (type.qualifiedName) { @@ -40,9 +38,7 @@ class DefaultDatabaseChangeEventConsumer( logger.debug("Payload parsed: {}", payload) - return useCase.createEvent(payload).flatMap { - Mono.empty() - } + return useCase.createEvent(payload) } else -> { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt index 457ec0e..e8906e3 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt @@ -1,16 +1,25 @@ package com.aamdigital.aambackendservice.reporting.changes.repository -import org.springframework.data.annotation.Id -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import reactor.core.publisher.Mono +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository +import java.util.* +@Entity data class SyncEntry( @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) var id: Long = 0, var database: String, + @Column(columnDefinition = "TEXT") var latestRef: String, ) -interface SyncRepository : ReactiveCrudRepository { - fun findByDatabase(database: String): Mono +@Repository +interface SyncRepository : CrudRepository { + fun findByDatabase(database: String): Optional } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt index c2552a8..bf9d508 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt @@ -1,7 +1,8 @@ package com.aamdigital.aambackendservice.reporting.notification.controller import com.aamdigital.aambackendservice.domain.DomainReference -import com.aamdigital.aambackendservice.error.ForbiddenAccessException +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.reporting.notification.core.AddWebhookSubscriptionUseCase import com.aamdigital.aambackendservice.reporting.notification.core.CreateWebhookRequest import com.aamdigital.aambackendservice.reporting.notification.core.NotificationStorage @@ -9,6 +10,8 @@ import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookAuthenticationType import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookTarget import com.aamdigital.aambackendservice.reporting.notification.storage.WebhookOwner +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -17,7 +20,6 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono import java.security.Principal data class WebhookAuthenticationWriteDto( @@ -55,70 +57,132 @@ class WebhookController( @GetMapping fun fetchWebhooks( principal: Principal, - ): Mono> { - return notificationStorage.fetchAllWebhooks().map { webhooks -> - webhooks - .filter { - it.owner.creator == principal.name + ): ResponseEntity { + val webhooks = try { + notificationStorage.fetchAllWebhooks() + .filter { webhook -> + webhook.owner.creator == principal.name } - .map { - mapToDto(it) + .map { webhook -> + mapToDto(webhook) } + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) + } } + return ResponseEntity.ok(webhooks) } @GetMapping("/{webhookId}") fun fetchWebhook( @PathVariable webhookId: String, principal: Principal, - ): Mono { - return notificationStorage.fetchWebhook(DomainReference(webhookId)) - .handle { webhook, sink -> - if (webhook.owner.creator == principal.name) { - sink.next(mapToDto(webhook)) - } else { - sink.error(ForbiddenAccessException()) - } + ): ResponseEntity { + val webhook = try { + notificationStorage.fetchWebhook(DomainReference(webhookId)) + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) } + } + + return if (webhook.owner.creator == principal.name) { + ResponseEntity.ok(mapToDto(webhook)) + } else { + ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } } @PostMapping fun storeWebhook( @RequestBody request: CreateWebhookRequestDto, principal: Principal, - ): Mono { - return notificationStorage.createWebhook( - CreateWebhookRequest( - user = principal.name, - label = request.label, - target = request.target, - authentication = request.authentication + ): ResponseEntity { + val webhook = try { + notificationStorage.createWebhook( + CreateWebhookRequest( + user = principal.name, + label = request.label, + target = request.target, + authentication = request.authentication + ) ) - ).map { - DomainReference(it.id) + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) + } } + + return ResponseEntity.ok(DomainReference(webhook.id)) } @PostMapping("/{webhookId}/subscribe/report/{reportId}") fun registerReportNotification( @PathVariable webhookId: String, @PathVariable reportId: String, - ): Mono { - return addWebhookSubscriptionUseCase.subscribe( + ): ResponseEntity<*> { + addWebhookSubscriptionUseCase.subscribe( report = DomainReference(reportId), webhook = DomainReference(webhookId) ) + + return ResponseEntity.ok().build() } @DeleteMapping("/{webhookId}/subscribe/report/{reportId}") fun unregisterReportNotification( @PathVariable webhookId: String, @PathVariable reportId: String, - ): Mono { - return notificationStorage.removeSubscription( + ): ResponseEntity<*> { + notificationStorage.removeSubscription( DomainReference(webhookId), DomainReference(reportId) ) + + return ResponseEntity.ok().build() } private fun mapToDto(it: Webhook): WebhookDto { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt index 9632b64..2bd3e97 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt @@ -1,8 +1,7 @@ package com.aamdigital.aambackendservice.reporting.notification.core import com.aamdigital.aambackendservice.domain.DomainReference -import reactor.core.publisher.Mono interface AddWebhookSubscriptionUseCase { - fun subscribe(report: DomainReference, webhook: DomainReference): Mono + fun subscribe(report: DomainReference, webhook: DomainReference) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt index 0873ccc..af4421d 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt @@ -5,7 +5,6 @@ import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase -import reactor.core.publisher.Mono class DefaultAddWebhookSubscriptionUseCase( private val notificationStorage: NotificationStorage, @@ -13,33 +12,31 @@ class DefaultAddWebhookSubscriptionUseCase( private val notificationService: NotificationService, private val createReportCalculationUseCase: CreateReportCalculationUseCase ) : AddWebhookSubscriptionUseCase { - override fun subscribe(report: DomainReference, webhook: DomainReference): Mono { - return notificationStorage.addSubscription( + override fun subscribe(report: DomainReference, webhook: DomainReference) { + notificationStorage.addSubscription( webhookRef = webhook, entityRef = report - ).flatMap { - reportingStorage.fetchCalculations( - reportReference = report - ).flatMap { reportCalculations -> - handleReportCalculations(reportCalculations, report, webhook) - } - } + ) + + val reportCalculations = reportingStorage.fetchCalculations( + reportReference = report + ) + + handleReportCalculations(reportCalculations, report, webhook) } private fun handleReportCalculations( calculations: List, report: DomainReference, webhook: DomainReference - ): Mono { - return if (calculations.isEmpty()) { + ) { + if (calculations.isEmpty()) { createReportCalculationUseCase.createReportCalculation( CreateReportCalculationRequest( report = report, args = mutableMapOf() ) - ).flatMap { - Mono.just(Unit) - } + ) } else { notificationService.triggerWebhook( report = report, @@ -48,7 +45,6 @@ class DefaultAddWebhookSubscriptionUseCase( calculations.sortedByDescending { it.calculationCompleted }.first().id ) ) - Mono.just(Unit) } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt index c5d5df6..de76449 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt @@ -9,7 +9,6 @@ import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpRejectAndDontRequeueException import org.springframework.amqp.core.Message import org.springframework.amqp.rabbit.annotation.RabbitListener -import reactor.core.publisher.Mono class DefaultNotificationEventConsumer( private val messageParser: QueueMessageParser, @@ -20,13 +19,12 @@ class DefaultNotificationEventConsumer( @RabbitListener( queues = [NOTIFICATION_QUEUE], - ackMode = "MANUAL" ) - override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + override fun consume(rawMessage: String, message: Message, channel: Channel) { val type = try { messageParser.getTypeKClass(rawMessage.toByteArray()) } catch (ex: AamException) { - return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } when (type.qualifiedName) { @@ -35,12 +33,16 @@ class DefaultNotificationEventConsumer( body = rawMessage.toByteArray(), kClass = NotificationEvent::class ) - logger.debug("Payload parsed: {}", payload) - - return useCase.trigger(payload).flatMap { - Mono.empty() + try { + useCase.trigger(payload) + } catch (ex: Exception) { + throw AmqpRejectAndDontRequeueException( + "[USECASE_ERROR] ${ex.localizedMessage}", + ex + ) } + return } else -> { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt index 9e35517..35d1508 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt @@ -1,5 +1,6 @@ package com.aamdigital.aambackendservice.reporting.notification.core +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.queue.core.QueueMessage @@ -18,6 +19,10 @@ class DefaultNotificationEventPublisher( private val rabbitTemplate: RabbitTemplate, ) : NotificationEventPublisher { + enum class DefaultNotificationEventPublisherErrorCode : AamErrorCode { + EVENT_PUBLISH_ERROR + } + private val logger = LoggerFactory.getLogger(javaClass) @Throws(AamException::class) @@ -39,7 +44,7 @@ class DefaultNotificationEventPublisher( } catch (ex: AmqpException) { throw InternalServerException( message = "Could not publish NotificationEvent: $event", - code = "EVENT_PUBLISH_ERROR", + code = DefaultNotificationEventPublisherErrorCode.EVENT_PUBLISH_ERROR, cause = ex ) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt index d02e92d..0684f7c 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt @@ -6,9 +6,7 @@ import org.slf4j.LoggerFactory import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono +import org.springframework.web.client.RestClient import java.net.URI /** @@ -16,57 +14,52 @@ import java.net.URI */ class DefaultTriggerWebhookUseCase( private val notificationStorage: NotificationStorage, - private val webClient: WebClient, + private val httpClient: RestClient, private val uriParser: UriParser, ) : TriggerWebhookUseCase { private val logger = LoggerFactory.getLogger(javaClass) - override fun trigger(notificationEvent: NotificationEvent): Mono { - return notificationStorage.fetchWebhook( + override fun trigger(notificationEvent: NotificationEvent) { + val webhook = notificationStorage.fetchWebhook( webhookRef = DomainReference(notificationEvent.webhookId) ) - .flatMap { webhook -> - val uri = URI( - uriParser.replacePlaceholder( - webhook.target.url, - mapOf( - Pair("reportId", notificationEvent.reportId) - ) - ) + + val uri = URI( + uriParser.replacePlaceholder( + webhook.target.url, + mapOf( + Pair("reportId", notificationEvent.reportId) ) + ) + ) - webClient - .method(HttpMethod.valueOf(webhook.target.method)) - .uri { - it.scheme(uri.scheme) - it.host(uri.host) - it.path(uri.path) - it.build() - } - .headers { - it.set(HttpHeaders.AUTHORIZATION, "Token ${webhook.authentication.secret}") - } - .body( - BodyInserters.fromValue( - mapOf( - Pair("calculation_id", notificationEvent.calculationId) - ) - ) - ) - .accept(MediaType.APPLICATION_JSON) - .exchangeToMono { response -> - response.bodyToMono(String::class.java) - } - .map { - logger.debug( - "[DefaultTriggerWebhookUseCase] Webhook trigger completed for Webhook:" + - " {} Report: {} Calculation: {} - Response: {}", - notificationEvent.webhookId, - notificationEvent.reportId, - notificationEvent.calculationId, - it - ) - } + val response = httpClient + .method(HttpMethod.valueOf(webhook.target.method)) + .uri { + it.scheme(uri.scheme) + it.host(uri.host) + it.path(uri.path) + it.build() + } + .headers { + it.set(HttpHeaders.AUTHORIZATION, "Token ${webhook.authentication.secret}") } + .body( + mapOf( + Pair("calculation_id", notificationEvent.calculationId) + ) + ) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String::class.java) + + logger.debug( + "[DefaultTriggerWebhookUseCase] Webhook trigger completed for Webhook:" + + " {} Report: {} Calculation: {} - Response: {}", + notificationEvent.webhookId, + notificationEvent.reportId, + notificationEvent.calculationId, + response + ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt index 54dc398..04a1482 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt @@ -2,8 +2,7 @@ package com.aamdigital.aambackendservice.reporting.notification.core import com.rabbitmq.client.Channel import org.springframework.amqp.core.Message -import reactor.core.publisher.Mono interface NotificationEventConsumer { - fun consume(rawMessage: String, message: Message, channel: Channel): Mono + fun consume(rawMessage: String, message: Message, channel: Channel) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt index f387002..4f75b8e 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt @@ -5,7 +5,6 @@ import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent import com.aamdigital.aambackendservice.reporting.notification.di.NotificationQueueConfiguration import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import reactor.core.publisher.Mono @Service class NotificationService( @@ -14,31 +13,30 @@ class NotificationService( ) { private val logger = LoggerFactory.getLogger(javaClass) - fun getAffectedWebhooks(report: DomainReference): Mono> { - return notificationStorage.fetchAllWebhooks() - .map { webhooks -> - val affectedWebhooks: MutableList = mutableListOf() - webhooks.forEach { webhook -> - if (webhook.reportSubscriptions.contains(report)) { - affectedWebhooks.add(DomainReference(webhook.id)) - } - } - affectedWebhooks + fun getAffectedWebhooks(report: DomainReference): List { + val webhooks = notificationStorage.fetchAllWebhooks() + + val affectedWebhooks: MutableList = mutableListOf() + webhooks.forEach { webhook -> + if (webhook.reportSubscriptions.contains(report)) { + affectedWebhooks.add(DomainReference(webhook.id)) } + } + + return affectedWebhooks } - fun sendNotifications(report: DomainReference, reportCalculation: DomainReference): Mono { + fun sendNotifications(report: DomainReference, reportCalculation: DomainReference) { logger.debug("[NotificationService]: Trigger all affected webhooks for ${report.id}") - return getAffectedWebhooks(report) - .map { webhooks -> - webhooks.map { webhook -> - triggerWebhook( - report = report, - reportCalculation = reportCalculation, - webhook = webhook - ) - } - } + val affectedWebhooks = getAffectedWebhooks(report) + + affectedWebhooks.map { webhook -> + triggerWebhook( + report = report, + reportCalculation = reportCalculation, + webhook = webhook + ) + } } fun triggerWebhook(report: DomainReference, reportCalculation: DomainReference, webhook: DomainReference) { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt index a93322a..e406a5f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt @@ -4,7 +4,6 @@ import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.notification.controller.WebhookAuthenticationWriteDto import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookTarget -import reactor.core.publisher.Mono data class CreateWebhookRequest( val user: String, @@ -14,9 +13,9 @@ data class CreateWebhookRequest( ) interface NotificationStorage { - fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono - fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono - fun fetchAllWebhooks(): Mono> - fun fetchWebhook(webhookRef: DomainReference): Mono - fun createWebhook(request: CreateWebhookRequest): Mono + fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference) + fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference) + fun fetchAllWebhooks(): List + fun fetchWebhook(webhookRef: DomainReference): Webhook + fun createWebhook(request: CreateWebhookRequest): DomainReference } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt index 562b823..fe69297 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt @@ -1,8 +1,7 @@ package com.aamdigital.aambackendservice.reporting.notification.core import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent -import reactor.core.publisher.Mono interface TriggerWebhookUseCase { - fun trigger(notificationEvent: NotificationEvent): Mono + fun trigger(notificationEvent: NotificationEvent) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt index c2fe3a3..4bfb58e 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt @@ -16,9 +16,7 @@ import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateR import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient +import org.springframework.web.client.RestClient @Configuration class NotificationConfiguration { @@ -43,16 +41,16 @@ class NotificationConfiguration { @Bean fun defaultTriggerWebhookUseCase( notificationStorage: NotificationStorage, - @Qualifier("webhook-web-client") webClient: WebClient, + @Qualifier("webhook-web-client") restClient: RestClient, uriParser: UriParser, - ): TriggerWebhookUseCase = DefaultTriggerWebhookUseCase(notificationStorage, webClient, uriParser) + ): TriggerWebhookUseCase = DefaultTriggerWebhookUseCase(notificationStorage, restClient, uriParser) @Bean(name = ["webhook-web-client"]) - fun webhookWebClient(): WebClient { + fun webhookWebClient(): RestClient { val clientBuilder = - WebClient.builder() + RestClient.builder() - return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + return clientBuilder.build() } @Bean diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt index 5622791..5f51207 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt @@ -7,60 +7,47 @@ import com.aamdigital.aambackendservice.reporting.notification.core.CreateWebhoo import com.aamdigital.aambackendservice.reporting.notification.core.NotificationStorage import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookAuthentication -import reactor.core.publisher.Mono import java.util.* class DefaultNotificationStorage( private val webhookRepository: WebhookRepository, private val cryptoService: CryptoService, ) : NotificationStorage { - override fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono { - return webhookRepository.fetchWebhook( + override fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference) { + val webhook = webhookRepository.fetchWebhook( webhookRef = webhookRef ) - .map { webhook -> - if (webhook.reportSubscriptions.indexOf(entityRef.id) == -1) { - webhook.reportSubscriptions.add(entityRef.id) - } - webhook - } - .flatMap { webhook -> - webhookRepository.storeWebhook(webhook) - } - .map { } + + if (webhook.reportSubscriptions.indexOf(entityRef.id) == -1) { + webhook.reportSubscriptions.add(entityRef.id) + } + + webhookRepository.storeWebhook(webhook) } - override fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono { - return webhookRepository.fetchWebhook( + override fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference) { + val webhook = webhookRepository.fetchWebhook( webhookRef = webhookRef ) - .map { document -> - document.reportSubscriptions.remove(entityRef.id) - document - } - .flatMap { - webhookRepository.storeWebhook(it) - } - .map { } + webhook.reportSubscriptions.remove(entityRef.id) + webhookRepository.storeWebhook(webhook) } - override fun fetchAllWebhooks(): Mono> { - return webhookRepository.fetchAllWebhooks().map { entities -> - entities.map { mapFromEntity(it) } + override fun fetchAllWebhooks(): List { + return webhookRepository.fetchAllWebhooks().map { + mapFromEntity(it) } } - override fun fetchWebhook(webhookRef: DomainReference): Mono { - return webhookRepository.fetchWebhook(webhookRef = webhookRef).map { - mapFromEntity(it) - } + override fun fetchWebhook(webhookRef: DomainReference): Webhook { + return mapFromEntity(webhookRepository.fetchWebhook(webhookRef = webhookRef)) } - override fun createWebhook(request: CreateWebhookRequest): Mono { + override fun createWebhook(request: CreateWebhookRequest): DomainReference { val encryptedKey = cryptoService.encrypt(request.authentication.apiKey) val newId = "Webhook:${UUID.randomUUID()}" - return webhookRepository.storeWebhook( + webhookRepository.storeWebhook( webhook = WebhookEntity( id = newId, label = request.label, @@ -78,9 +65,9 @@ class DefaultNotificationStorage( ), reportSubscriptions = mutableListOf() ) - ).map { - DomainReference(newId) - } + ) + + return DomainReference(newId) } private fun mapFromEntity(entity: WebhookEntity): Webhook = diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt index 4b1f50b..fea97f7 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt @@ -4,45 +4,53 @@ import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient import com.aamdigital.aambackendservice.couchdb.core.getQueryParamsAllDocs import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.InternalServerException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Mono @Service class WebhookRepository( private val couchDbClient: CouchDbClient, private val objectMapper: ObjectMapper, ) { + + enum class WebhookRepositoryErrorCode : AamErrorCode { + INVALID_RESPONSE + } + companion object { private const val WEBHOOK_DATABASE = "notification-webhook" } - fun fetchAllWebhooks(): Mono> { - return couchDbClient + fun fetchAllWebhooks(): List { + val objectNode = couchDbClient .getDatabaseDocument( database = WEBHOOK_DATABASE, documentId = "_all_docs", queryParams = getQueryParamsAllDocs("Webhook"), kClass = ObjectNode::class - ).map { objectNode -> - val data = - (objectMapper.convertValue(objectNode, Map::class.java)["rows"] as Iterable<*>) - .map { entry -> - if (entry is LinkedHashMap<*, *>) { - objectMapper.convertValue(entry["doc"], WebhookEntity::class.java) - } else { - throw InternalServerException("Invalid response") - } - } - - data - } + ) + + val data = + (objectMapper.convertValue(objectNode, Map::class.java)["rows"] as Iterable<*>) + .map { entry -> + if (entry is LinkedHashMap<*, *>) { + objectMapper.convertValue(entry["doc"], WebhookEntity::class.java) + } else { + throw InternalServerException( + message = "Invalid response", + code = WebhookRepositoryErrorCode.INVALID_RESPONSE + ) + } + } + + return data } - fun fetchWebhook(webhookRef: DomainReference): Mono { + fun fetchWebhook(webhookRef: DomainReference): WebhookEntity { return couchDbClient .getDatabaseDocument( database = WEBHOOK_DATABASE, @@ -52,7 +60,7 @@ class WebhookRepository( ) } - fun storeWebhook(webhook: WebhookEntity): Mono { + fun storeWebhook(webhook: WebhookEntity): DocSuccess { return couchDbClient .putDatabaseDocument( database = WEBHOOK_DATABASE, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/controller/ReportController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/controller/ReportController.kt index ee8e575..ea48413 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/controller/ReportController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/controller/ReportController.kt @@ -1,16 +1,18 @@ package com.aamdigital.aambackendservice.reporting.report.controller import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.HttpErrorDto import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import com.aamdigital.aambackendservice.reporting.report.dto.ReportDto import com.aamdigital.aambackendservice.reporting.storage.DefaultReportStorage +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono @RestController @RequestMapping("/v1/reporting/report") @@ -20,50 +22,112 @@ class ReportController( private val reportStorage: DefaultReportStorage, ) { @GetMapping - fun fetchReports(): Mono> { - return reportStorage.fetchAllReports("sql") - .zipWith( - reportingStorage.fetchPendingCalculations() - ).map { results -> - val reports = results.t1 - val calculations = results.t2 - reports.map { report -> - ReportDto( - id = report.id, - name = report.name, - schema = report.schema, - calculationPending = calculations.any { - it.id == report.id - } + fun fetchReports(): ResponseEntity { + val allReports = try { + reportStorage.fetchAllReports("sql") + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) + } + } + + val pendingCalculations = try { + reportingStorage.fetchPendingCalculations() + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) ) - } } + } + + return ResponseEntity.ok(allReports.map { report -> + ReportDto( + id = report.id, + name = report.name, + schema = report.schema, + calculationPending = pendingCalculations.any { + it.id == report.id + } + ) + }) } @GetMapping("/{reportId}") fun fetchReport( @PathVariable reportId: String - ): Mono { - return reportStorage - .fetchReport(DomainReference(id = reportId)) - .zipWith( - reportingStorage.fetchPendingCalculations() - ).map { results -> - val reportOptional = results.t1 - val calculations = results.t2 + ): ResponseEntity { + val reportOptional = + reportStorage.fetchReport(DomainReference(id = reportId)) - val report = reportOptional.orElseThrow { - NotFoundException() - } - - ReportDto( - id = report.id, - name = report.name, - schema = report.schema, - calculationPending = calculations.any { - it.id == report.id - } + if (reportOptional.isEmpty) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = "Could not fetch report with id $reportId", + ) ) + } + + val report = reportOptional.get() + + val pendingCalculations = try { + reportingStorage.fetchPendingCalculations() + } catch (ex: Exception) { + return when (ex) { + is NotFoundException -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = ex.localizedMessage, + ) + ) + + else -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + HttpErrorDto( + errorCode = "INTERNAL_SERVER_ERROR", + errorMessage = ex.localizedMessage, + ) + ) + } + } + + return ResponseEntity.ok(ReportDto( + id = report.id, + name = report.name, + schema = report.schema, + calculationPending = pendingCalculations.any { + it.id == report.id } + )) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt index d7b3656..7386778 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt @@ -3,31 +3,30 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.aamdigital.aambackendservice.reporting.storage.DefaultReportStorage -import reactor.core.publisher.Mono class DefaultIdentifyAffectedReportsUseCase( private val reportStorage: DefaultReportStorage, private val reportSchemaGenerator: ReportSchemaGenerator, ) : IdentifyAffectedReportsUseCase { - override fun analyse(documentChangeEvent: DocumentChangeEvent): Mono> { + override fun analyse(documentChangeEvent: DocumentChangeEvent): List { val changedEntity = documentChangeEvent.documentId.split(":").first() - return reportStorage.fetchAllReports("sql") - .map { reports -> - val affectedReports: MutableList = mutableListOf() - reports.forEach { report -> - // todo better change detection (fields) - val affectedEntities = reportSchemaGenerator.getAffectedEntities(report) - val affected = affectedEntities.any { - it == changedEntity - } - if (affected) { - affectedReports.add(DomainReference(report.id)) - } - } - affectedReports + val reports = reportStorage.fetchAllReports("sql") + val affectedReports: MutableList = mutableListOf() + + reports.forEach { report -> + // todo better change detection (fields) + val affectedEntities = reportSchemaGenerator.getAffectedEntities(report) + val affected = affectedEntities.any { + it == changedEntity + } + if (affected) { + affectedReports.add(DomainReference(report.id)) } + } + + return affectedReports } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt index 4fab11c..942b4f3 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt @@ -1,11 +1,10 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.aamdigital.aambackendservice.domain.DomainReference -import com.aamdigital.aambackendservice.error.NotFoundException -import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculator -import reactor.core.publisher.Mono import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.* @@ -14,54 +13,54 @@ class DefaultReportCalculationProcessor( private val reportingStorage: ReportingStorage, private val reportCalculator: ReportCalculator, ) : ReportCalculationProcessor { - override fun processNextPendingCalculation(): Mono { - var calculation: ReportCalculation - return reportingStorage.fetchPendingCalculations() - .flatMap { calculations -> - calculation = calculations.firstOrNull() - ?: return@flatMap Mono.just(Unit) + enum class DefaultReportCalculationProcessorErrorCode : AamErrorCode { + CALCULATION_UPDATE_ERROR + } + + override fun processNextPendingCalculation() { + var calculation = reportingStorage.fetchPendingCalculations().firstOrNull() + ?: return + + calculation = reportingStorage.storeCalculation( + reportCalculation = calculation + .setStatus(ReportCalculationStatus.RUNNING) + .setStartDate( + startDate = getIsoLocalDateTime() + ) + ) + + try { + reportCalculator.calculate(reportCalculation = calculation) + + calculation = reportingStorage.fetchCalculation(DomainReference(calculation.id)).orElseThrow { + InternalServerException( + message = "[DefaultReportCalculationProcessor]" + + " updated Calculation not available after reportCalculator.calculate()", + code = DefaultReportCalculationProcessorErrorCode.CALCULATION_UPDATE_ERROR + ) + } + + reportingStorage.storeCalculation( + reportCalculation = calculation + .setStatus(ReportCalculationStatus.FINISHED_SUCCESS) + .setEndDate(getIsoLocalDateTime()) + ) + } catch (ex: Exception) { + /* + We should think about moving the "prefetch" inside the ReportCalculationStorage, + instead of manually think about this every time. The "prefetch" ensures, + that the latest calculation is edited + */ + reportingStorage.fetchCalculation(DomainReference(calculation.id)).ifPresent { reportingStorage.storeCalculation( - reportCalculation = calculation - .setStatus(ReportCalculationStatus.RUNNING) - .setStartDate( - startDate = getIsoLocalDateTime() - ) + reportCalculation = it + .setStatus(ReportCalculationStatus.FINISHED_ERROR) + .setErrorDetails(ex.localizedMessage) + .setEndDate(getIsoLocalDateTime()) ) - .flatMap { - reportCalculator.calculate(reportCalculation = it) - } - .flatMap { - reportingStorage.fetchCalculation(DomainReference(calculation.id)) - } - .flatMap { updatedCalculation -> - reportingStorage.storeCalculation( - reportCalculation = updatedCalculation.orElseThrow { - NotFoundException( - "[DefaultReportCalculationProcessor]" + - " updated Calculation not available after reportCalculator.calculate()" - ) - } - .setStatus(ReportCalculationStatus.FINISHED_SUCCESS) - .setEndDate(getIsoLocalDateTime()) - ).map {} - } - .onErrorResume { error -> - /* - We should think about moving the "prefetch" inside the ReportCalculationStorage, - instead of manually think about this every time. The "prefetch" ensures, - that the latest calculation is edited - */ - reportingStorage.fetchCalculation(DomainReference(calculation.id)).flatMap { - reportingStorage.storeCalculation( - reportCalculation = calculation - .setStatus(ReportCalculationStatus.FINISHED_ERROR) - .setErrorDetails(error.localizedMessage) - .setEndDate(getIsoLocalDateTime()) - ).map {} - } - } } + } } private fun getIsoLocalDateTime(): String = Date().toInstant() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt index 6b2868e..44252f4 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt @@ -13,7 +13,6 @@ import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpRejectAndDontRequeueException import org.springframework.amqp.core.Message import org.springframework.amqp.rabbit.annotation.RabbitListener -import reactor.core.publisher.Mono class DefaultReportDocumentChangeEventConsumer( private val messageParser: QueueMessageParser, @@ -26,16 +25,14 @@ class DefaultReportDocumentChangeEventConsumer( @RabbitListener( queues = [ReportQueueConfiguration.DOCUMENT_CHANGES_REPORT_QUEUE], - ackMode = "MANUAL", // avoid concurrent processing so that we do not trigger multiple calculations for same data unnecessarily concurrency = "1-1", - batch = "1" ) - override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + override fun consume(rawMessage: String, message: Message, channel: Channel) { val type = try { messageParser.getTypeKClass(rawMessage.toByteArray()) } catch (ex: AamException) { - return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } when (type.qualifiedName) { @@ -52,44 +49,41 @@ class DefaultReportDocumentChangeEventConsumer( val reportRef = payload.currentVersion["_id"] as String - return createReportCalculationUseCase.createReportCalculation( + createReportCalculationUseCase.createReportCalculation( request = CreateReportCalculationRequest( report = DomainReference(reportRef), args = mutableMapOf() ) - ).flatMap { Mono.empty() } + ) + + return } if (payload.documentId.startsWith("ReportCalculation:")) { if (payload.deleted) { - return Mono.empty() + return } - return reportCalculationChangeUseCase.handle( + reportCalculationChangeUseCase.handle( documentChangeEvent = payload - ).flatMap { Mono.empty() } + ) + return } - return identifyAffectedReportsUseCase.analyse( + val affectedReports = identifyAffectedReportsUseCase.analyse( documentChangeEvent = payload ) - .flatMap { affectedReports -> - Mono.zip(affectedReports.map { report -> - createReportCalculationUseCase - .createReportCalculation( - request = CreateReportCalculationRequest( - report = report, - args = mutableMapOf() - ) - ) - }) { - it.iterator() - } - } - .flatMap { Mono.empty() } - .doOnError { - logger.error(it.localizedMessage) - } + affectedReports.forEach { report -> + createReportCalculationUseCase + .createReportCalculation( + request = CreateReportCalculationRequest( + report = report, + args = mutableMapOf() + ) + ) + } + + return } else -> { @@ -97,12 +91,9 @@ class DefaultReportDocumentChangeEventConsumer( "[DefaultReportDocumentChangeEventConsumer] Could not find any use case for this EventType: {}", type.qualifiedName, ) - - return Mono.error { - throw AmqpRejectAndDontRequeueException( - "[NO_USECASE_CONFIGURED] Could not find matching use case for: ${type.qualifiedName}", - ) - } + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not find matching use case for: ${type.qualifiedName}", + ) } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt index dc213ca..133d48c 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt @@ -2,8 +2,7 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent -import reactor.core.publisher.Mono interface IdentifyAffectedReportsUseCase { - fun analyse(documentChangeEvent: DocumentChangeEvent): Mono> + fun analyse(documentChangeEvent: DocumentChangeEvent): List } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt index ce8edce..49fd282 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt @@ -1,9 +1,12 @@ package com.aamdigital.aambackendservice.reporting.report.core -import reactor.core.publisher.Mono +import org.slf4j.LoggerFactory class NoopReportCalculationProcessor : ReportCalculationProcessor { - override fun processNextPendingCalculation(): Mono { - return Mono.just(Unit) + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun processNextPendingCalculation() { + logger.trace("[NoopReportCalculationProcessor] processNextPendingCalculation() called.") } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt index c8ac3a0..aefd8b4 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt @@ -1,9 +1,8 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.aamdigital.aambackendservice.reporting.report.sqs.QueryRequest -import org.springframework.core.io.buffer.DataBuffer -import reactor.core.publisher.Flux +import java.io.InputStream interface QueryStorage { - fun executeQuery(query: QueryRequest): Flux + fun executeQuery(query: QueryRequest): InputStream } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt index 5151922..4c96854 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt @@ -1,7 +1,5 @@ package com.aamdigital.aambackendservice.reporting.report.core -import reactor.core.publisher.Mono - interface ReportCalculationProcessor { - fun processNextPendingCalculation(): Mono + fun processNextPendingCalculation() } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt index a074631..f763201 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt @@ -2,8 +2,7 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.rabbitmq.client.Channel import org.springframework.amqp.core.Message -import reactor.core.publisher.Mono interface ReportDocumentChangeEventConsumer { - fun consume(rawMessage: String, message: Message, channel: Channel): Mono + fun consume(rawMessage: String, message: Message, channel: Channel) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt index 7df2344..06ea22a 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt @@ -2,23 +2,21 @@ package com.aamdigital.aambackendservice.reporting.report.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import java.io.InputStream import java.util.* interface ReportingStorage { - fun fetchPendingCalculations(): Mono> - fun fetchCalculations(reportReference: DomainReference): Mono> + fun fetchPendingCalculations(): List + fun fetchCalculations(reportReference: DomainReference): List fun fetchCalculation( calculationReference: DomainReference - ): Mono> + ): Optional - fun storeCalculation(reportCalculation: ReportCalculation): Mono + fun storeCalculation(reportCalculation: ReportCalculation): ReportCalculation - fun headData(calculationReference: DomainReference): Mono - fun fetchData(calculationReference: DomainReference): Flux + fun headData(calculationReference: DomainReference): HttpHeaders + fun fetchData(calculationReference: DomainReference): InputStream - fun isCalculationOngoing(reportReference: DomainReference): Mono + fun isCalculationOngoing(reportReference: DomainReference): Boolean } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt index b2151f1..b532119 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt @@ -3,28 +3,22 @@ package com.aamdigital.aambackendservice.reporting.report.di import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient +import org.springframework.web.client.RestClient @Configuration class SqsConfiguration { @Bean(name = ["sqs-client"]) - fun sqsWebClient(configuration: SqsClientConfiguration): WebClient { - val clientBuilder = - WebClient.builder() - .codecs { - it.defaultCodecs() - .maxInMemorySize(configuration.maxInMemorySizeInMegaBytes * 1024 * 1024) - } - .baseUrl(configuration.basePath) - .defaultHeaders { - it.setBasicAuth( - configuration.basicAuthUsername, - configuration.basicAuthPassword, - ) - } - return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + fun sqsRestClient(configuration: SqsClientConfiguration): RestClient { + val clientBuilder = RestClient.builder() + .baseUrl(configuration.basePath) + .defaultHeaders { + it.setBasicAuth( + configuration.basicAuthUsername, + configuration.basicAuthPassword, + ) + } + + return clientBuilder.build() } } @@ -33,5 +27,4 @@ class SqsClientConfiguration( val basePath: String, val basicAuthUsername: String, val basicAuthPassword: String, - val maxInMemorySizeInMegaBytes: Int = 16, ) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt index 2dcf172..73787b1 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt @@ -9,15 +9,14 @@ import org.springframework.scheduling.annotation.Scheduled class ReportCalculationJob( private val reportCalculationProcessor: ReportCalculationProcessor ) { - private val logger = LoggerFactory.getLogger(javaClass) @Scheduled(fixedDelay = 10000) fun handleReportCalculation() { - reportCalculationProcessor.processNextPendingCalculation() - .doOnError { - logger.error("[ReportCalculationJob] Error in job: processNextPendingCalculation()", it) - } - .subscribe() + try { + reportCalculationProcessor.processNextPendingCalculation() + } catch (ex: Exception) { + logger.error("[ReportCalculationJob] Error in job: processNextPendingCalculation()", ex) + } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt index c00e4f4..235540b 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt @@ -1,17 +1,14 @@ package com.aamdigital.aambackendservice.reporting.report.sqs -import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.ExternalSystemException import com.aamdigital.aambackendservice.reporting.report.core.QueryStorage -import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.Resource import org.springframework.http.MediaType import org.springframework.stereotype.Service -import org.springframework.web.reactive.function.BodyExtractors -import org.springframework.web.reactive.function.BodyInserters -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Flux -import reactor.kotlin.core.publisher.toFlux +import org.springframework.web.client.RestClient +import java.io.InputStream data class QueryRequest( val query: String, @@ -20,40 +17,33 @@ data class QueryRequest( @Service class SqsQueryStorage( - @Qualifier("sqs-client") private val sqsClient: WebClient, + @Qualifier("sqs-client") private val sqsClient: RestClient, private val schemaService: SqsSchemaService, ) : QueryStorage { - private val logger = LoggerFactory.getLogger(javaClass) - override fun executeQuery(query: QueryRequest): Flux { + enum class SqsQueryStorageErrorCode : AamErrorCode { + EMPTY_RESPONSE, + } + + override fun executeQuery(query: QueryRequest): InputStream { val schemaPath = schemaService.getSchemaPath() - return schemaService.updateSchema() - .toFlux() - .flatMap { - sqsClient.post() - .uri(schemaPath) - .contentType(MediaType.APPLICATION_JSON) - .body(BodyInserters.fromValue(query)) - .accept(MediaType.APPLICATION_JSON) - .exchangeToFlux { response -> - if (response.statusCode().is2xxSuccessful) { - response.body(BodyExtractors.toDataBuffers()) - } else { - response.bodyToFlux(String::class.java) - .flatMap { - logger.error( - "[SqsQueryStorage] " + - "Invalid response (${response.statusCode()}) from SQS: $it" - ) - Flux.error(InvalidArgumentException(it)) - } - } - } - .onErrorResume { - logger.error("[SqsQueryStorage]: ${it.localizedMessage}", it) - Flux.error(InvalidArgumentException(it.localizedMessage)) - } - } + schemaService.updateSchema() + + val response = sqsClient.post() + .uri(schemaPath) + .contentType(MediaType.APPLICATION_JSON) + .body(query) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(Resource::class.java) + + if (response == null) { + throw ExternalSystemException( + message = "[SqsQueryStorage] Could not fetch response from SQS", + code = SqsQueryStorageErrorCode.EMPTY_RESPONSE + ) + } + return response.inputStream } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt index 0cb30f9..0428a60 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt @@ -7,12 +7,10 @@ import com.aamdigital.aambackendservice.domain.EntityConfig import com.aamdigital.aambackendservice.domain.EntityType import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Mono import java.security.MessageDigest -import java.util.* -import kotlin.jvm.optionals.getOrNull data class AppConfigAttribute( val dataType: String, @@ -76,62 +74,58 @@ class SqsSchemaService( private val couchDbClient: CouchDbClient, ) { + private val logger = LoggerFactory.getLogger(javaClass) + companion object { private const val FILENAME_CONFIG_ENTITY = "Config:CONFIG_ENTITY"; private const val SCHEMA_PATH = "_design/sqlite:config" private const val TARGET_DATABASE = "app" } - fun getSchemaPath(): String = "/$TARGET_DATABASE/$SCHEMA_PATH" - fun updateSchema(): Mono { - return Mono.zip( - couchDbClient.getDatabaseDocument( - database = TARGET_DATABASE, - documentId = FILENAME_CONFIG_ENTITY, - queryParams = LinkedMultiValueMap(), - kClass = AppConfigFile::class - ) - .map { config -> - val entities: List = config.data.orEmpty().keys - .filter { - it.startsWith("entity:") - } - .map { - val entityType: AppConfigEntry = config.data.orEmpty().getValue(it) - parseEntityConfig(it, entityType) - } + fun updateSchema() { + val config = couchDbClient.getDatabaseDocument( + database = TARGET_DATABASE, + documentId = FILENAME_CONFIG_ENTITY, + queryParams = LinkedMultiValueMap(), + kClass = AppConfigFile::class + ) + + val entities: List = config.data.orEmpty().keys + .filter { + it.startsWith("entity:") + } + .map { + val entityType: AppConfigEntry = config.data.orEmpty().getValue(it) + parseEntityConfig(it, entityType) + } - EntityConfig(config.rev, entities) - }, + val entityConfig = EntityConfig(config.rev, entities) + + val currentSqsSchema = try { couchDbClient.getDatabaseDocument( database = TARGET_DATABASE, documentId = SCHEMA_PATH, queryParams = LinkedMultiValueMap(), kClass = SqsSchema::class ) - .map { Optional.of(it) } - .onErrorResume { Mono.just(Optional.empty()) } - ) - .flatMap { - val entityConfig = it.t1 - val currentSqsSchema = it.t2.getOrNull() - val newSqsSchema: SqsSchema = mapToSqsSchema(entityConfig) + } catch (ex: Exception) { + logger.warn("[SqsSchemaService] No current SQS Schema found. Creating it.", ex) + null + } - if (currentSqsSchema?.configVersion == newSqsSchema.configVersion) { - return@flatMap Mono.just(Unit) - } + val newSqsSchema: SqsSchema = mapToSqsSchema(entityConfig) - couchDbClient.putDatabaseDocument( - database = TARGET_DATABASE, - documentId = SCHEMA_PATH, - body = newSqsSchema, - ) - .flatMap { - Mono.just(Unit) - } - } + if (currentSqsSchema?.configVersion == newSqsSchema.configVersion) { + return + } + + couchDbClient.putDatabaseDocument( + database = TARGET_DATABASE, + documentId = SCHEMA_PATH, + body = newSqsSchema, + ) } private fun mapToSqsSchema(entityConfig: EntityConfig): SqsSchema { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt index e009383..a1a5663 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt @@ -1,8 +1,9 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.controller import com.aamdigital.aambackendservice.domain.DomainReference -import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.error.HttpErrorDto import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.export.controller.TemplateExportControllerResponse import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest @@ -11,10 +12,12 @@ import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateR import com.aamdigital.aambackendservice.reporting.reportcalculation.dto.ReportCalculationData import com.aamdigital.aambackendservice.reporting.reportcalculation.dto.ReportCalculationDto import com.aamdigital.aambackendservice.reporting.storage.DefaultReportStorage -import org.springframework.core.io.buffer.DataBuffer -import org.springframework.core.io.buffer.DefaultDataBufferFactory +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -22,13 +25,11 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.kotlin.core.publisher.toFlux +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.OutputStream import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.* -import kotlin.jvm.optionals.getOrElse @RestController @@ -38,128 +39,218 @@ class ReportCalculationController( private val reportingStorage: ReportingStorage, private val reportStorage: DefaultReportStorage, private val createReportCalculationUseCase: CreateReportCalculationUseCase, + private val objectMapper: ObjectMapper, ) { + companion object { + private const val BYTE_ARRAY_BUFFER_LENGTH = 4096 + } + + /* + * Needed so be able to return "ResponseEntity" without the need to write a converter. + */ + private fun getErrorStreamingBody(errorCode: String, errorMessage: String) = + StreamingResponseBody { outputStream: OutputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + + val bodyStream = objectMapper.writeValueAsString( + TemplateExportControllerResponse.ErrorControllerResponse( + errorCode = errorCode, + errorMessage = errorMessage, + ) + ).byteInputStream() + + while ((bodyStream.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + @PostMapping("/report/{reportId}") fun startCalculation( @PathVariable reportId: String, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") from: Date?, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") to: Date?, - ): Mono { - return reportStorage.fetchReport(DomainReference(id = reportId)).flatMap { reportOptional -> - val report = reportOptional.orElseThrow { - NotFoundException() - } + ): ResponseEntity { + val reportOptional = reportStorage.fetchReport(DomainReference(id = reportId)) - val args = mutableMapOf() + if (reportOptional.isEmpty) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = "Could not find report with id $reportId" + ) + ) + } - if (from != null) { - args["from"] = from.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME) - } + val report = reportOptional.get() - if (to != null) { - args["to"] = to.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME) - } + val args = mutableMapOf() - createReportCalculationUseCase.createReportCalculation( - CreateReportCalculationRequest( - report = DomainReference(report.id), args = args - ) - ).handle { result, sink -> - when (result) { - is CreateReportCalculationResult.Failure -> { - sink.error(InternalServerException()) - } - - is CreateReportCalculationResult.Success -> sink.next(result.calculation) - } + if (from != null) { + args["from"] = from.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME) + } + + if (to != null) { + args["to"] = to.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME) + } + + val result = createReportCalculationUseCase.createReportCalculation( + CreateReportCalculationRequest( + report = DomainReference(report.id), args = args + ) + ) + + return when (result) { + is CreateReportCalculationResult.Failure -> { + return ResponseEntity.internalServerError().build() } + + is CreateReportCalculationResult.Success -> ResponseEntity.ok(result.calculation) } } @GetMapping("/report/{reportId}") fun fetchReportCalculations( @PathVariable reportId: String - ): Mono> { - return reportingStorage.fetchCalculations(DomainReference(id = reportId)).map { calculations -> - calculations.map { toDto(it) } - } + ): List { + val reportCalculations = reportingStorage.fetchCalculations(DomainReference(id = reportId)) + + return reportCalculations.map { toDto(it) } } @GetMapping("/{calculationId}") fun fetchReportCalculation( @PathVariable calculationId: String - ): Mono { - return reportingStorage.fetchCalculation(DomainReference(id = calculationId)).map { calculationOptional -> - val calculation = calculationOptional.orElseThrow { - NotFoundException() - } + ): ResponseEntity { - // TODO Auth check (https://github.com/Aam-Digital/aam-services/issues/10) + val reportCalculationOptional = reportingStorage.fetchCalculation(DomainReference(id = calculationId)) - toDto(calculation) + if (reportCalculationOptional.isEmpty) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + HttpErrorDto( + errorCode = "NOT_FOUND", + errorMessage = "Could not find reportCalculation with id $calculationId" + ) + ) } + + val reportCalculation = reportCalculationOptional.get() + + // TODO Auth check (https://github.com/Aam-Digital/aam-services/issues/10) + + return ResponseEntity.ok(toDto(reportCalculation)) } @GetMapping("/{calculationId}/data", produces = [MediaType.APPLICATION_JSON_VALUE]) fun fetchReportCalculationData( @PathVariable calculationId: String - ): Flux { + ): ResponseEntity { // TODO Auth check (https://github.com/Aam-Digital/aam-services/issues/10) - return reportingStorage.headData(DomainReference(calculationId)) - .toFlux() - .flatMap { - if (it.eTag.isNullOrBlank()) { - return@flatMap Flux.error { NotFoundException("No data available") } - } - - val fileContent = reportingStorage - .fetchData(DomainReference(id = calculationId)) - - reportingStorage.fetchCalculation(DomainReference(calculationId)) - .toFlux() - .flatMap { - - val calculation = it.getOrElse { - return@flatMap Flux.error { NotFoundException("No data available") } - } - - val prefix = """ - { - "id": "${calculationId}_data.json", - "report": { - "id": "${calculation.report.id}" - }, - "calculation": { - "id": "$calculationId" - }, - "dataHash": "${calculation.attachments["data.json"]?.digest}", - "data": - """.trimIndent().toByteArray() - val prefixBuffer = DefaultDataBufferFactory().allocateBuffer(prefix.size) - prefixBuffer.write(prefix) - - val suffix = """ - } - """.trimIndent().toByteArray() - val suffixBuffer = DefaultDataBufferFactory().allocateBuffer(suffix.size) - suffixBuffer.write(suffix) - - Flux.concat( - Flux.just(prefixBuffer), - fileContent, - Flux.just(suffixBuffer), - ) - } + val headData = reportingStorage.headData(DomainReference(calculationId)) + + if (headData.eTag.isNullOrBlank()) { + val errorStreamingBody = + getErrorStreamingBody(errorCode = "NOT_FOUND", "Could not fetch data for $calculationId") + val headers = HttpHeaders() + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + + return ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.NOT_FOUND, + ) + } + + val calculationOptional = reportingStorage.fetchCalculation(DomainReference(calculationId)) + + if (calculationOptional.isEmpty) { + val errorStreamingBody = + getErrorStreamingBody(errorCode = "NOT_FOUND", "Could not fetch calculation: $calculationId") + val headers = HttpHeaders() + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + + return ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.NOT_FOUND, + ) + } + + val calculation = calculationOptional.get() + + val fileStream = reportingStorage + .fetchData(DomainReference(id = calculationId)) + + val prefix = """ + { + "id": "${calculationId}_data.json", + "report": { + "id": "${calculation.report.id}" + }, + "calculation": { + "id": "$calculationId" + }, + "dataHash": "${calculation.attachments["data.json"]?.digest}", + "data": + """.trimIndent().toByteArray() + + val suffix = """ + } + """.trimIndent().toByteArray() + + val responseBody = StreamingResponseBody { outputStream: OutputStream -> + outputStream.write(prefix.inputStream().readBytes()) + + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + while ((fileStream.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) } + + outputStream.write(suffix.inputStream().readBytes()) + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=calculation-data.json") + .contentType(MediaType.APPLICATION_JSON) + .body(responseBody) } @GetMapping("/{calculationId}/data-stream", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) fun fetchReportCalculationDataStream( @PathVariable calculationId: String - ): Flux { - // TODO Auth check (https://github.com/Aam-Digital/aam-services/issues/10) - return reportingStorage.fetchData(DomainReference(id = calculationId)) + ): ResponseEntity { + val fileStream = try { + reportingStorage + .fetchData(DomainReference(id = calculationId)) + } catch (ex: NotFoundException) { + val errorStreamingBody = + getErrorStreamingBody(errorCode = ex.code.toString(), ex.localizedMessage) + val headers = HttpHeaders() + headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + + return ResponseEntity( + errorStreamingBody, + headers, + HttpStatus.NOT_FOUND, + ) + } + + val responseBody = StreamingResponseBody { outputStream: OutputStream -> + val buffer = ByteArray(BYTE_ARRAY_BUFFER_LENGTH) + var bytesRead: Int + while ((fileStream.read(buffer).also { bytesRead = it }) != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=calculation-data.json") + .contentType(MediaType.APPLICATION_JSON) + .body(responseBody) } private fun toDto(it: ReportCalculation): ReportCalculationDto = ReportCalculationDto( diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt index 72f6a27..f7161cd 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt @@ -3,7 +3,6 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core import com.aamdigital.aambackendservice.domain.DomainReference import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo -import reactor.core.publisher.Mono data class CreateReportCalculationRequest( val report: DomainReference, @@ -32,5 +31,5 @@ sealed class CreateReportCalculationResult { } interface CreateReportCalculationUseCase { - fun createReportCalculation(request: CreateReportCalculationRequest): Mono + fun createReportCalculation(request: CreateReportCalculationRequest): CreateReportCalculationResult } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt index 3775872..2a6f498 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt @@ -5,7 +5,6 @@ import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import org.springframework.stereotype.Service -import reactor.core.publisher.Mono import java.util.* @Service @@ -13,7 +12,7 @@ class DefaultCreateReportCalculationUseCase( private val reportingStorage: ReportingStorage, ) : CreateReportCalculationUseCase { - override fun createReportCalculation(request: CreateReportCalculationRequest): Mono { + override fun createReportCalculation(request: CreateReportCalculationRequest): CreateReportCalculationResult { val calculation = ReportCalculation( id = "ReportCalculation:${UUID.randomUUID()}", report = request.report, @@ -21,30 +20,26 @@ class DefaultCreateReportCalculationUseCase( args = request.args ) - return reportingStorage.fetchCalculations(request.report) - .flatMap { reportCalculations -> - val i = reportCalculations.filter { reportCalculation -> - reportCalculation.status == ReportCalculationStatus.PENDING && - reportCalculation.args == calculation.args - } - - if (i.isNotEmpty()) { - Mono.just( - CreateReportCalculationResult.Success( - DomainReference( - id = i.first().id - ) - ) - ) - } else { - reportingStorage.storeCalculation(calculation).map { - handleResponse(it) - } - } + return try { + val reportCalculations = reportingStorage.fetchCalculations(request.report) + + val i = reportCalculations.filter { reportCalculation -> + reportCalculation.status == ReportCalculationStatus.PENDING && + reportCalculation.args == calculation.args } - .onErrorResume { - handleError(it) + + if (i.isNotEmpty()) { + CreateReportCalculationResult.Success( + DomainReference( + id = i.first().id + ) + ) + } else { + handleResponse(reportingStorage.storeCalculation(calculation)) } + } catch (ex: Exception) { + handleError(ex) + } } private fun handleResponse(reportCalculation: ReportCalculation): CreateReportCalculationResult { @@ -53,11 +48,9 @@ class DefaultCreateReportCalculationUseCase( ) } - private fun handleError(it: Throwable): Mono { - return Mono.just( - CreateReportCalculationResult.Failure( - errorCode = CreateReportCalculationResult.ErrorCode.INTERNAL_SERVER_ERROR, cause = it - ) + private fun handleError(it: Throwable): CreateReportCalculationResult { + return CreateReportCalculationResult.Failure( + errorCode = CreateReportCalculationResult.ErrorCode.INTERNAL_SERVER_ERROR, cause = it ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt index 68cb873..cfeb24e 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt @@ -8,7 +8,6 @@ import com.aamdigital.aambackendservice.reporting.notification.core.Notification import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.LoggerFactory -import reactor.core.publisher.Mono class DefaultReportCalculationChangeUseCase( private val reportingStorage: ReportingStorage, @@ -18,41 +17,37 @@ class DefaultReportCalculationChangeUseCase( private val logger = LoggerFactory.getLogger(javaClass) - override fun handle(documentChangeEvent: DocumentChangeEvent): Mono { - + override fun handle(documentChangeEvent: DocumentChangeEvent) { val currentReportCalculation = objectMapper.convertValue(documentChangeEvent.currentVersion, ReportCalculation::class.java) if (currentReportCalculation.status != ReportCalculationStatus.FINISHED_SUCCESS) { - return Mono.just(Unit) + return } - return reportingStorage.fetchCalculations( - reportReference = currentReportCalculation.report - ) - .map { calculations -> - calculations - .filter { it.id != currentReportCalculation.id } - .sortedBy { it.calculationCompleted } - } - .flatMap { - val existingDigest = it.lastOrNull()?.attachments?.get("data.json")?.digest - val currentDigest = currentReportCalculation.attachments["data.json"]?.digest - - if (existingDigest != currentDigest - ) { - notificationService.sendNotifications( - report = currentReportCalculation.report, - reportCalculation = DomainReference(currentReportCalculation.id) - ) - } else { - logger.debug("skipped notification for ${currentReportCalculation.id}") - Mono.just(Unit) - } - } - .onErrorResume { - logger.warn("Could not find ${currentReportCalculation.id}. Skipped.") - Mono.just(Unit) + try { + val calculations = reportingStorage.fetchCalculations( + reportReference = currentReportCalculation.report + ) + + val existingDigest = calculations + .filter { it.id != currentReportCalculation.id } + .sortedBy { it.calculationCompleted } + .lastOrNull()?.attachments?.get("data.json")?.digest + + val currentDigest = currentReportCalculation.attachments["data.json"]?.digest + + if (existingDigest != currentDigest + ) { + notificationService.sendNotifications( + report = currentReportCalculation.report, + reportCalculation = DomainReference(currentReportCalculation.id) + ) + } else { + logger.debug("skipped notification for ${currentReportCalculation.id}") } + } catch (ex: Exception) { + logger.warn("Could not fetch ${currentReportCalculation.id}. Skipped.", ex) + } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt index da05c51..51e0a9b 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt @@ -1,6 +1,7 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.InvalidArgumentException import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation @@ -8,8 +9,6 @@ import com.aamdigital.aambackendservice.reporting.report.core.QueryStorage import com.aamdigital.aambackendservice.reporting.report.sqs.QueryRequest import com.aamdigital.aambackendservice.reporting.storage.DefaultReportStorage import org.springframework.stereotype.Service -import reactor.core.publisher.Mono -import kotlin.jvm.optionals.getOrDefault @Service class DefaultReportCalculator( @@ -18,40 +17,49 @@ class DefaultReportCalculator( private val couchDbClient: CouchDbClient, ) : ReportCalculator { + enum class DefaultReportCalculatorErrorCode : AamErrorCode { + NOT_FOUND, + INVALID_ARGUMENT, + } + companion object { const val DEFAULT_FROM_DATE = "0000-01-01" const val DEFAULT_TO_DATE = "9999-12-31T23:59:59.999Z" const val INDEX_ISO_STRING_DATE_END = 10 } - override fun calculate(reportCalculation: ReportCalculation): Mono { - return reportStorage.fetchReport(reportCalculation.report) - .flatMap { reportOptional -> - val report = reportOptional.getOrDefault(null) - ?: return@flatMap Mono.error(NotFoundException()) + override fun calculate(reportCalculation: ReportCalculation): ReportCalculation { + val report = reportStorage.fetchReport(reportCalculation.report).orElseThrow { + NotFoundException( + message = "[DefaultReportCalculator] Could not fetch Report ${reportCalculation.report.id}", + code = DefaultReportCalculatorErrorCode.NOT_FOUND + ) + } - if (report.mode != "sql") { - return@flatMap Mono.error(InvalidArgumentException()) - } + if (report.mode != "sql") { + throw InvalidArgumentException( + message = "[DefaultReportCalculator] Just 'sql' reports are supported.", + code = DefaultReportCalculatorErrorCode.INVALID_ARGUMENT + ) + } - setToDateToLastMinuteOfDay(reportCalculation.args) + setToDateToLastMinuteOfDay(reportCalculation.args) - val queryResult = queryStorage.executeQuery( - query = QueryRequest( - query = report.query, - args = getReportCalculationArgs(report.neededArgs, reportCalculation.args) - ) - ) + val queryResult = queryStorage.executeQuery( + query = QueryRequest( + query = report.query, + args = getReportCalculationArgs(report.neededArgs, reportCalculation.args) + ) + ) - couchDbClient.putAttachment( - database = "report-calculation", - documentId = reportCalculation.id, - attachmentId = "data.json", - file = queryResult, - ).map { - reportCalculation - } - } + couchDbClient.putAttachment( + database = "report-calculation", + documentId = reportCalculation.id, + attachmentId = "data.json", + file = queryResult, + ) + + return reportCalculation } private fun setToDateToLastMinuteOfDay(args: MutableMap) { @@ -85,7 +93,9 @@ class DefaultReportCalculator( givenArgs[it] ?: getDefaultValue(it) ?: throw NotFoundException( - "Argument $it is missing. All report args are needed for a successful ReportCalculation." + message = "Argument $it is missing. " + + "All report args are needed for a successful ReportCalculation.", + code = DefaultReportCalculatorErrorCode.NOT_FOUND ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt index 240cd6d..47f6e22 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt @@ -1,8 +1,7 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent -import reactor.core.publisher.Mono interface ReportCalculationChangeUseCase { - fun handle(documentChangeEvent: DocumentChangeEvent): Mono + fun handle(documentChangeEvent: DocumentChangeEvent) } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt index ca71825..c96eb49 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt @@ -1,8 +1,7 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation -import reactor.core.publisher.Mono interface ReportCalculator { - fun calculate(reportCalculation: ReportCalculation): Mono + fun calculate(reportCalculation: ReportCalculation): ReportCalculation } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportStorage.kt index df15e3f..286c27c 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportStorage.kt @@ -8,9 +8,9 @@ import com.aamdigital.aambackendservice.reporting.domain.ReportSchema import com.aamdigital.aambackendservice.reporting.report.core.ReportSchemaGenerator import com.aamdigital.aambackendservice.reporting.report.dto.FetchReportsResponse import com.aamdigital.aambackendservice.reporting.report.dto.ReportDoc +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Mono import java.util.* @Service @@ -23,55 +23,60 @@ class DefaultReportStorage( private const val REPORT_DATABASE = "app" } - fun fetchAllReports(mode: String): Mono> { - return couchDbClient.getDatabaseDocument( + private val logger = LoggerFactory.getLogger(javaClass) + + fun fetchAllReports(mode: String): List { + val response = couchDbClient.getDatabaseDocument( database = REPORT_DATABASE, documentId = "_all_docs", getQueryParamsAllDocs("ReportConfig"), FetchReportsResponse::class ) - .map { response -> - if (response.rows.isEmpty()) { - return@map emptyList() - } - response.rows.filter { - it.doc.mode == mode - }.map { - Report( - id = it.id, - name = it.doc.title, - mode = it.doc.mode, - query = it.doc.aggregationDefinition ?: "", - schema = ReportSchema( - fields = reportSchemaGenerator.getTableNamesByQuery(it.doc.aggregationDefinition ?: "") - ), - neededArgs = it.doc.neededArgs - ) - } - } + if (response.rows.isEmpty()) { + return emptyList() + } + + return response.rows.filter { + it.doc.mode == mode + }.map { + Report( + id = it.id, + name = it.doc.title, + mode = it.doc.mode, + query = it.doc.aggregationDefinition ?: "", + schema = ReportSchema( + fields = reportSchemaGenerator.getTableNamesByQuery(it.doc.aggregationDefinition ?: "") + ), + neededArgs = it.doc.neededArgs + ) + } } - fun fetchReport(report: DomainReference): Mono> { - return couchDbClient.getDatabaseDocument( - database = REPORT_DATABASE, - documentId = report.id, - queryParams = LinkedMultiValueMap(), - kClass = ReportDoc::class - ).map { reportDoc -> - Optional.of( - Report( - id = reportDoc.id, - name = reportDoc.title, - query = reportDoc.aggregationDefinition ?: "", - mode = reportDoc.mode, - schema = ReportSchema( - fields = reportSchemaGenerator.getTableNamesByQuery(reportDoc.aggregationDefinition ?: "") - ), - neededArgs = reportDoc.neededArgs - ) + fun fetchReport(report: DomainReference): Optional { + val reportDoc = try { + couchDbClient.getDatabaseDocument( + database = REPORT_DATABASE, + documentId = report.id, + queryParams = LinkedMultiValueMap(), + kClass = ReportDoc::class ) + } catch (ex: Exception) { + logger.warn("[DefaultReportStorage] Could not fetch DatabaseDocument: ${report.id}", ex) + return Optional.empty() } - .onErrorReturn(Optional.empty()) + + return Optional.of( + Report( + id = reportDoc.id, + name = reportDoc.title, + query = reportDoc.aggregationDefinition ?: "", + mode = reportDoc.mode, + schema = ReportSchema( + fields = reportSchemaGenerator.getTableNamesByQuery(reportDoc.aggregationDefinition ?: "") + ), + neededArgs = reportDoc.neededArgs + ) + ) } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportingStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportingStorage.kt index f7cb4ce..fa14d70 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportingStorage.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/DefaultReportingStorage.kt @@ -2,16 +2,15 @@ package com.aamdigital.aambackendservice.reporting.storage import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChange import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import com.fasterxml.jackson.annotation.JsonProperty -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders import org.springframework.stereotype.Service -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import java.io.InputStream import java.util.* data class ReportCalculationEntity( @@ -33,63 +32,63 @@ data class FetchReportCalculationsResponse( class DefaultReportingStorage( private val reportCalculationRepository: ReportCalculationRepository, ) : ReportingStorage { - override fun fetchPendingCalculations(): Mono> { - return reportCalculationRepository.fetchCalculations() - .map { response -> - response.rows - .filter { reportCalculationEntity -> - reportCalculationEntity.doc.status == ReportCalculationStatus.PENDING - } - .map { reportCalculationEntity -> mapFromEntity(reportCalculationEntity) } + + enum class DefaultReportingStorageErrorCode : AamErrorCode { + NOT_FOUND + } + + override fun fetchPendingCalculations(): List { + val calculations = reportCalculationRepository.fetchCalculations() + return calculations.rows + .filter { reportCalculationEntity -> + reportCalculationEntity.doc.status == ReportCalculationStatus.PENDING } + .map { reportCalculationEntity -> mapFromEntity(reportCalculationEntity) } } - override fun fetchCalculations(reportReference: DomainReference): Mono> { - return reportCalculationRepository.fetchCalculations() - .map { response -> - response.rows - .filter { entity -> - entity.doc.report.id == reportReference.id - } - .map { entity -> - mapFromEntity(entity) - } + override fun fetchCalculations(reportReference: DomainReference): List { + val calculations = reportCalculationRepository.fetchCalculations() + return calculations.rows + .filter { entity -> + entity.doc.report.id == reportReference.id + } + .map { entity -> + mapFromEntity(entity) } } - override fun fetchCalculation(calculationReference: DomainReference): Mono> { + override fun fetchCalculation(calculationReference: DomainReference): Optional { return reportCalculationRepository.fetchCalculation(calculationReference) } - override fun storeCalculation(reportCalculation: ReportCalculation): Mono { - return reportCalculationRepository.storeCalculation(reportCalculation = reportCalculation) - .flatMap { entity -> - fetchCalculation(DomainReference(entity.id)) - } - .map { - it.orElseThrow { NotFoundException() } - } + override fun storeCalculation(reportCalculation: ReportCalculation): ReportCalculation { + val doc = reportCalculationRepository.storeCalculation(reportCalculation = reportCalculation) + + return fetchCalculation(DomainReference(doc.id)).orElseThrow { + NotFoundException( + message = "Calculation not found", + code = DefaultReportingStorageErrorCode.NOT_FOUND + ) + } } - override fun fetchData(calculationReference: DomainReference): Flux { + override fun fetchData(calculationReference: DomainReference): InputStream { return reportCalculationRepository.fetchData(calculationReference) } - override fun headData(calculationReference: DomainReference): Mono { + override fun headData(calculationReference: DomainReference): HttpHeaders { return reportCalculationRepository.headData(calculationReference) } - override fun isCalculationOngoing(reportReference: DomainReference): Mono { - return reportCalculationRepository.fetchCalculations() - .map { response -> - response.rows - .filter { reportCalculation -> - reportCalculation.doc.report.id == reportReference.id - }.any { reportCalculation -> - reportCalculation.doc.status == ReportCalculationStatus.PENDING || - reportCalculation.doc.status == ReportCalculationStatus.RUNNING + override fun isCalculationOngoing(reportReference: DomainReference): Boolean { + val calculations = reportCalculationRepository.fetchCalculations() + return calculations.rows + .filter { reportCalculation -> + reportCalculation.doc.report.id == reportReference.id + }.any { reportCalculation -> + reportCalculation.doc.status == ReportCalculationStatus.PENDING || + reportCalculation.doc.status == ReportCalculationStatus.RUNNING - } } } diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/ReportCalculationRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/ReportCalculationRepository.kt index 1839522..80efd1e 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/ReportCalculationRepository.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/storage/ReportCalculationRepository.kt @@ -5,12 +5,10 @@ import com.aamdigital.aambackendservice.couchdb.core.getQueryParamsAllDocs import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpHeaders import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import java.io.InputStream import java.util.* @Service @@ -23,7 +21,7 @@ class ReportCalculationRepository( fun storeCalculation( reportCalculation: ReportCalculation, - ): Mono { + ): DocSuccess { return couchDbClient.putDatabaseDocument( database = REPORT_CALCULATION_DATABASE, documentId = reportCalculation.id, @@ -31,7 +29,7 @@ class ReportCalculationRepository( ) } - fun fetchCalculations(): Mono { + fun fetchCalculations(): FetchReportCalculationsResponse { return couchDbClient.getDatabaseDocument( database = REPORT_CALCULATION_DATABASE, documentId = "_all_docs", @@ -40,19 +38,22 @@ class ReportCalculationRepository( ) } - fun fetchCalculation(calculationReference: DomainReference): Mono> { - return couchDbClient.getDatabaseDocument( - database = REPORT_CALCULATION_DATABASE, - documentId = calculationReference.id, - queryParams = LinkedMultiValueMap(), - kClass = ReportCalculation::class - ) - .map { Optional.of(it) } - .defaultIfEmpty(Optional.empty()) - .onErrorReturn(Optional.empty()) + fun fetchCalculation(calculationReference: DomainReference): Optional { + return try { + return Optional.of( + couchDbClient.getDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = calculationReference.id, + queryParams = LinkedMultiValueMap(), + kClass = ReportCalculation::class + ) + ) + } catch (ex: Exception) { + Optional.empty() + } } - fun headData(calculationReference: DomainReference): Mono { + fun headData(calculationReference: DomainReference): HttpHeaders { return couchDbClient.headAttachment( database = "report-calculation", documentId = calculationReference.id, @@ -60,7 +61,7 @@ class ReportCalculationRepository( ) } - fun fetchData(calculationReference: DomainReference): Flux { + fun fetchData(calculationReference: DomainReference): InputStream { return couchDbClient.getAttachment( database = "report-calculation", documentId = calculationReference.id, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt index adaefcc..f2b0a11 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt @@ -9,19 +9,19 @@ import com.aamdigital.aambackendservice.error.NetworkException import com.aamdigital.aambackendservice.error.NotFoundException import com.aamdigital.aambackendservice.error.UnauthorizedAccessException import org.springframework.boot.web.error.ErrorAttributeOptions -import org.springframework.boot.web.reactive.error.DefaultErrorAttributes +import org.springframework.boot.web.servlet.error.DefaultErrorAttributes import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.validation.FieldError import org.springframework.web.bind.support.WebExchangeBindException -import org.springframework.web.reactive.function.server.ServerRequest -import org.springframework.web.reactive.resource.NoResourceFoundException +import org.springframework.web.context.request.WebRequest +import org.springframework.web.servlet.resource.NoResourceFoundException @Component class AamErrorAttributes : DefaultErrorAttributes() { override fun getErrorAttributes( - request: ServerRequest, + request: WebRequest, options: ErrorAttributeOptions ): MutableMap { val errorAttributes = super.getErrorAttributes(request, options) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationEntryPoint.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationEntryPoint.kt new file mode 100644 index 0000000..e9f2d83 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/AamAuthenticationEntryPoint.kt @@ -0,0 +1,43 @@ +package com.aamdigital.aambackendservice.security + +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint +import org.springframework.security.web.AuthenticationEntryPoint + +class AamAuthenticationEntryPoint( + private val parentEntryPoint: BearerTokenAuthenticationEntryPoint, + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + parentEntryPoint.commence(request, response, authException) + + var errorCode = HttpStatus.UNAUTHORIZED.value().toString() + + if (authException is OAuth2AuthenticationException) { + errorCode = authException.error.errorCode + } + + response.addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + + response.writer.println( + objectMapper.writeValueAsString( + HttpErrorDto( + errorCode = errorCode, + errorMessage = authException.message.toString() + ) + ) + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt index 9310e57..5d66c74 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt @@ -1,62 +1,61 @@ package com.aamdigital.aambackendservice.security -import com.aamdigital.aambackendservice.error.ForbiddenAccessException -import com.aamdigital.aambackendservice.error.UnauthorizedAccessException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod -import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity -import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity -import org.springframework.security.config.web.server.ServerHttpSecurity -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.server.SecurityWebFilterChain -import org.springframework.security.web.server.ServerAuthenticationEntryPoint -import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler -import org.springframework.web.server.ServerWebExchange -import reactor.core.publisher.Mono +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint +import org.springframework.security.web.SecurityFilterChain @Configuration -@EnableWebFluxSecurity -@EnableReactiveMethodSecurity +@EnableWebSecurity class SecurityConfiguration { @Bean - fun securityWebFilterChain( - http: ServerHttpSecurity, - ): SecurityWebFilterChain { - return http - .authorizeExchange { - it.pathMatchers(HttpMethod.GET, "/").permitAll() - it.pathMatchers(HttpMethod.GET, "/actuator").permitAll() - it.pathMatchers(HttpMethod.GET, "/actuator/**").permitAll() - it.anyExchange().authenticated() + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeRequests { + authorize(HttpMethod.GET, "/", permitAll) + authorize(HttpMethod.GET, "/actuator", permitAll) + authorize(HttpMethod.GET, "/actuator/**", permitAll) + authorize(anyRequest, authenticated) } - .csrf { - it.disable() + httpBasic { + disable() } - .exceptionHandling { - it.accessDeniedHandler(customServerAccessDeniedHandler()) - it.authenticationEntryPoint(CustomAuthenticationEntryPoint()) + csrf { + disable() } - .oauth2ResourceServer { - it.jwt {} + formLogin { + disable() + } + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.STATELESS + } + exceptionHandling { + authenticationEntryPoint = + AamAuthenticationEntryPoint( + parentEntryPoint = BearerTokenAuthenticationEntryPoint(), + objectMapper = jacksonObjectMapper() + ) + } + oauth2ResourceServer { + jwt { + authenticationEntryPoint = + AamAuthenticationEntryPoint( + parentEntryPoint = BearerTokenAuthenticationEntryPoint(), + objectMapper = jacksonObjectMapper() + ) + } } - .build() - } - - private fun customServerAccessDeniedHandler(): ServerAccessDeniedHandler { - return ServerAccessDeniedHandler { _, denied -> - throw ForbiddenAccessException( - message = "Access Token not sufficient for operation", - cause = denied - ) } + return http.build() } - private class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint { - override fun commence(exchange: ServerWebExchange, ex: AuthenticationException): Mono { - throw UnauthorizedAccessException("Access Token invalid or missing") - } - } } + diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 4298aa6..c96f730 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -6,14 +6,22 @@ spring: jackson: deserialization: accept-empty-string-as-null-object: true - r2dbc: - url: r2dbc:h2:file://././data/dbh2;DB_CLOSE_DELAY=-1 - username: local - password: local rabbitmq: listener: simple: prefetch: 1 + datasource: + driver-class-name: org.h2.Driver + username: local + password: local + url: jdbc:h2:file:./data/dbh2;DB_CLOSE_DELAY=-1 + jpa: + generate-ddl: true + hibernate: + ddl-auto: update + threads: + virtual: + enabled: true management: endpoint: @@ -27,12 +35,6 @@ management: - info - health -sqs-client-configuration: - max-in-memory-size-in-mega-bytes: 16 - -couch-db-client-configuration: - max-in-memory-size-in-mega-bytes: 64 - --- spring: @@ -51,14 +53,15 @@ spring: retry: enabled: true max-attempts: 5 - webflux: - base-path: /api server: error: include-message: always include-binding-errors: always port: 9000 + servlet: + context-path: /api + logging: level: # org.springframework.amqp.rabbit: warn diff --git a/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql b/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql deleted file mode 100644 index ac5841c..0000000 --- a/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql +++ /dev/null @@ -1,9 +0,0 @@ --- ----------------------------------------------------------------------------------- -- --- Will create the SYNC_ENTITY Table, needed for internal state handling and caching -- --- ----------------------------------------------------------------------------------- -- -CREATE TABLE IF NOT EXISTS SYNC_ENTRY -( - ID SERIAL PRIMARY KEY, - DATABASE TEXT(1000), - LATEST_REF TEXT(1000) -); diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/common/WebClientTestBase.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/common/WebClientTestBase.kt index a6329ed..1b90902 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/common/WebClientTestBase.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/common/WebClientTestBase.kt @@ -10,23 +10,22 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach -import org.springframework.web.reactive.function.client.WebClient -import reactor.core.publisher.Mono +import org.springframework.web.client.RestClient open class WebClientTestBase { companion object { const val WEBSERVER_PORT = 6000 val objectMapper = jacksonObjectMapper() - lateinit var webClient: WebClient + lateinit var restClient: RestClient lateinit var mockWebServer: MockWebServer @JvmStatic @BeforeAll fun init() { val config = AamRenderApiConfiguration() - webClient = config.aamRenderApiClient( + restClient = config.aamRenderApiClient( authProvider = object : AuthProvider { - override fun fetchToken(authClientConfig: AuthConfig): Mono = Mono.empty() + override fun fetchToken(authClientConfig: AuthConfig): TokenResponse = TokenResponse("dummy-token") }, configuration = AamRenderApiClientConfiguration( basePath = "http://localhost:$WEBSERVER_PORT", diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/domain/BasicDomainUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/domain/BasicDomainUseCaseTest.kt index 56ec479..8b810bd 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/domain/BasicDomainUseCaseTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/domain/BasicDomainUseCaseTest.kt @@ -1,28 +1,26 @@ package com.aamdigital.aambackendservice.domain +import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.InternalServerException import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension -import reactor.core.publisher.Mono -import reactor.test.StepVerifier + +enum class TestErrorCode : AamErrorCode { + TEST_EXCEPTION, +} @ExtendWith(MockitoExtension::class) class BasicDomainUseCaseTest { - enum class BasicUseCaseErrorCode : UseCaseErrorCode { - UNKNOWN - } + class BasicTestUseCase : DomainUseCase() { - class BasicTestUseCase : DomainUseCase { - override fun apply(request: UseCaseRequest): Mono> { - throw InternalServerException("error") - } - override fun handleError(it: Throwable): Mono> { - return Mono.just( - UseCaseOutcome.Failure(errorCode = BasicUseCaseErrorCode.UNKNOWN, cause = it) + override fun apply(request: UseCaseRequest): UseCaseOutcome { + throw InternalServerException( + message = "error", + code = TestErrorCode.TEST_EXCEPTION ) } } @@ -30,13 +28,13 @@ class BasicDomainUseCaseTest { private val useCase = BasicTestUseCase() @Test - fun `should catch exception in UseCaseOutcome when call execute()`() { + fun `should catch exception in UseCaseOutcome when call apply()`() { val request: UseCaseRequest = object : UseCaseRequest {} - StepVerifier.create( - useCase.execute(request) - ).assertNext { - Assertions.assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - } + val response = useCase.run(request) + + Assertions.assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + Assertions.assertThat((response as UseCaseOutcome.Failure).errorCode) + .isEqualTo(TestErrorCode.TEST_EXCEPTION) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/CucumberIntegrationTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/CucumberIntegrationTest.kt index 9c29cea..d0ed958 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/CucumberIntegrationTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/CucumberIntegrationTest.kt @@ -1,5 +1,6 @@ package com.aamdigital.aambackendservice.e2e +import com.aamdigital.aambackendservice.container.TestContainers import io.cucumber.java.After import io.cucumber.java.en.Given import io.cucumber.java.en.Then @@ -54,6 +55,14 @@ class CucumberIntegrationTest : SpringIntegrationTest() { ) } + @Given("template {} is stored in template engine") + fun `store template in template engine`(file: String) { + exchangeMultipart( + "http://${TestContainers.CONTAINER_PDF.host}:${TestContainers.CONTAINER_PDF.getMappedPort(4000)}/template", + ClassPathResource("files/$file") + ) + } + @When("the client calls GET {word}") @Throws(Throwable::class) fun `the client issues GET endpoint`(endpoint: String) { diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCaseTest.kt index 2e89cff..5b61e4f 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCaseTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCaseTest.kt @@ -2,7 +2,7 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.common.WebClientTestBase import com.aamdigital.aambackendservice.domain.UseCaseOutcome -import com.aamdigital.aambackendservice.export.core.CreateTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.CreateTemplateError import com.aamdigital.aambackendservice.export.core.CreateTemplateRequest import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase import okhttp3.mockwebserver.MockResponse @@ -11,22 +11,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension -import org.springframework.core.io.buffer.DataBuffer -import org.springframework.http.HttpHeaders -import org.springframework.http.codec.multipart.FilePart -import org.springframework.util.LinkedMultiValueMap -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.test.StepVerifier -import java.nio.file.Path - -class FilePartTestImpl(val name: String) : FilePart { - override fun name(): String = name - override fun headers(): HttpHeaders = HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap()) - override fun content(): Flux = Flux.empty() - override fun filename(): String = "$name.file" - override fun transferTo(dest: Path): Mono = Mono.empty() -} +import org.springframework.mock.web.MockMultipartFile @ExtendWith(MockitoExtension::class) class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { @@ -36,7 +21,7 @@ class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { override fun setUp() { super.setUp() service = DefaultCreateTemplateUseCase( - webClient = webClient, + restClient = restClient, objectMapper = objectMapper, ) } @@ -47,20 +32,18 @@ class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { mockWebServer.enqueue(MockResponse().setBody("invalid json")) // when - StepVerifier.create( - service.execute( - CreateTemplateRequest( - file = FilePartTestImpl("test"), - ) + val response = service.run( + CreateTemplateRequest( + file = MockMultipartFile("test", "dummy-content".byteInputStream()), ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - CreateTemplateErrorCode.PARSE_RESPONSE_ERROR, - (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + CreateTemplateError.PARSE_RESPONSE_ERROR, + (response as UseCaseOutcome.Failure).errorCode + ) } @Test @@ -68,20 +51,18 @@ class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { // given // when - StepVerifier.create( - service.execute( - CreateTemplateRequest( - file = FilePartTestImpl("test"), - ) - ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - CreateTemplateErrorCode.CREATE_TEMPLATE_REQUEST_FAILED_ERROR, - (it as UseCaseOutcome.Failure).errorCode + val response = service.run( + CreateTemplateRequest( + file = MockMultipartFile("test", "dummy-content".byteInputStream()), ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + CreateTemplateError.CREATE_TEMPLATE_REQUEST_FAILED_ERROR, + (response as UseCaseOutcome.Failure).errorCode + ) } @Test @@ -101,19 +82,17 @@ class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { ) // when - StepVerifier.create( - service.execute( - CreateTemplateRequest( - file = FilePartTestImpl("test"), - ) - ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Success::class.java) - assertEquals( - "template-id", - (it as UseCaseOutcome.Success).outcome.templateRef.id + val response = service.run( + CreateTemplateRequest( + file = MockMultipartFile("test", "dummy-content".byteInputStream()), ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + assertEquals( + "template-id", + (response as UseCaseOutcome.Success).data.templateRef.id + ) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCaseTest.kt index be94e4d..1bc3d35 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCaseTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCaseTest.kt @@ -2,10 +2,11 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.common.WebClientTestBase import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.TestErrorCode import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.ExternalSystemException import com.aamdigital.aambackendservice.error.InternalServerException -import com.aamdigital.aambackendservice.error.InvalidArgumentException -import com.aamdigital.aambackendservice.export.core.FetchTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.FetchTemplateError import com.aamdigital.aambackendservice.export.core.FetchTemplateRequest import com.aamdigital.aambackendservice.export.core.FetchTemplateUseCase import com.aamdigital.aambackendservice.export.core.TemplateExport @@ -22,10 +23,8 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.reset import org.mockito.kotlin.whenever -import org.springframework.core.io.buffer.DataBuffer -import reactor.core.publisher.Mono -import reactor.test.StepVerifier -import java.io.File +import org.springframework.core.io.ClassPathResource +import java.io.InputStream @ExtendWith(MockitoExtension::class) @@ -41,55 +40,65 @@ class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { reset(templateStorage) service = DefaultFetchTemplateUseCase( - webClient = webClient, + restClient = restClient, templateStorage = templateStorage ) } @Test fun `should return Failure when fetchTemplate returns an error`() { - // Arrange + // given val templateRef = DomainReference("some-id") - val exception = InternalServerException(message = "fetchTemplate error", cause = null) + val exception = InternalServerException( + message = "fetchTemplate error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.error(exception)) + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { + throw exception + } - // Act & Assert - StepVerifier.create( - service.execute( - FetchTemplateRequest( - templateRef - ) - ) - ).assertNext { - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (it as UseCaseOutcome.Failure).errorCode + // when + val response = service.run( + FetchTemplateRequest( + templateRef ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test fun `should return Failure when fetchTemplate returns empty response`() { - // Arrange + // given val templateRef = DomainReference("some-id") - val exception = InternalServerException(message = "fetchTemplate error", cause = null) + val exception = InternalServerException( + message = "fetchTemplate error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.empty()) + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { + throw exception + } - // Act & Assert - StepVerifier.create( - service.execute( - FetchTemplateRequest( - templateRef - ) - ) - ).assertNext { - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (it as UseCaseOutcome.Failure).errorCode + // when + val response = service.run( + FetchTemplateRequest( + templateRef ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test @@ -97,29 +106,32 @@ class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { // given val templateRef = DomainReference("some-id") + val exception = ExternalSystemException( + message = "fetchTemplate error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { - throw InvalidArgumentException() + throw exception } // when - StepVerifier.create( - service.execute( - FetchTemplateRequest( - templateRef - ) + val response = service.run( + FetchTemplateRequest( + templateRef ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - FetchTemplateErrorCode.INTERNAL_SERVER_ERROR, (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() + ) + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test fun `should return Failure when response could not be processed`() { - // Arrange + // given val templateRef = DomainReference("some-id") val templateExport = TemplateExport( @@ -131,25 +143,23 @@ class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) - // Act & Assert - StepVerifier.create( - service.execute( - FetchTemplateRequest( - templateRef - ) - ) - ).assertNext { - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - FetchTemplateErrorCode.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (it as UseCaseOutcome.Failure).errorCode + // when + val response = service.run( + FetchTemplateRequest( + templateRef ) - }.verifyComplete() + ) + + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + FetchTemplateError.FETCH_TEMPLATE_REQUEST_FAILED_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test - fun `should return Success with DataBuffer`() { + fun `should return Success with InputStream`() { // given val templateRef = DomainReference("some-id") @@ -162,10 +172,10 @@ class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) val buffer = Buffer() - buffer.writeAll(File("src/test/resources/files/docx-test-file-1.docx").source()) + buffer.writeAll(ClassPathResource("files/docx-test-file-1.docx").file.source()) mockWebServer.enqueue( MockResponse() @@ -180,19 +190,16 @@ class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { ) - StepVerifier.create( - service.execute( - FetchTemplateRequest( - templateRef - ) + // when + val response = service.run( + FetchTemplateRequest( + templateRef ) ) - .consumeNextWith { response -> - // then - assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) - assertThat((response as UseCaseOutcome.Success).outcome.file).isInstanceOf(DataBuffer::class.java) - } - .verifyComplete() + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + assertThat((response as UseCaseOutcome.Success).data.file).isInstanceOf(InputStream::class.java) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCaseTest.kt index ac7efce..7f38b25 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCaseTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCaseTest.kt @@ -2,11 +2,12 @@ package com.aamdigital.aambackendservice.export.usecase import com.aamdigital.aambackendservice.common.WebClientTestBase import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.TestErrorCode import com.aamdigital.aambackendservice.domain.UseCaseOutcome -import com.aamdigital.aambackendservice.error.InternalServerException -import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.ExternalSystemException import com.aamdigital.aambackendservice.error.NetworkException -import com.aamdigital.aambackendservice.export.core.RenderTemplateErrorCode +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.export.core.RenderTemplateError import com.aamdigital.aambackendservice.export.core.RenderTemplateRequest import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase import com.aamdigital.aambackendservice.export.core.TemplateExport @@ -25,11 +26,10 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.reset import org.mockito.kotlin.whenever -import org.springframework.core.io.buffer.DataBuffer -import org.springframework.web.reactive.function.client.WebClientRequestException -import reactor.core.publisher.Mono -import reactor.test.StepVerifier +import org.springframework.web.client.RestClientException +import java.io.ByteArrayInputStream import java.io.File +import java.net.SocketTimeoutException @ExtendWith(MockitoExtension::class) @@ -45,7 +45,7 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { reset(templateStorage) service = DefaultRenderTemplateUseCase( - webClient = webClient, + renderClient = restClient, objectMapper = objectMapper, templateStorage = templateStorage, ) @@ -53,30 +53,36 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { @Test fun `should return Failure when fetchTemplate returns an error`() { - // Arrange + // given val templateRef = DomainReference("some-id") val bodyData: JsonNode = objectMapper.readValue( """ {"foo":"bar"} """.trimIndent() ) - val exception = InternalServerException(message = "fetchTemplate error", cause = null) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.error(exception)) + val exception = ExternalSystemException( + message = "fetchTemplate error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) - // Act & Assert - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) - ) - ).assertNext { - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - RenderTemplateErrorCode.FETCH_TEMPLATE_FAILED_ERROR, (it as UseCaseOutcome.Failure).errorCode + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { + throw exception + } + + // when + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + RenderTemplateError.FETCH_TEMPLATE_FAILED_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test @@ -97,27 +103,25 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) mockWebServer.enqueue(MockResponse().setBody("invalid json")) // when - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - RenderTemplateErrorCode.PARSE_RESPONSE_ERROR, (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + RenderTemplateError.PARSE_RESPONSE_ERROR, (response as UseCaseOutcome.Failure).errorCode + ) } @Test - fun `should return Failure when fetchTemplateRequest throws exception`() { + fun `should return Failure with NOT_FOUND_ERROR when fetchTemplate throws NotFoundException exception`() { // given val templateRef = DomainReference("some-id") val bodyData: JsonNode = objectMapper.readValue( @@ -127,79 +131,23 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { ) whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { - throw InvalidArgumentException() + throw NotFoundException( + code = TestErrorCode.TEST_EXCEPTION, + ) } // when - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - RenderTemplateErrorCode.INTERNAL_SERVER_ERROR, (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() - } - - @Test - fun `should return Failure when fetchTemplateRequest returns Mono error`() { - // given - val templateRef = DomainReference("some-id") - val bodyData: JsonNode = objectMapper.readValue( - """ - {"foo":"bar"} - """.trimIndent() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.error(RuntimeException())) - - // when - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) - ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - RenderTemplateErrorCode.INTERNAL_SERVER_ERROR, (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() - } - - @Test - fun `should return Failure when fetchTemplateRequest returns Mono empty`() { - // given - val templateRef = DomainReference("some-id") - val bodyData: JsonNode = objectMapper.readValue( - """ - {"foo":"bar"} - """.trimIndent() + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + RenderTemplateError.NOT_FOUND_ERROR, (response as UseCaseOutcome.Failure).errorCode ) - - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.empty()) - - // when - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) - ) - ).assertNext { - // then - assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals( - RenderTemplateErrorCode.FETCH_TEMPLATE_FAILED_ERROR, (it as UseCaseOutcome.Failure).errorCode - ) - }.verifyComplete() } @Test @@ -220,7 +168,7 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) mockWebServer.enqueue( MockResponse().setBody( @@ -251,20 +199,17 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { ) - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + // when + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) ) - .consumeNextWith { response -> - // then - assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) - assertThat((response as UseCaseOutcome.Success).outcome.file).isInstanceOf(DataBuffer::class.java) - } - .verifyComplete() + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + assertThat((response as UseCaseOutcome.Success).data.file).isInstanceOf(ByteArrayInputStream::class.java) } @Test @@ -285,7 +230,7 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) mockWebServer.enqueue( MockResponse().setBody( @@ -298,24 +243,21 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { ) ) - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + // when + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) ) - .consumeNextWith { response -> - // then - assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertEquals("this is an error message", (response as UseCaseOutcome.Failure).errorMessage) - } - .verifyComplete() + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + + assertEquals("this is an error message", (response as UseCaseOutcome.Failure).errorMessage) } @Test - fun `should return Failure when createRenderRequest returns no response`() { + fun `should return Failure when createRenderRequest runs in timeout`() { // given val templateRef = DomainReference("some-id") val bodyData: JsonNode = objectMapper.readValue( @@ -332,29 +274,26 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + // when + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) ) - .consumeNextWith { response -> - // then - assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertThat((response as UseCaseOutcome.Failure).cause) - .isInstanceOf(WebClientRequestException::class.java) + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertThat(response.cause?.cause) - .isInstanceOf(NetworkException::class.java) + assertThat((response as UseCaseOutcome.Failure).cause) + .isInstanceOf(ExternalSystemException::class.java) - assertThat(response.errorCode) - .isEqualTo(RenderTemplateErrorCode.CREATE_RENDER_REQUEST_FAILED_ERROR) - } - .verifyComplete() + assertThat(response.cause?.cause) + .isInstanceOf(RestClientException::class.java) + + assertThat(response.errorCode) + .isEqualTo(RenderTemplateError.CREATE_RENDER_REQUEST_FAILED_ERROR) } @Test @@ -375,7 +314,7 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { applicableForEntityTypes = emptyList() ) - whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(templateExport) mockWebServer.enqueue( MockResponse().setBody( @@ -390,26 +329,23 @@ class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { ) ) - StepVerifier.create( - service.execute( - RenderTemplateRequest( - templateRef, bodyData - ) + // when + val response = service.run( + RenderTemplateRequest( + templateRef, bodyData ) ) - .consumeNextWith { response -> - // then - assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertThat((response as UseCaseOutcome.Failure).cause) - .isInstanceOf(WebClientRequestException::class.java) + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) - assertThat(response.cause?.cause) - .isInstanceOf(NetworkException::class.java) + assertThat((response as UseCaseOutcome.Failure).cause) + .isInstanceOf(NetworkException::class.java) - assertThat(response.errorCode) - .isEqualTo(RenderTemplateErrorCode.FETCH_RENDER_ID_REQUEST_FAILED_ERROR) - } - .verifyComplete() + assertThat(response.cause?.cause) + .isInstanceOf(SocketTimeoutException::class.java) + + assertThat(response.errorCode) + .isEqualTo(RenderTemplateError.FETCH_RENDER_ID_REQUEST_FAILED_ERROR) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumerTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumerTest.kt index 9888f10..3d2e6c1 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumerTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumerTest.kt @@ -1,14 +1,15 @@ package com.aamdigital.aambackendservice.reporting.report.core +import com.aamdigital.aambackendservice.domain.TestErrorCode import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.queue.core.QueueMessageParser import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationChangeUseCase import com.rabbitmq.client.Channel -import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension @@ -17,7 +18,6 @@ import org.mockito.kotlin.reset import org.mockito.kotlin.whenever import org.springframework.amqp.AmqpRejectAndDontRequeueException import org.springframework.amqp.core.Message -import reactor.test.StepVerifier @ExtendWith(MockitoExtension::class) class DefaultReportDocumentChangeEventConsumerTest { @@ -66,18 +66,20 @@ class DefaultReportDocumentChangeEventConsumerTest { whenever(messageParser.getTypeKClass(any())) .thenAnswer { - throw InternalServerException() + throw InternalServerException( + message = "error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) } - StepVerifier - // when - .create(service.consume(rawMessage, mockMessage, mockChannel)) - // then - .expectErrorSatisfies { - assertThat(it).isInstanceOf(AmqpRejectAndDontRequeueException::class.java) - Assertions.assertTrue(it.localizedMessage.startsWith("[INTERNAL_SERVER_ERROR]")) - } - .verify() + // when + val response = assertThrows { + service.consume(rawMessage, mockMessage, mockChannel) + } + + // then + Assertions.assertTrue(response.localizedMessage.startsWith("[TEST_EXCEPTION]")) } @Test @@ -90,14 +92,12 @@ class DefaultReportDocumentChangeEventConsumerTest { String::class } - StepVerifier - // when - .create(service.consume(rawMessage, mockMessage, mockChannel)) - // then - .expectErrorSatisfies { - assertThat(it).isInstanceOf(AmqpRejectAndDontRequeueException::class.java) - Assertions.assertTrue(it.localizedMessage.startsWith("[NO_USECASE_CONFIGURED]")) - } - .verify() + // when + val response = assertThrows { + service.consume(rawMessage, mockMessage, mockChannel) + } + + // then + Assertions.assertTrue(response.localizedMessage.startsWith("[NO_USECASE_CONFIGURED]")) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCaseTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCaseTest.kt index 3e2ee89..a79847a 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCaseTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCaseTest.kt @@ -1,6 +1,7 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.TestErrorCode import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage import org.assertj.core.api.Assertions.assertThat @@ -13,8 +14,6 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.reset import org.mockito.kotlin.whenever -import reactor.core.publisher.Mono -import reactor.test.StepVerifier @ExtendWith(MockitoExtension::class) class DefaultCreateReportCalculationUseCaseTest { @@ -35,29 +34,26 @@ class DefaultCreateReportCalculationUseCaseTest { // given whenever(reportingStorage.fetchCalculations(any())) .thenAnswer { - Mono.error> { - InternalServerException() - } + throw InternalServerException( + message = "error", + code = TestErrorCode.TEST_EXCEPTION, + cause = null + ) } - StepVerifier - // when - .create( - service.createReportCalculation( - CreateReportCalculationRequest( - report = DomainReference("Report:1"), - args = mutableMapOf() - ) - ) + // when + val response = service.createReportCalculation( + CreateReportCalculationRequest( + report = DomainReference("Report:1"), + args = mutableMapOf() ) - // then - .assertNext { - assertThat(it).isInstanceOf(CreateReportCalculationResult.Failure::class.java) - Assertions.assertEquals( - CreateReportCalculationResult.ErrorCode.INTERNAL_SERVER_ERROR, - (it as CreateReportCalculationResult.Failure).errorCode - ) - } - .verifyComplete() + ) + + // then + assertThat(response).isInstanceOf(CreateReportCalculationResult.Failure::class.java) + Assertions.assertEquals( + CreateReportCalculationResult.ErrorCode.INTERNAL_SERVER_ERROR, + (response as CreateReportCalculationResult.Failure).errorCode + ) } } diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculatorTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculatorTest.kt index 58dd240..02aa20c 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculatorTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculatorTest.kt @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.keycloak.common.util.Base64.InputStream import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -24,10 +25,6 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.reset import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.springframework.core.io.buffer.DataBuffer -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.test.StepVerifier import java.util.* @ExtendWith(MockitoExtension::class) @@ -79,39 +76,34 @@ class DefaultReportCalculatorTest { whenever(reportStorage.fetchReport(reportCalculation.report)) .thenAnswer { - Mono.just(Optional.of(report)) + Optional.of(report) } whenever(queryStorage.executeQuery(any())) .thenAnswer { - Flux.empty() + InputStream.nullInputStream() } configureCouchDbClientDocSuccessResponse(reportCalculation.id) - StepVerifier - // when - .create( - service.calculate( - reportCalculation = reportCalculation - ) - ) - // then - .assertNext { - assertThat(it).isInstanceOf(ReportCalculation::class.java) - Assertions.assertEquals(reportCalculation, it) - verify(queryStorage).executeQuery( - eq( - QueryRequest( - query = "SELECT * FROM foo", - args = listOf( - "2010-01-15", "2010-01-16T23:59:59.999Z" - ) - ) + // when + val response = service.calculate( + reportCalculation = reportCalculation + ) + + // then + assertThat(response).isInstanceOf(ReportCalculation::class.java) + Assertions.assertEquals(reportCalculation, response) + verify(queryStorage).executeQuery( + eq( + QueryRequest( + query = "SELECT * FROM foo", + args = listOf( + "2010-01-15", "2010-01-16T23:59:59.999Z" ) ) - } - .verifyComplete() + ) + ) } @Test @@ -140,50 +132,43 @@ class DefaultReportCalculatorTest { whenever(reportStorage.fetchReport(reportCalculation.report)) .thenAnswer { - Mono.just(Optional.of(report)) + Optional.of(report) } whenever(queryStorage.executeQuery(any())) .thenAnswer { - Flux.empty() + InputStream.nullInputStream() } configureCouchDbClientDocSuccessResponse(reportCalculation.id) - StepVerifier - // when - .create( - service.calculate( - reportCalculation = reportCalculation - ) - ) - // then - .assertNext { - assertThat(it).isInstanceOf(ReportCalculation::class.java) - Assertions.assertEquals(reportCalculation, it) - verify(queryStorage).executeQuery( - eq( - QueryRequest( - query = "SELECT * FROM foo", - args = listOf( - DEFAULT_FROM_DATE, DEFAULT_TO_DATE - ) - ) + // when + val response = service.calculate( + reportCalculation = reportCalculation + ) + + // then + assertThat(response).isInstanceOf(ReportCalculation::class.java) + Assertions.assertEquals(reportCalculation, response) + verify(queryStorage).executeQuery( + eq( + QueryRequest( + query = "SELECT * FROM foo", + args = listOf( + DEFAULT_FROM_DATE, DEFAULT_TO_DATE ) ) - } - .verifyComplete() + ) + ) } private fun configureCouchDbClientDocSuccessResponse(id: String) { whenever(couchDbClient.putAttachment(any(), any(), any(), any())) .thenAnswer { - Mono.just( - DocSuccess( - id = id, - ok = true, - rev = "foo" - ) + DocSuccess( + id = id, + ok = true, + rev = "foo" ) } } diff --git a/application/aam-backend-service/src/test/resources/application-e2e.yaml b/application/aam-backend-service/src/test/resources/application-e2e.yaml index e5673d3..9b8e988 100644 --- a/application/aam-backend-service/src/test/resources/application-e2e.yaml +++ b/application/aam-backend-service/src/test/resources/application-e2e.yaml @@ -6,10 +6,18 @@ spring: jackson: deserialization: accept-empty-string-as-null-object: true - r2dbc: - url: r2dbc:h2:file://././data/dbh2;DB_CLOSE_DELAY=-1 + datasource: + driver-class-name: org.h2.Driver username: local password: local + url: jdbc:h2:file:./test-data/dbh2;DB_CLOSE_DELAY=-1 + jpa: + generate-ddl: true + hibernate: + ddl-auto: create-drop + threads: + virtual: + enabled: true rabbitmq: virtual-host: / listener: @@ -18,6 +26,7 @@ spring: enabled: true max-attempts: 5 + server: port: 9000 @@ -42,13 +51,11 @@ aam-render-api-client-configuration: scope: couch-db-client-configuration: - max-in-memory-size-in-mega-bytes: 16 base-path: http://localhost:5984 basic-auth-username: admin basic-auth-password: docker sqs-client-configuration: - max-in-memory-size-in-mega-bytes: 16 base-path: http://localhost:4984 basic-auth-username: admin basic-auth-password: docker @@ -65,3 +72,7 @@ crypto-configuration: sentry: logging: enabled: false + +logging: + level: + com.aamdigital.aambackendservice: trace diff --git a/application/aam-backend-service/src/test/resources/cucumber/features/export/export.feature b/application/aam-backend-service/src/test/resources/cucumber/features/export/export.feature index e25c12c..a13a098 100644 --- a/application/aam-backend-service/src/test/resources/cucumber/features/export/export.feature +++ b/application/aam-backend-service/src/test/resources/cucumber/features/export/export.feature @@ -18,6 +18,7 @@ Feature: the export endpoint handles template creation Scenario: client makes call to GET /export/template/TemplateExport:2 and receives an docx Given database app is created + Given template docx-test-file-1.docx is stored in template engine Given document TemplateExport:2 is stored in database app Given signed in as client dummy-client with secret client-secret in realm dummy-realm When the client calls GET /v1/export/template/TemplateExport:2