Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Arrow Raise validation #4

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ dependencies {

implementation "org.jooq:jooq:3.17.6"

implementation "io.arrow-kt:arrow-core:1.2.0"

testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion"
testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion"
testImplementation "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/gildedrose/domain/ID.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.gildedrose.domain

import arrow.core.raise.Raise

@JvmInline
value class ID<@Suppress("unused") T>(val value: NonBlankString) {

companion object {
operator fun <T> invoke(value: String): ID<T>? =
NonBlankString(value)?.let { ID<T>(it) }

context(Raise<String>)
operator fun <T> invoke(value: String): ID<T> = ID(NonBlankString(value))
}

override fun toString() = value.toString()
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/com/gildedrose/domain/NonBlankString.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package com.gildedrose.domain

import arrow.core.raise.Raise

@JvmInline
value class NonBlankString
private constructor(val value: String) : CharSequence by value {
companion object {
operator fun invoke(value: String): NonBlankString? =
if (value.isNotBlank()) NonBlankString(value)
else null

context(Raise<String>)
operator fun invoke(value: String): NonBlankString =
if (value.isNotBlank()) NonBlankString(value)
else raise("String cannot be blank")
}

init {
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/com/gildedrose/domain/NonNegativeInt.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package com.gildedrose.domain

import arrow.core.raise.Raise

@JvmInline
value class NonNegativeInt
private constructor(val value: Int) {
companion object {
operator fun invoke(value: Int): NonNegativeInt? =
if (value >= 0) NonNegativeInt(value)
else null

context(Raise<String>)
operator fun invoke(value: Int): NonNegativeInt =
if (value >= 0) NonNegativeInt(value)
else raise("Integer cannot be negative")
}

init {
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/gildedrose/domain/Quality.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.gildedrose.domain

import arrow.core.raise.Raise

@JvmInline
value class Quality(
private val value: NonNegativeInt
Expand All @@ -11,6 +13,10 @@ value class Quality(

operator fun invoke(value: Int): Quality? =
NonNegativeInt(value)?.let { Quality(it) }

context(Raise<String>)
operator fun invoke(value: Int): Quality =
Quality(NonNegativeInt(value))
}

override fun toString() = value.toString()
Expand Down
79 changes: 49 additions & 30 deletions src/main/java/com/gildedrose/routes.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
package com.gildedrose

import arrow.core.raise.*
import com.gildedrose.domain.*
import com.gildedrose.foundation.AnalyticsEvent
import com.gildedrose.foundation.magic
import com.gildedrose.foundation.runIO
import com.gildedrose.http.ResponseErrors
import com.gildedrose.http.ResponseErrors.withError
import com.gildedrose.http.catchAll
import com.gildedrose.http.reportHttpTransactions
import com.gildedrose.rendering.render
import java.time.Duration
import java.time.LocalDate
import java.time.format.DateTimeParseException
import org.http4k.core.*
import org.http4k.core.body.Form
import org.http4k.core.body.form
import org.http4k.filter.ServerFilters
import org.http4k.lens.*
import org.http4k.lens.ParamMeta.IntegerParam
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.time.Duration
import java.time.LocalDate


val App.routes: HttpHandler
get() = ServerFilters.RequestTracing()
Expand All @@ -33,39 +35,56 @@ val App.routes: HttpHandler
)
)

internal fun App.addHandler(request: Request): Response {
val idLens = FormField.nonBlankString().map { ID<Item>(it) }.required("new-itemId")
val nameLens = FormField.nonBlankString().required("new-itemName")
val sellByLens = FormField.nullOnEmptyLocalDate().optional("new-itemSellBy")
val qualityLens = FormField.nonNegativeInt().map { Quality(it) }.required("new-itemQuality")
val formBody = Body.webForm(Validator.Feedback, idLens, nameLens, sellByLens, qualityLens).toLens()
val form: WebForm = formBody(request)
if (form.errors.isNotEmpty())
return Response(Status.BAD_REQUEST).withError(NewItemFailedEvent(form.errors.toString()))

val item = Item(idLens(form), nameLens(form), sellByLens(form), qualityLens(form))
runIO {
addItem(newItem = item)
internal fun App.addHandler(request: Request): Response = recover({
with(request.form()) {
val item = zipOrAccumulate(
{ required("new-itemId") { ID(it) }},
{ required("new-itemName") { NonBlankString(it) } },
{ optional("new-itemSellBy") { it?.ifEmpty { null }?.toLocalDate() } },
{ required("new-itemQuality") { Quality(it.toIntSafe()) } },
::Item)
runIO { addItem(newItem = item) }
}
return Response(Status.SEE_OTHER).header("Location", "/")
Response(Status.SEE_OTHER).header("Location", "/")
}) { errorList ->
Response(Status.BAD_REQUEST).withError(NewItemFailedEvent(errorList.toList().toString()))
}


data class NewItemFailedEvent(val message: String) : AnalyticsEvent

fun FormField.nonNegativeInt() =
mapWithNewMeta(
BiDiMapping<String, NonNegativeInt>(
{ NonNegativeInt(it.toInt()) ?: throw IllegalArgumentException("Integer cannot be negative") },
NonNegativeInt::toString
), IntegerParam
)
context(Raise<String>)
fun Form.required(name: String): String =
findSingle(name) ?: raise("formData '$name' is required")

fun Form.optional(name: String): String? = findSingle(name)

// The following 2 functions take care of including the invalid field name in the error message.
context(Raise<String>)
fun <T> Form.required(name: String, transform: context(Raise<String>) (String) -> T): T {
val value = required(name)
return withError({ e -> "formData '$name': $e" }) {
transform(magic(), value)
}
}

context(Raise<String>)
fun <T> Form.optional(name: String, transform: context(Raise<String>) (String?) -> T): T {
val value = optional(name)
return withError({ e -> "formData '$name': $e" }) {
transform(magic(), value)
}
}

fun FormField.nullOnEmptyLocalDate() = string().map { if (it.isEmpty()) null else LocalDate.parse(it) }
// In a Raise world, these would already be defined instead of their exception-throwing counterparts
context(Raise<String>)
fun String.toLocalDate(): LocalDate =
// This catch function is reified! So it only catches DateTimeParseException
catch<DateTimeParseException, _>({ LocalDate.parse(this) }) { raise("Invalid date format") }

fun FormField.nonBlankString(): BiDiLensSpec<WebForm, NonBlankString> =
map(BiDiMapping<String, NonBlankString>({ s: String ->
NonBlankString(s) ?: throw IllegalArgumentException("String cannot be blank")
}, { it.toString() }))
context(Raise<String>)
fun String.toIntSafe(): Int =
catch<NumberFormatException, _>({ toInt() }) { raise("Invalid number format") }

private fun App.listHandler(
@Suppress("UNUSED_PARAMETER") request: Request
Expand Down
2 changes: 1 addition & 1 deletion src/test/java/com/gildedrose/AddItemTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class AddItemTests {
app.addHandler(postWithTwoMissingFields),
hasStatus(Status.BAD_REQUEST) and
hasAttachedError(
NewItemFailedEvent("[formData 'new-itemId' is required, formData 'new-itemQuality' must be integer]")
NewItemFailedEvent("[formData 'new-itemId' is required, formData 'new-itemQuality': Invalid number format]")
)
)
}
Expand Down