Skip to content

Commit

Permalink
m6l3 - Validation
Browse files Browse the repository at this point in the history
  • Loading branch information
svok authored and evgnep committed Sep 10, 2024
1 parent 4e2f123 commit f3cd4cc
Show file tree
Hide file tree
Showing 33 changed files with 1,163 additions and 4 deletions.
16 changes: 16 additions & 0 deletions lessons/m6l3-konform/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
kotlin("jvm")
}

val coroutinesVersion: String by project

dependencies {
implementation(kotlin("stdlib"))

testImplementation(kotlin("test"))
testImplementation(kotlin("test-junit"))

testImplementation("io.konform:konform:0.4.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
}
61 changes: 61 additions & 0 deletions lessons/m6l3-konform/src/test/kotlin/KonformTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ru.otus.otuskotlin.marketplace.lib.koform

import io.konform.validation.Invalid
import io.konform.validation.Validation
import io.konform.validation.ValidationBuilder
import io.konform.validation.jsonschema.pattern
import kotlinx.datetime.*
import org.junit.Test
import kotlin.test.assertContains
import kotlin.test.assertIs
import kotlin.time.Duration.Companion.days

// Konform - DSL для валидации
class KonformTest {
@Test
fun konform() {
val objValidator = Validation<SomeObject> {
SomeObject::userId {
pattern("^[0-9a-zA-Z_-]{1,64}\$")
}
SomeObject::dob {
minAge(15)
}
}

val resultUserId = objValidator.validate(SomeObject())
assertIs<Invalid<SomeObject>>(resultUserId)
val errorUserId = resultUserId.errors.firstOrNull { it.dataPath == ".userId" }
assertContains(errorUserId?.message ?: "", "pattern")
println("RESULT: ${errorUserId?.dataPath} ${errorUserId?.message}")

val resultAge = objValidator.validate(SomeObject(userId = "987987987"))
assertIs<Invalid<SomeObject>>(resultAge)
val errorAge = resultAge.errors.firstOrNull { it.dataPath == ".dob" }
assertContains(errorAge?.message ?: "", "Age")
println("RESULT: ${errorAge?.dataPath} ${errorAge?.message}")
}
}

private fun ValidationBuilder<LocalDate?>.minAge(age: Int) = addConstraint(
errorMessage = "Age cannot be in range of 15..130 years",
"15..130"
) {
if (it == null) {
println("EMPTY!")
false
} else {
val date = Clock.System
.now()
.minus((age * 365).days)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
println("DATE: $date ~ $it ${it.compareTo(date)}")
(it.compareTo(date) in 0..1)
}
}

data class SomeObject(
val userId: String = "",
val dob: LocalDate? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package ru.otus.otuskotlin.marketplace.api.v2.mappers

import ru.otus.otuskotlin.marketplace.api.v2.models.*
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.models.*
import ru.otus.otuskotlin.marketplace.common.stubs.MkplStubs

// Демонстрация форматной валидации в мапере
private sealed interface Result<T,E>
private data class Ok<T,E>(val value: T) : Result<T,E>
private data class Err<T,E>(val errors: List<E>) : Result<T,E> {
constructor(error: E) : this(listOf(error))
}

private fun <T,E> Result<T,E>.getOrExec(default: T, block: (Err<T,E>) -> Unit = {}): T = when (this) {
is Ok<T,E> -> this.value
is Err<T,E> -> {
block(this)
default
}
}

@Suppress("unused")
private fun <T,E> Result<T,E>.getOrNull(block: (Err<T,E>) -> Unit = {}): T? = when (this) {
is Ok<T,E> -> this.value
is Err<T,E> -> {
block(this)
null
}
}

private fun String?.transportToStubCaseValidated(): Result<MkplStubs,MkplError> = when (this) {
"success" -> Ok(MkplStubs.SUCCESS)
"notFound" -> Ok(MkplStubs.NOT_FOUND)
"badId" -> Ok(MkplStubs.BAD_ID)
"badTitle" -> Ok(MkplStubs.BAD_TITLE)
"badDescription" -> Ok(MkplStubs.BAD_DESCRIPTION)
"badVisibility" -> Ok(MkplStubs.BAD_VISIBILITY)
"cannotDelete" -> Ok(MkplStubs.CANNOT_DELETE)
"badSearchString" -> Ok(MkplStubs.BAD_SEARCH_STRING)
null -> Ok(MkplStubs.NONE)
else -> Err(
MkplError(
code = "wrong-stub-case",
group = "mapper-validation",
field = "debug.stub",
message = "Unsupported value for case \"$this\""
)
)
}

@Suppress("unused")
fun MkplContext.fromTransportValidated(request: AdCreateRequest) {
command = MkplCommand.CREATE
// Вся магия здесь!
stubCase = request
.debug
?.stub
?.value
.transportToStubCaseValidated()
.getOrExec(MkplStubs.NONE) { err: Err<MkplStubs,MkplError> ->
errors.addAll(err.errors)
state = MkplState.FAILING
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ru.otus.otuskotlin.marketplace.api.v2.mappers

import ru.otus.otuskotlin.marketplace.api.v2.models.AdCreateRequest
import ru.otus.otuskotlin.marketplace.api.v2.models.AdDebug
import ru.otus.otuskotlin.marketplace.api.v2.models.AdRequestDebugStubs
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.stubs.MkplStubs
import kotlin.test.Test
import kotlin.test.assertEquals

class MapperValidatedTest {
@Test
fun fromTransportValidated() {
val req = AdCreateRequest(
debug = AdDebug(
stub = AdRequestDebugStubs.SUCCESS,
),
)

val context = MkplContext()
context.fromTransportValidated(req)

assertEquals(MkplStubs.SUCCESS, context.stubCase)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import ru.otus.otuskotlin.marketplace.biz.general.initStatus
import ru.otus.otuskotlin.marketplace.biz.general.operation
import ru.otus.otuskotlin.marketplace.biz.general.stubs
import ru.otus.otuskotlin.marketplace.biz.stubs.*
import ru.otus.otuskotlin.marketplace.biz.validation.*
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.MkplCorSettings
import ru.otus.otuskotlin.marketplace.common.models.MkplAdId
import ru.otus.otuskotlin.marketplace.common.models.MkplAdLock
import ru.otus.otuskotlin.marketplace.common.models.MkplCommand
import ru.otus.otuskotlin.marketplace.cor.rootChain
import ru.otus.otuskotlin.marketplace.cor.worker

class MkplAdProcessor(
private val corSettings: MkplCorSettings = MkplCorSettings.NONE
Expand All @@ -25,6 +29,18 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adValidating") { adValidating = adRequest.deepCopy() }
worker("Очистка id") { adValidating.id = MkplAdId.NONE }
worker("Очистка заголовка") { adValidating.title = adValidating.title.trim() }
worker("Очистка описания") { adValidating.description = adValidating.description.trim() }
validateTitleNotEmpty("Проверка, что заголовок не пуст")
validateTitleHasContent("Проверка символов")
validateDescriptionNotEmpty("Проверка, что описание не пусто")
validateDescriptionHasContent("Проверка символов")

finishAdValidation("Завершение проверок")
}
}
operation("Получить объявление", MkplCommand.READ) {
stubs("Обработка стабов") {
Expand All @@ -33,6 +49,14 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adValidating") { adValidating = adRequest.deepCopy() }
worker("Очистка id") { adValidating.id = MkplAdId(adValidating.id.asString().trim()) }
validateIdNotEmpty("Проверка на непустой id")
validateIdProperFormat("Проверка формата id")

finishAdValidation("Успешное завершение процедуры валидации")
}
}
operation("Изменить объявление", MkplCommand.UPDATE) {
stubs("Обработка стабов") {
Expand All @@ -43,6 +67,23 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adValidating") { adValidating = adRequest.deepCopy() }
worker("Очистка id") { adValidating.id = MkplAdId(adValidating.id.asString().trim()) }
worker("Очистка lock") { adValidating.lock = MkplAdLock(adValidating.lock.asString().trim()) }
worker("Очистка заголовка") { adValidating.title = adValidating.title.trim() }
worker("Очистка описания") { adValidating.description = adValidating.description.trim() }
validateIdNotEmpty("Проверка на непустой id")
validateIdProperFormat("Проверка формата id")
validateLockNotEmpty("Проверка на непустой lock")
validateLockProperFormat("Проверка формата lock")
validateTitleNotEmpty("Проверка на непустой заголовок")
validateTitleHasContent("Проверка на наличие содержания в заголовке")
validateDescriptionNotEmpty("Проверка на непустое описание")
validateDescriptionHasContent("Проверка на наличие содержания в описании")

finishAdValidation("Успешное завершение процедуры валидации")
}
}
operation("Удалить объявление", MkplCommand.DELETE) {
stubs("Обработка стабов") {
Expand All @@ -51,6 +92,18 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adValidating") {
adValidating = adRequest.deepCopy()
}
worker("Очистка id") { adValidating.id = MkplAdId(adValidating.id.asString().trim()) }
worker("Очистка lock") { adValidating.lock = MkplAdLock(adValidating.lock.asString().trim()) }
validateIdNotEmpty("Проверка на непустой id")
validateIdProperFormat("Проверка формата id")
validateLockNotEmpty("Проверка на непустой lock")
validateLockProperFormat("Проверка формата lock")
finishAdValidation("Успешное завершение процедуры валидации")
}
}
operation("Поиск объявлений", MkplCommand.SEARCH) {
stubs("Обработка стабов") {
Expand All @@ -59,6 +112,12 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adFilterValidating") { adFilterValidating = adFilterRequest.deepCopy() }
validateSearchStringLength("Валидация длины строки поиска в фильтре")

finishAdFilterValidation("Успешное завершение процедуры валидации")
}
}
operation("Поиск подходящих предложений для объявления", MkplCommand.OFFERS) {
stubs("Обработка стабов") {
Expand All @@ -67,6 +126,15 @@ class MkplAdProcessor(
stubDbError("Имитация ошибки работы с БД")
stubNoCase("Ошибка: запрошенный стаб недопустим")
}
validation {
worker("Копируем поля в adValidating") { adValidating = adRequest.deepCopy() }
worker("Очистка id") { adValidating.id = MkplAdId(adValidating.id.asString().trim()) }
validateIdNotEmpty("Проверка на непустой id")
validateIdProperFormat("Проверка формата id")

finishAdValidation("Успешное завершение процедуры валидации")
}
}
}.build()
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ru.otus.otuskotlin.marketplace.biz.validation

import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.models.MkplState
import ru.otus.otuskotlin.marketplace.cor.ICorChainDsl
import ru.otus.otuskotlin.marketplace.cor.worker

fun ICorChainDsl<MkplContext>.finishAdValidation(title: String) = worker {
this.title = title
on { state == MkplState.RUNNING }
handle {
adValidated = adValidating
}
}

fun ICorChainDsl<MkplContext>.finishAdFilterValidation(title: String) = worker {
this.title = title
on { state == MkplState.RUNNING }
handle {
adFilterValidated = adFilterValidating
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ru.otus.otuskotlin.marketplace.biz.validation

import ru.otus.otuskotlin.marketplace.common.helpers.errorValidation
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.helpers.fail
import ru.otus.otuskotlin.marketplace.cor.ICorChainDsl
import ru.otus.otuskotlin.marketplace.cor.worker

// пример обработки ошибки в рамках бизнес-цепочки
fun ICorChainDsl<MkplContext>.validateDescriptionHasContent(title: String) = worker {
this.title = title
val regExp = Regex("\\p{L}")
on { adValidating.description.isNotEmpty() && !adValidating.description.contains(regExp) }
handle {
fail(
errorValidation(
field = "description",
violationCode = "noContent",
description = "field must contain letters"
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ru.otus.otuskotlin.marketplace.biz.validation

import ru.otus.otuskotlin.marketplace.cor.ICorChainDsl
import ru.otus.otuskotlin.marketplace.cor.worker
import ru.otus.otuskotlin.marketplace.common.helpers.errorValidation
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.helpers.fail

fun ICorChainDsl<MkplContext>.validateDescriptionNotEmpty(title: String) = worker {
this.title = title
on { adValidating.description.isEmpty() }
handle {
fail(
errorValidation(
field = "description",
violationCode = "empty",
description = "field must not be empty"
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ru.otus.otuskotlin.marketplace.biz.validation

import ru.otus.otuskotlin.marketplace.cor.ICorChainDsl
import ru.otus.otuskotlin.marketplace.cor.worker
import ru.otus.otuskotlin.marketplace.common.helpers.errorValidation
import ru.otus.otuskotlin.marketplace.common.MkplContext
import ru.otus.otuskotlin.marketplace.common.helpers.fail

fun ICorChainDsl<MkplContext>.validateIdNotEmpty(title: String) = worker {
this.title = title
on { adValidating.id.asString().isEmpty() }
handle {
fail(
errorValidation(
field = "id",
violationCode = "empty",
description = "field must not be empty"
)
)
}
}
Loading

0 comments on commit f3cd4cc

Please sign in to comment.