Skip to content

Commit

Permalink
feat: add export template api (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomwwinter authored Oct 7, 2024
1 parent 5bcb2ac commit b26c060
Show file tree
Hide file tree
Showing 43 changed files with 2,829 additions and 26 deletions.
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(
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

0 comments on commit b26c060

Please sign in to comment.