diff --git a/README.md b/README.md index 1ac9932..b936423 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Aam Digital Services (aam-backend-service) + Collection of aam-digital services and tools [![Maintainability](https://api.codeclimate.com/v1/badges/57213b5887a579196d6d/maintainability)](https://codeclimate.com/github/Aam-Digital/aam-services/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/57213b5887a579196d6d/test_coverage)](https://codeclimate.com/github/Aam-Digital/aam-services/test_coverage) @@ -6,13 +7,16 @@ Collection of aam-digital services and tools A modularize Spring Boot application that contains API modules for [Aam Digital's case management platform](https://github.com/Aam-Digital/ndb-core). ## Setup + 1. Create additional databases in CouchDB: `report-calculation` and `notification-webhook` (used by the Reporting Module to store details) 2. Set up necessary environment variables (e.g. using an `application.env` file for docker compose): - - see [example .env](./docs/examples/application.env) - - CRYPTO_CONFIGURATION_SECRET: _a random secret used to encrypt data_ -3. See ndb-setup for instructions to enable the backend in an overall system: [ndb-setup README](https://github.com/Aam-Digital/ndb-setup?tab=readme-ov-file#api-integrations-and-sql-reports) +- see [example .env](./docs/examples/application.env) +- CRYPTO_CONFIGURATION_SECRET: _a random secret used to encrypt data_ + +3. See ndb-setup for instructions to enable the backend in an overall system: [ndb-setup README](https://github.com/Aam-Digital/ndb-setup?tab=readme-ov-file#api-integrations-and-sql-reports) ## API Modules - **[Reporting](./docs/modules/reporting.md)**: Calculate aggregated reports and run queries on all data, accessible for external services for API integrations of systems +- **[Export](./docs/modules/export.md)**: Template based file export API. Uses [carbone.io](https://carbone.io) as templating engine. diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 0229a4d..60e7bac 100644 --- a/application/aam-backend-service/build.gradle.kts +++ b/application/aam-backend-service/build.gradle.kts @@ -66,10 +66,13 @@ dependencies { testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.2") testImplementation("net.joshka:junit-json-params:5.10.2-r0") - testImplementation("org.eclipse.parsson:parsson:1.1.1") + testImplementation("org.eclipse.parsson:parsson:1.1.7") testImplementation("io.projectreactor:reactor-test") + testImplementation("com.squareup.okhttp3:okhttp:4.12.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.testcontainers:junit-jupiter:1.19.7") { constraints { testImplementation("org.apache.commons:commons-compress:1.26.1") { @@ -78,7 +81,7 @@ dependencies { } } testImplementation("org.testcontainers:rabbitmq:1.19.7") - testImplementation("com.github.dasniko:testcontainers-keycloak:3.3.0") { + testImplementation("com.github.dasniko:testcontainers-keycloak:3.4.0") { constraints { testImplementation("org.apache.james:apache-mime4j-core:0.8.11") { because("previous versions have security issues") 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 new file mode 100644 index 0000000..4b0c7d6 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt @@ -0,0 +1,32 @@ +package com.aamdigital.aambackendservice.auth.core + +import reactor.core.publisher.Mono + +data class TokenResponse(val token: String) + + +/** + * Configuration data class for handling authentication. + * + * @property clientId The client ID used for authentication. + * @property clientSecret The client secret used for authentication. + * @property tokenEndpoint The endpoint URL to request the token. + * @property grantType The OAuth grant type to be used. + * @property scope The scope of the access request. + */ +data class AuthConfig( + val clientId: String, + val clientSecret: String, + val tokenEndpoint: String, + val grantType: String, + val scope: String, +) + +/** + * Interface representing an authentication provider responsible for fetching an authentication token. + * + * Used for fetching access tokens for third party systems. + */ +interface AuthProvider { + fun fetchToken(authClientConfig: AuthConfig): Mono +} 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 new file mode 100644 index 0000000..c27e084 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt @@ -0,0 +1,73 @@ +package com.aamdigital.aambackendservice.auth.core + +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 + +data class KeycloakTokenResponse( + @JsonProperty("access_token") val accessToken: String, +) + +/** + * This class is an implementation of the AuthProvider interface and is responsible for + * fetching tokens from a Keycloak authentication server. Used for accessing + * third party systems and services. + * + * Not related to authentication mechanics related to endpoints we provide to others. + * + * @property webClient The WebClient used for making HTTP requests. + * @property objectMapper The ObjectMapper used for parsing JSON responses. + */ +class KeycloakAuthProvider( + val webClient: WebClient, + val objectMapper: ObjectMapper, +) : AuthProvider { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun fetchToken(authClientConfig: AuthConfig): Mono { + val formData = LinkedMultiValueMap( + mutableMapOf( + "client_id" to listOf(authClientConfig.clientId), + "client_secret" to listOf(authClientConfig.clientSecret), + "grant_type" to listOf(authClientConfig.grantType), + ).also { + if (authClientConfig.scope.isNotBlank()) { + "scope" to listOf(authClientConfig.scope) + } + } + ) + + return webClient.post() + .uri(authClientConfig.tokenEndpoint) + .headers { + it.contentType = MediaType.APPLICATION_FORM_URLENCODED + } + .body( + BodyInserters.fromFormData( + formData + ) + ).exchangeToMono { + it.bodyToMono(String::class.java) + }.map { + parseResponse(it) + }.doOnError { logger.error(it.message, it) } + } + + private fun parseResponse(raw: String): TokenResponse { + 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) + } + } +} 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 new file mode 100644 index 0000000..d95439e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/di/AuthConfiguration.kt @@ -0,0 +1,47 @@ +package com.aamdigital.aambackendservice.auth.di + +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, +) + +@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() + } + + @Bean(name = ["aam-keycloak"]) + fun aamKeycloakAuthProvider( + @Qualifier("aam-keycloak-client") webClient: WebClient, + objectMapper: ObjectMapper, + ): AuthProvider = + KeycloakAuthProvider(webClient = webClient, objectMapper = objectMapper) + +} 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 b64784f..7afd075 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,7 +3,9 @@ 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.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 @@ -102,13 +104,21 @@ class DefaultCouchDbClient( queryParams: MultiValueMap, kClass: KClass, ): Mono { - return webClient.get().uri { - it.path("/$database/$documentId") - it.queryParams(queryParams) - it.build() - }.accept(MediaType.APPLICATION_JSON).exchangeToMono { - handleResponse(it, kClass) - } + return webClient.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\"" + ) + } + handleResponse(response, kClass) + } } override fun putDatabaseDocument( @@ -264,10 +274,20 @@ class DefaultCouchDbClient( } private fun handleResponse( - response: ClientResponse, typeReference: KClass + response: ClientResponse, + typeReference: KClass ): Mono { - return response.bodyToMono(typeReference.java).mapNotNull { - it - } + 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, + ) + } + } } } 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 new file mode 100644 index 0000000..52cdfe6 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainUseCase.kt @@ -0,0 +1,36 @@ +package com.aamdigital.aambackendservice.domain + +import reactor.core.publisher.Mono + + +interface UseCaseRequest +interface UseCaseData +interface UseCaseErrorCode + +sealed interface UseCaseOutcome { + data class Success( + val outcome: D + ) : UseCaseOutcome + + data class Failure( + val errorCode: E, + val errorMessage: String? = "An unspecific error occurred, while executing this use case", + val cause: Throwable? = null + ) : UseCaseOutcome +} + +interface DomainUseCase { + fun apply(request: R): Mono> + fun handleError(it: Throwable): Mono> + + fun execute(request: R): Mono> { + return try { + apply(request) + .onErrorResume { + handleError(it) + } + } catch (ex: Exception) { + handleError(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 f64a729..58e944d 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 @@ -11,37 +11,43 @@ sealed class AamException( } class InternalServerException( - message: String = "", + message: String = "Unspecific InternalServerException", cause: Throwable? = null, code: String = "INTERNAL_SERVER_ERROR" ) : AamException(message, cause, code) class ExternalSystemException( - message: String = "", + message: String = "Unspecific ExternalSystemException", cause: Throwable? = null, code: String = "EXTERNAL_SYSTEM_ERROR" ) : AamException(message, cause, code) +class NetworkException( + message: String = "Unspecific NetworkException", + cause: Throwable? = null, + code: String = "NETWORK_EXCEPTION" +) : AamException(message, cause, code) + class InvalidArgumentException( - message: String = "", + message: String = "Unspecific InvalidArgumentException", cause: Throwable? = null, code: String = "BAD_REQUEST" ) : AamException(message, cause, code) class UnauthorizedAccessException( - message: String = "", + message: String = "Unspecific UnauthorizedAccessException", cause: Throwable? = null, code: String = "UNAUTHORIZED" ) : AamException(message, cause, code) class ForbiddenAccessException( - message: String = "", + message: String = "Unspecific ForbiddenAccessException", cause: Throwable? = null, code: String = "FORBIDDEN" ) : AamException(message, cause, code) class NotFoundException( - message: String = "", + message: String = "Unspecific NotFoundException", cause: Throwable? = null, code: String = "NOT_FOUND" ) : AamException(message, cause, code) 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 new file mode 100644 index 0000000..8587076 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt @@ -0,0 +1,169 @@ +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.export.core.CreateTemplateRequest +import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase +import com.aamdigital.aambackendservice.export.core.FetchTemplateErrorCode +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.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 org.springframework.http.HttpStatus +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 +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.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + + +/** + * @param templateId The external identifier of the implementing TemplateEngine + */ +data class CreateTemplateResponseDto( + val templateId: String, +) + +/** + * REST controller responsible for handling export operations related to templates. + * Provides endpoints for checking status, posting a new template, and rendering a template. + * + * 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. + */ +@RestController +@RequestMapping("/v1/export") +@Validated +class TemplateExportController( + @Qualifier("aam-render-api-client") val webClient: WebClient, + val createTemplateUseCase: CreateTemplateUseCase, + val fetchTemplateUseCase: FetchTemplateUseCase, + val renderTemplateUseCase: RenderTemplateUseCase, +) { + + @PostMapping("/template") + fun postTemplate( + @RequestPart("template") file: FilePart + ): Mono { + return createTemplateUseCase + .execute( + 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) + ) + } + } + } + + @GetMapping("/template/{templateId}") + fun fetchTemplate( + @PathVariable templateId: String, + ): Mono> { + return fetchTemplateUseCase.execute( + FetchTemplateRequest( + templateRef = DomainReference(templateId), + ) + ).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() + ) + ) + } + } + } + + @PostMapping("/render/{templateId}") + fun renderTemplate( + @PathVariable templateId: String, + @RequestBody templateData: JsonNode, + ): Mono> { + return renderTemplateUseCase.execute( + 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() + ) + ) + } + } + } + + 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) + } + + 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) + } + + 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() + } +} 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 new file mode 100644 index 0000000..d4529bb --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt @@ -0,0 +1,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.http.codec.multipart.FilePart + +data class CreateTemplateRequest( + val file: FilePart, +) : UseCaseRequest + +data class CreateTemplateData( + val templateRef: DomainReference, +) : UseCaseData + + +enum class CreateTemplateErrorCode : UseCaseErrorCode { + INTERNAL_SERVER_ERROR, + PARSE_RESPONSE_ERROR, + CREATE_TEMPLATE_REQUEST_FAILED_ERROR +} + +interface 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 new file mode 100644 index 0000000..e2ea24b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/FetchTemplateUseCase.kt @@ -0,0 +1,28 @@ +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 org.springframework.http.HttpHeaders + +data class FetchTemplateRequest( + val templateRef: DomainReference, +) : UseCaseRequest + +data class FetchTemplateData( + val file: DataBuffer, + val responseHeaders: HttpHeaders, +) : UseCaseData + + +enum class FetchTemplateErrorCode : UseCaseErrorCode { + INTERNAL_SERVER_ERROR, + FETCH_TEMPLATE_REQUEST_FAILED_ERROR, + NOT_FOUND_ERROR +} + +interface 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 new file mode 100644 index 0000000..db71edc --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt @@ -0,0 +1,32 @@ +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.fasterxml.jackson.databind.JsonNode +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.http.HttpHeaders + +data class RenderTemplateRequest( + val templateRef: DomainReference, + val bodyData: JsonNode, +) : UseCaseRequest + +data class RenderTemplateData( + val file: DataBuffer, + val responseHeaders: HttpHeaders, +) : UseCaseData + +enum class RenderTemplateErrorCode : UseCaseErrorCode { + INTERNAL_SERVER_ERROR, + FETCH_TEMPLATE_FAILED_ERROR, + CREATE_RENDER_REQUEST_FAILED_ERROR, + FETCH_RENDER_ID_REQUEST_FAILED_ERROR, + PARSE_RESPONSE_ERROR, + NOT_FOUND_ERROR +} + +interface RenderTemplateUseCase : + DomainUseCase diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateExport.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateExport.kt new file mode 100644 index 0000000..eddd86b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateExport.kt @@ -0,0 +1,21 @@ +package com.aamdigital.aambackendservice.export.core + +/** + * Represents a template configuration used for exporting data. + * this entity is normally managed in the frontend. + * + * @property id Unique identifier of the export template. + * @property templateId Identifier of the corresponding template in the template engine. + * @property targetFileName Name of the target file to be generated. + * @property title Title of the export template, visible in frontend + * @property description (optional) Description providing details about the export template, visible in frontend. + * @property applicableForEntityTypes List of entity types for which this export template is applicable. + */ +data class TemplateExport( + val id: String, + val templateId: String, + val targetFileName: String, + val title: String, + val description: String? = null, + val applicableForEntityTypes: List, +) 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 new file mode 100644 index 0000000..448c7bd --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/TemplateStorage.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.export.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import reactor.core.publisher.Mono + +interface TemplateStorage { + fun fetchTemplate(template: DomainReference): Mono +} 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 new file mode 100644 index 0000000..59a1b21 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt @@ -0,0 +1,63 @@ +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 + + +@ConfigurationProperties("aam-render-api-client-configuration") +class AamRenderApiClientConfiguration( + val basePath: String, + val authConfig: AuthConfig? = null, + val responseTimeoutInSeconds: Long = 30L, + val maxInMemorySizeInMegaBytes: Int = 16, +) + +@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) + + authProvider.fetchToken(configuration.authConfig).map { + ClientRequest.from(request).header(HttpHeaders.AUTHORIZATION, "Bearer ${it.token}").build() + }.flatMap { + next.exchange(it) + } + } + + return clientBuilder.clientConnector( + ReactorClientHttpConnector( + HttpClient + .create() + .doOnConnected { + it.addHandlerLast(AamReadTimeoutHandler(configuration.responseTimeoutInSeconds)) + } + ) + ).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 new file mode 100644 index 0000000..7e86c98 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt @@ -0,0 +1,44 @@ +package com.aamdigital.aambackendservice.export.di + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase +import com.aamdigital.aambackendservice.export.core.FetchTemplateUseCase +import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase +import com.aamdigital.aambackendservice.export.core.TemplateStorage +import com.aamdigital.aambackendservice.export.storage.DefaultTemplateStorage +import com.aamdigital.aambackendservice.export.usecase.DefaultCreateTemplateUseCase +import com.aamdigital.aambackendservice.export.usecase.DefaultFetchTemplateUseCase +import com.aamdigital.aambackendservice.export.usecase.DefaultRenderTemplateUseCase +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 + +@Configuration +class UseCaseConfiguration { + @Bean(name = ["default-template-storage"]) + fun defaultTemplateStorage( + couchDbClient: CouchDbClient, + ): TemplateStorage = DefaultTemplateStorage(couchDbClient) + + @Bean(name = ["default-create-template-use-case"]) + fun defaultCreateTemplateUseCase( + @Qualifier("aam-render-api-client") webClient: WebClient, + objectMapper: ObjectMapper + ): CreateTemplateUseCase = DefaultCreateTemplateUseCase(webClient, objectMapper) + + + @Bean(name = ["default-fetch-template-use-case"]) + fun defaultFetchTemplateUseCase( + @Qualifier("aam-render-api-client") webClient: WebClient, + templateStorage: TemplateStorage + ): FetchTemplateUseCase = DefaultFetchTemplateUseCase(webClient, templateStorage) + + @Bean(name = ["default-render-template-use-case"]) + fun defaultRenderTemplateUseCase( + @Qualifier("aam-render-api-client") webClient: WebClient, + objectMapper: ObjectMapper, + templateStorage: TemplateStorage + ): RenderTemplateUseCase = DefaultRenderTemplateUseCase(webClient, 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 new file mode 100644 index 0000000..bae921a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt @@ -0,0 +1,47 @@ +package com.aamdigital.aambackendservice.export.storage + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.domain.DomainReference +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 + +data class TemplateExportDto( + @JsonProperty("_id") + val id: String, + val templateId: String, + val targetFileName: String, + val title: String, + val description: String? = null, + val applicableForEntityTypes: List, +) + +class DefaultTemplateStorage( + private val couchDbClient: CouchDbClient +) : TemplateStorage { + companion object { + 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) + } + } + + private fun toEntity(dto: TemplateExportDto): TemplateExport = TemplateExport( + id = dto.id, + targetFileName = dto.targetFileName, + templateId = dto.templateId, + title = dto.title, + description = dto.description, + applicableForEntityTypes = dto.applicableForEntityTypes + ) +} 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 new file mode 100644 index 0000000..dba2969 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt @@ -0,0 +1,109 @@ +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.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 + +data class CreateTemplateResponseDto( + val success: Boolean, + val data: CreateTemplateResponseDataDto +) + +data class CreateTemplateResponseDataDto( + val templateId: String, +) + + +/** + * Default implementation of the [CreateTemplateUseCase] interface. + * + * This use case is responsible for creating a template 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 objectMapper The ObjectMapper used to parse JSON responses. + */ +class DefaultCreateTemplateUseCase( + private val webClient: WebClient, + private val objectMapper: ObjectMapper, +) : CreateTemplateUseCase { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun apply( + request: CreateTemplateRequest + ): Mono> { + val builder = MultipartBodyBuilder() + + builder + .part("template", request.file) + .filename(request.file.filename()) + + 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) + + logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + + return Mono.just( + UseCaseOutcome.Failure( + errorMessage = it.localizedMessage, + errorCode = errorCode, + cause = it.cause + ) + ) + } + + private fun parseResponse(raw: String): String { + try { + val renderApiClientResponse = objectMapper.readValue(raw, CreateTemplateResponseDto::class.java) + return renderApiClientResponse.data.templateId + } catch (ex: Exception) { + + throw ExternalSystemException( + cause = ex, + message = ex.localizedMessage, + code = PARSE_RESPONSE_ERROR.toString() + ) + } + } +} 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 new file mode 100644 index 0000000..e6f3c54 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCase.kt @@ -0,0 +1,137 @@ +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.NotFoundException +import com.aamdigital.aambackendservice.export.core.FetchTemplateData +import com.aamdigital.aambackendservice.export.core.FetchTemplateErrorCode +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 + +/** + * Default implementation of the [FetchTemplateUseCase]. + * + * 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 templateStorage The TemplateStorage instance used to fetch template metadata. + */ +class DefaultFetchTemplateUseCase( + private val webClient: WebClient, + private val templateStorage: TemplateStorage, +) : FetchTemplateUseCase { + + private val logger = LoggerFactory.getLogger(javaClass) + + private data class FileResponse( + val file: DataBuffer, + 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() + + val forwardHeaders = HttpHeaders() + forwardHeaders.contentType = responseHeaders.contentType + + if (!responseHeaders["Content-Disposition"].isNullOrEmpty()) { + forwardHeaders["Content-Disposition"] = responseHeaders["Content-Disposition"] + } + + 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 + ) + ) + ) + } + } + } catch (it: Exception) { + handleError(it) + } + } + + override fun handleError(it: Throwable): Mono> { + val errorCode: FetchTemplateErrorCode = runCatching { + FetchTemplateErrorCode.valueOf((it as AamException).code) + }.getOrDefault(FetchTemplateErrorCode.INTERNAL_SERVER_ERROR) + + logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + + return Mono.just( + UseCaseOutcome.Failure( + errorMessage = it.localizedMessage, + errorCode = errorCode, + cause = it.cause + ) + ) + } + + 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() + ) + } + } + } +} 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 new file mode 100644 index 0000000..471dc9c --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt @@ -0,0 +1,217 @@ +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.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.RenderTemplateRequest +import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase +import com.aamdigital.aambackendservice.export.core.TemplateExport +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 + +data class RenderRequestResponseDto( + val success: Boolean, + val data: RenderRequestResponseDataDto, +) + +data class RenderRequestErrorResponseDto( + val success: Boolean, + val error: String, +) + +data class RenderRequestResponseDataDto( + val renderId: String, +) + +/** + * Default implementation of the [RenderTemplateUseCase] interface. + * + * This use case is responsible for creating a template rendering request to a specified template endpoint + * and fetch the rendered file afterward. + * + * The file metadata is forwarded to the client. + * + * @property webClient The WebClient 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 objectMapper: ObjectMapper, + private val templateStorage: TemplateStorage, +) : RenderTemplateUseCase { + + private val logger = LoggerFactory.getLogger(javaClass) + + private data class FileResponse( + val file: DataBuffer, + val headers: HttpHeaders, + ) + + override fun apply( + request: RenderTemplateRequest + ): Mono> { + return try { + return fetchTemplateRequest(request.templateRef) + .flatMap { template: TemplateExport -> + val fileName = template.targetFileName + + (request.bodyData as ObjectNode).put( + "reportName", fileName + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + ) + + 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) + } + } + + override fun handleError( + it: Throwable + ): Mono> { + val errorCode: RenderTemplateErrorCode = runCatching { + RenderTemplateErrorCode.valueOf((it as AamException).code) + }.getOrDefault(RenderTemplateErrorCode.INTERNAL_SERVER_ERROR) + + logger.error("[${errorCode}] ${it.localizedMessage}", it.cause) + + return Mono.just( + UseCaseOutcome.Failure( + errorMessage = it.localizedMessage, + errorCode = errorCode, + cause = it.cause + ) + ) + } + + 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() + ) + ) + } + .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() + ) + } + } + + 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() + + val forwardHeaders = HttpHeaders() + forwardHeaders.contentType = responseHeaders.contentType + + if (!responseHeaders["Content-Disposition"].isNullOrEmpty()) { + forwardHeaders["Content-Disposition"] = responseHeaders["Content-Disposition"] + } + + FileResponse( + file = dataBuffer, + headers = forwardHeaders + ) + } + } + .onErrorMap { + ExternalSystemException( + cause = it, + message = it.localizedMessage, + code = FETCH_RENDER_ID_REQUEST_FAILED_ERROR.toString() + ) + } + } + + private fun parseRenderRequestResponse(raw: String): String { + try { + val renderApiClientResponse = objectMapper.readValue(raw, RenderRequestResponseDto::class.java) + return renderApiClientResponse.data.renderId + } catch (ex: Exception) { + val renderApiClientResponse = try { + val response = objectMapper.readValue(raw, RenderRequestErrorResponseDto::class.java) + response.error + } catch (ex: Exception) { + ex.localizedMessage + } + + throw ExternalSystemException( + cause = ex, + message = renderApiClientResponse, + code = RenderTemplateErrorCode.PARSE_RESPONSE_ERROR.toString() + ) + } + } +} 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 new file mode 100644 index 0000000..6445e59 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/http/AamReadTimeoutHandler.kt @@ -0,0 +1,28 @@ +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/rest/AamErrorAttributes.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt index 6eae691..adaefcc 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 @@ -5,6 +5,7 @@ import com.aamdigital.aambackendservice.error.ExternalSystemException import com.aamdigital.aambackendservice.error.ForbiddenAccessException import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.error.InvalidArgumentException +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 @@ -69,6 +70,7 @@ class AamErrorAttributes : DefaultErrorAttributes() { private fun getStatus(error: AamException) = when (error) { is InternalServerException -> HttpStatus.INTERNAL_SERVER_ERROR.value() is ExternalSystemException -> HttpStatus.INTERNAL_SERVER_ERROR.value() + is NetworkException -> HttpStatus.INTERNAL_SERVER_ERROR.value() is InvalidArgumentException -> HttpStatus.BAD_REQUEST.value() is UnauthorizedAccessException -> HttpStatus.UNAUTHORIZED.value() is ForbiddenAccessException -> HttpStatus.FORBIDDEN.value() diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 55c61b6..4298aa6 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -62,10 +62,20 @@ server: logging: level: # org.springframework.amqp.rabbit: warn - # org.springframework.http.client: debug - # reactor.netty.http.client: debug + # org.springframework.web: debug + # org.springframework.http: debug + # reactor.netty: debug com.aamdigital.aambackendservice: trace +aam-render-api-client-configuration: + base-path: https://pdf.aam-digital.dev +# auth-config: +# client-id: +# client-secret: +# token-endpoint: +# grant-type: +# scope: + couch-db-client-configuration: base-path: http://localhost:5984 basic-auth-username: admin 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 new file mode 100644 index 0000000..a6329ed --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/common/WebClientTestBase.kt @@ -0,0 +1,62 @@ +package com.aamdigital.aambackendservice.common + +import com.aamdigital.aambackendservice.auth.core.AuthConfig +import com.aamdigital.aambackendservice.auth.core.AuthProvider +import com.aamdigital.aambackendservice.auth.core.TokenResponse +import com.aamdigital.aambackendservice.export.di.AamRenderApiClientConfiguration +import com.aamdigital.aambackendservice.export.di.AamRenderApiConfiguration +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +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 + +open class WebClientTestBase { + companion object { + const val WEBSERVER_PORT = 6000 + val objectMapper = jacksonObjectMapper() + lateinit var webClient: WebClient + lateinit var mockWebServer: MockWebServer + + @JvmStatic + @BeforeAll + fun init() { + val config = AamRenderApiConfiguration() + webClient = config.aamRenderApiClient( + authProvider = object : AuthProvider { + override fun fetchToken(authClientConfig: AuthConfig): Mono = Mono.empty() + }, + configuration = AamRenderApiClientConfiguration( + basePath = "http://localhost:$WEBSERVER_PORT", + responseTimeoutInSeconds = 10 + ) + ) + } + } + + @BeforeEach + fun beforeEach() { + startWebserver() + setUp() + } + + @AfterEach + fun afterEach() { + stopWebserver() + cleanUp() + } + + open fun setUp() {} + open fun cleanUp() {} + + private fun startWebserver() { + mockWebServer = MockWebServer() + mockWebServer.start(WEBSERVER_PORT) + } + + private fun stopWebserver() { + mockWebServer.shutdown() + } +} diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt index 915abd2..5d25f0d 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/container/TestContainers.kt @@ -23,6 +23,7 @@ object TestContainers { CONTAINER_KEYCLOAK.start() CONTAINER_COUCHDB.start() CONTAINER_SQS.start() + CONTAINER_PDF.start() registry.add( "spring.security.oauth2.resourceserver.jwt.issuer-uri" ) { @@ -38,6 +39,17 @@ object TestContainers { ) { "http://localhost:${CONTAINER_SQS.getMappedPort(4984)}" } + registry.add( + "aam-render-api-client-configuration.base-path", + ) { + "http://localhost:${CONTAINER_PDF.getMappedPort(4000)}" + } + registry.add( + "aam-render-api-client-configuration.auth-config.token-endpoint", + ) { + "http://localhost:${CONTAINER_KEYCLOAK.getMappedPort(8080)}" + + "/realms/dummy-realm/protocol/openid-connect/token" + } } @Container @@ -94,4 +106,17 @@ object TestContainers { ) .withExposedPorts(4984) + @Container + @JvmStatic + val CONTAINER_PDF: GenericContainer<*> = + GenericContainer( + DockerImageName + .parse("carbone/carbone-ee") + .withTag("latest") + ) + .withImagePullPolicy(PullPolicy.alwaysPull()) + .withNetwork(network) + .withNetworkAliases("pdf") + .withExposedPorts(4000) + } 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 new file mode 100644 index 0000000..56ec479 --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/domain/BasicDomainUseCaseTest.kt @@ -0,0 +1,42 @@ +package com.aamdigital.aambackendservice.domain + +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 + +@ExtendWith(MockitoExtension::class) +class BasicDomainUseCaseTest { + + enum class BasicUseCaseErrorCode : UseCaseErrorCode { + UNKNOWN + } + + 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) + ) + } + } + + private val useCase = BasicTestUseCase() + + @Test + fun `should catch exception in UseCaseOutcome when call execute()`() { + val request: UseCaseRequest = object : UseCaseRequest {} + + StepVerifier.create( + useCase.execute(request) + ).assertNext { + Assertions.assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) + } + } +} 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 a238d86..9c29cea 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 @@ -8,6 +8,7 @@ import io.cucumber.spring.CucumberContextConfiguration import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Assert +import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpMethod import java.io.File @@ -78,6 +79,12 @@ class CucumberIntegrationTest : SpringIntegrationTest() { exchange(endpoint, HttpMethod.POST, File("src/test/resources/database/documents/$body.json").readText()) } + @When("the client calls POST {} with file {}") + @Throws(Throwable::class) + fun `the client issues POST endpoint with file`(endpoint: String, file: String) { + exchangeMultipart(endpoint, ClassPathResource("files/$file")) + } + @Then("the client receives a json array") @Throws(Throwable::class) fun `the client receives list of values`() { @@ -96,6 +103,13 @@ class CucumberIntegrationTest : SpringIntegrationTest() { Assert.assertEquals(statusCode, latestResponseStatus?.value()) } + @Then("the client receives value {} for header {}") + @Throws(Throwable::class) + fun `the client receives value for header `(value: String, property: String) { + Assert.assertEquals(true, parseHeader(property).isNotEmpty()) + Assert.assertEquals(value, parseHeader(property).first()) + } + @Then("the client receives value {} for property {}") @Throws(Throwable::class) fun `the client receives value for property`(value: String, property: String) { diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/SpringIntegrationTest.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/SpringIntegrationTest.kt index eb3a4f0..a4b4050 100644 --- a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/SpringIntegrationTest.kt +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/e2e/SpringIntegrationTest.kt @@ -12,15 +12,19 @@ import com.fasterxml.jackson.module.kotlin.readValue import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.testcontainers.context.ImportTestcontainers import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.core.io.Resource import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.HttpStatusCode +import org.springframework.http.MediaType +import org.springframework.http.client.MultipartBodyBuilder import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestTemplate + @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT ) @@ -54,6 +58,7 @@ abstract class SpringIntegrationTest { ) var latestResponseBody: String? = null + var latestResponseHeaders: HttpHeaders? = null var latestResponseStatus: HttpStatusCode? = null var authToken: String? = null @@ -64,6 +69,8 @@ abstract class SpringIntegrationTest { headers.set(HttpHeaders.AUTHORIZATION, "Bearer $authToken") } + headers.contentType = MediaType.APPLICATION_JSON + val requestEntity = HttpEntity(body, headers) try { @@ -75,10 +82,46 @@ abstract class SpringIntegrationTest { ).let { latestResponseStatus = it.statusCode latestResponseBody = it.body + latestResponseHeaders = it.headers + } + } catch (ex: HttpClientErrorException) { + latestResponseStatus = ex.statusCode + latestResponseBody = ex.responseBodyAsString + latestResponseHeaders = ex.responseHeaders + } + } + + fun exchangeMultipart(url: String, file: Resource) { + val headers = HttpHeaders() + + if (authToken != null) { + headers.set(HttpHeaders.AUTHORIZATION, "Bearer $authToken") + } + + headers.contentType = MediaType.MULTIPART_FORM_DATA + headers.accept = listOf(MediaType.APPLICATION_JSON) + + val builder = MultipartBodyBuilder() + + builder + .part("template", file) + + val requestEntity = HttpEntity(builder.build(), headers) + + try { + restTemplate.postForEntity( + url, + requestEntity, + String::class.java, + ).let { + latestResponseStatus = it.statusCode + latestResponseBody = it.body + latestResponseHeaders = it.headers } } catch (ex: HttpClientErrorException) { latestResponseStatus = ex.statusCode latestResponseBody = ex.responseBodyAsString + latestResponseHeaders = ex.responseHeaders } } @@ -88,6 +131,10 @@ abstract class SpringIntegrationTest { } } + fun parseHeader(name: String): List { + return latestResponseHeaders?.getOrElse(name) { emptyList() } ?: emptyList() + } + fun parseBodyToArrayNode(): ArrayNode? { return latestResponseBody?.let { objectMapper.readValue(it) 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 new file mode 100644 index 0000000..2e89cff --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCaseTest.kt @@ -0,0 +1,119 @@ +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.CreateTemplateRequest +import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase +import okhttp3.mockwebserver.MockResponse +import org.assertj.core.api.Assertions.assertThat +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() +} + +@ExtendWith(MockitoExtension::class) +class DefaultCreateTemplateUseCaseTest : WebClientTestBase() { + + private lateinit var service: CreateTemplateUseCase + + override fun setUp() { + super.setUp() + service = DefaultCreateTemplateUseCase( + webClient = webClient, + objectMapper = objectMapper, + ) + } + + @Test + fun `should return Failure when json response could not be parsed`() { + // given + mockWebServer.enqueue(MockResponse().setBody("invalid json")) + + // when + StepVerifier.create( + service.execute( + CreateTemplateRequest( + file = FilePartTestImpl("test"), + ) + ) + ).assertNext { + // then + assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + CreateTemplateErrorCode.PARSE_RESPONSE_ERROR, + (it as UseCaseOutcome.Failure).errorCode + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when request returns Mono error`() { + // 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 + ) + }.verifyComplete() + } + + @Test + fun `should return Success when request returns valid response`() { + // given + mockWebServer.enqueue( + MockResponse().setBody( + """ + { + "success": "true", + "data": { + "templateId": "template-id" + } + } + """.trimIndent() + ) + ) + + // 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 + ) + }.verifyComplete() + } +} 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 new file mode 100644 index 0000000..be94e4d --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultFetchTemplateUseCaseTest.kt @@ -0,0 +1,198 @@ +package com.aamdigital.aambackendservice.export.usecase + +import com.aamdigital.aambackendservice.common.WebClientTestBase +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +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.FetchTemplateRequest +import com.aamdigital.aambackendservice.export.core.FetchTemplateUseCase +import com.aamdigital.aambackendservice.export.core.TemplateExport +import com.aamdigital.aambackendservice.export.core.TemplateStorage +import okhttp3.Headers.Companion.toHeaders +import okhttp3.mockwebserver.MockResponse +import okio.Buffer +import okio.source +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +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 + + +@ExtendWith(MockitoExtension::class) +class DefaultFetchTemplateUseCaseTest : WebClientTestBase() { + + @Mock + lateinit var templateStorage: TemplateStorage + + private lateinit var service: FetchTemplateUseCase + + override fun setUp() { + super.setUp() + reset(templateStorage) + + service = DefaultFetchTemplateUseCase( + webClient = webClient, + templateStorage = templateStorage + ) + } + + @Test + fun `should return Failure when fetchTemplate returns an error`() { + // Arrange + val templateRef = DomainReference("some-id") + val exception = InternalServerException(message = "fetchTemplate error", cause = null) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.error(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 + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when fetchTemplate returns empty response`() { + // Arrange + val templateRef = DomainReference("some-id") + val exception = InternalServerException(message = "fetchTemplate error", cause = null) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.empty()) + + // 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 + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when fetchTemplateRequest throws exception`() { + // given + val templateRef = DomainReference("some-id") + + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { + throw InvalidArgumentException() + } + + // when + StepVerifier.create( + service.execute( + FetchTemplateRequest( + templateRef + ) + ) + ).assertNext { + // then + assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + FetchTemplateErrorCode.INTERNAL_SERVER_ERROR, (it as UseCaseOutcome.Failure).errorCode + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when response could not be processed`() { + // Arrange + val templateRef = DomainReference("some-id") + + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(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 + ) + }.verifyComplete() + } + + @Test + fun `should return Success with DataBuffer`() { + // given + val templateRef = DomainReference("some-id") + + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + + val buffer = Buffer() + buffer.writeAll(File("src/test/resources/files/docx-test-file-1.docx").source()) + + mockWebServer.enqueue( + MockResponse() + .setHeaders( + mapOf( + "Content-Type" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Content-Length" to buffer.size.toString(), + "Cache-Control" to "no-cache, no-store, max-age=0, must-revalidate", + ).toHeaders() + ) + .setBody(buffer) + + ) + + StepVerifier.create( + service.execute( + FetchTemplateRequest( + templateRef + ) + ) + ) + .consumeNextWith { response -> + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Success::class.java) + + assertThat((response as UseCaseOutcome.Success).outcome.file).isInstanceOf(DataBuffer::class.java) + } + .verifyComplete() + } +} 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 new file mode 100644 index 0000000..ac7efce --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCaseTest.kt @@ -0,0 +1,415 @@ +package com.aamdigital.aambackendservice.export.usecase + +import com.aamdigital.aambackendservice.common.WebClientTestBase +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.NetworkException +import com.aamdigital.aambackendservice.export.core.RenderTemplateErrorCode +import com.aamdigital.aambackendservice.export.core.RenderTemplateRequest +import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase +import com.aamdigital.aambackendservice.export.core.TemplateExport +import com.aamdigital.aambackendservice.export.core.TemplateStorage +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.readValue +import okhttp3.Headers.Companion.toHeaders +import okhttp3.mockwebserver.MockResponse +import okio.Buffer +import okio.source +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +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 java.io.File + + +@ExtendWith(MockitoExtension::class) +class DefaultRenderTemplateUseCaseTest : WebClientTestBase() { + + @Mock + lateinit var templateStorage: TemplateStorage + + private lateinit var service: RenderTemplateUseCase + + override fun setUp() { + super.setUp() + reset(templateStorage) + + service = DefaultRenderTemplateUseCase( + webClient = webClient, + objectMapper = objectMapper, + templateStorage = templateStorage, + ) + } + + @Test + fun `should return Failure when fetchTemplate returns an error`() { + // Arrange + 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)) + + // 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 + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when json response could not be parsed`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + mockWebServer.enqueue(MockResponse().setBody("invalid json")) + + // when + StepVerifier.create( + service.execute( + RenderTemplateRequest( + templateRef, bodyData + ) + ) + ).assertNext { + // then + assertThat(it).isInstanceOf(UseCaseOutcome.Failure::class.java) + assertEquals( + RenderTemplateErrorCode.PARSE_RESPONSE_ERROR, (it as UseCaseOutcome.Failure).errorCode + ) + }.verifyComplete() + } + + @Test + fun `should return Failure when fetchTemplateRequest throws exception`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenAnswer { + throw InvalidArgumentException() + } + + // 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 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() + ) + + 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 + fun `should return Success with DataBuffer`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + + mockWebServer.enqueue( + MockResponse().setBody( + """ + { + "success": true, + "data": { + "renderId": "some-render-id" + } + } + """.trimIndent() + ) + ) + + val buffer = Buffer() + buffer.writeAll(File("src/test/resources/files/pdf-test-file-1.pdf").source()) + + mockWebServer.enqueue( + MockResponse() + .setHeaders( + mapOf( + "Content-Type" to "application/pdf", + "Content-Length" to buffer.size.toString(), + "Cache-Control" to "no-cache, no-store, max-age=0, must-revalidate", + ).toHeaders() + ) + .setBody(buffer) + + ) + + StepVerifier.create( + service.execute( + 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() + } + + @Test + fun `should return Failure with parsed error message`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + + mockWebServer.enqueue( + MockResponse().setBody( + """ + { + "success": false, + "error": "this is an error message" + } + """.trimIndent() + ) + ) + + StepVerifier.create( + service.execute( + 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() + } + + @Test + fun `should return Failure when createRenderRequest returns no response`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + + StepVerifier.create( + service.execute( + RenderTemplateRequest( + templateRef, bodyData + ) + ) + ) + .consumeNextWith { response -> + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + + assertThat((response as UseCaseOutcome.Failure).cause) + .isInstanceOf(WebClientRequestException::class.java) + + assertThat(response.cause?.cause) + .isInstanceOf(NetworkException::class.java) + + assertThat(response.errorCode) + .isEqualTo(RenderTemplateErrorCode.CREATE_RENDER_REQUEST_FAILED_ERROR) + } + .verifyComplete() + } + + @Test + fun `should return Failure when fetchRenderIdRequest fails`() { + // given + val templateRef = DomainReference("some-id") + val bodyData: JsonNode = objectMapper.readValue( + """ + {"foo":"bar"} + """.trimIndent() + ) + val templateExport = TemplateExport( + id = "export-id", + templateId = "export-template-id", + targetFileName = "target_file_name.file", + title = "export-title", + description = "export-description", + applicableForEntityTypes = emptyList() + ) + + whenever(templateStorage.fetchTemplate(templateRef)).thenReturn(Mono.just(templateExport)) + + mockWebServer.enqueue( + MockResponse().setBody( + """ + { + "success": true, + "data": { + "renderId": "some-render-id" + } + } + """.trimIndent() + ) + ) + + StepVerifier.create( + service.execute( + RenderTemplateRequest( + templateRef, bodyData + ) + ) + ) + .consumeNextWith { response -> + // then + assertThat(response).isInstanceOf(UseCaseOutcome.Failure::class.java) + + assertThat((response as UseCaseOutcome.Failure).cause) + .isInstanceOf(WebClientRequestException::class.java) + + assertThat(response.cause?.cause) + .isInstanceOf(NetworkException::class.java) + + assertThat(response.errorCode) + .isEqualTo(RenderTemplateErrorCode.FETCH_RENDER_ID_REQUEST_FAILED_ERROR) + } + .verifyComplete() + } +} 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 7dc2826..e5673d3 100644 --- a/application/aam-backend-service/src/test/resources/application-e2e.yaml +++ b/application/aam-backend-service/src/test/resources/application-e2e.yaml @@ -33,6 +33,14 @@ management: - info - health +aam-render-api-client-configuration: + auth-config: + client-id: dummy-client + client-secret: client-secret + token-endpoint: /realms/dummy-realm/protocol/openid-connect/token + grant-type: client_credentials + scope: + couch-db-client-configuration: max-in-memory-size-in-mega-bytes: 16 base-path: http://localhost:5984 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 new file mode 100644 index 0000000..e25c12c --- /dev/null +++ b/application/aam-backend-service/src/test/resources/cucumber/features/export/export.feature @@ -0,0 +1,67 @@ +Feature: the export endpoint handles template creation + + Scenario: client makes call to POST /export/template with pdf-test-file and receives an template id + Given database app is created + Given signed in as client dummy-client with secret client-secret in realm dummy-realm + When the client calls POST /v1/export/template with file pdf-test-file-1.pdf + Then the client receives an json object + Then the client receives status code of 200 + Then the client receives value 042ca26f8ca2eb3df4a6ee4ad0dc1f509928f7e83af24c01fd44362a2cc5921f for property templateId + + Scenario: client makes call to POST /export/template with docx-test-file and receives an template id + Given database app is created + Given signed in as client dummy-client with secret client-secret in realm dummy-realm + When the client calls POST /v1/export/template with file docx-test-file-1.docx + Then the client receives an json object + Then the client receives status code of 200 + Then the client receives value 9545b4b168c892e32367e499fa913ce85c738562b0fd3bfc3a4023204adcebf0 for property templateId + + Scenario: client makes call to GET /export/template/TemplateExport:2 and receives an docx + Given database app is created + 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 + Then the client receives status code of 200 + Then the client receives value application/vnd.openxmlformats-officedocument.wordprocessingml.document for header Content-Type + + Scenario: client makes call to GET /export/template/TemplateExport:not-existing and receives an 404 response + Given database app is created + Given signed in as client dummy-client with secret client-secret in realm dummy-realm + When the client calls GET /v1/export/template/TemplateExport:not-existing + Then the client receives status code of 404 + + Scenario: client makes call to POST /export/template without token and receives Unauthorized + Given database app is created + When the client calls POST /v1/export/template with file pdf-test-file-1.pdf + Then the client receives an json object + Then the client receives status code of 401 + + Scenario: client makes call to POST /export/render/TemplateExport:1 and receives an template id + Given database app is created + Given document TemplateExport:1 is stored in database app + Given signed in as client dummy-client with secret client-secret in realm dummy-realm + When the client calls POST /v1/export/render/TemplateExport:1 with body RenderRequest:1 + Then the client receives status code of 200 + Then the client receives value application/pdf for header Content-Type + + Scenario: client makes call to POST /export/render/TemplateExport:2 and receives an template id + Given database app is created + 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 POST /v1/export/render/TemplateExport:2 with body RenderRequest:1 + Then the client receives status code of 200 + Then the client receives value application/pdf for header Content-Type + + Scenario: client makes call to POST /export/render/TemplateExport:2 and receives an docx + Given database app is created + 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 POST /v1/export/render/TemplateExport:2 with body RenderRequest:2 + Then the client receives status code of 200 + Then the client receives value application/vnd.openxmlformats-officedocument.wordprocessingml.document for header Content-Type + + Scenario: client makes call to POST /export/render/TemplateExport:not-existing and receives an 404 + Given database app is created + Given signed in as client dummy-client with secret client-secret in realm dummy-realm + When the client calls POST /v1/export/render/TemplateExport:not-existing with body RenderRequest:2 + Then the client receives status code of 404 diff --git a/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:1.json b/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:1.json new file mode 100644 index 0000000..f81fd64 --- /dev/null +++ b/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:1.json @@ -0,0 +1,10 @@ +{ + "convertTo": "pdf", + "data": { + "participant": "Mr. Robot", + "sign": "An Aam Digital TEST", + "issuer": "Aam Digital GmbH", + "date": "11.09.2024" + }, + "reportName": "test_certificate_{d.issuer}_{d.participant}" +} diff --git a/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:2.json b/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:2.json new file mode 100644 index 0000000..b6c0818 --- /dev/null +++ b/application/aam-backend-service/src/test/resources/database/documents/RenderRequest:2.json @@ -0,0 +1,10 @@ +{ + "convertTo": "docx", + "data": { + "participant": "Mr. Robot", + "sign": "An Aam Digital TEST", + "issuer": "Aam Digital GmbH", + "date": "11.09.2024" + }, + "reportName": "test_certificate_{d.issuer}_{d.participant}" +} diff --git a/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:1.json b/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:1.json new file mode 100644 index 0000000..89cc5e7 --- /dev/null +++ b/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:1.json @@ -0,0 +1,11 @@ +{ + "_id": "TemplateExport:1", + "templateId": "042ca26f8ca2eb3df4a6ee4ad0dc1f509928f7e83af24c01fd44362a2cc5921f", + "targetFileName": "test.file", + "title": "TemplateExport 1", + "description": "This is a test TemplateExport", + "applicableForEntityTypes": [ + "School", + "Child" + ] +} diff --git a/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:2.json b/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:2.json new file mode 100644 index 0000000..4f89263 --- /dev/null +++ b/application/aam-backend-service/src/test/resources/database/documents/TemplateExport:2.json @@ -0,0 +1,11 @@ +{ + "_id": "TemplateExport:2", + "templateId": "9545b4b168c892e32367e499fa913ce85c738562b0fd3bfc3a4023204adcebf0", + "targetFileName": "test.file", + "title": "TemplateExport 2", + "description": "This is a test TemplateExport", + "applicableForEntityTypes": [ + "School", + "Child" + ] +} diff --git a/application/aam-backend-service/src/test/resources/files/docx-test-file-1.docx b/application/aam-backend-service/src/test/resources/files/docx-test-file-1.docx new file mode 100644 index 0000000..d7ffbb2 Binary files /dev/null and b/application/aam-backend-service/src/test/resources/files/docx-test-file-1.docx differ diff --git a/application/aam-backend-service/src/test/resources/files/pdf-test-file-1.pdf b/application/aam-backend-service/src/test/resources/files/pdf-test-file-1.pdf new file mode 100644 index 0000000..eae643a Binary files /dev/null and b/application/aam-backend-service/src/test/resources/files/pdf-test-file-1.pdf differ diff --git a/docs/api-specs/carboneio-api-spec.yaml b/docs/api-specs/carboneio-api-spec.yaml new file mode 100644 index 0000000..9f16e28 --- /dev/null +++ b/docs/api-specs/carboneio-api-spec.yaml @@ -0,0 +1,426 @@ +openapi: 3.0.3 + +info: + description: |- + Carbone Cloud/On-premise Open API reference. + + For requesting: + - Carbone Cloud API: find your API key on your [Carbone account](https://account.carbone.io). Home page > Copy the `production` or `testing` API key. + - Carbone On-premise: Update the `Server URL` on the Open API specification. + + Useful links: + - [API Flow](https://carbone.io/api-reference.html#quickstart-api-flow) + - [Integration / SDKs](https://carbone.io/api-reference.html#api-integration) + - [Generated document storage](https://carbone.io/api-reference.html#report-storage) + - [Request timeout](https://carbone.io/api-reference.html#api-timeout) + version: "1.2.0" + title: "Carbone API" + contact: + name: "Carbone Support" + email: "support@carbone.io" + url: "https://help.carbone.io" + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: https://api.carbone.io + description: Production server + +tags: + - name: "template" + description: "Manage templates" + - name: "render" + description: "Manage renders" + - name: "status" + description: "API Status" + +paths: + /template: + post: + tags: + - "template" + summary: "Upload a template." + description: "Documentation: https://carbone.io/api-reference.html#add-templates" + security: + - bearerAuth: [ ] + parameters: + - $ref: '#/components/parameters/carboneVersion' + requestBody: + required: true + description: "Template File to upload, supported formats: DOCX, XLSX, PPTX, ODT, ODS, ODP, ODG, XHTML, IDML, HTML or an XML file" + content: + multipart/form-data: + schema: + type: object + required: + - "template" + properties: + template: + type: string + format: binary + application/json: + schema: + type: object + required: + - "template" + properties: + template: + type: string + example: "base64-encoded file contents" + responses: + '200': # status code + description: On success, the `template ID` is returned. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + templateId: + type: string + '400': + $ref: '#/components/responses/NotFileError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '415': + $ref: '#/components/responses/TemplateFormatError' + '422': + $ref: '#/components/responses/MissingTemplateFieldError' + /template/{templateId}: + get: + description: "Documentation: https://carbone.io/api-reference.html#get-templates" + tags: + - "template" + summary: "Download a template from a template ID" + security: + - bearerAuth: [ ] + parameters: + - $ref: '#/components/parameters/templateId' + - $ref: '#/components/parameters/carboneVersion' + responses: + '200': + description: "stream of the file content" + headers: + content-disposition: + schema: + type: string + description: "Template name, for instance: 'filename=\"{templateid}.docx\"'." + content-type: + schema: + type: string + description: "File type" + '400': + $ref: '#/components/responses/templateIdNotValidError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/TemplateNotFoundError' + delete: + description: "Documentation: https://carbone.io/api-reference.html#delete-templates" + tags: + - "template" + summary: "Delete a template from a template ID" + security: + - bearerAuth: [ ] + parameters: + - $ref: '#/components/parameters/templateId' + - $ref: '#/components/parameters/carboneVersion' + responses: + '200': + description: "The template is deleted" + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: true + '400': + $ref: '#/components/responses/templateIdNotValidError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/TemplateNotFoundError' + /render/{templateId}: + post: + summary: "Generate a document from a template ID, and a JSON data-set" + description: "Documentation: https://carbone.io/api-reference.html#render-reports" + security: + - bearerAuth: [ ] + tags: + - "render" + parameters: + - $ref: '#/components/parameters/templateId' + - $ref: '#/components/parameters/carboneVersion' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + example: { "id": "42", "name": "John", "type": "invoice" } + description: "Required - JSON data-set merged into the template to generate a document" + convertTo: + type: string + example: "pdf" + description: "Optional - Convert the document into another format. Accepted values: ods xlsx xls csv pdf txt odp ppt pptx jpg png odt doc docx txt jpg png epub html xml idml. List of supported formats: https://carbone.io/documentation.html#supported-files-and-features-list" + timezone: + type: string + example: "Europe/Paris" + description: "Optional - Convert document dates to a timezone. The default timezone is `Europe/Paris`. The date must be chained with the `:formatD` formatter, for instance `{d.date:formatD(YYYY-MM-DD HH:MM)}`. List of accepted timezones (Column TZ identifier): https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + lang: + type: string + example: "fr-fr" + description: "Optional - Locale of the generated doocument, it will used for translation `{t()}`, formatting numbers with `:formatN`, and currencies `:formatC`. List of supported locales: https://github.com/carboneio/carbone/blob/master/formatters/_locale.js" + complement: + type: object + example: { } + description: "Optional - Object|Array, extra data accessible in the template with {c.} instead of {d.}" + variableStr: + type: string + example: "{#def = d.id}" + description: "Optional - Predefine alias, related documenation: https://carbone.io/documentation.html#alias" + reportName: + type: string + example: "{d.date}.odt" + description: "Optional - Static or dynamic file name returned on the `content-disposition` header when the generated report is fetched with `GET /report/:renderI`. Multiple Carbone tags are accepted, such as `{d.type}-{d.date}.pdf`" + enum: + type: object + example: { } + description: "Optional - List of enumerations, use it in reports with `convEnum` formatters, documentation: https://carbone.io/documentation.html#convenum-type-" + translations: + type: object + example: { "fr": { "one": "un" }, "es": { "one": "uno" } } + description: "Optional - When the report is generated, all text between `{t( )}` is replaced with the corresponding translation. The `lang` option is required to select the correct translation. Learn more: https://carbone.io/documentation.html#translations" + currencySource: + type: string + example: "EUR" + description: "Optional - Currency source coming from your JSON data. The option is used by `formatC` to convert the currency based on the `currencyTarget` and `currencyRates`. Learn more: https://carbone.io/documentation.html#formatc-precisionorformat-" + currencyTarget: + type: string + example: "USD" + description: "Optional - Target currency when the document is generated. The option is used by `formatC` to convert the currency based on the `currencySource` and `currencyRates`. Learn more: https://carbone.io/documentation.html#formatc-precisionorformat-" + currencyRates: + type: object + example: { "EUR": 1, "USD": 1.2 } + description: "Optional - Currency exchange rates for conversions from `currencySource` to `currencyTarget`. Learn more: https://carbone.io/documentation.html#formatc-precisionorformat-" + hardRefresh: + type: boolean + example: false + description: "Optional - If true, the report content is refreshed at the end of the rendering process. To use this option, `convertTo` has to be defined. It is mostly used to refresh a table of content." + + responses: + "200": + description: "On success, a `render ID` is returned, a unique identifier for the generated document." + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: true + data: + type: object + properties: + renderId: + type: string + '400': + $ref: '#/components/responses/NotJsonError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/TemplateNotFoundError' + '422': + $ref: '#/components/responses/MissingDataFieldError' + '500': + $ref: '#/components/responses/GenerateReportError' + /render/{renderId}: + get: + summary: "Retreive a generated document from a render ID." + description: "Documentation: https://carbone.io/api-reference.html#download-rendered-reports" + tags: + - "render" + parameters: + - $ref: '#/components/parameters/renderId' + - $ref: '#/components/parameters/carboneVersion' + responses: + '200': + description: "Stream of the generated document" + headers: + content-disposition: + schema: + type: string + description: "File name, for instance: 'filename=\"report.pdf\"'. The default value is 'report'. The file name can be changed dynamically thanks to the `reportName` option when generating a document with `POST /render/:templateId`." + content-type: + schema: + type: string + description: "File type" + + '400': + $ref: '#/components/responses/RenderIdNotValidError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/FileDoesNotExistError' + /status: + get: + tags: + - "status" + summary: "Fetch the API status and version" + responses: + '200': + description: "Check the API status and version" + content: + application/json: + schema: + type: object + example: { "success": true, "code": 200, "message": "OK", "version": "4.13.0" } + properties: + success: + type: boolean + code: + type: number + message: + type: string + version: + type: string + '500': + description: "Something went wrong, contact support on the chat" + + + +components: + securitySchemes: + bearerAuth: # arbitrary name for the security scheme + type: http + description: "Get you test or production API Key on https://account.carbone.io" + scheme: bearer + bearerFormat: "eyJhbGci..." + + parameters: + carboneVersion: + name: "carbone-version" + description: "Carbone version" + in: "header" + required: true + schema: + type: integer + format: int32 + default: 4 + templateId: + in: path + name: "templateId" + description: "Unique identifier of the template" + schema: + type: string + required: true + renderId: + in: path + name: "renderId" + description: "Unique identifier of the report" + schema: + type: string + required: true + + responses: + CResponseError: + description: Error response when the request is invalid. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: false + data: + type: object + UnauthorizedError: + description: "Unauthorized, please provide a correct API key on the `Authorization ` header. The API key is available on your Carbone account: https://account.carbone.io" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Unauthorized, please provide a valid API key on the 'Authorization' header" } + NotJsonError: + description: "The body request type is not correct, it must be a JSON type and the `Content-type` header must be `application/json`" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "'Content-Type' header is not 'application/json'" } + TemplateNotFoundError: + description: "The template is not found" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Template not found" } + MissingDataFieldError: + description: "The 'data' property is missing on the body request." + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Missing 'data' property in body" } + GenerateReportError: + description: "Something went wrong when merging the JSON data-set into the template. The design of the template has an issue." + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Error while rendering template" } + NotFileError: + description: "The body request type is not correct, it must be a FormData or a JSON. The `Content-type` header must be either `application/json` or `multipart/form-data`" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Content-Type header should be multipart/form-data or application/json" } + TemplateFormatError: + description: "Template format not supported, it must be an XML-based document: DOCX, XLSX, PPTX, ODT, ODS, ODP, ODG, XHTML, IDML, HTML or an XML file" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Template format not supported" } + MissingTemplateFieldError: + description: "The `template` field is empty on the body request" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "'template' field is empty" } + templateIdNotValidError: + description: "The `template ID` is not valid" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Invalid templateId" } + FileDoesNotExistError: + description: "The file does not exist." + content: + application/json: + schema: + type: object + example: { "success": false, "error": "File not found" } + RenderIdNotValidError: + description: "The `render ID` is not valid" + content: + application/json: + schema: + type: object + example: { "success": false, "error": "Invalid render ID" } diff --git a/docs/api-specs/export-api-v1.yaml b/docs/api-specs/export-api-v1.yaml new file mode 100644 index 0000000..285af0a --- /dev/null +++ b/docs/api-specs/export-api-v1.yaml @@ -0,0 +1,128 @@ +openapi: 3.0.3 +info: + title: Template Export API + description: REST API for handling export operations related to templates in Aam. + version: 1.0.0 +servers: + - url: /v1/export + +paths: + /template: + post: + summary: Post a new template + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + template: + type: string + format: binary + description: The template file to upload + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + templateId: + type: string + description: The external identifier of the implementing TemplateEngine + + /render/{templateId}: + post: + summary: Render an existing template + parameters: + - name: templateId + in: path + required: true + schema: + type: string + description: The ID of the template to be rendered + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + convertTo: + type: string + description: The format to convert the template to + data: + type: object + additionalProperties: true + description: Additional data for rendering the template + responses: + '200': + description: Rendered template + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + errorCode: + type: string + errorMessage: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + errorCode: + type: string + errorMessage: + type: string + get: + summary: Fetch an existing template + parameters: + - name: templateId + in: path + required: true + schema: + type: string + description: The ID of the template to be rendered + responses: + '200': + description: Rendered template + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + errorCode: + type: string + errorMessage: + type: string + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + errorCode: + type: string + errorMessage: + type: string diff --git a/docs/modules/export.md b/docs/modules/export.md new file mode 100644 index 0000000..61bae4f --- /dev/null +++ b/docs/modules/export.md @@ -0,0 +1,37 @@ +# Aam Digital - Export API + +## Overview + +The export module is responsible for handling various template-based export operations. This module primarily focuses on generating PDFs for entities within the AAM system. + +### Dependencies + +This service is using an external template engine for handle placeholder replacement in files and render PDF files. +See https://carbone.io for more information and the specification [carboneio-api-spec.yaml](../api-specs/carboneio-api-spec.yaml) + +## Controllers + +### TemplateExportController + +REST controller responsible for handling export operations related to templates. It provides endpoints for creating new templates, fetching existing templates, and rendering templates. + +#### Specification + +[export-api-v1.yaml](../api-specs/export-api-v1.yaml) + +## Setup + +Configure a compatible render api in the environment. You can use the default, aam-internal implementation, +but make sure, that the authentication is configured: + +``` +aam-render-api-client-configuration: + base-path: https://pdf.aam-digital.dev + auth-config: + client-id: + client-secret: + token-endpoint: + grant-type: + scope: +``` +