Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add export template api #35

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
# 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)

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.
7 changes: 5 additions & 2 deletions application/aam-backend-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TokenResponse>
}
Original file line number Diff line number Diff line change
@@ -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(
tomwwinter marked this conversation as resolved.
Show resolved Hide resolved
val webClient: WebClient,
val objectMapper: ObjectMapper,
) : AuthProvider {

private val logger = LoggerFactory.getLogger(javaClass)

override fun fetchToken(authClientConfig: AuthConfig): Mono<TokenResponse> {
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,13 +104,21 @@ class DefaultCouchDbClient(
queryParams: MultiValueMap<String, String>,
kClass: KClass<T>,
): Mono<T> {
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(
Expand Down Expand Up @@ -264,10 +274,20 @@ class DefaultCouchDbClient(
}

private fun <T : Any> handleResponse(
response: ClientResponse, typeReference: KClass<T>
response: ClientResponse,
typeReference: KClass<T>
): Mono<T> {
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,
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.aamdigital.aambackendservice.domain

import reactor.core.publisher.Mono


interface UseCaseRequest
interface UseCaseData
interface UseCaseErrorCode

sealed interface UseCaseOutcome<D : UseCaseData, E : UseCaseErrorCode> {
data class Success<D : UseCaseData, E : UseCaseErrorCode>(
val outcome: D
) : UseCaseOutcome<D, E>

data class Failure<D : UseCaseData, E : UseCaseErrorCode>(
val errorCode: E,
val errorMessage: String? = "An unspecific error occurred, while executing this use case",
val cause: Throwable? = null
) : UseCaseOutcome<D, E>
}

interface DomainUseCase<R : UseCaseRequest, D : UseCaseData, E : UseCaseErrorCode> {
fun apply(request: R): Mono<UseCaseOutcome<D, E>>
fun handleError(it: Throwable): Mono<UseCaseOutcome<D, E>>

fun execute(request: R): Mono<UseCaseOutcome<D, E>> {
return try {
apply(request)
.onErrorResume {
handleError(it)
}
} catch (ex: Exception) {
handleError(ex)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading