Changed to Kotlin

This commit is contained in:
Jim Martens 2023-09-23 20:32:54 +02:00
parent 9bade2780c
commit d377cbd886
122 changed files with 2402 additions and 2343 deletions

View File

@ -1,15 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MainApplication" type="Application" factoryName="Application" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="de.twomartens.template.MainApplication" />
<module name="template.server.main" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="de.twomartens.template.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,21 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MainApplicationKt" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" value="dev" />
<module name="timetable.server.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="de.twomartens.timetable.MainApplicationKt" />
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="true" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
<ENTRY IS_ENABLED="true" PARSER="env" IS_EXECUTABLE="false" PATH=".env" />
</ENTRIES>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -1,13 +0,0 @@
plugins {
id 'twomartens.versions'
id 'twomartens.nebula-release'
}
versionCatalogUpdate {
sortByKey = false
keep {
keepUnusedVersions = true
keepUnusedLibraries = true
keepUnusedPlugins = true
}
}

17
build.gradle.kts Normal file
View File

@ -0,0 +1,17 @@
plugins {
id("twomartens.versions")
id("twomartens.nebula-release")
}
nebulaRelease {
addReleaseBranchPattern("/main/")
}
versionCatalogUpdate {
sortByKey.set(false)
keep {
keepUnusedVersions.set(true)
keepUnusedLibraries.set(true)
keepUnusedPlugins.set(true)
}
}

View File

@ -1,15 +0,0 @@
plugins {
id 'groovy-gradle-plugin'
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation libs.plugin.springboot
implementation libs.plugin.lombok
implementation libs.plugin.nebula.release
implementation libs.plugin.gradle.versions
implementation libs.plugin.version.catalog
}

19
buildSrc/build.gradle.kts Normal file
View File

@ -0,0 +1,19 @@
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
mavenCentral()
}
dependencies {
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
implementation(libs.plugin.kotlin.gradle)
implementation(libs.plugin.springboot)
implementation(libs.plugin.lombok)
implementation(libs.plugin.nebula.release)
implementation(libs.plugin.gradle.versions)
implementation(libs.plugin.version.catalog)
implementation(libs.plugin.jib)
}

View File

@ -1,4 +1,4 @@
rootProject.name = 'twomartens.config'
rootProject.name = "twomartens.config"
dependencyResolutionManagement {
versionCatalogs {

View File

@ -1,6 +0,0 @@
plugins {
id 'idea'
id 'eclipse'
}
group = projectgroup

View File

@ -1,23 +0,0 @@
plugins {
id 'checkstyle'
id 'twomartens.java-base'
}
checkstyle {
toolVersion '10.0'
ignoreFailures = false
maxWarnings = 0
configFile rootProject.file('config/checkstyle/checkstyle.xml')
configProperties = ['org.checkstyle.google.suppressionfilter.config': "${project.rootDir}/config/checkstyle/checkstyle-suppressions.xml"]
tasks.withType(Checkstyle).tap {
configureEach {
reports {
xml.required = true
html.required = true
}
}
}
}

View File

@ -1,22 +0,0 @@
plugins {
id 'java'
id 'java-library'
id 'twomartens.base'
}
sourceCompatibility = projectSourceCompatibility
targetCompatibility = projectSourceCompatibility
repositories {
mavenCentral()
}
tasks.register('buildAll') {
group 'build'
dependsOn(build)
dependsOn(test)
}
clean {
delete 'out'
}

View File

@ -1,15 +0,0 @@
plugins {
id 'twomartens.java-base'
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += "--enable-preview"
}
tasks.withType(Test).configureEach {
jvmArgs += "--enable-preview"
}
tasks.withType(JavaExec).configureEach {
jvmArgs += '--enable-preview'
}

View File

@ -1,71 +0,0 @@
import java.text.SimpleDateFormat
plugins {
id 'jacoco'
id 'io.freefair.lombok'
id 'twomartens.java-base'
id 'twomartens.checkstyle'
}
dependencies {
constraints.implementation libs.bundles.logging
implementation libs.slf4j.api
runtimeOnly libs.bundles.logging
testImplementation libs.bundles.test
}
configurations {
configureEach {
exclude group: 'junit', module: 'junit'
// we are using log4j-slf4j2-impl, so we need to suppress spring include of log4j-slf4j-impl
exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl'
}
}
test {
systemProperty 'junit.jupiter.execution.parallel.enabled', true
systemProperty 'junit.jupiter.execution.parallel.mode.default', "concurrent"
useJUnitPlatform()
maxHeapSize = "4g"
workingDir = rootProject.projectDir
finalizedBy jacocoTestReport
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
jacocoTestReport {
dependsOn(test)
reports {
xml.required = true
}
}
jar {
doFirst {
manifest {
attributes 'Implementation-Title': rootProject.name,
'Implementation-Version': archiveVersion.get(),
'Implementation-Vendor': "Jim Martens",
'Build-Timestamp': new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
'Created-By': "Gradle ${gradle.gradleVersion}",
'Build-Jdk': "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']})",
'Build-OS': "${System.properties['os.name']} ${System.properties['os.arch']} ${System.properties['os.version']}"
}
}
}
normalization.runtimeClasspath.metaInf {
ignoreAttribute("Build-Timestamp")
}
tasks.register('cleanLibs') {
delete("${buildDir}/libs")
}
tasks.build.dependsOn("cleanLibs")

View File

@ -1,16 +0,0 @@
plugins {
id 'com.netflix.nebula.release'
id 'twomartens.base'
}
nebulaRelease {
addReleaseBranchPattern(/main/)
}
task writeVersionProperties() {
group 'version'
buildDir.mkdirs()
file("$buildDir/version.properties").text = "VERSION=${project.version.toString()}\n"
mustRunAfter("release")
outputs.file("$buildDir/version.properties")
}

View File

@ -1,40 +0,0 @@
plugins {
id 'twomartens.spring-boot'
}
dependencies {
implementation platform(libs.spring.cloud)
implementation libs.bundles.spring.boot.server
implementation libs.spring.openapi
implementation libs.httpclient
implementation libs.prometheus
}
sourceSets {
"server-test" {
java {
compileClasspath += main.output + test.output
runtimeClasspath += main.output + test.output
srcDir file('src/server-test/java')
}
resources.srcDir file('src/server-test/resources')
}
}
configurations {
serverTestImplementation.extendsFrom testImplementation
}
tasks.register('serverTest', Test) {
outputs.upToDateWhen { false }
systemProperty 'junit.jupiter.execution.parallel.enabled', true
systemProperty 'junit.jupiter.execution.parallel.mode.default', "concurrent"
systemProperty 'junit.jupiter.execution.parallel.mode.classes.default', "concurrent"
useJUnitPlatform()
maxHeapSize = "4g"
group = 'verification'
workingDir = rootProject.projectDir
testClassesDirs = sourceSets."server-test".output.classesDirs
classpath = sourceSets."server-test".runtimeClasspath
}

View File

@ -1,60 +0,0 @@
plugins {
id 'org.springframework.boot'
id 'twomartens.java'
}
dependencies {
implementation platform(libs.spring.boot)
implementation libs.bundles.spring.boot
testImplementation libs.spring.boot.test
annotationProcessor libs.spring.boot.config
}
sourceSets {
"integration-test" {
java {
compileClasspath += main.output + test.output
runtimeClasspath += main.output + test.output
srcDir file('src/integration-test/java')
}
resources.srcDir file('src/integration-test/resources')
}
}
configurations {
configureEach {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
integrationTestImplementation.extendsFrom testImplementation
}
tasks.register('integrationTest', Test) {
systemProperty 'junit.jupiter.execution.parallel.enabled', true
systemProperty 'junit.jupiter.execution.parallel.mode.default', "concurrent"
systemProperty 'junit.jupiter.execution.parallel.mode.classes.default', "concurrent"
useJUnitPlatform()
maxHeapSize = "4g"
group = 'verification'
workingDir = rootProject.projectDir
testClassesDirs = sourceSets."integration-test".output.classesDirs
classpath = sourceSets."integration-test".runtimeClasspath
}
tasks.named("buildAll").configure() {
dependsOn(integrationTest)
}
springBoot {
buildInfo()
}
bootJar {
enabled = false
}
jar {
enabled = true
archiveClassifier.set("")
}

View File

@ -1,29 +0,0 @@
plugins {
id "com.github.ben-manes.versions"
id "nl.littlerobots.version-catalog-update"
}
dependencyUpdates {
revision = "release"
gradleReleaseChannel = "current"
}
def isNonStable = { String version ->
def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) }
def regex = /^[0-9,.v-]+(-r)?$/
return !stableKeyword && !(version ==~ regex)
}
tasks.named("dependencyUpdates").configure {
rejectVersionIf {
isNonStable(it.candidate.version)
}
}
tasks.named("versionCatalogUpdate").configure {
group 'version'
}
tasks.named("dependencyUpdates").configure {
group 'version'
}

View File

@ -0,0 +1,6 @@
plugins {
idea
}
val projectgroup: String = providers.gradleProperty("projectgroup").get()
group = projectgroup

View File

@ -0,0 +1,23 @@
plugins {
checkstyle
id("twomartens.java-base")
}
checkstyle {
toolVersion = "10.0"
isIgnoreFailures = false
maxWarnings = 0
configFile = rootProject.file("config/checkstyle/checkstyle.xml")
configProperties = mapOf<String, String>(
"org.checkstyle.google.suppressionfilter.config" to
"${project.rootDir}/config/checkstyle/checkstyle-suppressions.xml")
}
tasks.withType<Checkstyle>().configureEach {
reports {
xml.required.set(true)
html.required.set(true)
}
}

View File

@ -0,0 +1,30 @@
plugins {
java
`java-library`
id("twomartens.base")
application
}
val projectSourceCompatibility: String = rootProject.properties["projectSourceCompatibility"].toString()
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(projectSourceCompatibility))
}
}
repositories {
mavenCentral()
}
tasks.register("buildAll") {
group = "build"
dependsOn("build")
dependsOn("test")
}
tasks.clean {
doFirst {
delete("out")
}
}

View File

@ -0,0 +1,15 @@
plugins {
id("twomartens.java-base")
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.plusAssign("--enable-preview")
}
tasks.withType<Test>().configureEach {
jvmArgs.plusAssign("--enable-preview")
}
tasks.withType<JavaExec>().configureEach {
jvmArgs.plusAssign("--enable-preview")
}

View File

@ -0,0 +1,62 @@
import org.gradle.accessors.dm.LibrariesForLibs
plugins {
jacoco
id("io.freefair.lombok")
id("twomartens.java-base")
id("twomartens.checkstyle")
}
val libs = the<LibrariesForLibs>()
dependencies {
constraints.implementation(libs.bundles.logging)
implementation(libs.slf4j.api)
runtimeOnly(libs.bundles.logging)
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.launcher)
}
configurations {
configureEach {
exclude(group="junit", module="junit")
// we are using log4j-slf4j2-impl, so we need to suppress spring include of log4j-slf4j-impl
exclude(group="org.apache.logging.log4j", module="log4j-slf4j-impl")
}
}
tasks.withType<Test>().configureEach {
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
useJUnitPlatform()
maxHeapSize = "4g"
workingDir = rootProject.projectDir
finalizedBy(tasks.jacocoTestReport)
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
}
}
normalization.runtimeClasspath.metaInf {
ignoreAttribute("Build-Timestamp")
}
tasks.register("cleanLibs") {
delete("${buildDir}/libs")
}
tasks.build {
dependsOn("cleanLibs")
}

View File

@ -0,0 +1,21 @@
plugins {
id("com.google.cloud.tools.jib")
id("twomartens.java-base")
}
tasks.named("jib") {
dependsOn("build")
}
tasks.named("jibDockerBuild") {
dependsOn("build")
}
tasks.named("build") {
dependsOn("cleanCache")
}
tasks.register("cleanCache") {
delete("${buildDir}/jib-cache")
delete("${buildDir}/libs")
}

View File

@ -0,0 +1,18 @@
import org.gradle.accessors.dm.LibrariesForLibs
plugins {
kotlin("jvm")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(libs.kotlin.logging)
implementation(kotlin("reflect"))
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xjvm-default=all")
}
}

View File

@ -0,0 +1,18 @@
import java.nio.file.Files
plugins {
id("twomartens.base")
}
apply(plugin="com.netflix.nebula.release")
tasks.register("writeVersionProperties") {
group = "version"
mustRunAfter("release")
outputs.file("$buildDir/version.properties")
val directory = buildDir
doLast {
Files.createDirectories(directory.toPath())
File("$buildDir/version.properties").writeText("VERSION=${project.version}\n")
}
}

View File

@ -0,0 +1,49 @@
import org.gradle.accessors.dm.LibrariesForLibs
plugins {
id("twomartens.spring-boot")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.cloud))
implementation(libs.bundles.spring.boot.server)
implementation(libs.spring.openapi)
implementation(libs.httpclient)
implementation(libs.prometheus)
}
sourceSets {
create("server-test") {
java {
compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output
runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output
setSrcDirs(listOf("src/server-test"))
}
}
}
idea {
module {
testSources.from(sourceSets["server-test"].java.srcDirs)
}
}
val serverTestImplementation: Configuration by configurations.getting {
extendsFrom(configurations["testImplementation"])
}
tasks.register<Test>("serverTest") {
outputs.upToDateWhen { false }
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent")
useJUnitPlatform()
maxHeapSize = "4g"
group = "verification"
workingDir = rootProject.projectDir
testClassesDirs = sourceSets["server-test"].output.classesDirs
classpath = sourceSets["server-test"].runtimeClasspath
}

View File

@ -0,0 +1,80 @@
import org.gradle.accessors.dm.LibrariesForLibs
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatter.ofPattern
plugins {
id("org.springframework.boot")
id("twomartens.java")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.boot))
implementation(libs.bundles.spring.boot)
testImplementation(libs.spring.boot.test)
}
sourceSets {
create("integration-test") {
java {
compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output
runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output
setSrcDirs(listOf("src/integration-test"))
}
}
}
idea {
module {
testSources.from(sourceSets["integration-test"].java.srcDirs)
}
}
val integrationTestImplementation: Configuration by configurations.getting {
extendsFrom(configurations.testImplementation.get())
}
configurations {
configureEach {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
}
}
tasks.register<Test>("integrationTest") {
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent")
useJUnitPlatform()
maxHeapSize = "4g"
group = "verification"
workingDir = rootProject.projectDir
testClassesDirs = sourceSets["integration-test"].output.classesDirs
classpath = sourceSets["integration-test"].runtimeClasspath
}
tasks.named("buildAll") {
dependsOn("integrationTest")
}
val formatter: DateTimeFormatter = ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
tasks.jar {
manifest {
attributes["Implementation-Title"] = rootProject.name
attributes["Implementation-Version"] = archiveVersion.get()
attributes["Implementation-Vendor"] = "Jim Martens"
attributes["Build-Timestamp"] = ZonedDateTime.now().format(formatter)
attributes["Created-By"] = "Gradle ${gradle.gradleVersion}"
attributes["Build-Jdk"] = "${providers.systemProperty("java.version").get()} (${providers.systemProperty("java.vendor").get()} ${providers.systemProperty("java.vm.version").get()})"
attributes["Build-OS"] = "${providers.systemProperty("os.name").get()} ${providers.systemProperty("os.arch").get()} ${providers.systemProperty("os.version").get()}"
}
}
springBoot {
buildInfo()
mainClass.set("de.twomartens.timetable.MainApplicationKt")
}

View File

@ -0,0 +1,32 @@
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
plugins {
id("com.github.ben-manes.versions")
id("nl.littlerobots.version-catalog-update")
}
tasks.withType<DependencyUpdatesTask>().configureEach {
revision = "release"
gradleReleaseChannel = "current"
}
fun String.isNonStable(): Boolean {
val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { uppercase().contains(it) }
val regex = "^[0-9,.v-]+(-r)?$".toRegex()
val isStable = stableKeyword || regex.matches(this)
return isStable.not()
}
tasks.withType<DependencyUpdatesTask> {
rejectVersionIf {
candidate.version.isNonStable()
}
}
tasks.named("versionCatalogUpdate").configure {
group = "version"
}
tasks.named("dependencyUpdates").configure {
group = "version"
}

View File

@ -1,4 +1,4 @@
projectname=template
projectname=timetable
projectgroup=de.2martens
projectSourceCompatibility=17
file.encoding=utf-8
@ -6,4 +6,5 @@ org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.welcome=never
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
kapt.include.compile.classpath=false

View File

@ -1,22 +1,30 @@
[versions]
spring-boot = "3.0.5"
spring-boot = "3.1.2"
spring-doc = "2.1.0"
spring-cloud = "2022.0.2"
spring-cloud = "2022.0.4"
spring-grpc = "2.14.0.RELEASE"
grpc = "1.54.0"
grpc = "1.57.0"
tomcat-annotations = "6.0.53"
httpclient = "5.2.1"
jaxb = "4.0.3"
jakarta-xml = "4.0.1"
slf4j = "2.0.7"
log4j = "2.20.0"
log4j-ecs = "1.5.0"
mapstruct = "1.5.3.Final"
junit = "5.9.2"
mapstruct = "1.5.5.Final"
junit = "5.10.0"
assertj = "3.24.2"
mockito = "5.3.0"
plugin-nebula-release = "17.1.0"
mockito = "5.4.0"
keycloak = "22.0.1"
kotlin-logging = "3.0.5"
kotlin-reflect = "1.9.0"
kotlin-lombok = "1.9.0"
plugin-nebula-release = "17.2.2"
plugin-lombok = "8.0.1"
plugin-gradle-versions = "0.46.0"
plugin-version-catalog = "0.8.0"
plugin-kotlin-gradle = "1.9.0"
plugin-jib = "3.3.2"
[libraries]
spring-boot = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
@ -27,14 +35,23 @@ spring-boot-thymeleaf = { module = "org.springframework.boot:spring-boot-starter
spring-boot-mongo = { module = "org.springframework.boot:spring-boot-starter-data-mongodb" }
spring-boot-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
spring-boot-config = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" }
spring-cloud = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "spring-cloud" }
spring-cloud-starter = { module = "org.springframework.cloud:spring-cloud-starter" }
spring-cloud-starter-bus-kafka = { module = "org.springframework.cloud:spring-cloud-starter-bus-kafka" }
spring-cloud-starter-config = { module = "org.springframework.cloud:spring-cloud-starter-config" }
spring-cloud-config-server = { module = "org.springframework.cloud:spring-cloud-config-server" }
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter" }
spring-grpc = { module = "net.devh:grpc-spring-boot-starter", version.ref = "spring-grpc" }
spring-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "spring-doc" }
spring-sec = { module = "org.springdoc:springdoc-openapi-starter-common", version.ref = "spring-doc" }
spring-openapi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-api", version.ref = "spring-doc" }
jaxb-impl = { module = "com.sun.xml.bind:jaxb-impl", version.ref="jaxb" }
jakarta-xml-binding = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version.ref = "jakarta-xml" }
keycloak-core = { module = "org.keycloak:keycloak-core", version.ref = "keycloak" }
keycloak-policy-enforcer = { module = "org.keycloak:keycloak-policy-enforcer", version.ref = "keycloak" }
grpc-api = { module = "io.grpc:grpc-api", version.ref = "grpc" }
grpc-context = { module = "io.grpc:grpc-context", version.ref = "grpc" }
grpc-core = { module = "io.grpc:grpc-core", version.ref = "grpc" }
@ -58,15 +75,20 @@ log4j-jul = { module = "org.apache.logging.log4j:log4j-jul", version.ref = "log4
log4j-slf4j = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-launcher = { module = "org.junit.platform:junit-platform-launcher" }
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockito-inline = "org.mockito:mockito-inline:5.2.0"
mockito-junit = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-reflect" }
plugin-nebula-release = { module = "com.netflix.nebula:nebula-release-plugin", version.ref = "plugin-nebula-release" }
plugin-springboot = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" }
plugin-lombok = { module = "io.freefair.gradle:lombok-plugin", version.ref = "plugin-lombok" }
plugin-gradle-versions = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "plugin-gradle-versions" }
plugin-version-catalog = { module = "nl.littlerobots.vcu:plugin", version.ref = "plugin-version-catalog" }
plugin-kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "plugin-kotlin-gradle" }
plugin-jib = { module = "com.google.cloud.tools:jib-gradle-plugin", version.ref = "plugin-jib" }
[bundles]
logging = [
@ -88,10 +110,9 @@ grpc = [
"grpc-stub",
]
spring-boot = [
"spring-boot-starter",
"spring-boot-log4j",
"spring-boot-starter",
]
spring-boot-server = [
"spring-boot-actuator",
"spring-boot-log4j",
@ -103,6 +124,12 @@ spring-boot-server = [
"spring-sec",
"spring-ui",
]
spring-boot-security = [
"keycloak-core",
"keycloak-policy-enforcer",
"spring-boot-oauth2-resource-server",
"spring-boot-security",
]
test = [
"assertj",
"junit-jupiter",
@ -111,3 +138,6 @@ test = [
"mockito-inline",
"mockito-junit",
]
[plugins]
kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin-lombok" }

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,38 @@
plugins {
id("twomartens.jib")
}
dependencies {
implementation(project(":server"))
}
jib {
from {
image = "amazoncorretto:" + properties["projectSourceCompatibility"] + "-alpine"
platforms {
platform {
architecture = "amd64"
os = "linux"
}
platform {
architecture = "arm64"
os = "linux"
}
}
}
to {
image = "2martens/timetable"
tags = setOf(
"latest",
properties["version"].toString().replace("+", "-"))
auth {
username = System.getenv("USERNAME")
password = System.getenv("PASSWORD")
}
}
container {
mainClass = "de.twomartens.timetable.MainApplicationKt"
jvmFlags = listOf("-XX:+UseContainerSupport",
"-XX:MaxRAMPercentage=75.0")
}
}

View File

@ -1,7 +0,0 @@
plugins {
id 'twomartens.java'
}
dependencies {
}

View File

@ -1,5 +0,0 @@
package de.twomartens.template.model;
public record Name(String value) {
}

View File

@ -1,7 +0,0 @@
plugins {
id 'twomartens.java'
}
dependencies {
implementation project(':lib')
}

View File

@ -1,13 +0,0 @@
package de.twomartens.template;
import de.twomartens.template.model.Name;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Main {
public static void main(String[] args) {
Name name = new Name("World");
log.info("Hello %s!".formatted(name));
}
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Configuration status="warn" monitorInterval="30">
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout alwaysWriteExceptions="true"
pattern="%5p %d{HH:mm:ss.SSS} (%F:%L) %notEmpty{[%X{REQTYPE}] }%notEmpty{[%marker] }%K{event.end}%m%n%xEx{full}"/>
</Console>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="console"/>
</Root>
</Loggers>
</Configuration>

View File

@ -1,9 +0,0 @@
plugins {
id 'twomartens.spring-boot-cloud'
}
dependencies {
implementation libs.mapstruct.base
annotationProcessor libs.mapstruct.processor
}

View File

@ -0,0 +1,16 @@
plugins {
id("twomartens.spring-boot-cloud")
id("twomartens.kotlin")
kotlin("kapt")
}
dependencies {
implementation(libs.mapstruct.base)
implementation(libs.bundles.spring.boot.security)
implementation(libs.jaxb.impl)
implementation(libs.jakarta.xml.binding)
annotationProcessor(libs.mapstruct.processor)
kapt(libs.mapstruct.processor)
implementation(libs.spring.cloud.starter.config)
// implementation(libs.spring.cloud.starter.bus.kafka)
}

View File

@ -1,35 +0,0 @@
package de.twomartens.template;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class MainApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(MainApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
@Bean
public OpenAPI customOpenAPI(@Value("${openapi.description}") String apiDesciption,
@Value("${openapi.version}") String apiVersion, @Value("${openapi.title}") String apiTitle) {
return new OpenAPI()
.info(new Info()
.title(apiTitle)
.version(apiVersion)
.description(apiDesciption));
}
}

View File

@ -1,20 +0,0 @@
package de.twomartens.template.configuration;
import de.twomartens.template.property.ServiceProperties;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class ClockConfiguration {
private final ServiceProperties serviceProperties;
@Bean
public Clock clock() {
return Clock.system(serviceProperties.getDefaultTimeZone());
}
}

View File

@ -1,22 +0,0 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import de.twomartens.template.interceptor.LoggingInterceptorRest;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InterceptorConfiguration {
@Bean
public LoggingInterceptorRest loggingInterceptorRest(Clock clock) {
return new LoggingInterceptorRest(clock);
}
@Bean
public HeaderInterceptorRest headerInterceptorRest() {
return new HeaderInterceptorRest();
}
}

View File

@ -1,18 +0,0 @@
package de.twomartens.template.configuration;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfiguration {
@Bean
public GroupedOpenApi swaggerApi10() {
return GroupedOpenApi.builder()
.group("1.0")
.pathsToMatch("/template/v1/**")
.build();
}
}

View File

@ -1,34 +0,0 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import de.twomartens.template.interceptor.LoggingInterceptorRest;
import de.twomartens.template.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
public class RestTemplateConfiguration {
@Bean
public RestTemplate restTemplate(HeaderInterceptorRest headerInterceptorRest,
LoggingInterceptorRest loggingInterceptor,
RestTemplateTimeoutProperties restTemplateTimeoutProperties) {
return new RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest, loggingInterceptor)
.setConnectTimeout(restTemplateTimeoutProperties.getConnectionRestTemplateTimeoutInMillis())
.setReadTimeout(restTemplateTimeoutProperties.getReadTimeoutRestTemplateInMillis())
.build();
}
@Bean
public RestTemplate restTemplateRestHealthIndicator(HeaderInterceptorRest headerInterceptorRest,
RestTemplateTimeoutProperties restTemplateTimeoutProperties) {
return new RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest)
.setConnectTimeout(restTemplateTimeoutProperties.getConnectionRestHealthIndicatorTimeoutInMillis())
.setReadTimeout(restTemplateTimeoutProperties.getReadTimeoutRestHealthIndicatorInMillis())
.build();
}
}

View File

@ -1,28 +0,0 @@
package de.twomartens.template.configuration;
import de.twomartens.template.monitoring.statusprobe.CountBasedStatusProbe;
import de.twomartens.template.monitoring.statusprobe.StatusProbe;
import de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality;
import de.twomartens.template.monitoring.statusprobe.StatusProbeLogger;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class StatusProbeConfiguration {
private final Clock clock;
@Bean
public StatusProbeLogger statusProbeLogger() {
return new StatusProbeLogger(clock);
}
@Bean
public StatusProbe testStatusProbe(StatusProbeLogger statusProbeLogger) {
return new CountBasedStatusProbe(1,
clock, StatusProbeCriticality.K1, "testStatusProbe", statusProbeLogger);
}
}

View File

@ -1,28 +0,0 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfiguration implements WebMvcConfigurer {
private final HeaderInterceptorRest headerInterceptorRest;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(headerInterceptorRest);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
CorsRegistration registration = registry.addMapping("/**");
registration.allowedOrigins("*");
}
}

View File

@ -1,37 +0,0 @@
package de.twomartens.template.controller;
import de.twomartens.template.exception.HttpStatusException;
import de.twomartens.template.model.dto.ErrorMessage;
import lombok.extern.slf4j.Slf4j;
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)
@Slf4j
public class ExceptionController extends ResponseEntityExceptionHandler {
@ExceptionHandler(HttpStatusException.class)
public ResponseEntity<ErrorMessage> handleException(HttpStatusException e) {
if (e.getCause() != null) {
log.info(e.getCause().toString(), e.getCause());
} else {
log.info(e.toString());
}
return ResponseEntity.status(e.getStatus()).body(ErrorMessage.builder()
.message(e.getMessage())
.build());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorMessage> handleRuntimeException(RuntimeException e) {
log.error("unexpected exception occurred", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorMessage.builder()
.message(e.getMessage())
.build());
}
}

View File

@ -1,95 +0,0 @@
package de.twomartens.template.controller;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
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;
/**
* used to show version and title information on html pages
*/
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping(value = "/vorlage")
public class VersionHtmlController {
@GetMapping(path = "/html/version.html")
public String version() {
return "version";
}
@ControllerAdvice
public static class VersionControllerAdvice {
@ModelAttribute("version")
public String getApplicationVersion() {
return getTitle() + " " + getVersion();
}
@ModelAttribute("footerString")
public String getApplicationVersion(@RequestHeader("host") String hostName) {
return getTitle() + " " + getVersion() + " - " + hostName;
}
private String getTitle() {
return Optional.ofNullable(VersionControllerAdvice.class.getPackage().getImplementationTitle())
.filter(s -> !s.isBlank())
.orElse("application");
}
public String getVersion() {
return Optional.ofNullable(VersionControllerAdvice.class.getPackage().getImplementationVersion())
.filter(s -> !s.isBlank())
.orElse("DEVELOPER");
}
@ModelAttribute("hostname")
public String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
log.warn(e.toString(), e);
}
return "";
}
@ModelAttribute("manifest")
private Collection<String> getManifest() {
try {
URL location = getClass().getProtectionDomain().getCodeSource().getLocation();
String jarFileName = Paths.get(location.toURI()).toString();
try (JarFile jarFile = new JarFile(jarFileName)) {
ZipEntry entry = jarFile.getEntry(JarFile.MANIFEST_NAME);
try (InputStream in = jarFile.getInputStream(entry)) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8).lines().toList();
}
}
} catch (FileNotFoundException ignored) {
// do nothing if manifest file is not available
} catch (Exception e) {
log.info(e.toString(), e);
}
return List.of(getTitle() + " " + getVersion());
}
}
}

