add settings module

This commit is contained in:
Alexander Buntakov 2021-06-10 19:48:07 +03:00
parent 0372d139ee
commit 3cb78ce44d
25 changed files with 646 additions and 1 deletions

View File

@ -106,3 +106,7 @@ Interceptor для логирования запросов/ответов.
## common-geo-spatial4j-spring
Реализация интерфейса `GeoCalculator` с помощью библиотеки `spatial4j`
## settings-spring-jpa
Модуль для хранения настроек

View File

@ -58,7 +58,7 @@ subprojects {
dependency("org.junit.jupiter:junit-jupiter-engine:5.4.2")
dependency("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
dependency("org.mockito:mockito-inline:2.13.0")
dependency("org.mockito:mockito-inline:3.11.0")
dependency("com.github.zafarkhaja:java-semver:0.9.0")

View File

@ -0,0 +1,32 @@
plugins {
id("kotlin")
id("kotlin-spring")
id("maven-publish")
}
dependencies {
implementation(project(":common"))
implementation(project(":common-spring"))
implementation(project(":common-spring-jpa"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.liquibase:liquibase-core")
testImplementation(project(":common-spring-test-jpa"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.junit.jupiter:junit-jupiter-params")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin")
testRuntimeOnly("org.postgresql:postgresql")
}

View File

@ -0,0 +1,8 @@
package ru.touchin.settings.annotations
import org.springframework.beans.factory.annotation.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
annotation class SettingMapper

View File

@ -0,0 +1,25 @@
package ru.touchin.settings.configurations
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import ru.touchin.settings.annotations.SettingMapper
@Configuration
@ComponentScan(
"ru.touchin.settings.services"
)
class SettingsConfiguration {
@Bean
@SettingMapper
fun getSettingMapper(): ObjectMapper {
return ObjectMapper()
.registerModule(KotlinModule())
.setSerializationInclusion(JsonInclude.Include.ALWAYS)
}
}

View File

@ -0,0 +1,30 @@
package ru.touchin.settings.configurations
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import ru.touchin.common.spring.jpa.EnableJpaAuditingExtra
import ru.touchin.common.spring.jpa.liquibase.LiquibaseParams
@Configuration
@ComponentScan("ru.touchin.common.spring.jpa.liquibase")
@EntityScan("ru.touchin.settings.models")
@EnableJpaRepositories(basePackages = ["ru.touchin.settings.repositories"])
@EnableJpaAuditingExtra
class SettingsDatabaseConfiguration {
companion object {
const val SCHEMA: String = "settings"
}
@Bean
fun liquibaseParams(): LiquibaseParams {
return LiquibaseParams(
schema = SCHEMA,
changeLogPath = "settings/db/changelog/db.changelog-master.yaml",
)
}
}

View File

@ -0,0 +1,6 @@
package ru.touchin.settings.dto
data class SystemSetting<T>(
val key: String,
val value: T,
)

View File

@ -0,0 +1,8 @@
package ru.touchin.settings.exceptions
import ru.touchin.common.exceptions.CommonException
class CannotParseSettingValueException(value: String, clazz: Class<*>, exception: Throwable) : CommonException(
"Cannot parse setting value: $value to ${clazz.simpleName}",
exception,
)

View File

@ -0,0 +1,7 @@
package ru.touchin.settings.exceptions
import ru.touchin.common.exceptions.CommonNotFoundException
class SettingNotFoundException(key: String) : CommonNotFoundException(
"Setting not found key=$key"
)

View File

@ -0,0 +1,15 @@
package ru.touchin.settings.models
import ru.touchin.common.spring.jpa.models.AuditableEntity
import javax.persistence.Id
import javax.persistence.MappedSuperclass
@MappedSuperclass
open class AbstractSettingModel: AuditableEntity() {
@Id
lateinit var key: String
lateinit var value: String
}

View File

@ -0,0 +1,9 @@
package ru.touchin.settings.models
import ru.touchin.settings.configurations.SettingsDatabaseConfiguration.Companion.SCHEMA
import javax.persistence.Entity
import javax.persistence.Table
@Entity
@Table(name = "system_settings", schema = SCHEMA)
class SystemSettingModel : AbstractSettingModel()

View File

@ -0,0 +1,13 @@
package ru.touchin.settings.repositories
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.findByIdOrNull
import ru.touchin.settings.exceptions.SettingNotFoundException
import ru.touchin.settings.models.SystemSettingModel
interface SystemSettingsRepository: JpaRepository<SystemSettingModel, String>
fun SystemSettingsRepository.findByIdOrThrow(key: String): SystemSettingModel {
return findByIdOrNull(key)
?: throw SettingNotFoundException(key)
}

View File

@ -0,0 +1,15 @@
package ru.touchin.settings.services
import ru.touchin.settings.dto.SystemSetting
interface SystemSettingsService {
fun <T> save(setting: SystemSetting<T>): SystemSetting<T>
fun <T> getOrNull(settingKey: String, clazz: Class<T>): SystemSetting<T>?
fun <T> get(settingKey: String, clazz: Class<T>): SystemSetting<T>
fun delete(settingKey: String)
}

View File

@ -0,0 +1,79 @@
package ru.touchin.settings.services
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.touchin.settings.annotations.SettingMapper
import ru.touchin.settings.dto.SystemSetting
import ru.touchin.settings.exceptions.CannotParseSettingValueException
import ru.touchin.settings.models.SystemSettingModel
import ru.touchin.settings.repositories.SystemSettingsRepository
import ru.touchin.settings.repositories.findByIdOrThrow
@Service
class SystemSettingsServiceImpl(
private val systemSettingsRepository: SystemSettingsRepository,
@SettingMapper
private val settingsObjectMapper: ObjectMapper,
) : SystemSettingsService {
@Transactional
override fun <T> save(setting: SystemSetting<T>): SystemSetting<T> {
val settingModel = systemSettingsRepository.findByIdOrNull(setting.key)
?: SystemSettingModel().apply {
key = setting.key
}
settingModel.value = settingsObjectMapper.writeValueAsString(setting.value)
systemSettingsRepository.save(settingModel)
return setting
}
private fun <T> createSetting(model: SystemSettingModel, clazz: Class<T>): SystemSetting<T> {
val value = kotlin
.runCatching {
settingsObjectMapper.readValue(model.value, clazz)
}
.recoverCatching { exception ->
when(exception) {
is JsonProcessingException,
is JsonMappingException -> {
throw CannotParseSettingValueException(model.value, clazz, exception)
}
else -> {
throw exception
}
}
}
.getOrThrow()
return SystemSetting(
key = model.key,
value = value,
)
}
@Transactional(readOnly = true)
override fun <T> getOrNull(settingKey: String, clazz: Class<T>): SystemSetting<T>? {
return systemSettingsRepository.findByIdOrNull(settingKey)
?.let { createSetting(it, clazz) }
}
@Transactional(readOnly = true)
override fun <T> get(settingKey: String, clazz: Class<T>): SystemSetting<T> {
return createSetting(systemSettingsRepository.findByIdOrThrow(settingKey), clazz)
}
@Transactional
override fun delete(settingKey: String) {
return systemSettingsRepository.deleteById(settingKey)
}
}

View File

@ -0,0 +1 @@
CREATE SCHEMA IF NOT EXISTS settings;

View File

@ -0,0 +1,40 @@
databaseChangeLog:
- changeSet:
id: create_table__system_settings
author: touchin
preConditions:
- onFail: MARK_RAN
not:
tableExists:
tableName: system_settings
changes:
- createTable:
tableName: system_settings
columns:
- column:
name: key
type: VARCHAR(128)
constraints:
nullable: false
primaryKey: true
primaryKeyName: PK_SYSTEM_SETTINGS
- column:
name: value
type: VARCHAR(1024)
constraints:
nullable: false
- column:
name: created_at
type: TIMESTAMP WITH TIME ZONE
defaultValueDate: CURRENT_TIMESTAMP
constraints:
nullable: false
- column:
name: updated_at
type: TIMESTAMP WITH TIME ZONE
- column:
name: created_by
type: VARCHAR(255)
- column:
name: updated_by
type: VARCHAR(255)

View File

@ -0,0 +1,18 @@
package ru.touchin.settings
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Profile
import ru.touchin.settings.configurations.SettingsDatabaseConfiguration
@Profile("test-slow")
@EnableAutoConfiguration
@TestConfiguration
@ComponentScan
@Import(SettingsDatabaseConfiguration::class)
class SettingsSlowTestConfiguration {
}

View File

@ -0,0 +1,13 @@
package ru.touchin.settings
import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
import org.springframework.test.context.ContextConfiguration
import ru.touchin.settings.configurations.SettingsConfiguration
@SpringBootConfiguration
@ContextConfiguration(classes = [SettingsConfiguration::class])
@TestConfiguration
@Import(SettingsConfiguration::class, SettingsSlowTestConfiguration::class)
class SettingsTestApplication

View File

@ -0,0 +1,53 @@
package ru.touchin.settings.repositories
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.internal.matchers.apachecommons.ReflectionEquals
import org.springframework.beans.factory.annotation.Autowired
import ru.touchin.common.spring.test.jpa.repository.RepositoryTest
import ru.touchin.settings.exceptions.SettingNotFoundException
import ru.touchin.settings.models.SystemSettingModel
import javax.persistence.EntityManager
@RepositoryTest
internal class SystemSettingRepositoryTest {
@Autowired
private lateinit var systemSettingsRepository: SystemSettingsRepository
@Autowired
private lateinit var entityManager: EntityManager
@Test
@DisplayName("Настройки должны сохраняться в базе")
fun shouldBeSaved() {
val setting = SystemSettingModel()
.apply {
key = "max.threads"
value = "5"
}
systemSettingsRepository.save(setting)
// если не вызывать clear, то репозиторий возвращает тот же самый объект и кеша
entityManager.apply {
flush()
clear()
}
val actualSetting = systemSettingsRepository.findByIdOrThrow(setting.key)
Assertions.assertTrue(ReflectionEquals(setting, "createdAt").matches(actualSetting))
}
@Test
@DisplayName("Если настройки не найдены, должна быть ошибка SettingNotFoundException")
fun shouldBeSettingNotFoundException() {
assertThrows<SettingNotFoundException> {
systemSettingsRepository.findByIdOrThrow("missing")
}
}
}

View File

@ -0,0 +1,137 @@
package ru.touchin.settings.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.internal.matchers.apachecommons.ReflectionEquals
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import ru.touchin.settings.annotations.SettingMapper
import ru.touchin.settings.dto.SystemSetting
import ru.touchin.settings.models.SystemSettingModel
import ru.touchin.settings.repositories.SystemSettingsRepository
import java.util.*
import javax.annotation.PostConstruct
internal data class UserObject(val name: String, val age: Int)
@ActiveProfiles("test")
@SpringBootTest
internal class SystemSettingsServiceImplDeserializationTest {
@Autowired
@SettingMapper
private lateinit var settingMapper: ObjectMapper
private val systemSettingsRepository: SystemSettingsRepository = mock {}
private lateinit var systemSettingsService: SystemSettingsService
@PostConstruct
fun init() {
systemSettingsService = spy(
SystemSettingsServiceImpl(
systemSettingsRepository = systemSettingsRepository,
settingsObjectMapper = settingMapper
)
)
}
private fun <T> check(systemSetting: SystemSetting<T>, serializedValue: String, assert: (T, T) -> Unit = { e, a ->
assertTrue(ReflectionEquals(e).matches(a))
}) {
doReturn(
Optional.of(SystemSettingModel().apply {
key = systemSetting.key
value = serializedValue
})
).`when`(systemSettingsRepository).findById(any())
systemSetting.value
?.let { actualValue ->
val actualSystemSetting = systemSettingsService.get(systemSetting.key, actualValue::class.java)
assert(systemSetting.value, actualSystemSetting.value)
}
?: run {
assertNull(systemSettingsService.get(systemSetting.key, ""::class.java).value)
}
}
@Test
fun stringShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.string",
value = "hello"
)
check(systemSetting, "\"hello\"") { expected, actual ->
assertEquals(expected, actual)
}
}
@Test
fun intShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.int",
value = 23
)
check(systemSetting, "23")
}
@Test
fun nullShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.null",
value = null
)
check(systemSetting, "null")
}
@Test
fun arrayShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.array",
value = arrayOf(1,2,3)
)
check(systemSetting, "[1,2,3]")
}
@Test
fun listShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.list",
value = listOf(1,2,3)
)
check(systemSetting, "[1,2,3]") { expected, actual ->
assertEquals(expected, actual)
}
}
@Test
fun objectShouldBeDeserialized() {
val systemSetting = SystemSetting(
key = "setting.object",
value = UserObject("mike", 32)
)
check(systemSetting, "{\"name\":\"mike\",\"age\":32}")
}
}

