feat: Support CRUD functionality (#7)

The station search implementation only works via DB and is very slow. For a simple query, the answer takes about 1s or longer. That is 100 times slower than it should be. For now, however, this solution is adequate to achieve a first prototype that includes the core functionality.

feat: Add station search
This commit is contained in:
2024-01-06 14:06:43 +01:00
committed by GitHub
parent b5681181fb
commit 7fbd270019
166 changed files with 2039 additions and 482 deletions
@@ -0,0 +1,15 @@
package de.twomartens.support.configuration
import de.twomartens.support.property.TimeProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class ClockConfiguration(private val properties: TimeProperties) {
@Bean
open fun clock(): Clock {
return Clock.system(properties.defaultTimeZone)
}
}
@@ -0,0 +1,13 @@
package de.twomartens.support.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.ForwardedHeaderFilter
@Configuration
open class FilterConfiguration {
@Bean
open fun forwardedFilter(): ForwardedHeaderFilter {
return ForwardedHeaderFilter()
}
}
@@ -0,0 +1,20 @@
package de.twomartens.support.configuration
import de.twomartens.support.interceptor.HeaderInterceptorRest
import de.twomartens.support.interceptor.LoggingInterceptorRest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class InterceptorConfiguration {
@Bean
open fun loggingInterceptorRest(clock: Clock): LoggingInterceptorRest {
return LoggingInterceptorRest(clock)
}
@Bean
open fun headerInterceptorRest(): HeaderInterceptorRest {
return HeaderInterceptorRest()
}
}
@@ -0,0 +1,24 @@
package de.twomartens.support.configuration
import de.twomartens.support.interceptor.HeaderInterceptorRest
import de.twomartens.support.interceptor.LoggingInterceptorRest
import de.twomartens.support.property.RestTemplateTimeoutProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter
import org.springframework.web.client.RestClient
@Configuration
open class RestClientConfiguration {
@Bean("restClient")
open fun restClient(
headerInterceptorRest: HeaderInterceptorRest,
loggingInterceptor: LoggingInterceptorRest,
restTemplateTimeoutProperties: RestTemplateTimeoutProperties
): RestClient {
return RestClient.builder()
.messageConverters { it.add(Jaxb2RootElementHttpMessageConverter()) }
.requestInterceptors { listOf(headerInterceptorRest, loggingInterceptor) }
.build()
}
}
@@ -0,0 +1,37 @@
package de.twomartens.support.configuration
import de.twomartens.support.interceptor.HeaderInterceptorRest
import de.twomartens.support.interceptor.LoggingInterceptorRest
import de.twomartens.support.property.RestTemplateTimeoutProperties
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestTemplate
@Configuration
open class RestTemplateConfiguration {
@Bean("restTemplate")
open fun restTemplate(
headerInterceptorRest: HeaderInterceptorRest,
loggingInterceptor: LoggingInterceptorRest,
restTemplateTimeoutProperties: RestTemplateTimeoutProperties
): RestTemplate {
return RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest, loggingInterceptor)
.setConnectTimeout(restTemplateTimeoutProperties.connectionRestTemplateTimeoutInMillis)
.setReadTimeout(restTemplateTimeoutProperties.readTimeoutRestTemplateInMillis)
.build()
}
@Bean("restTemplateRestHealthIndicator")
open fun restTemplateRestHealthIndicator(
headerInterceptorRest: HeaderInterceptorRest,
restTemplateTimeoutProperties: RestTemplateTimeoutProperties
): RestTemplate {
return RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest)
.setConnectTimeout(restTemplateTimeoutProperties.connectionRestHealthIndicatorTimeoutInMillis)
.setReadTimeout(restTemplateTimeoutProperties.readTimeoutRestHealthIndicatorInMillis)
.build()
}
}
@@ -0,0 +1,25 @@
package de.twomartens.support.configuration
import de.twomartens.support.monitoring.statusprobe.CountBasedStatusProbe
import de.twomartens.support.monitoring.statusprobe.StatusProbe
import de.twomartens.support.monitoring.statusprobe.StatusProbeCriticality
import de.twomartens.support.monitoring.statusprobe.StatusProbeLogger
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class StatusProbeConfiguration(private val clock: Clock) {
@Bean
open fun statusProbeLogger(): StatusProbeLogger {
return StatusProbeLogger(clock)
}
@Bean
open fun testStatusProbe(statusProbeLogger: StatusProbeLogger): StatusProbe {
return CountBasedStatusProbe(
1,
clock, StatusProbeCriticality.K1, "testStatusProbe", statusProbeLogger
)
}
}
@@ -0,0 +1,36 @@
package de.twomartens.support.controller
import de.twomartens.support.exception.HttpStatusException
import de.twomartens.support.model.dto.ErrorMessage
import mu.KotlinLogging
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
@ControllerAdvice(annotations = [RestController::class])
class ExceptionController : ResponseEntityExceptionHandler() {
@ExceptionHandler(HttpStatusException::class)
fun handleException(e: HttpStatusException): ResponseEntity<ErrorMessage> {
if (e.cause != null) {
log.info(e.cause.toString(), e.cause)
} else {
log.info(e.toString())
}
return ResponseEntity.status(e.status)
.body(ErrorMessage(e.message!!))
}
@ExceptionHandler(RuntimeException::class)
fun handleRuntimeException(e: RuntimeException): ResponseEntity<ErrorMessage> {
log.error("unexpected exception occurred", e)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorMessage(message = e.message!!))
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,19 @@
package de.twomartens.support.controller
import de.twomartens.support.property.HealthCheckProperties
import io.swagger.v3.oas.annotations.Hidden
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(value = ["/timetable"])
class HealthCheckController(private val properties: HealthCheckProperties) {
@Hidden
@GetMapping("/healthCheck")
fun healthCheck(message: String): String {
return properties.greeting.format(message)
}
}
@@ -0,0 +1,82 @@
package de.twomartens.support.controller
import mu.KotlinLogging
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import java.io.FileNotFoundException
import java.net.InetAddress
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.util.jar.JarFile
@Controller
@RequestMapping(value = ["/timetable"])
class VersionHtmlController {
@GetMapping(path = ["/version"])
fun version(): String {
return "version"
}
@ModelAttribute("version")
private fun getApplicationVersion(): String {
return "${getTitle()} ${getVersion()}"
}
@ModelAttribute("footerString")
private fun getApplicationVersion(@RequestHeader("host") hostName: String): String {
return "${getTitle()} ${getVersion()} - $hostName"
}
private fun getTitle(): String {
val title = VersionHtmlController::class.java.`package`.implementationTitle
?: return "application"
return title.ifBlank {
"application"
}
}
private fun getVersion(): String {
val version = VersionHtmlController::class.java.`package`.implementationVersion
?: return "DEVELOPER"
return version.ifBlank {
"DEVELOPER"
}
}
@ModelAttribute("hostname")
private fun getHostname(): String {
try {
return InetAddress.getLocalHost().hostName
} catch (e: UnknownHostException) {
log.warn(e.toString(), e)
}
return ""
}
@ModelAttribute("manifest")
private fun getManifest(): Collection<String> {
try {
val location = javaClass.getProtectionDomain().codeSource.location
val jarFileName = Paths.get(location.toURI()).toString()
JarFile(jarFileName).use { jarFile ->
val entry = jarFile.getEntry(JarFile.MANIFEST_NAME)
jarFile.getInputStream(entry).use { `in` ->
return String(`in`.readAllBytes(), StandardCharsets.UTF_8).lines().toList()
}
}
} catch (ignored: FileNotFoundException) {
// do nothing if manifest file is not available
} catch (e: Exception) {
log.info(e.toString(), e)
}
return listOf("${getTitle()} ${getVersion()}")
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,6 @@
package de.twomartens.support.exception
import org.springframework.http.HttpStatus
class HttpStatusException(val status: HttpStatus, message: String, cause: Throwable?) :
RuntimeException(message, cause)
@@ -0,0 +1,39 @@
package de.twomartens.support.interceptor
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatusCode
import org.springframework.http.client.ClientHttpResponse
import org.springframework.util.StreamUtils
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
class BufferingClientHttpResponseWrapper(private val response: ClientHttpResponse) : ClientHttpResponse {
private var body: ByteArray? = null
@Throws(IOException::class)
override fun getStatusCode(): HttpStatusCode {
return response.statusCode
}
@Throws(IOException::class)
override fun getStatusText(): String {
return response.statusText
}
override fun getHeaders(): HttpHeaders {
return response.headers
}
@Throws(IOException::class)
override fun getBody(): InputStream {
if (body == null) {
body = StreamUtils.copyToByteArray(response.body)
}
return ByteArrayInputStream(body)
}
override fun close() {
response.close()
}
}
@@ -0,0 +1,85 @@
package de.twomartens.support.interceptor
import org.slf4j.MDC
import java.io.Closeable
import java.util.*
abstract class HeaderInterceptor {
companion object {
const val LOGGER_TRACE_ID = "trace.id"
const val LOGGER_REQTYPE_ID = "REQTYPE"
const val HEADER_FIELD_TRACE_ID = "X-TraceId"
const val HEADER_FIELD_B3_TRACE_ID = "x-b3-traceid"
const val HEADER_FIELD_TYPE_ID = "x-type"
const val REQ_TYPE_HEALTHCHECK = "HEALTH_CHECK"
const val REQ_TYPE_INTEGRATION_TEST = "INTEGRATION_TEST"
const val REQ_TYPE_SERVER_TEST = "SERVER_TEST"
const val REQ_TYPE_WARMUP = "WARMUP"
fun createNewTraceId(): String {
return UUID.randomUUID().toString()
}
fun getTraceId(): String {
val traceId = MDC.get(LOGGER_TRACE_ID)
return if (traceId.isNullOrBlank()) createNewTraceId() else traceId
}
fun getRequestType(): String? {
val type = MDC.get(LOGGER_REQTYPE_ID)
return if (type.isNullOrBlank()) null else type
}
private fun setTraceId(traceId: String): InterceptorCloseables {
return InterceptorCloseables(MDC.putCloseable(LOGGER_TRACE_ID, traceId))
}
private fun mark(requestType: String?): InterceptorCloseables {
return InterceptorCloseables(MDC.putCloseable(LOGGER_REQTYPE_ID, requestType))
}
fun set(traceId: String, requestType: String?): InterceptorCloseables {
return if (requestType != null) {
InterceptorCloseables(setTraceId(traceId), mark(requestType))
} else setTraceId(traceId)
}
}
fun markAsHealthCheck(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_HEALTHCHECK), setTraceId(createNewTraceId()))
}
fun markAsIntegrationTest(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_INTEGRATION_TEST), setTraceId(createNewTraceId()))
}
fun markAsServerTest(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_SERVER_TEST), setTraceId(createNewTraceId()))
}
fun markAsWarmup(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_WARMUP), setTraceId(createNewTraceId()))
}
class InterceptorCloseables(vararg closeables: Closeable) : Closeable {
private val closeables: Array<out Closeable>
init {
this.closeables = closeables
}
override fun close() {
closeables.forEach {
try {
it.close()
} catch (ignored: Exception) {
// do nothing
}
}
}
}
}
@@ -0,0 +1,88 @@
package de.twomartens.support.interceptor
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpRequest
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.web.servlet.HandlerInterceptor
import java.io.IOException
import java.util.*
class HeaderInterceptorRest : HeaderInterceptor(), HandlerInterceptor, ClientHttpRequestInterceptor {
companion object {
val CLASS_NAME: String = HeaderInterceptorRest::class.java.getName()
fun extractTraceId(request: HttpServletRequest): String {
var traceId = request.getHeader(HEADER_FIELD_TRACE_ID)
if (traceId.isNullOrBlank()) traceId = request.getHeader(HEADER_FIELD_B3_TRACE_ID)
if (traceId.isNullOrBlank()) return createNewTraceId()
return traceId
}
fun extractTraceId(request: HttpRequest): String {
var traceId = request.headers[HEADER_FIELD_TRACE_ID]?.first()
if (traceId.isNullOrBlank()) traceId = request.headers[HEADER_FIELD_B3_TRACE_ID]?.first()
if (traceId.isNullOrBlank()) return UUID.randomUUID().toString()
return traceId
}
fun extractRequestType(request: HttpServletRequest): String? {
val type = request.getHeader(HEADER_FIELD_TYPE_ID)
if (type.isNullOrBlank()) return null
return type
}
fun extractRequestType(request: HttpRequest): String? {
val type = request.headers[HEADER_FIELD_TYPE_ID]?.first()
if (type.isNullOrBlank()) return null
return type
}
}
// ClientHttpRequestInterceptor
@Throws(IOException::class)
override fun intercept(
request: HttpRequest, body: ByteArray,
execution: ClientHttpRequestExecution
): ClientHttpResponse {
request.headers.add(HEADER_FIELD_TRACE_ID, getTraceId())
val requestType = getRequestType()
if (requestType != null) {
request.headers.add(HEADER_FIELD_TYPE_ID, requestType)
}
return try {
execution.execute(request, body)
} finally {
request.headers.remove(HEADER_FIELD_TRACE_ID)
request.headers.remove(HEADER_FIELD_TYPE_ID)
}
}
// HandlerInterceptor
override fun preHandle(
request: HttpServletRequest, response: HttpServletResponse,
handler: Any
): Boolean {
val traceId = extractTraceId(request)
val requestType = extractRequestType(request)
val closeable = set(traceId, requestType)
request.setAttribute(CLASS_NAME, closeable)
return true
}
// HandlerInterceptor
// override fun postHandle(
// request: HttpServletRequest,
// response: HttpServletResponse,
// handler: Any,
// modelAndView: ModelAndView?
// ) {
// val obj = request.getAttribute(CLASS_NAME)
// if (obj != null && obj is InterceptorCloseables) {
// obj.close()
// }
// }
}
@@ -0,0 +1,439 @@
package de.twomartens.support.interceptor
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import mu.KotlinLogging
import org.apache.logging.log4j.message.StringMapMessage
import org.slf4j.Marker
import org.slf4j.MarkerFactory
import org.springframework.http.*
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.util.StreamUtils
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import java.io.IOException
import java.net.URI
import java.net.URL
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.time.Clock
import java.time.Duration
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.function.Function
class LoggingInterceptorRest(
private val requestLogBehaviour: FieldLogBehaviour,
private val responseLogBehaviour: FieldLogBehaviour,
val clock: Clock
) : Filter, ClientHttpRequestInterceptor {
constructor(clock: Clock) : this(FieldLogBehaviour.NEVER, FieldLogBehaviour.NEVER, clock)
@Throws(IOException::class, ServletException::class)
override fun doFilter(
request: ServletRequest, response: ServletResponse,
chain: FilterChain
) {
val requestTime = ZonedDateTime.now(clock)
val httpRequest = request as HttpServletRequest
val requestWrapper = ContentCachingRequestWrapper(httpRequest)
val responseWrapper = ContentCachingResponseWrapper(
(response as HttpServletResponse)
)
var fullResponseBytes: ByteArray? = null
var throwable: Throwable? = null
var responseBody: String? = null
var httpStatusCode = -1
try {
try {
chain.doFilter(
if (requestLogBehaviour != FieldLogBehaviour.NEVER) requestWrapper else httpRequest,
responseWrapper
)
if (responseLogBehaviour != FieldLogBehaviour.NEVER) {
fullResponseBytes = responseWrapper.contentAsByteArray
}
httpStatusCode = responseWrapper.status
} finally {
responseWrapper.copyBodyToResponse()
}
} catch (e: Exception) {
throwable = e
throw e
} finally {
try {
val responseSize = responseWrapper.contentSize
val responseHeaders = extractHeaders(
headerNames = responseWrapper.headerNames.iterator()
) { responseWrapper.getHeaders(it).iterator() }
if (
(responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS)
&& fullResponseBytes != null
) {
responseBody = String(
fullResponseBytes,
determineResponseEncoding()
)
}
val query = if (httpRequest.queryString != null) "?${httpRequest.queryString}" else ""
val requestUrl = URI.create("${httpRequest.requestURL}$query").toURL()
val requestHeaders = extractHeaders(
headerNames = httpRequest.headerNames.asIterator(),
ignoreList = listOf("authorization")) {
httpRequest.getHeaders(it).asIterator()
}
var requestBody: String? = null
var businessType: String? = null
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS
) {
val fullRequestBytes = requestWrapper.contentAsByteArray
requestBody = String(fullRequestBytes, determineRequestEncoding())
businessType = determineBusinessType()
}
log(
LogMessage(
requestHeaders = requestHeaders,
responseHeaders = responseHeaders,
url = requestUrl,
method = httpRequest.method,
requestMimeType = typeToString(request.getContentType()),
responseMimeType = typeToString(response.getContentType()),
requestBody = requestBody,
responseBody = responseBody,
requestSize = httpRequest.contentLength,
responseSize = responseSize,
httpStatus = httpStatusCode,
direction = DIRECTION_IN,
requestTime = requestTime,
responseTime = ZonedDateTime.now(clock),
traceId = HeaderInterceptor.getTraceId(),
requestType = HeaderInterceptor.getRequestType(),
businessType = businessType,
throwable = throwable
)
)
val interceptorCloseables =
request.getAttribute(HeaderInterceptorRest.CLASS_NAME) as HeaderInterceptor.InterceptorCloseables
interceptorCloseables.close()
} catch (e: java.lang.RuntimeException) {
log.error(e.toString(), e)
}
}
}
private fun isError(httpStatusCode: Int): Boolean {
return httpStatusCode in 400..599
}
@Throws(IOException::class)
override fun intercept(
request: HttpRequest,
requestBytes: ByteArray, execution: ClientHttpRequestExecution
): ClientHttpResponse {
val requestTime = ZonedDateTime.now(clock)
var responseSize = 0
var responseHeaders: Map<String, Collection<String>> = emptyMap()
var responseMediaType: MediaType? = null
var httpStatusCode = -1
var throwable: Throwable? = null
var requestBody: String? = null
var responseBody: String? = null
var businessType: String? = null
return try {
val result = BufferingClientHttpResponseWrapper(
execution.execute(request, requestBytes)
)
val responseBytes = StreamUtils.copyToByteArray(result.getBody())
responseSize = responseBytes.size
responseHeaders = extractHeaders(result.headers)
responseMediaType = result.headers.getContentType()
httpStatusCode = result.statusCode.value()
if (responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS
) {
responseBody = String(
responseBytes,
determineRequestEncoding()
)
}
result
} catch (e: Exception) {
throwable = e
throw e
} finally {
try {
val url = request.uri.toURL()
val requestHeaders: Map<String, Collection<String>> = extractHeaders(request.headers)
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS
) {
requestBody = String(
requestBytes,
determineRequestEncoding()
)
businessType = determineBusinessType()
}
log(
LogMessage(
requestHeaders = requestHeaders,
responseHeaders = responseHeaders,
url = url,
method = request.method.name(),
requestMimeType = typeToString(request.headers.getContentType()),
requestBody = requestBody,
responseBody = responseBody,
responseMimeType = typeToString(responseMediaType!!),
requestSize = requestBytes.size,
responseSize = responseSize,
httpStatus = httpStatusCode,
direction = DIRECTION_OUT,
requestTime = requestTime,
responseTime = ZonedDateTime.now(clock),
businessType = businessType,
throwable = throwable,
traceId = HeaderInterceptorRest.extractTraceId(request),
requestType = HeaderInterceptorRest.extractRequestType(request),
)
)
} catch (e: java.lang.RuntimeException) {
log.error(e.toString(), e)
}
}
}
private fun log(logMessage: LogMessage) {
val stringMapMessage = StringMapMessage()
addLogString(
stringMapMessage, PARAM_REQUEST_HEADERS,
toHeaderString(logMessage.requestHeaders)
)
addLogString(
stringMapMessage, PARAM_RESPONSE_HEADERS,
toHeaderString(logMessage.responseHeaders)
)
addLogString(stringMapMessage, PARAM_URL_FULL, logMessage.url.toString())
addLogString(stringMapMessage, PARAM_URL_DOMAIN, logMessage.url.host)
addLogString(
stringMapMessage, PARAM_URL_EXTENSION,
extractExtension(logMessage.url.path)
)
addLogString(stringMapMessage, PARAM_URL_PATH, logMessage.url.path)
addLogString(stringMapMessage, PARAM_URL_PORT, logMessage.url.port.toString())
addLogString(stringMapMessage, PARAM_URL_SCHEME, logMessage.url.protocol)
addLogString(stringMapMessage, PARAM_URL_QUERY, logMessage.url.query)
addLogString(stringMapMessage, PARAM_REQUEST_METHOD, logMessage.method)
addLogString(
stringMapMessage, PARAM_REQUEST_REFERER,
getHeader(logMessage.requestHeaders, HttpHeaders.REFERER)
)
addLogString(stringMapMessage, PARAM_REQUEST_MIMETYPE, logMessage.requestMimeType)
addLogString(stringMapMessage, PARAM_RESPONSE_MIMETYPE, logMessage.responseMimeType)
addLogString(stringMapMessage, PARAM_REQUEST_BYTES, logMessage.requestSize.toString())
addLogString(stringMapMessage, PARAM_RESPONSE_BYTES, logMessage.responseSize.toString())
addLogString(stringMapMessage, PARAM_RESPONSE_STATUS, logMessage.httpStatus.toString())
addLogString(stringMapMessage, PARAM_DIRECTION, logMessage.direction)
addLogString(stringMapMessage, PARAM_PROTOCOL, PROTOCOL_NAME)
addLogString(
stringMapMessage, PARAM_REQUEST_TIME,
logMessage.requestTime.format(DATE_TIME_FORMATTER)
)
addLogString(
stringMapMessage, PARAM_RESPONSE_TIME,
logMessage.responseTime.format(DATE_TIME_FORMATTER)
)
addLogString(
stringMapMessage,
PARAM_DURATION,
getDurationBetweenRequestAndResponseTime(logMessage).toNanos().toString()
)
addLogString(
stringMapMessage, PARAM_USER_AGENT,
getHeader(logMessage.requestHeaders, HttpHeaders.USER_AGENT)
)
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_TRACE_ID, logMessage.traceId)
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_REQTYPE_ID, logMessage.requestType)
addLogString(stringMapMessage, PARAM_BUSINESS_TYPE, logMessage.businessType)
addLogString(stringMapMessage, PARAM_REQUEST_BODY, cutToMaxLength(logMessage.requestBody))
addLogString(stringMapMessage, PARAM_RESPONSE_BODY, cutToMaxLength(logMessage.responseBody))
log.debug(MARKER, stringMapMessage.asString(), logMessage.throwable)
}
private fun getDurationBetweenRequestAndResponseTime(logMessage: LogMessage): Duration {
return Duration.between(logMessage.requestTime, logMessage.responseTime)
}
private fun getHeader(headers: Map<String, Collection<String?>>, headerKey: String): String? {
return headers.entries.asSequence()
.filter { it.key.equals(headerKey, ignoreCase = true) }
.flatMap { it.value.asSequence() }
.firstOrNull()
}
private fun addLogString(stringMapMessage: StringMapMessage, key: String, value: String?) {
if (!value.isNullOrBlank()) {
stringMapMessage.with(key, value.trim { it <= ' ' })
}
}
/**
* usually returns null, but can be overridden to implement more complex logic
*/
private fun determineBusinessType(): String? {
return null
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
private fun determineRequestEncoding(): Charset {
return StandardCharsets.UTF_8
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
private fun determineResponseEncoding(): Charset {
return StandardCharsets.UTF_8
}
companion object {
private val log = KotlinLogging.logger {}
private const val MAX_LOG_SIZE = 20480 // 20 KB - logging could fail with bigger logmessages
val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
val MARKER: Marker = MarkerFactory.getMarker("communication")
const val DIRECTION_IN = "inbound"
const val DIRECTION_OUT = "outbound"
const val PROTOCOL_NAME = "http"
const val PARAM_URL_FULL = "url.full"
const val PARAM_URL_DOMAIN = "url.domain"
const val PARAM_URL_EXTENSION = "url.extension"
const val PARAM_URL_PATH = "url.path"
const val PARAM_URL_PORT = "url.port"
const val PARAM_URL_SCHEME = "url.scheme"
const val PARAM_URL_QUERY = "url.query"
const val PARAM_BUSINESS_TYPE = "http.request.type"
const val PARAM_REQUEST_BODY = "http.request.body.content"
const val PARAM_RESPONSE_BODY = "http.response.body.content"
const val PARAM_RESPONSE_STATUS = "http.response.status_code"
const val PARAM_REQUEST_HEADERS = "http.request.headers"
const val PARAM_RESPONSE_HEADERS = "http.response.headers"
const val PARAM_REQUEST_BYTES = "http.request.body.bytes"
const val PARAM_RESPONSE_BYTES = "http.response.body.bytes"
const val PARAM_REQUEST_MIMETYPE = "http.request.mime_type"
const val PARAM_RESPONSE_MIMETYPE = "http.response.mime_type"
const val PARAM_REQUEST_METHOD = "http.request.method"
const val PARAM_REQUEST_REFERER = "http.request.referrer"
const val PARAM_REQUEST_TIME = "event.start"
const val PARAM_RESPONSE_TIME = "event.end"
const val PARAM_DURATION = "event.duration"
const val PARAM_USER_AGENT = "user_agent.original"
const val PARAM_DIRECTION = "network.direction"
const val PARAM_PROTOCOL = "network.protocol"
private fun extractHeaders(
headerNames: Iterator<String>,
ignoreList: List<String> = emptyList(),
headerValuesSupplier: Function<String, Iterator<String>>
): Map<String, MutableCollection<String>> {
val requestHeaders: MutableMap<String, MutableCollection<String>> = mutableMapOf()
while (headerNames.hasNext()) {
val name = headerNames.next()
if (name in ignoreList) continue
val values = requestHeaders.computeIfAbsent(name) { mutableSetOf() }
val headerValues = headerValuesSupplier.apply(name)
while (headerValues.hasNext()) {
values.add(headerValues.next())
}
}
return requestHeaders
}
private fun extractHeaders(headers: HttpHeaders): Map<String, Collection<String>> {
val result: MutableMap<String, Collection<String>> = mutableMapOf()
for ((key, value) in headers) {
result[key] = value.toList()
}
return result
}
private fun toHeaderString(headerMap: Map<String, Collection<String>>): String {
return headerMap.entries.asSequence()
.flatMap { entry ->
val key = entry.key
entry.value.asSequence().map { Pair(key, it) }
}
.map { "${it.first}=${it.second}" }
.joinToString(separator = ",")
}
private fun typeToString(contentType: String?): String? {
return try {
if (contentType == null) return null
val mediaType = MediaType.parseMediaType(contentType)
return typeToString(mediaType)
} catch (e: InvalidMediaTypeException) {
log.info(e.toString(), e)
e.toString()
}
}
private fun typeToString(mediaType: MediaType?): String? {
return try {
if (mediaType == null) return null
"${mediaType.type}/${mediaType.subtype}"
} catch (e: RuntimeException) {
log.info(e.toString(), e)
e.toString()
}
}
private fun extractExtension(fileName: String?): String? {
if (fileName == null || !fileName.contains(".")) return null
return fileName.substring(fileName.lastIndexOf('.') + 1)
}
private fun cutToMaxLength(string: String?): String? {
return if (string != null && string.length > MAX_LOG_SIZE) {
string.substring(0, MAX_LOG_SIZE)
} else string
}
private data class LogMessage(
val requestHeaders: Map<String, Collection<String>>,
val responseHeaders: Map<String, Collection<String>>,
val url: URL,
val method: String,
val requestMimeType: String?,
val responseMimeType: String?,
val requestBody: String?,
val responseBody: String?,
val requestSize: Int,
val responseSize: Int,
val httpStatus: Int,
val direction: String,
val requestTime: ZonedDateTime,
val responseTime: ZonedDateTime,
val traceId: String?,
val requestType: String?,
val businessType: String?,
val throwable: Throwable?
)
enum class FieldLogBehaviour {
NEVER,
ONLY_ON_ERROR,
ALWAYS
}
}
}
@@ -0,0 +1,8 @@
package de.twomartens.support.model
import org.springframework.stereotype.Component
@Component
class LeadershipStatus {
var isLeader = false
}
@@ -0,0 +1,3 @@
package de.twomartens.support.model.dto
data class ErrorMessage(val message: String)
@@ -0,0 +1,83 @@
package de.twomartens.support.monitoring.actuator
import mu.KotlinLogging
import org.slf4j.MDC
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.boot.actuate.health.Status
import java.io.IOException
import java.time.Clock
import java.time.Duration
import java.util.*
abstract class AbstractHealthIndicator(
private val clock: Clock,
private val preparable: Preparable
) : HealthIndicator {
private val logStatusDownMessage = "health indicator '${indicatorName()}' invoked with status '${Status.DOWN.code}'"
private val logStatusUpMessage = "health indicator '${indicatorName()}' invoked with status '${Status.UP.code}'"
private var firstTime = true
/**
* main method that determines the health of the service
*/
protected abstract fun determineHealth(): Health
override fun health(): Health {
try {
preparable.prepare().use {
var result: Health? = null
var exception: Exception? = null
val start = clock.millis()
try {
result = determineHealth()
} catch (e: RuntimeException) {
exception = e
result = Health.down().withException(e).build()
} finally {
logInvocation(result, exception, start, clock.millis())
}
return result!!
}
} catch (e: IOException) {
log.error("unexpected exception occurred", e)
return Health.down(e).build()
}
}
private fun logInvocation(health: Health?, exception: Exception?, start: Long, end: Long) {
val duration = Duration.ofMillis(end - start)
MDC.putCloseable("event.duration", duration.toNanos().toString()).use {
if (exception != null || health == null) {
log.error(logStatusDownMessage, exception)
firstTime = true
} else if (health.status === Status.DOWN) {
log.warn(logStatusDownMessage)
firstTime = true
} else if (firstTime) {
log.info(logStatusUpMessage)
firstTime = false
} else {
log.trace(logStatusUpMessage)
}
}
}
private fun indicatorName(): String {
return this.javaClass.getSimpleName()
.replace("HealthIndicator", "")
.lowercase(Locale.getDefault())
}
companion object {
const val HOST = "localhost"
const val HTTP_PREFIX = "http://"
const val HOST_PORT_SEPARATOR = ":"
const val PATH_SEPARATOR = "/"
const val PARAMETER_SEPARATOR = "?"
const val DETAIL_ENDPOINT_KEY = "endpoint"
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,30 @@
package de.twomartens.support.monitoring.actuator
import de.twomartens.support.monitoring.statusprobe.StatusProbe
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import java.time.Clock
abstract class AbstractStatusProbeHealthIndicator(
timeProvider: Clock, headerInterceptor: Preparable,
private val statusProbe: StatusProbe
) : AbstractHealthIndicator(timeProvider, headerInterceptor), HealthIndicator {
override fun determineHealth(): Health {
val healthBuilder = Health.status(statusProbe.status)
if (statusProbe.lastStatusChange != null) {
healthBuilder.withDetail(LAST_STATUS_CHANGE_KEY, statusProbe.lastStatusChange)
}
if (statusProbe.throwable != null) {
healthBuilder.withException(statusProbe.throwable)
}
if (statusProbe.message != null) {
healthBuilder.withDetail(MESSAGE_KEY, statusProbe.message)
}
return healthBuilder.build()
}
companion object {
const val MESSAGE_KEY = "message"
const val LAST_STATUS_CHANGE_KEY = "lastStatusChange"
}
}
@@ -0,0 +1,7 @@
package de.twomartens.support.monitoring.actuator
import java.io.Closeable
fun interface Preparable {
fun prepare(): Closeable
}
@@ -0,0 +1,59 @@
package de.twomartens.support.monitoring.actuator
import de.twomartens.support.interceptor.HeaderInterceptorRest
import de.twomartens.support.property.HealthCheckProperties
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.boot.actuate.health.Status
import org.springframework.boot.autoconfigure.web.ServerProperties
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import java.security.SecureRandom
import java.time.Clock
/**
* A Health check which checks if the rest services are working.
*
*
* If you have a complex service, you should think about an easy greeting or echo service, which
* only tests the
* network/service stack and not the full application.
*
*
* The health check will be called by kubernetes to check if the container/pod should be in load
* balancing. It is possible
* to have as much health checks as you like.
*
*
* There should be a health check which is ok not before all data is loaded.
*/
@Component
class RestHealthIndicator(
clock: Clock, interceptor: HeaderInterceptorRest,
serverProperties: ServerProperties,
private val restTemplateRestHealthIndicator: RestTemplate,
private val serviceProperties: HealthCheckProperties
) : AbstractHealthIndicator(clock, Preparable { interceptor.markAsHealthCheck() }), HealthIndicator {
private val randomizer = SecureRandom()
private val urlPrefix: String = (HTTP_PREFIX + HOST + HOST_PORT_SEPARATOR
+ serverProperties.port
+ URL_PATH + PARAMETER_SEPARATOR + GET_PARAMETER)
/**
* main method that determines the health of the service
*/
override fun determineHealth(): Health {
val random = randomizer.nextInt(100000, 999999).toString()
val url = "$urlPrefix{random}"
val response = restTemplateRestHealthIndicator.getForEntity(url, String::class.java, random)
val status = if (response.body == serviceProperties.greeting.format(random)) Status.UP else Status.DOWN
return Health.status(status)
.withDetail(DETAIL_ENDPOINT_KEY, url)
.build()
}
companion object {
private const val URL_PATH = "/timetable/healthCheck"
private const val GET_PARAMETER = "message="
}
}
@@ -0,0 +1,25 @@
package de.twomartens.support.monitoring.statusprobe
import org.springframework.boot.actuate.health.Status
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
class CountBasedStatusProbe(
private val maxFailureCount: Int, clock: Clock, criticality: StatusProbeCriticality, name: String,
statusProbeLogger: StatusProbeLogger
) : StatusProbe(clock, criticality, name, statusProbeLogger) {
private val failureCount = AtomicInteger(0)
@Synchronized
override fun setStatus(status: Status, throwable: Throwable?, message: String?) {
if (status === Status.DOWN) {
val failureCount = failureCount.incrementAndGet()
if (failureCount > maxFailureCount) {
super.setStatus(status, throwable, message)
}
} else if (status === Status.UP) {
failureCount.set(0)
super.setStatus(status, throwable, message)
}
}
}
@@ -0,0 +1,51 @@
package de.twomartens.support.monitoring.statusprobe
import org.springframework.boot.actuate.health.Status
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import java.time.Clock
import java.time.Duration
/**
* uses the percentage of down statuses within a given period (default: 1 min) to determine if status of probe is down.
* this is meant to be used to avoid flickering status probes on services that have lots of status updates. When there
* is no significant amount of requests during one scheduling period, the behavior may be arbitrary.
*/
class PercentageBasedStatusProbe(
private val maxFailurePercent: Int, clock: Clock,
threadPoolTaskScheduler: ThreadPoolTaskScheduler, schedulePeriod: Duration, criticality: StatusProbeCriticality,
name: String, statusProbeLogger: StatusProbeLogger
) : StatusProbe(clock, criticality, name, statusProbeLogger), ScheduledStatusProbe {
private var requestCount = 0
private var downCount = 0
private var temporaryMessage: String? = null
private var temporaryThrowable: Throwable? = null
init {
scheduleTask(threadPoolTaskScheduler, schedulePeriod)
}
@Synchronized
override fun setStatus(status: Status, throwable: Throwable?, message: String?) {
if (status === Status.DOWN) {
downCount++
this.temporaryThrowable = throwable
this.temporaryMessage = message
}
requestCount++
}
private fun reset() {
requestCount = 0
downCount = 0
}
@Synchronized
override fun runScheduledTask() {
if (requestCount > 0 && downCount * 100.0 / requestCount > maxFailurePercent) {
super.setStatus(Status.DOWN, temporaryThrowable, temporaryMessage)
} else {
super.setStatus(Status.UP, null, null)
}
reset()
}
}
@@ -0,0 +1,22 @@
package de.twomartens.support.monitoring.statusprobe
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.scheduling.support.PeriodicTrigger
import java.time.Duration
interface ScheduledStatusProbe {
fun runScheduledTask()
fun scheduleTask(
threadPoolTaskScheduler: ThreadPoolTaskScheduler,
schedulePeriod: Duration
) {
val periodicTrigger = PeriodicTrigger(
Duration.ofSeconds(schedulePeriod.toSeconds())
)
threadPoolTaskScheduler.schedule(periodicTrigger) { runScheduledTask() }
}
}
fun ThreadPoolTaskScheduler.schedule(periodicTrigger: PeriodicTrigger, task: Runnable) {
this.schedule(task, periodicTrigger)
}
@@ -0,0 +1,59 @@
package de.twomartens.support.monitoring.statusprobe
import lombok.Getter
import org.springframework.boot.actuate.health.Status
import java.time.Clock
import java.time.ZonedDateTime
@Getter
open class StatusProbe(
private val clock: Clock, criticality: StatusProbeCriticality, private val name: String,
private val statusProbeLogger: StatusProbeLogger
) {
var status: Status = Status.UP
private set
var throwable: Throwable? = null
private set
var message: String? = null
private set
var lastStatusChange: ZonedDateTime? = null
private set
init {
statusProbeLogger.registerStatusProbe(name, criticality)
}
protected open fun setStatus(status: Status, throwable: Throwable?, message: String?) {
if (status !== this.status) {
lastStatusChange = ZonedDateTime.now(clock)
statusProbeLogger.logStatusChange(name, message, status, lastStatusChange, throwable)
}
this.status = status
this.throwable = throwable
this.message = message
}
fun up() {
setStatus(Status.UP, null, null)
}
fun up(message: String) {
setStatus(Status.UP, null, message)
}
fun down() {
setStatus(Status.DOWN, null, null)
}
fun down(throwable: Throwable) {
setStatus(Status.DOWN, throwable, null)
}
fun down(message: String) {
setStatus(Status.DOWN, null, message)
}
protected fun down(throwable: Throwable, message: String) {
setStatus(Status.DOWN, throwable, message)
}
}
@@ -0,0 +1,7 @@
package de.twomartens.support.monitoring.statusprobe
enum class StatusProbeCriticality {
K1,
K2,
K3
}
@@ -0,0 +1,109 @@
package de.twomartens.support.monitoring.statusprobe
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
import org.apache.logging.log4j.MarkerManager
import org.apache.logging.log4j.message.StringMapMessage
import org.springframework.boot.actuate.health.Status
import java.time.Clock
import java.time.ZonedDateTime
class StatusProbeLogger internal constructor(private val clock: Clock, private val commLog: Logger) {
private val statusProbeToStatus: MutableMap<ProbeIdent, Status> = mutableMapOf()
constructor(clock: Clock) : this(clock, LogManager.getLogger("statusprobe"))
fun registerStatusProbe(name: String, criticality: StatusProbeCriticality) {
statusProbeToStatus[ProbeIdent(name, criticality)] = Status.UP
logStatusChange(name, "Startup", Status.UP, ZonedDateTime.now(clock), null)
}
fun logStatusChange(
name: String, message: String?, status: Status, lastStatusChange: ZonedDateTime?,
throwable: Throwable?
) {
var probeIdent = getProbeIdent(name)
if (probeIdent == null) {
probeIdent = ProbeIdent(name, StatusProbeCriticality.K1)
}
statusProbeToStatus[probeIdent] = status
createLog(message, lastStatusChange, throwable)
}
private fun getProbeIdent(name: String): ProbeIdent? {
return statusProbeToStatus.keys.asSequence()
.filter { it.name == name }
.firstOrNull()
}
private fun createLog(message: String?, lastStatusChange: ZonedDateTime?, throwable: Throwable?) {
val cleanedMessage = message ?: ""
val overallStatus = overallStatus
val criticality = overallCriticality
if (Status.UP == overallStatus) {
commLog.info(
MARKER, StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, cleanedMessage)
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange)
)
} else {
commLog.error(
MARKER, StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, cleanedMessage)
.with(LABEL_REASON, reason)
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange), throwable
)
}
}
private val overallCriticality: StatusProbeCriticality
get() {
val crits = statusProbeToStatus.keys.asSequence()
.map { it.criticality }
.toList()
return if (crits.contains(StatusProbeCriticality.K1)) StatusProbeCriticality.K1
else if (crits.contains(StatusProbeCriticality.K2)) StatusProbeCriticality.K2
else StatusProbeCriticality.K3
}
private val overallStatus: Status
get() = if (statusProbeToStatus.containsValue(Status.DOWN)) Status.DOWN
else Status.UP
private val reason: String
get() {
val probesDown = statusProbeToStatus.entries.asSequence()
.filter { it.value == Status.DOWN }
.map { it.key }
.toList()
val reasonK1 = getDownStatusProbes(probesDown, StatusProbeCriticality.K1)
val reasonK2 = getDownStatusProbes(probesDown, StatusProbeCriticality.K2)
val reasonK3 = getDownStatusProbes(probesDown, StatusProbeCriticality.K3)
return "$reasonK1$reasonK2$reasonK3".trim { it <= ' ' }
}
private fun getDownStatusProbes(probesDown: List<ProbeIdent>, criticality: StatusProbeCriticality): String {
val downProbeNames = probesDown.asSequence()
.filter { it.criticality == criticality }
.map { it.name }
.toList()
return if (downProbeNames.isNotEmpty()) {
"$criticality failed: ${downProbeNames.joinToString(separator = ",")}\n"
} else ""
}
data class ProbeIdent(val name: String, val criticality: StatusProbeCriticality)
companion object {
private val MARKER = MarkerManager.getMarker("statusprobe")
private const val LABEL_CRITICALITY = "label.status.criticality"
private const val LABEL_STATUS = "label.status.status"
private const val LABEL_REASON = "label.status.reason"
private const val LABEL_MESSAGE = "label.status.description"
private const val LABEL_LAST_STATUS_CHANGE = "label.status.last_change"
}
}
@@ -0,0 +1,48 @@
package de.twomartens.support.monitoring.statusprobe
import org.springframework.boot.actuate.health.Status
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import java.time.Clock
import java.time.Duration
import java.time.ZonedDateTime
class TimeBasedStatusProbe(
private val maxFailureDuration: Duration, private val clock: Clock,
threadPoolTaskScheduler: ThreadPoolTaskScheduler, schedulePeriod: Duration, criticality: StatusProbeCriticality,
name: String, statusProbeLogger: StatusProbeLogger
) : StatusProbe(clock, criticality, name, statusProbeLogger), ScheduledStatusProbe {
private var lastSuccess: ZonedDateTime? = null
private var temporaryThrowable: Throwable? = null
private var temporaryMessage: String? = null
init {
scheduleTask(threadPoolTaskScheduler, schedulePeriod)
}
@Synchronized
override fun setStatus(status: Status, throwable: Throwable?, message: String?) {
if (status === Status.DOWN) {
this.temporaryThrowable = throwable
this.temporaryMessage = message
} else if (status === Status.UP) {
lastSuccess = ZonedDateTime.now(clock)
super.setStatus(status, throwable, message)
}
}
private val isOverdue: Boolean
get() {
if (lastSuccess == null) {
return false
}
val timeSinceLastSuccess = Duration.between(lastSuccess, ZonedDateTime.now(clock))
return maxFailureDuration.minus(timeSinceLastSuccess).isNegative
}
@Synchronized
override fun runScheduledTask() {
if (isOverdue) {
super.setStatus(Status.DOWN, temporaryThrowable, temporaryMessage)
}
}
}
@@ -0,0 +1,10 @@
package de.twomartens.support.property
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "de.twomartens.support.health")
@Schema(description = "Properties, to configure health check")
class HealthCheckProperties {
lateinit var greeting: String
}
@@ -0,0 +1,15 @@
package de.twomartens.support.property
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
import org.springframework.boot.convert.DurationUnit
import java.time.Duration
import java.time.temporal.ChronoUnit
@ConfigurationProperties(prefix = "resttemplate.timeout")
data class RestTemplateTimeoutProperties @ConstructorBinding constructor(
@DurationUnit(ChronoUnit.MILLIS) val readTimeoutRestHealthIndicatorInMillis: Duration,
@DurationUnit(ChronoUnit.MILLIS) val connectionRestHealthIndicatorTimeoutInMillis: Duration,
@DurationUnit(ChronoUnit.MILLIS) val readTimeoutRestTemplateInMillis: Duration,
@DurationUnit(ChronoUnit.MILLIS) val connectionRestTemplateTimeoutInMillis: Duration
)
@@ -0,0 +1,19 @@
package de.twomartens.support.property
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
import org.springframework.boot.convert.DurationUnit
import org.springframework.cloud.context.config.annotation.RefreshScope
import java.time.Duration
import java.time.temporal.ChronoUnit
@RefreshScope
@ConfigurationProperties(prefix = "de.twomartens.timetable.statusprobe")
@Schema(description = "Properties, to configure this Application")
data class StatusProbeProperties @ConstructorBinding constructor(
@DurationUnit(ChronoUnit.SECONDS) val scheduleDuration: Duration,
@DurationUnit(ChronoUnit.MINUTES) val maxKafkaFailureDuration: Duration,
val maxBlobFailureCount: Int,
val maxFailurePercent: Int
)
@@ -0,0 +1,14 @@
package de.twomartens.support.property
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
import org.springframework.cloud.context.config.annotation.RefreshScope
import java.time.ZoneId
@RefreshScope
@ConfigurationProperties(prefix = "time")
@Schema(description = "Properties to configure time")
data class TimeProperties @ConstructorBinding constructor(
val defaultTimeZone: ZoneId
)
@@ -0,0 +1,65 @@
package de.twomartens.support.security
import mu.KotlinLogging
import org.keycloak.AuthorizationContext
import org.keycloak.adapters.authorization.PolicyEnforcer
import org.keycloak.adapters.authorization.spi.HttpRequest
import org.keycloak.adapters.authorization.spi.HttpResponse
import org.keycloak.authorization.client.ClientAuthorizationContext
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode
import org.keycloak.representations.idm.authorization.Permission
class SpringPolicyEnforcer(private val policyEnforcer: PolicyEnforcer,
private val policyEnforcerConfig: PolicyEnforcerConfig) {
fun enforce(request: HttpRequest, response: HttpResponse): AuthorizationContext {
if (log.isDebugEnabled) {
log.debug("Policy enforcement is enabled. Enforcing policy decisions for path [{}].", request.uri)
}
val context = authorize(request, response)
if (log.isDebugEnabled) {
log.debug("Policy enforcement result for path [{}] is : {}", request.uri, if (context.isGranted) "GRANTED" else "DENIED")
log.debug("Returning authorization context with permissions:")
for (permission in context.permissions) {
log.debug(permission.toString())
}
}
return context
}
private fun authorize(request: HttpRequest, response: HttpResponse): AuthorizationContext {
val enforcementMode = policyEnforcerConfig.enforcementMode
return if (EnforcementMode.DISABLED == enforcementMode) {
createAuthorizedContext()
} else policyEnforcer.enforce(request, response)
}
private fun createAuthorizedContext(): AuthorizationContext {
return object : ClientAuthorizationContext(policyEnforcer.authzClient) {
override fun hasPermission(resourceName: String, scopeName: String): Boolean {
return true
}
override fun hasResourcePermission(resourceName: String): Boolean {
return true
}
override fun hasScopePermission(scopeName: String): Boolean {
return true
}
override fun getPermissions(): List<Permission> {
return emptyList()
}
override fun isGranted(): Boolean {
return true
}
}
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,74 @@
package de.twomartens.support.security
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import mu.KotlinLogging
import org.keycloak.AuthorizationContext
import org.keycloak.adapters.authorization.PolicyEnforcer
import org.keycloak.adapters.authorization.integration.elytron.ServletHttpRequest
import org.keycloak.adapters.authorization.integration.elytron.ServletHttpResponse
import org.keycloak.adapters.authorization.spi.ConfigurationResolver
import org.keycloak.adapters.authorization.spi.HttpRequest
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
class SpringPolicyEnforcerFilter(private val configResolver: ConfigurationResolver) : Filter {
private val policyEnforcer: MutableMap<PolicyEnforcerConfig, SpringPolicyEnforcer> = ConcurrentHashMap()
@Throws(IOException::class, ServletException::class)
override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse?, filterChain: FilterChain) {
val request = servletRequest as HttpServletRequest
val response = servletResponse as HttpServletResponse?
val httpRequest = ServletHttpRequest(request) { extractBearerToken(request) }
val policyEnforcer = getOrCreatePolicyEnforcer(httpRequest)
val authzContext = policyEnforcer.enforce(httpRequest, ServletHttpResponse(response))
request.setAttribute(AuthorizationContext::class.java.name, authzContext)
if (authzContext.isGranted) {
log.debug("Request authorized, continuing the filter chain")
filterChain.doFilter(servletRequest, servletResponse)
} else {
log.debug("Unauthorized request to path [{}], aborting the filter chain", request.requestURI)
}
}
private fun extractBearerToken(request: HttpServletRequest): String? {
val authorizationHeaderValues = request.getHeaders("Authorization")
while (authorizationHeaderValues.hasMoreElements()) {
val value = authorizationHeaderValues.nextElement()
val parts = value.trim()
.split("\\s+".toRegex())
.dropLastWhile { it.isEmpty() }
.toTypedArray()
if (parts.size != 2) {
continue
}
val bearer = parts[0]
if (bearer.equals("Bearer", ignoreCase = true)) {
return parts[1]
}
}
return null
}
private fun getOrCreatePolicyEnforcer(request: HttpRequest): SpringPolicyEnforcer {
return policyEnforcer.computeIfAbsent(configResolver.resolve(request)) { createPolicyEnforcer(it) }
}
private fun createPolicyEnforcer(enforcerConfig: PolicyEnforcerConfig): SpringPolicyEnforcer {
val authServerUrl = enforcerConfig.authServerUrl
return SpringPolicyEnforcer(PolicyEnforcer.builder()
.authServerUrl(authServerUrl)
.realm(enforcerConfig.realm)
.clientId(enforcerConfig.resource)
.credentials(enforcerConfig.credentials)
.bearerOnly(false)
.enforcerConfig(enforcerConfig).build(), enforcerConfig)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,17 @@
package de.twomartens.support.service
import org.springframework.beans.factory.ObjectProvider
import org.springframework.cloud.bus.BusBridge
import org.springframework.cloud.bus.event.RemoteApplicationEvent
import org.springframework.stereotype.Service
@Service
class BusService(
private val busBridge: ObjectProvider<BusBridge>
) {
fun publishEvent(event: RemoteApplicationEvent) {
busBridge.ifAvailable {
it.send(event)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
plugins {
id("twomartens.spring-boot-cloud-base")
id("twomartens.kotlin")
kotlin("kapt")
}
dependencies {
implementation(libs.spring.boot.actuator)
implementation(libs.spring.boot.web)
implementation(libs.spring.openapi)
implementation(libs.bundles.spring.boot.security)
implementation(libs.spring.cloud.leader.election)
implementation(libs.spring.cloud.starter.bus.kafka)
}