View File

@ -1,59 +0,0 @@
package de.twomartens.template.controller.v1;
import de.twomartens.template.mapper.v1.GreetingMapper;
import de.twomartens.template.model.dto.v1.Greeting;
import de.twomartens.template.service.GreetingService;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.factory.Mappers;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/template/v1")
@Tag(name = "Greeting Example", description = "all requests relating to greetings")
public class GreetingRestController {
private final GreetingMapper mapper = Mappers.getMapper(GreetingMapper.class);
private final GreetingService service;
@Operation(
summary = "Returns a greeting message",
responses = {@ApiResponse(
responseCode = "200")
}
)
@GetMapping("/greeting")
public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
return mapper.map(service.createGreeting(name));
}
@Operation(
summary = "Posts a greeting message to db",
responses = {@ApiResponse(
responseCode = "200")
}
)
@PostMapping("/greeting")
public void postGreeting(@RequestBody Greeting greeting) {
service.postGreeting(mapper.map(greeting));
}
@Hidden
@GetMapping("/healthCheck")
public String checkHealth(@RequestParam(value = "message") String message) {
return message;
}
}

View File

@ -1,20 +0,0 @@
package de.twomartens.template.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class HttpStatusException extends RuntimeException {
private final HttpStatus status;
public HttpStatusException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public HttpStatusException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
}

View File

@ -1,82 +0,0 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.twomartens.template.interceptor;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.StreamUtils;
/**
* Simple implementation of {@link ClientHttpResponse} that reads the response's body into memory, thus allowing for
* multiple invocations of {@link #getBody()}.
*
* @author Arjen Poutsma
* @since 3.1
*/
public final class BufferingClientHttpResponseWrapper implements ClientHttpResponse {
private final ClientHttpResponse response;
@Nullable
private byte[] body;
public BufferingClientHttpResponseWrapper(ClientHttpResponse response) {
this.response = response;
}
@Override
public HttpStatusCode getStatusCode() throws IOException {
return this.response.getStatusCode();
}
@Override
@Deprecated
public int getRawStatusCode() throws IOException {
return this.response.getRawStatusCode();
}
@Override
public String getStatusText() throws IOException {
return this.response.getStatusText();
}
@Override
public HttpHeaders getHeaders() {
return this.response.getHeaders();
}
@Override
public InputStream getBody() throws IOException {
if (this.body == null) {
this.body = StreamUtils.copyToByteArray(this.response.getBody());
}
return new ByteArrayInputStream(this.body);
}
@Override
public void close() {
this.response.close();
}
}

View File

@ -1,87 +0,0 @@
package de.twomartens.template.interceptor;
import java.io.Closeable;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
public abstract class HeaderInterceptor {
public static final String LOGGER_TRACE_ID = "trace.id";
public static final String LOGGER_REQTYPE_ID = "REQTYPE";
public static final String HEADER_FIELD_TRACE_ID = "X-TraceId";
public static final String HEADER_FIELD_B3_TRACE_ID = "x-b3-traceid";
public static final String HEADER_FIELD_TYPE_ID = "x-type";
public static final String REQ_TYPE_HEALTHCHECK = "HEALTH_CHECK";
public static final String REQ_TYPE_INTEGRATION_TEST = "INTEGRATION_TEST";
public static final String REQ_TYPE_SERVER_TEST = "SERVER_TEST";
public static final String REQ_TYPE_WARMUP = "WARMUP";
public String getTraceId() {
return Optional.ofNullable(MDC.get(LOGGER_TRACE_ID))
.filter(s -> !s.isBlank())
.orElse(createNewTraceId());
}
public Optional<String> getRequestType() {
return Optional.ofNullable(MDC.get(LOGGER_REQTYPE_ID))
.filter(requestType -> !requestType.isBlank());
}
public InterceptorCloseables setTraceId(String traceId) {
return new InterceptorCloseables(MDC.putCloseable(LOGGER_TRACE_ID, traceId));
}
public InterceptorCloseables mark(String requestType) {
return new InterceptorCloseables(MDC.putCloseable(LOGGER_REQTYPE_ID, requestType));
}
public InterceptorCloseables set(String traceId, String requestType) {
return requestType != null
? new InterceptorCloseables(setTraceId(traceId), mark(requestType))
: setTraceId(traceId);
}
public InterceptorCloseables markAsHealthCheck() {
return new InterceptorCloseables(mark(REQ_TYPE_HEALTHCHECK), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsIntegrationTest() {
return new InterceptorCloseables(mark(REQ_TYPE_INTEGRATION_TEST), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsServerTest() {
return new InterceptorCloseables(mark(REQ_TYPE_SERVER_TEST), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsWarmup() {
return new InterceptorCloseables(mark(REQ_TYPE_WARMUP), setTraceId(createNewTraceId()));
}
private static String createNewTraceId() {
return UUID.randomUUID().toString();
}
public static class InterceptorCloseables implements Closeable {
private final Closeable[] closeables;
private InterceptorCloseables(Closeable... closeables) {
this.closeables = closeables;
}
@Override
public void close() {
for (Closeable closeable : closeables) {
try {
closeable.close();
} catch (Exception ignored) {
// do nothing
}
}
}
}
}

View File

@ -1,68 +0,0 @@
package de.twomartens.template.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
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.lang.NonNull;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
public class HeaderInterceptorRest extends HeaderInterceptor
implements HandlerInterceptor, ClientHttpRequestInterceptor {
public static final String CLASS_NAME = HeaderInterceptorRest.class.getName();
// ClientHttpRequestInterceptor
@Override
@NonNull
public ClientHttpResponse intercept(HttpRequest request, @NonNull byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add(HEADER_FIELD_TRACE_ID, getTraceId());
getRequestType().ifPresent(requestType -> request.getHeaders().add(HEADER_FIELD_TYPE_ID, requestType));
try {
return execution.execute(request, body);
} finally {
request.getHeaders().remove(HEADER_FIELD_TRACE_ID);
request.getHeaders().remove(HEADER_FIELD_TYPE_ID);
}
}
// HandlerInterceptor
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull Object handler) {
String traceId = extractTraceId(request);
String requestType = extractRequestType(request);
InterceptorCloseables closeable = set(traceId, requestType);
request.setAttribute(CLASS_NAME, closeable);
return true;
}
public static String extractTraceId(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HEADER_FIELD_TRACE_ID)).filter(s -> !s.isBlank())
.or(() -> Optional.ofNullable(request.getHeader(HEADER_FIELD_B3_TRACE_ID)).filter(s -> !s.isBlank()))
.orElse(UUID.randomUUID().toString());
}
public static String extractRequestType(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HEADER_FIELD_TYPE_ID))
.filter(reqType -> !reqType.isBlank())
.orElse(null);
}
// HandlerInterceptor
@Override
public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull Object handler, ModelAndView modelAndView) {
Optional.ofNullable(request.getAttribute(CLASS_NAME))
.filter(InterceptorCloseables.class::isInstance)
.map(InterceptorCloseables.class::cast)
.ifPresent(InterceptorCloseables::close);
}
}

View File