View File

@ -0,0 +1,121 @@
package ru.touchin.settings.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.verify
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import ru.touchin.settings.annotations.SettingMapper
import ru.touchin.settings.dto.SystemSetting
import ru.touchin.settings.models.SystemSettingModel
import ru.touchin.settings.repositories.SystemSettingsRepository
import java.util.*
import javax.annotation.PostConstruct
@ActiveProfiles("test")
@SpringBootTest
internal class SystemSettingsServiceImplSerializationTest {
@Autowired
@SettingMapper
private lateinit var settingMapper: ObjectMapper
private val systemSettingsRepository: SystemSettingsRepository = mock {}
private lateinit var systemSettingsService: SystemSettingsService
@PostConstruct
fun init() {
systemSettingsService = spy(
SystemSettingsServiceImpl(
systemSettingsRepository = systemSettingsRepository,
settingsObjectMapper = settingMapper
)
)
}
private fun check(systemSetting: SystemSetting<*>, serializedValue: String) {
doReturn(Optional.empty<SystemSettingModel>()).`when`(systemSettingsRepository).findById(any())
doAnswer { it.getArgument(0) as SystemSettingModel }.`when`(systemSettingsRepository).save(any())
systemSettingsService.save(systemSetting)
verify(systemSettingsRepository).save(argThat {
value == serializedValue
})
}
@Test
fun stringShouldBeSerialized() {
val systemSetting = SystemSetting(
key = "setting.string",
value = "hello"
)
check(systemSetting, "\"hello\"")
}
@Test
fun intShouldBeSerialized() {
val systemSetting = SystemSetting(
key = "setting.int",
value = 23
)
check(systemSetting, "23")
}
@Test
fun nullShouldBeSerialized() {
val systemSetting = SystemSetting(
key = "setting.null",
value = null
)
check(systemSetting, "null")
}
@Test
fun arrayShouldBeSerialized() {
val systemSetting = SystemSetting(
key = "setting.array",
value = arrayOf(1,2,3)
)
check(systemSetting, "[1,2,3]")
}
@Test
fun listShouldBeSerialized() {
val systemSetting = SystemSetting(
key = "setting.list",
value = listOf(1,2,3)
)
check(systemSetting, "[1,2,3]")
}
@Test
fun objectShouldBeSerialized() {
data class UserObject(val name: String, val age: Int)
val systemSetting = SystemSetting(
key = "setting.object",
value = UserObject("mike", 32)
)
check(systemSetting, "{\"name\":\"mike\",\"age\":32}")
}
}

View File

@ -0,0 +1,3 @@
spring:
config:
import: "test-slow.yml"

View File

@ -0,0 +1,3 @@
spring:
config:
import: "test.yml"

View File

@ -0,0 +1,4 @@
databaseChangeLog:
- changeset:
id: 1
author: test

View File

@ -36,3 +36,4 @@ include("exception-handler-spring-web")
include("exception-handler-logger-spring-web")
include("version-spring-web")
include("response-wrapper-spring-web")
include("settings-spring-jpa")