diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 0229a4d..f8032e6 100644 --- a/application/aam-backend-service/build.gradle.kts +++ b/application/aam-backend-service/build.gradle.kts @@ -66,7 +66,7 @@ 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") @@ -78,7 +78,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..d6bdca6 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/AuthProvider.kt @@ -0,0 +1,17 @@ +package com.aamdigital.aambackendservice.auth.core + +import reactor.core.publisher.Mono + +data class TokenResponse(val token: String) + +data class AuthConfig( + val clientId: String, + val clientSecret: String, + val tokenEndpoint: String, + val grantType: String, + val scope: String, +) + +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..fe73131 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/auth/core/KeycloakAuthProvider.kt @@ -0,0 +1,63 @@ +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, +) + +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/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..4e87391 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/controller/TemplateExportController.kt @@ -0,0 +1,73 @@ +package com.aamdigital.aambackendservice.export.controller + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.export.core.CreateTemplateRequest +import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase +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.MediaType +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 org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + + +/** + * @param templateId The external identifier of the implementing TemplateEngine + */ +data class CreateTemplateResponseDto( + val templateId: String, +) + +@RestController +@RequestMapping("/v1/export") +@Validated +class TemplateExportController( + @Qualifier("aam-render-api-client") val webClient: WebClient, + val createTemplateUseCase: CreateTemplateUseCase, + val renderTemplateUseCase: RenderTemplateUseCase, +) { + + @GetMapping("/status") + fun getStatus(): Mono { + return webClient.get().uri("/status").exchangeToMono { + it.bodyToMono() + } + } + + @PostMapping("/template") + fun postTemplate( + @RequestPart("template") file: FilePart + ): Mono { + return createTemplateUseCase.createTemplate( + CreateTemplateRequest( + file = file + ) + ).map { createTemplateResponse -> + CreateTemplateResponseDto( + templateId = createTemplateResponse.template.id + ) + } + } + + @PostMapping("/render/{templateId}", produces = [MediaType.APPLICATION_PDF_VALUE]) + fun getTemplate( + @PathVariable templateId: String, + @RequestBody templateData: JsonNode, + ): Mono { + return renderTemplateUseCase.renderTemplate( + templateRef = DomainReference(templateId), + bodyData = templateData + ) + } +} 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..f942ca9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/CreateTemplateUseCase.kt @@ -0,0 +1,17 @@ +package com.aamdigital.aambackendservice.export.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import org.springframework.http.codec.multipart.FilePart +import reactor.core.publisher.Mono + +data class CreateTemplateResponse( + val template: DomainReference, +) + +data class CreateTemplateRequest( + val file: FilePart, +) + +interface CreateTemplateUseCase { + fun createTemplate(createTemplateRequest: CreateTemplateRequest): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/ExportTemplate.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/ExportTemplate.kt new file mode 100644 index 0000000..79f64f1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/ExportTemplate.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.export.core + +data class ExportTemplate( + val id: String, + val templateId: String, + val title: String, + val description: String, + val applicableForEntityTypes: List, +) 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..9748ae5 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/core/RenderTemplateUseCase.kt @@ -0,0 +1,10 @@ +package com.aamdigital.aambackendservice.export.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.fasterxml.jackson.databind.JsonNode +import org.springframework.core.io.buffer.DataBuffer +import reactor.core.publisher.Mono + +interface RenderTemplateUseCase { + fun renderTemplate(templateRef: DomainReference, bodyData: JsonNode): Mono +} 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..13373e6 --- /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..6f15a71 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/AamRenderApiConfiguration.kt @@ -0,0 +1,51 @@ +package com.aamdigital.aambackendservice.export.di + +import com.aamdigital.aambackendservice.auth.core.AuthConfig +import com.aamdigital.aambackendservice.auth.core.AuthProvider +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, + 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 -> + 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())).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..7765e37 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/di/UseCaseConfiguration.kt @@ -0,0 +1,35 @@ +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.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.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-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..828ba75 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/storage/DefaultTemplateStorage.kt @@ -0,0 +1,45 @@ +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.ExportTemplate +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 ExportTemplateDto( + @JsonProperty("_id") + val id: String, + val templateId: String, + val title: String, + val description: String, + 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 = ExportTemplateDto::class + ).map { + toEntity(it) + } + } + + private fun toEntity(dto: ExportTemplateDto): ExportTemplate = ExportTemplate( + id = dto.id, + 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..ca993d2 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultCreateTemplateUseCase.kt @@ -0,0 +1,63 @@ +package com.aamdigital.aambackendservice.export.usecase + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.export.core.CreateTemplateRequest +import com.aamdigital.aambackendservice.export.core.CreateTemplateResponse +import com.aamdigital.aambackendservice.export.core.CreateTemplateUseCase +import com.fasterxml.jackson.databind.ObjectMapper +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, +) + +/** + * Will create a template in our aam-central template export engine (pdf service) and return the + * external Identifier. + * The ExportTemplate entity is then created in the frontend. + */ +class DefaultCreateTemplateUseCase( + private val webClient: WebClient, + private val objectMapper: ObjectMapper, +) : CreateTemplateUseCase { + override fun createTemplate(createTemplateRequest: CreateTemplateRequest): Mono { + val builder = MultipartBodyBuilder() + + builder + .part("template", createTemplateRequest.file) + .filename(createTemplateRequest.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) + } + .map { + parseResponse(it) + } + } + + private fun parseResponse(raw: String): CreateTemplateResponse { + try { + val renderApiClientResponse = objectMapper.readValue(raw, CreateTemplateResponseDto::class.java) + return CreateTemplateResponse( + template = DomainReference(renderApiClientResponse.data.templateId) + ) + } catch (e: Exception) { + throw ExternalSystemException("Could not parse templateId from aam-render-api-client", e) + } + } +} 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..6c8019a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/export/usecase/DefaultRenderTemplateUseCase.kt @@ -0,0 +1,65 @@ +package com.aamdigital.aambackendservice.export.usecase + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.export.core.RenderTemplateUseCase +import com.aamdigital.aambackendservice.export.core.TemplateStorage +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.core.io.buffer.DataBuffer +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 + +data class RenderRequestResponseDto( + val success: Boolean, + val data: RenderRequestResponseDataDto, +) + +data class RenderRequestResponseDataDto( + val renderId: String, +) + +class DefaultRenderTemplateUseCase( + private val webClient: WebClient, + private val objectMapper: ObjectMapper, + private val templateStorage: TemplateStorage, +) : RenderTemplateUseCase { + override fun renderTemplate(templateRef: DomainReference, bodyData: JsonNode): Mono { + return templateStorage.fetchTemplate(templateRef) + .map { template -> + template.templateId + } + .flatMap { templateId -> + webClient.post() + .uri("/render/$templateId") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(bodyData)) + .exchangeToMono { + it.bodyToMono(String::class.java) + } + .map { + parseRenderRequestResponse(it) + } + .flatMap { renderId -> + webClient.get() + .uri("/render/$renderId") + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono { + it.bodyToMono(DataBuffer::class.java) + } + } + } + } + + private fun parseRenderRequestResponse(raw: String): String { + try { + val renderApiClientResponse = objectMapper.readValue(raw, RenderRequestResponseDto::class.java) + return renderApiClientResponse.data.renderId + } catch (e: Exception) { + throw ExternalSystemException("Could not parse renderId from aam-render-api-client", e) + } + } +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 55c61b6..daa473f 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -62,10 +62,19 @@ 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 +# 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/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" }