@ -1,419 +0,0 @@
package de.twomartens.template.interceptor;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
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.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.MarkerManager.Log4jMarker;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.util.StreamUtils;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
@Log4j2
@RequiredArgsConstructor
public class LoggingInterceptorRest implements Filter, ClientHttpRequestInterceptor {
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
public static final Marker MARKER = new Log4jMarker("communication");
private static final int MAX_LOG_SIZE = 20480; // 20 KB - logging could fail with bigger logmessages
public static final String DIRECTION_IN = "inbound";
public static final String DIRECTION_OUT = "outbound";
public static final String PROTOCOL_NAME = "http";
public static final String PARAM_URL_FULL = "url.full";
public static final String PARAM_URL_DOMAIN = "url.domain";
public static final String PARAM_URL_EXTENSION = "url.extension";
public static final String PARAM_URL_PATH = "url.path";
public static final String PARAM_URL_PORT = "url.port";
public static final String PARAM_URL_SCHEME = "url.scheme";
public static final String PARAM_URL_QUERY = "url.query";
public static final String PARAM_BUSINESS_TYPE = "http.request.type";
public static final String PARAM_REQUEST_BODY = "http.request.body.content";
public static final String PARAM_RESPONSE_BODY = "http.response.body.content";
public static final String PARAM_RESPONSE_STATUS = "http.response.status_code";
public static final String PARAM_REQUEST_HEADERS = "http.request.headers";
public static final String PARAM_RESPONSE_HEADERS = "http.response.headers";
public static final String PARAM_REQUEST_BYTES = "http.request.body.bytes";
public static final String PARAM_RESPONSE_BYTES = "http.response.body.bytes";
public static final String PARAM_REQUEST_MIMETYPE = "http.request.mime_type";
public static final String PARAM_RESPONSE_MIMETYPE = "http.response.mime_type";
public static final String PARAM_REQUEST_METHOD = "http.request.method";
public static final String PARAM_REQUEST_REFERER = "http.request.referrer";
public static final String PARAM_REQUEST_TIME = "event.start";
public static final String PARAM_RESPONSE_TIME = "event.end";
public static final String PARAM_DURATION = "event.duration";
public static final String PARAM_USER_AGENT = "user_agent.original";
public static final String PARAM_DIRECTION = "network.direction";
public static final String PARAM_PROTOCOL = "network.protocol";
private final FieldLogBehaviour requestLogBehaviour;
private final FieldLogBehaviour responseLogBehaviour;
private final Clock clock;
public LoggingInterceptorRest(Clock clock) {
this(FieldLogBehaviour.NEVER, FieldLogBehaviour.NEVER, clock);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
ZonedDateTime requestTime = ZonedDateTime.now(clock);
HttpServletRequest httpRequest = (HttpServletRequest) request;
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(
(HttpServletResponse) response);
byte[] fullResponseBytes = null;
Throwable throwable = null;
String responseBody = null;
int httpStatusCode = -1;
try {
try {
chain.doFilter(
requestLogBehaviour != FieldLogBehaviour.NEVER ? requestWrapper : httpRequest,
responseWrapper);
if (responseLogBehaviour != FieldLogBehaviour.NEVER) {
fullResponseBytes = responseWrapper.getContentAsByteArray();
}
httpStatusCode = responseWrapper.getStatus();
} finally {
responseWrapper.copyBodyToResponse();
}
} catch (Exception e) {
throwable = e;
throw e;
} finally {
try {
int responseSize = responseWrapper.getContentSize();
Map<String, Collection<String>> responseHeaders = extractHeaders(
responseWrapper.getHeaderNames().iterator(),
s -> responseWrapper.getHeaders(s).iterator());
if ((responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS) && fullResponseBytes != null) {
responseBody = new String(fullResponseBytes,
determineResponseEncoding(responseHeaders, fullResponseBytes));
}
URL requestUrl = new URL(Optional.ofNullable(httpRequest.getQueryString())
.map(qs -> httpRequest.getRequestURL().toString() + "?" + qs)
.orElse(httpRequest.getRequestURL().toString()));
Map<String, Collection<String>> requestHeaders = extractHeaders(
httpRequest.getHeaderNames().asIterator(),
s -> httpRequest.getHeaders(s).asIterator());
String requestBody = null;
String businessType = null;
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS) {
byte[] fullRequestBytes = requestWrapper.getContentAsByteArray();
if (fullRequestBytes != null) {
requestBody = new String(fullRequestBytes,
determineRequestEncoding(requestHeaders, fullRequestBytes));
}
businessType = determineBusinessType(requestUrl, requestBody);
}
log(LogMessage.builder()
.requestHeaders(requestHeaders)
.responseHeaders(responseHeaders)
.url(requestUrl)
.method(httpRequest.getMethod())
.requestMimeType(typeToString(request.getContentType()))
.responseMimeType(typeToString(response.getContentType()))
.requestBody(requestBody)
.responseBody(responseBody)
.requestSize(httpRequest.getContentLength())
.responseSize(responseSize)
.httpStatus(httpStatusCode)
.direction(DIRECTION_IN)
.requestTime(requestTime)
.responseTime(ZonedDateTime.now(clock))
.traceId(HeaderInterceptorRest.extractTraceId(httpRequest))
.requestType(HeaderInterceptorRest.extractRequestType(httpRequest))
.businessType(businessType)
.throwable(throwable)
.build());
} catch (RuntimeException e) {
log.error(e.toString(), e);
}
}
}
private boolean isError(int httpStatusCode) {
return httpStatusCode >= 400 && httpStatusCode < 600;
}
@NonNull
@Override
public ClientHttpResponse intercept(@NonNull HttpRequest request,
@NonNull byte[] requestBytes, @NonNull ClientHttpRequestExecution execution)
throws IOException {
ZonedDateTime requestTime = ZonedDateTime.now(clock);
int responseSize = 0;
Map<String, Collection<String>> responseHeaders = Collections.emptyMap();
MediaType responseMediaType = null;
int httpStatusCode = -1;
Throwable throwable = null;
String requestBody = null;
String responseBody = null;
String businessType = null;
try {
BufferingClientHttpResponseWrapper result = new BufferingClientHttpResponseWrapper(
execution.execute(request, requestBytes));
byte[] responseBytes = StreamUtils.copyToByteArray(result.getBody());
responseSize = responseBytes.length;
responseHeaders = extractHeaders(result.getHeaders());
responseMediaType = result.getHeaders().getContentType();
httpStatusCode = result.getStatusCode().value();
if (responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS) {
responseBody = new String(responseBytes,
determineRequestEncoding(responseHeaders, responseBytes));
}
return result;
} catch (Exception e) {
throwable = e;
throw e;
} finally {
try {
URL url = request.getURI().toURL();
Map<String, Collection<String>> requestHeaders = extractHeaders(request.getHeaders());
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS) {
requestBody = new String(requestBytes,
determineRequestEncoding(requestHeaders, requestBytes));
businessType = determineBusinessType(url, requestBody);
}
log(LogMessage.builder()
.requestHeaders(requestHeaders)
.responseHeaders(responseHeaders)
.url(url)
.method(Objects.requireNonNull(request.getMethod()).toString())
.requestMimeType(typeToString(request.getHeaders().getContentType()))
.requestBody(requestBody)
.responseBody(responseBody)
.responseMimeType(typeToString(responseMediaType))
.requestSize(requestBytes.length)
.responseSize(responseSize)
.httpStatus(httpStatusCode)
.direction(DIRECTION_OUT)
.requestTime(requestTime)
.responseTime(ZonedDateTime.now(clock))
.businessType(businessType)
.throwable(throwable)
.build());
} catch (RuntimeException e) {
log.error(e.toString(), e);
}
}
}
private void log(LogMessage logMessage) {
StringMapMessage stringMapMessage = new 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().getHost());
addLogString(stringMapMessage, PARAM_URL_EXTENSION,
extractExtension(logMessage.url().getPath()));
addLogString(stringMapMessage, PARAM_URL_PATH, logMessage.url().getPath());
addLogString(stringMapMessage, PARAM_URL_PORT, Integer.toString(logMessage.url().getPort()));
addLogString(stringMapMessage, PARAM_URL_SCHEME, logMessage.url().getProtocol());
addLogString(stringMapMessage, PARAM_URL_QUERY, logMessage.url().getQuery());
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, Integer.toString(logMessage.requestSize()));
addLogString(stringMapMessage, PARAM_RESPONSE_BYTES,
Integer.toString(logMessage.responseSize()));
addLogString(stringMapMessage, PARAM_RESPONSE_STATUS,
Integer.toString(logMessage.httpStatus()));
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,
Long.toString(getDurationBetweenRequestAndResponseTime(logMessage).toNanos()));
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, logMessage.throwable());
}
private Duration getDurationBetweenRequestAndResponseTime(LogMessage logMessage) {
return Duration.between(logMessage.requestTime(), logMessage.responseTime());
}
private String getHeader(Map<String, Collection<String>> headers, String headerKey) {
return headers.entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase(headerKey))
.flatMap(e -> e.getValue().stream())
.findAny()
.orElse(null);
}
private void addLogString(StringMapMessage stringMapMessage, String key, String value) {
if (value != null && !value.isBlank()) {
stringMapMessage.with(key, value.trim());
}
}
private static Map<String, Collection<String>> extractHeaders(Iterator<String> headerNames,
Function<String, Iterator<String>> headerValuesSupplier) {
Map<String, Collection<String>> requestHeaders = new HashMap<>();
while (headerNames.hasNext()) {
String name = headerNames.next();
Collection<String> values = requestHeaders.computeIfAbsent(name, n -> new TreeSet<>());
Iterator<String> headerValues = headerValuesSupplier.apply(name);
while (headerValues.hasNext()) {
values.add(headerValues.next());
}
}
return requestHeaders;
}
private static Map<String, Collection<String>> extractHeaders(HttpHeaders headers) {
Map<String, Collection<String>> result = new HashMap<>();
for (Entry<String, List<String>> entry : headers.entrySet()) {
result.put(entry.getKey(), new ArrayList<>(entry.getValue()));
}
return result;
}
private static String toHeaderString(Map<String, Collection<String>> headerMap) {
return headerMap.entrySet().stream()
.flatMap(es -> es.getValue().stream().map(v -> new KeyValuePair(es.getKey(), v)))
.map(kv -> kv.key() + "=" + kv.value())
.collect(Collectors.joining("\n"));
}
private static String typeToString(String contentType) {
try {
return Optional.ofNullable(contentType)
.map(MediaType::parseMediaType)
.map(LoggingInterceptorRest::typeToString)
.orElse(null);
} catch (RuntimeException e) {
log.info(e.toString(), e);
return e.toString();
}
}
private static String typeToString(MediaType mediaType) {
try {
return Optional.ofNullable(mediaType)
.map(m -> m.getType() + "/" + m.getSubtype())
.orElse(null);
} catch (RuntimeException e) {
log.info(e.toString(), e);
return e.toString();
}
}
private static String extractExtension(String fileName) {
return Optional.ofNullable(fileName)
.filter(name -> name.contains("."))
.map(name -> name.substring(name.lastIndexOf('.') + 1))
.orElse(null);
}
private static String cutToMaxLength(String string) {
if (string != null && string.length() > MAX_LOG_SIZE) {
return string.substring(0, MAX_LOG_SIZE);
}
return string;
}
/**
* usually returns null, but can be overridden to implement more complex logic
*/
public String determineBusinessType(URL requestUrl, String requestBody) {
return null;
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
public Charset determineRequestEncoding(Map<String, Collection<String>> requestHeaders,
byte[] fullRequest) {
return StandardCharsets.UTF_8;
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
public Charset determineResponseEncoding(Map<String, Collection<String>> responseHeaders,
byte[] fullResponse) {
return StandardCharsets.UTF_8;
}
@Builder
private record LogMessage(Map<String, Collection<String>> requestHeaders,
Map<String, Collection<String>> responseHeaders, URL url, String method,
String requestMimeType, String responseMimeType, String requestBody,
String responseBody,
int requestSize, int responseSize, int httpStatus, String direction,
ZonedDateTime requestTime, ZonedDateTime responseTime, String traceId,
String requestType,
String businessType, Throwable throwable) {
}
private record KeyValuePair(String key, String value) {
}
public enum FieldLogBehaviour {
NEVER, ONLY_ON_ERROR, ALWAYS
}
}

View File

@ -1,21 +0,0 @@
package de.twomartens.template.mapper.v1;
import de.twomartens.template.model.db.Greeting;
import org.mapstruct.CollectionMappingStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.NullValueCheckStrategy;
import org.mapstruct.ReportingPolicy;
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface GreetingMapper {
de.twomartens.template.model.dto.v1.Greeting map(Greeting greeting);
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
Greeting map(de.twomartens.template.model.dto.v1.Greeting greeting);
}

View File

@ -1,41 +0,0 @@
package de.twomartens.template.model.db;
import java.util.Date;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Greeting {
@Id
ObjectId id;
@CreatedDate
Date created;
@LastModifiedDate
Date lastModified;
String message;
}

View File

@ -1,24 +0,0 @@
package de.twomartens.template.model.dto;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorMessage {
String message;
}

View File

@ -1,31 +0,0 @@
package de.twomartens.template.model.dto.v1;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
import org.springframework.lang.NonNull;
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Schema(description = "Data container for a greeting message")
public class Greeting {
@NonNull
@Schema(description = "Data container for a greeting message", example = "Hello Helmut!",
defaultValue = "Hello World!")
private final String message;
}

View File

@ -1,84 +0,0 @@
package de.twomartens.template.monitoring.actuator;
import java.io.Closeable;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.slf4j.MDC.MDCCloseable;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
@Slf4j
@RequiredArgsConstructor
public abstract class AbstractHealthIndicator implements HealthIndicator {
public static final String HOST = "localhost";
public static final String HTTP_PREFIX = "http://";
public static final String HOST_PORT_SEPERATOR = ":";
public static final String PATH_SEPERATOR = "/";
public static final String PARAMETER_SEPERATOR = "?";
public static final String DETAIL_ENDPOINT_KEY = "endpoint";
private final String logStatusDownMessage = String.format("health indicator '%s' invoked with status '%s'",
indicatorName(), Status.DOWN.getCode());
private final String logStatusUpMessage = String.format("health indicator '%s' invoked with status '%s'",
indicatorName(), Status.UP.getCode());
private final Clock clock;
private final Preparable preparable;
private boolean firstTime = true;
/**
* main method that determines the health of the service
*/
protected abstract Health determineHealth();
@Override
public Health health() {
try (Closeable ignored = preparable.prepare()) {
Health result = null;
Exception exception = null;
long start = clock.millis();
try {
result = determineHealth();
} catch (RuntimeException e) {
exception = e;
result = Health.down().withException(e).build();
} finally {
logInvocation(result, exception, start, clock.millis());
}
return result;
} catch (IOException e) {
log.error("unexpected exception occurred", e);
return Health.down(e).build();
}
}
private void logInvocation(Health health, Exception exception, long start, long end) {
Duration duration = Duration.ofMillis(end - start);
try (MDCCloseable ignored = MDC.putCloseable("event.duration", Long.toString(duration.toNanos()))) {
if (exception != null || health == null) {
log.error(logStatusDownMessage, exception);
firstTime = true;
} else if (health.getStatus() == Status.DOWN) {
log.warn(logStatusDownMessage);
firstTime = true;
} else if (firstTime) {
log.info(logStatusUpMessage);
firstTime = false;
} else {
log.trace(logStatusUpMessage);
}
}
}
private String indicatorName() {
return this.getClass().getSimpleName()
.replace("HealthIndicator", "")
.toLowerCase();
}
}

View File

@ -1,37 +0,0 @@
package de.twomartens.template.monitoring.actuator;
import de.twomartens.template.monitoring.statusprobe.StatusProbe;
import java.time.Clock;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Health.Builder;
import org.springframework.boot.actuate.health.HealthIndicator;
@Slf4j
public abstract class AbstractStatusProbeHealthIndicator extends AbstractHealthIndicator
implements HealthIndicator {
public static final String MESSAGE_KEY = "message";
public static final String LAST_STATUS_CHANGE_KEY = "lastStatusChange";
private final StatusProbe statusProbe;
public AbstractStatusProbeHealthIndicator(Clock timeProvider, Preparable headerInterceptor,
StatusProbe statusProbe) {
super(timeProvider, headerInterceptor);
this.statusProbe = statusProbe;
}
@Override
protected Health determineHealth() {
Builder healthBuilder = Health.status(statusProbe.getStatus());
Optional.ofNullable(statusProbe.getLastStatusChange())
.ifPresent(l -> healthBuilder.withDetail(LAST_STATUS_CHANGE_KEY, l));
Optional.ofNullable(statusProbe.getThrowable()).ifPresent(healthBuilder::withException);
Optional.ofNullable(statusProbe.getMessage())
.ifPresent(m -> healthBuilder.withDetail(MESSAGE_KEY, m));
return healthBuilder.build();
}
}

View File

@ -1,10 +0,0 @@
package de.twomartens.template.monitoring.actuator;
import java.io.Closeable;
@FunctionalInterface
public interface Preparable {
Closeable prepare();
}

View File

@ -1,65 +0,0 @@
package de.twomartens.template.monitoring.actuator;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
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.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* A Health check which checks if the rest services are working.
* <p>
* 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.
* <p>
* 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.
* <p>
* There should be a health check which is ok not before all data is loaded.
*/
@Slf4j
@Component
public class RestHealthIndicator extends AbstractHealthIndicator implements HealthIndicator {
private static final String URL_PATH = "/template/v1/healthCheck";
private static final String GET_PARAMETER = "message=";
private final SecureRandom randomizer = new SecureRandom();
private final RestTemplate restTemplateRestHealthIndicator;
private final String urlPrefix;
public RestHealthIndicator(Clock clock, HeaderInterceptorRest interceptor,
ServerProperties serverProperties, RestTemplate restTemplateRestHealthIndicator) {
super(clock, interceptor::markAsHealthCheck);
this.restTemplateRestHealthIndicator = restTemplateRestHealthIndicator;
urlPrefix = HTTP_PREFIX + HOST + HOST_PORT_SEPERATOR + serverProperties.getPort()
+ URL_PATH + PARAMETER_SEPERATOR + GET_PARAMETER;
}
/**
* main method that determines the health of the service
*/
@Override
protected Health determineHealth() {
String random = Integer.toString(randomizer.nextInt(100000, 999999));
String url = urlPrefix + "{random}";
ResponseEntity<String> response = restTemplateRestHealthIndicator.getForEntity(url, String.class, random);
Status status = Optional.ofNullable(response.getBody())
.filter(random::equals)
.map(m -> Status.UP)
.orElse(Status.DOWN);
return Health.status(status)
.withDetail(DETAIL_ENDPOINT_KEY, url)
.build();
}
}

View File

@ -1,33 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.boot.actuate.health.Status;
public class CountBasedStatusProbe extends StatusProbe {
private final AtomicInteger failureCount = new AtomicInteger(0);
private final int maxFailureCount;
public CountBasedStatusProbe(int maxFailureCount, Clock clock, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.maxFailureCount = maxFailureCount;
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
int failureCount = this.failureCount.incrementAndGet();
if (failureCount > maxFailureCount) {
super.setStatus(status, throwable, message);
}
} else if (status == Status.UP) {
this.failureCount.set(0);
super.setStatus(status, throwable, message);
}
}
}

View File

@ -1,63 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.Duration;
import org.springframework.boot.actuate.health.Status;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/**
* 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.
*/
public class PercentageBasedStatusProbe extends StatusProbe implements ScheduledStatusProbe {
private final int maxFailurePercent;
private int requestCount = 0;
private int downCount = 0;
private Throwable throwable;
private String message;
public PercentageBasedStatusProbe(int maxFailurePercent, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this(maxFailurePercent, clock, threadPoolTaskScheduler, Duration.ofMinutes(1), criticality, name,
statusProbeLogger);
}
public PercentageBasedStatusProbe(int maxFailurePercent, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, Duration schedulePeriod, StatusProbeCriticality criticality,
String name, StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.maxFailurePercent = maxFailurePercent;
scheduleTask(threadPoolTaskScheduler, schedulePeriod);
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
downCount++;
this.throwable = throwable;
this.message = message;
}
requestCount++;
}
private void reset() {
requestCount = 0;
downCount = 0;
throwable = null;
message = null;
}
public synchronized void runScheduledTask() {
if (requestCount > 0 && (downCount * 100.0 / requestCount) > maxFailurePercent) {
super.setStatus(Status.DOWN, throwable, message);
} else {
super.setStatus(Status.UP, null, null);
}
reset();
}
}

View File

@ -1,18 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Duration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.PeriodicTrigger;
public interface ScheduledStatusProbe {
void runScheduledTask();
default void scheduleTask(ThreadPoolTaskScheduler threadPoolTaskScheduler,
Duration schedulePeriod) {
PeriodicTrigger periodicTrigger = new PeriodicTrigger(
Duration.ofSeconds(schedulePeriod.toSeconds()));
threadPoolTaskScheduler.schedule(this::runScheduledTask, periodicTrigger);
}
}

View File

@ -1,62 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.ZonedDateTime;
import lombok.Getter;
import org.springframework.boot.actuate.health.Status;
@Getter
public class StatusProbe {
private final Clock clock;
private Status status = Status.UP;
private Throwable throwable = null;
private String message = null;
private ZonedDateTime lastStatusChange;
private final StatusProbeLogger statusProbeLogger;
private final String name;
public StatusProbe(Clock clock, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this.clock = clock;
this.name = name;
this.statusProbeLogger = statusProbeLogger;
statusProbeLogger.registerStatusProbe(name, criticality);
}
protected void setStatus(Status status, Throwable throwable, String message) {
if (status != this.status) {
lastStatusChange = ZonedDateTime.now(clock);
statusProbeLogger.logStatusChange(name, message, status, lastStatusChange, throwable);
}
this.status = status;
this.throwable = throwable;
this.message = message;
}
public void up() {
setStatus(Status.UP, null, null);
}
public void up(String message) {
setStatus(Status.UP, null, message);
}
public void down() {
setStatus(Status.DOWN, null, null);
}
public void down(Throwable throwable) {
setStatus(Status.DOWN, throwable, null);
}
public void down(String message) {
setStatus(Status.DOWN, null, message);
}
protected void down(Throwable throwable, String message) {
setStatus(Status.DOWN, throwable, message);
}
}

View File

@ -1,7 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
public enum StatusProbeCriticality {
K1, K2, K3
}

View File

@ -1,124 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K1;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K2;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K3;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.MarkerManager;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.boot.actuate.health.Status;
public class StatusProbeLogger {
private static final Marker MARKER = MarkerManager.getMarker("statusprobe");
private static final String LABEL_CRITICALITY = "label.status.criticality";
private static final String LABEL_STATUS = "label.status.status";
private static final String LABEL_REASON = "label.status.reason";
private static final String LABEL_MESSAGE = "label.status.description";
private static final String LABEL_LAST_STATUS_CHANGE = "label.status.last_change";
private final Clock clock;
private final Logger commLog;
private final Map<ProbeIdent, Status> statusProbeToStatus = new HashMap<>();
public StatusProbeLogger(Clock clock) {
this(clock, LogManager.getLogger("statusprobe"));
}
/**
* only for testing purposes
*/
StatusProbeLogger(Clock clock, Logger commLog) {
this.clock = clock;
this.commLog = commLog;
}
public void registerStatusProbe(String name, StatusProbeCriticality criticality) {
statusProbeToStatus.put(new ProbeIdent(name, criticality), Status.UP);
logStatusChange(name, "Startup", Status.UP, ZonedDateTime.now(clock), null);
}
public void logStatusChange(String name, String message, Status status, ZonedDateTime lastStatusChange,
Throwable throwable) {
ProbeIdent probeIdent = getProbeIdent(name);
if (probeIdent == null) {
probeIdent = new ProbeIdent(name, K1);
}
statusProbeToStatus.put(probeIdent, status);
createLog(message, lastStatusChange, throwable);
}
private ProbeIdent getProbeIdent(String name) {
return statusProbeToStatus.keySet().stream().filter(key -> key.name.equals(name)).findFirst().orElse(null);
}
private void createLog(String message, ZonedDateTime lastStatusChange, Throwable throwable) {
Status overallStatus = getOverallStatus();
StatusProbeCriticality criticality = getOverallCriticality();
if (message == null) {
message = "";
}
if (Status.UP.equals(overallStatus)) {
commLog.info(MARKER, new StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, message)
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange));
} else {
commLog.error(MARKER, new StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, message)
.with(LABEL_REASON, getReason())
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange), throwable);
}
}
private StatusProbeCriticality getOverallCriticality() {
List<StatusProbeCriticality> crits = statusProbeToStatus.keySet().stream().map(key -> key.criticality).toList();
return crits.contains(K1) ? K1 : crits.contains(K2) ? K2 : K3;
}
private Status getOverallStatus() {
if (statusProbeToStatus.containsValue(Status.DOWN)) {
return Status.DOWN;
}
return Status.UP;
}
private String getReason() {
List<ProbeIdent> probesDown = statusProbeToStatus.entrySet().stream()
.filter(entry -> Status.DOWN.equals(entry.getValue()))
.map(Map.Entry::getKey)
.toList();
String reasonK1 = getDownStatusProbes(probesDown, K1);
String reasonK2 = getDownStatusProbes(probesDown, K2);
String reasonK3 = getDownStatusProbes(probesDown, K3);
return "%s%s%s".formatted(reasonK1, reasonK2, reasonK3).trim();
}
private String getDownStatusProbes(List<ProbeIdent> probesDown, StatusProbeCriticality criticality) {
List<String> downProbeNames = probesDown.stream().filter(probe -> probe.criticality.equals(criticality))
.map(probe -> probe.name).toList();
if (downProbeNames.size() > 0) {
return criticality + " failed: " + String.join(",", downProbeNames) + "\n";
}
return "";
}
public record ProbeIdent(String name, StatusProbeCriticality criticality) {
}
}

View File

@ -1,64 +0,0 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.Duration;
import java.time.ZonedDateTime;
import org.springframework.boot.actuate.health.Status;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
public class TimeBasedStatusProbe extends StatusProbe implements ScheduledStatusProbe {
private final Clock clock;
private final Duration maxFailureDuration;
private ZonedDateTime lastSuccess;
private Throwable throwable = null;
private String message = null;
public TimeBasedStatusProbe(Duration maxFailureDuration, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this(maxFailureDuration, clock, threadPoolTaskScheduler, Duration.ofMinutes(1), criticality, name,
statusProbeLogger);
}
public TimeBasedStatusProbe(Duration maxFailureDuration, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, Duration schedulePeriod, StatusProbeCriticality criticality,
String name, StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.clock = clock;
this.maxFailureDuration = maxFailureDuration;
this.lastSuccess = null;
scheduleTask(threadPoolTaskScheduler, schedulePeriod);
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
this.throwable = throwable;
this.message = message;
} else if (status == Status.UP) {
lastSuccess = ZonedDateTime.now(clock);
super.setStatus(status, throwable, message);
}
}
private boolean isOverdue() {
if (lastSuccess == null) {
return false;
}
Duration timeSinceLastSuccess = Duration.between(lastSuccess, ZonedDateTime.now(clock));
return maxFailureDuration.minus(timeSinceLastSuccess).isNegative();
}
public synchronized void runScheduledTask() {
if (isOverdue()) {
super.setStatus(Status.DOWN, throwable, message);
}
}
}

View File

@ -1,34 +0,0 @@
package de.twomartens.template.property;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.stereotype.Component;
@Getter
@Setter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor
@ConfigurationProperties(prefix = "resttemplate.timeout")
@Component
public class RestTemplateTimeoutProperties {
@DurationUnit(ChronoUnit.MILLIS)
private Duration readTimeoutRestHealthIndicatorInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration connectionRestHealthIndicatorTimeoutInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration readTimeoutRestTemplateInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration connectionRestTemplateTimeoutInMillis;
}

View File

@ -1,21 +0,0 @@
package de.twomartens.template.property;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZoneId;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "de.twomartens.template")
@Schema(description = "Properties, to configure this Application")
public class ServiceProperties {
private ZoneId defaultTimeZone;
private String greeting;
}

View File

@ -1,28 +0,0 @@
package de.twomartens.template.property;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "de.twomartens.template.statusprobe")
@Schema(description = "Properties, to configure this Application")
public class StatusProbeProperties {
@DurationUnit(ChronoUnit.SECONDS)
private Duration scheduleDuration;
@DurationUnit(ChronoUnit.MINUTES)
private Duration maxKafkaFailureDuration;
private int maxBlobFailureCount;
private int maxFailurePercent;
}

View File

@ -1,11 +0,0 @@
package de.twomartens.template.repository;
import de.twomartens.template.model.db.Greeting;
import java.util.Optional;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface GreetingRepository extends MongoRepository<Greeting, String> {
Optional<Greeting> findByMessageIgnoreCase(String message);
}

View File

@ -1,40 +0,0 @@
package de.twomartens.template.service;
import de.twomartens.template.model.db.Greeting;
import de.twomartens.template.property.ServiceProperties;
import de.twomartens.template.repository.GreetingRepository;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class GreetingService {
private final MeterRegistry meterRegistry;
private final ServiceProperties serviceProperties;
private final Counter counter;
private final GreetingRepository greetingRepository;
public GreetingService(MeterRegistry meterRegistry, ServiceProperties serviceProperties,
GreetingRepository greetingRepository) {
this.meterRegistry = meterRegistry;
this.serviceProperties = serviceProperties;
this.greetingRepository = greetingRepository;
counter = meterRegistry.counter("template.callCounter");
}
public Greeting createGreeting(String name) {
log.info("Create greeting for '{}'", name);
counter.increment();
meterRegistry.gauge("template.nameLength", name.length());
String greeting = serviceProperties.getGreeting();
return Greeting.builder().message(String.format(greeting, name)).build();
}
public void postGreeting(Greeting greeting) {
greetingRepository.save(greeting);
}
}

View File

@ -1,37 +0,0 @@
package de.twomartens.template.service;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.cloud.context.refresh.ContextRefresher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* automatically reloads application*.yaml. reload can also be triggered manually by doing a post
* request on http://localhost:12001/actuator/refresh
*/
@Log4j2
@Service
@RequiredArgsConstructor
public class PropertyReloadService {
public static final int REFRESH_SECONDS = 60;
public static final String PARAM_MESSAGE = "message";
public static final String PARAM_PROPERTIES = "labels.properties";
private final ContextRefresher contextRefresher;
@Scheduled(fixedDelay = REFRESH_SECONDS, initialDelay = REFRESH_SECONDS, timeUnit = TimeUnit.SECONDS)
public void refresh() {
Set<String> properties = contextRefresher.refresh();
if (!properties.isEmpty()) {
log.info(new StringMapMessage()
.with(PARAM_MESSAGE, "properties changed")
.with(PARAM_PROPERTIES, String.join("\n", properties)));
}
}
}

View File

@ -0,0 +1,39 @@
package de.twomartens.timetable
import de.twomartens.timetable.bahnApi.service.BahnApiService
import mu.KotlinLogging
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.boot.runApplication
import org.springframework.context.event.EventListener
import org.springframework.data.mongodb.config.EnableMongoAuditing
import org.springframework.scheduling.annotation.EnableScheduling
import java.time.LocalDate
import java.time.LocalTime
import java.time.Month
@EnableMongoAuditing
@EnableScheduling
@SpringBootApplication
open class MainApplication(
private val bahnApiService: BahnApiService
) {
@EventListener(ApplicationReadyEvent::class)
fun ready() {
val result = bahnApiService.fetchStations("Köln Hbf")
val koeln = result[0]
val stops = bahnApiService.fetchTimetable(koeln.eva,
LocalDate.of(2023, Month.SEPTEMBER, 23),
LocalTime.of(17, 0))
log.info("stations: $result")
}
companion object {
private val log = KotlinLogging.logger {}
}
}
fun main(args: Array<String>) {
runApplication<MainApplication>(*args)
}

View File

@ -0,0 +1,14 @@
package de.twomartens.timetable.configuration
import de.twomartens.timetable.bahnApi.property.BahnApiProperties
import de.twomartens.timetable.property.RestTemplateTimeoutProperties
import de.twomartens.timetable.property.ServiceProperties
import de.twomartens.timetable.property.StatusProbeProperties
import de.twomartens.timetable.property.TimeProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(RestTemplateTimeoutProperties::class, ServiceProperties::class,
StatusProbeProperties::class, TimeProperties::class, BahnApiProperties::class)
open class PropertyConfiguration

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.property
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.cloud.context.config.annotation.RefreshScope
@RefreshScope
@ConfigurationProperties(prefix = "de.twomartens.timetable")
@Schema(description = "Properties, to configure this Application")
class ServiceProperties {
lateinit var greeting: String
}

View File

@ -0,0 +1,15 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.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)
}
}

View File

@ -0,0 +1,13 @@
package de.twomartens.timetable.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()
}
}

View File

@ -0,0 +1,20 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.timetable.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()
}
}

View File

@ -0,0 +1,39 @@
package de.twomartens.timetable.support.configuration
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.OAuthFlow
import io.swagger.v3.oas.annotations.security.OAuthFlows
import io.swagger.v3.oas.annotations.security.SecurityScheme
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT")
@SecurityScheme(
name = "oauth2",
type = SecuritySchemeType.OAUTH2,
flows = OAuthFlows(
implicit = OAuthFlow(
authorizationUrl = "https://id.2martens.de/realms/2martens/protocol/openid-connect/auth",
tokenUrl = "https://id.2martens.de/realms/2martens/protocol/openid-connect/token"
)
)
)
@Configuration
open class OpenApiConfiguration {
@Bean
open fun customOpenAPI(
@Value("\${openapi.description}") apiDescription: String,
@Value("\${openapi.version}") apiVersion: String, @Value("\${openapi.title}") apiTitle: String
): OpenAPI {
return OpenAPI()
.info(
Info()
.title(apiTitle)
.version(apiVersion)
.description(apiDescription)
)
}
}

View File

@ -0,0 +1,37 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.timetable.support.interceptor.LoggingInterceptorRest
import de.twomartens.timetable.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()
}
}

View File

@ -0,0 +1,25 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.monitoring.statusprobe.CountBasedStatusProbe
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbe
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbeCriticality
import de.twomartens.timetable.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
)
}
}

View File

@ -0,0 +1,28 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
open class WebConfiguration(private val headerInterceptorRest: HeaderInterceptorRest) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(headerInterceptorRest)
}
override fun addCorsMappings(registry: CorsRegistry) {
val registration = registry.addMapping("/**")
registration.allowedMethods(
HttpMethod.GET.name(), HttpMethod.POST.name(),
HttpMethod.PUT.name(), HttpMethod.HEAD.name()
)
registration.allowCredentials(true)
registration.allowedOrigins(
"http://localhost:4200",
"https://timetable.2martens.de"
)
}
}

View File

@ -0,0 +1,95 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.security.SpringPolicyEnforcerFilter
import org.keycloak.adapters.authorization.spi.ConfigurationResolver
import org.keycloak.adapters.authorization.spi.HttpRequest
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig
import org.keycloak.util.JsonSerialization
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
import org.springframework.security.web.SecurityFilterChain
import java.io.IOException
@Configuration
@EnableWebSecurity
open class WebSecurityConfiguration {
@Value("\${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private lateinit var jwkSetUri: String
@Value("#{environment.CLIENT_SECRET}")
private lateinit var clientSecret: String
@Bean
@Throws(Exception::class)
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.authorizeHttpRequests { it.requestMatchers(*PERMITTED_PATHS.toTypedArray<String>()).permitAll() }
.authorizeHttpRequests { it.requestMatchers(HttpMethod.OPTIONS).permitAll() }
.authorizeHttpRequests { it.anyRequest().authenticated() }
.oauth2ResourceServer { obj: OAuth2ResourceServerConfigurer<HttpSecurity?> -> obj.jwt(Customizer.withDefaults()) }
.addFilterAfter(createPolicyEnforcerFilter(), BearerTokenAuthenticationFilter::class.java)
return http.build()
}
@Bean
open fun jwtDecoder(): JwtDecoder {
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build()
}
private fun createPolicyEnforcerFilter(): SpringPolicyEnforcerFilter {
return SpringPolicyEnforcerFilter(object : ConfigurationResolver {
override fun resolve(request: HttpRequest): PolicyEnforcerConfig {
return try {
val policyEnforcerConfig = JsonSerialization.readValue(
javaClass.getResourceAsStream("/policy-enforcer.json"), PolicyEnforcerConfig::class.java
)
policyEnforcerConfig.credentials = mapOf(Pair("secret", clientSecret))
if (request.method == HttpMethod.OPTIONS.name()) {
// always allow options request
policyEnforcerConfig.enforcementMode = EnforcementMode.DISABLED
} else {
policyEnforcerConfig.paths = PATHS
}
policyEnforcerConfig
} catch (e: IOException) {
throw RuntimeException(e)
}
}
})
}
companion object {
private val PERMITTED_PATHS: Collection<String> = listOf(
"/timetable/v1/healthCheck",
"/actuator/**",
"/timetable/v1/doc/**",
"/timetable/v1/api-docs/**",
"/error",
"/timetable/version",
)
private val PATHS = buildPathConfigs()
private fun buildPathConfigs(): List<PathConfig> {
val paths: MutableList<PathConfig> = mutableListOf()
for (path in PERMITTED_PATHS) {
val pathConfig = PathConfig()
pathConfig.path = path.replace("**", "*")
pathConfig.enforcementMode = EnforcementMode.DISABLED
paths.add(pathConfig)
}
return paths
}
}
}

View File

@ -0,0 +1,36 @@
package de.twomartens.timetable.support.controller
import de.twomartens.timetable.support.exception.HttpStatusException
import de.twomartens.timetable.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 {}
}
}

View File

@ -0,0 +1,81 @@
package de.twomartens.timetable.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 {}
}
}

View File

@ -0,0 +1,6 @@
package de.twomartens.timetable.support.exception
import org.springframework.http.HttpStatus
class HttpStatusException(val status: HttpStatus, message: String, cause: Throwable?):
RuntimeException(message, cause)

View File

@ -0,0 +1,45 @@
package de.twomartens.timetable.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
}
@Deprecated("Replace calls of getRawStatusCode with getStatusCode", ReplaceWith("getStatusCode()"))
@Throws(IOException::class)
override fun getRawStatusCode(): Int {
return statusCode.value()
}
@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()
}
}

View File

@ -0,0 +1,85 @@
package de.twomartens.timetable.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
}
}
}
}
}

View File

@ -0,0 +1,88 @@
package de.twomartens.timetable.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()
// }
// }
}

View File

@ -0,0 +1,438 @@
package de.twomartens.timetable.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.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 = URL("${httpRequest.requestURL}$query")
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
}
}
}

View File

@ -0,0 +1,3 @@
package de.twomartens.timetable.support.model.dto
data class ErrorMessage(val message: String)

View File

@ -0,0 +1,83 @@
package de.twomartens.timetable.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 {}
}
}

View File

@ -0,0 +1,30 @@
package de.twomartens.timetable.support.monitoring.actuator
import de.twomartens.timetable.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"
}
}

View File

@ -0,0 +1,7 @@
package de.twomartens.timetable.support.monitoring.actuator
import java.io.Closeable
fun interface Preparable {
fun prepare(): Closeable
}

View File

@ -0,0 +1,59 @@
package de.twomartens.timetable.support.monitoring.actuator
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.timetable.property.ServiceProperties
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: ServiceProperties
) : 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/v1/healthCheck"
private const val GET_PARAMETER = "message="
}
}

View File

@ -0,0 +1,25 @@
package de.twomartens.timetable.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)
}
}
}

View File

@ -0,0 +1,51 @@
package de.twomartens.timetable.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()
}
}

View File

@ -0,0 +1,22 @@
package de.twomartens.timetable.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)
}

Some files were not shown because too many files have changed in this diff Show More