Add example application

This commit is contained in:
Michael Bull 2018-01-11 18:30:41 +00:00
parent 389a87ffe2
commit cfaa57dd1f
17 changed files with 461 additions and 1 deletions

33
example/build.gradle Normal file
View File

@ -0,0 +1,33 @@
apply plugin: 'application'
apply plugin: 'kotlin'
mainClassName = 'io.ktor.server.netty.DevelopmentEngine'
repositories {
mavenCentral()
maven { url "http://dl.bintray.com/kotlin/ktor" }
maven { url "https://dl.bintray.com/kotlin/kotlinx" }
}
dependencies {
compile project(":jvm")
compile "ch.qos.logback:logback-classic:$logbackVersion"
compile "io.ktor:ktor-server-core:$ktorVersion"
compile "io.ktor:ktor-server-netty:$ktorVersion"
compile "io.ktor:ktor-gson:$ktorVersion"
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
kotlin {
experimental {
coroutines "enable"
}
}

View File

@ -0,0 +1,2 @@
ktorVersion=0.9.0
logbackVersion=1.2.1

View File

@ -0,0 +1,108 @@
package com.github.michaelbull.result.example
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.example.model.domain.Customer
import com.github.michaelbull.result.example.model.domain.CustomerId
import com.github.michaelbull.result.example.model.domain.DomainMessage
import com.github.michaelbull.result.example.model.dto.CustomerDto
import com.github.michaelbull.result.example.service.CustomerService
import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.mapError
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.Compression
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.routing
fun Application.main() {
install(DefaultHeaders)
install(Compression)
install(CallLogging)
install(ContentNegotiation) {
gson {
setPrettyPrinting()
}
}
routing {
get("/customers/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest)
} else {
CustomerId.create(id)
.andThen(CustomerService::getById)
.mapError(::messageToResponse)
.mapBoth(
success = { call.respond(HttpStatusCode.OK, CustomerDto.from(it)) },
failure = { call.respond(it.first, it.second) }
)
}
}
post("/customers/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest)
} else {
val dto = call.receive<CustomerDto>()
dto.id = id
Customer.from(dto)
.andThen(CustomerService::upsert)
.mapError(::messageToResponse)
.mapBoth(
success = {
if (it == null) {
call.respond(HttpStatusCode.NotModified)
} else {
val (status, message) = messageToResponse(it)
call.respond(status, message)
}
},
failure = { call.respond(it.first, it.second) }
)
}
}
}
}
private fun messageToResponse(message: DomainMessage) = when (message) {
DomainMessage.CustomerRequired,
DomainMessage.CustomerIdMustBePositive,
DomainMessage.FirstNameRequired,
DomainMessage.FirstNameTooLong,
DomainMessage.LastNameRequired,
DomainMessage.LastNameTooLong,
DomainMessage.EmailRequired,
DomainMessage.EmailTooLong,
DomainMessage.EmailInvalid ->
Pair(HttpStatusCode.BadRequest, "There is an error in your request")
// events
DomainMessage.CustomerCreated ->
Pair(HttpStatusCode.Created, "Customer created")
is DomainMessage.EmailAddressChanged ->
Pair(HttpStatusCode.OK, "Email address changed from ${message.old} to ${message.new}")
// exposed errors
DomainMessage.CustomerNotFound ->
Pair(HttpStatusCode.NotFound, "Unknown customer")
// internal errors
DomainMessage.SqlCustomerInvalid,
DomainMessage.DatabaseTimeout,
is DomainMessage.DatabaseError ->
Pair(HttpStatusCode.InternalServerError, "Internal server error occurred")
}

View File

@ -0,0 +1,28 @@
package com.github.michaelbull.result.example.model.domain
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.example.model.dto.CustomerDto
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.zip
data class Customer(
val id: CustomerId,
val name: PersonalName,
val email: EmailAddress
) {
companion object {
fun from(entity: CustomerEntity): Result<Customer, DomainMessage> {
val createId = { CustomerId.create(entity.id) }
val createName = { PersonalName.create(entity.firstName, entity.lastName) }
val createEmail = { EmailAddress.create(entity.email) }
return zip(createId, createName, createEmail, ::Customer)
}
fun from(dto: CustomerDto): Result<Customer, DomainMessage> {
val createId = { CustomerId.create(dto.id) }
val createName = { PersonalName.create(dto.firstName, dto.lastName) }
val createEmail = { EmailAddress.create(dto.email) }
return zip(createId, createName, createEmail, ::Customer)
}
}
}

View File

@ -0,0 +1,14 @@
package com.github.michaelbull.result.example.model.domain
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
data class CustomerId(val id: Long) {
companion object {
fun create(id: Long?) = when {
id == null -> Err(DomainMessage.CustomerRequired)
id < 1 -> Err(DomainMessage.CustomerIdMustBePositive)
else -> Ok(CustomerId(id))
}
}
}

View File

@ -0,0 +1,37 @@
package com.github.michaelbull.result.example.model.domain
/**
* All possible things that can happen in the use-cases
*/
sealed class DomainMessage {
/* validation errors */
object CustomerRequired : DomainMessage()
object CustomerIdMustBePositive : DomainMessage()
object FirstNameRequired : DomainMessage()
object FirstNameTooLong : DomainMessage()
object LastNameRequired : DomainMessage()
object LastNameTooLong : DomainMessage()
object EmailRequired : DomainMessage()
object EmailTooLong : DomainMessage()
object EmailInvalid : DomainMessage()
/* events */
object CustomerCreated : DomainMessage()
class EmailAddressChanged(val old: String, val new: String) : DomainMessage()
/* exposed errors */
object CustomerNotFound : DomainMessage()
/* internal errors */
object SqlCustomerInvalid : DomainMessage()
object DatabaseTimeout : DomainMessage()
class DatabaseError(val reason: String?) : DomainMessage()
}

View File

@ -0,0 +1,20 @@
package com.github.michaelbull.result.example.model.domain
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
data class EmailAddress(
val address: String
) {
companion object {
private val pattern = ".+@.+\\..+".toRegex() // crude validation
fun create(address: String?) = when {
address == null || address.isBlank() -> Err(DomainMessage.EmailRequired)
address.length > 20 -> Err(DomainMessage.EmailTooLong)
!address.matches(pattern) -> Err(DomainMessage.EmailInvalid)
else -> Ok(EmailAddress(address))
}
}
}

View File

@ -0,0 +1,19 @@
package com.github.michaelbull.result.example.model.domain
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
data class PersonalName(
val first: String,
val last: String
) {
companion object {
fun create(first: String?, last: String?) = when {
first == null || first.isBlank() -> Err(DomainMessage.FirstNameRequired)
last == null || last.isBlank() -> Err(DomainMessage.LastNameRequired)
first.length > 10 -> Err(DomainMessage.FirstNameTooLong)
last.length > 10 -> Err(DomainMessage.LastNameTooLong)
else -> Ok(PersonalName(first, last))
}
}
}

View File

@ -0,0 +1,9 @@
package com.github.michaelbull.result.example.model.domain.repository
import com.github.michaelbull.result.example.model.domain.Customer
import com.github.michaelbull.result.example.model.entity.CustomerEntity
/**
* A repository that stores [Customers][Customer] with a [Long] ID.
*/
interface CustomerRepository : Repository<CustomerEntity, Long>

View File

@ -0,0 +1,10 @@
package com.github.michaelbull.result.example.model.domain.repository
/**
* A class that encapsulates storage and retrieval of domain objects of type [T], identified by a key of type [ID].
*/
interface Repository<T, ID> {
fun findAll(): Collection<T>
fun update(entity: T)
fun insert(entity: T)
}

View File

@ -0,0 +1,23 @@
package com.github.michaelbull.result.example.model.dto
import com.github.michaelbull.result.example.model.domain.Customer
/**
* A [DTO](https://en.wikipedia.org/wiki/Data_transfer_object) sent over the network
* that represents a [Customer].
*/
data class CustomerDto(
var id: Long = 0L,
var firstName: String? = null,
var lastName: String? = null,
var email: String? = null
) {
companion object {
fun from(customer: Customer) = CustomerDto(
id = customer.id.id,
firstName = customer.name.first,
lastName = customer.name.last,
email = customer.email.address
)
}
}

View File

@ -0,0 +1,23 @@
package com.github.michaelbull.result.example.model.entity
import com.github.michaelbull.result.example.model.domain.Customer
/**
* Represents an [Entity](https://docs.oracle.com/cd/E17277_02/html/collections/tutorial/Entity.html)
* mapped to a table in a database.
*/
data class CustomerEntity(
var id: Long = 0L,
var firstName: String? = null,
var lastName: String? = null,
var email: String? = null
) {
companion object {
fun from(customer: Customer) = CustomerEntity(
id = customer.id.id,
firstName = customer.name.first,
lastName = customer.name.last,
email = customer.email.address
)
}
}

View File

@ -0,0 +1,65 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.*
import com.github.michaelbull.result.example.model.domain.Customer
import com.github.michaelbull.result.example.model.domain.CustomerId
import com.github.michaelbull.result.example.model.domain.DomainMessage
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import java.sql.SQLTimeoutException
object CustomerService {
private val repository = InMemoryCustomerRepository()
fun getAll(): Result<Collection<Customer>, DomainMessage> {
return Result.of(repository::findAll)
.mapError(this::exceptionToDomainMessage)
.andThen { result: Collection<CustomerEntity> ->
Ok(result.map {
val customer = Customer.from(it)
when (customer) {
is Ok -> customer.value
is Err -> return customer
}
})
}
}
fun getById(id: CustomerId): Result<Customer, DomainMessage> {
return getAll().andThen { it.findCustomer(id) }
}
fun upsert(customer: Customer): Result<DomainMessage?, DomainMessage> {
val entity = CustomerEntity.from(customer)
return getById(customer.id).mapBoth(
success = { existing ->
Result.of { repository.update(entity) }
.mapError(this::exceptionToDomainMessage)
.map {
if (customer.email != existing.email) {
DomainMessage.EmailAddressChanged(existing.email.address, customer.email.address)
} else {
null
}
}
},
failure = {
Result.of { repository.insert(entity) }
.mapError(this::exceptionToDomainMessage)
.map { DomainMessage.CustomerCreated }
}
)
}
private fun Collection<Customer>.findCustomer(id: CustomerId): Result<Customer, DomainMessage.CustomerNotFound> {
val customer = find { it.id == id }
return if (customer != null) Ok(customer) else Err(DomainMessage.CustomerNotFound)
}
private fun exceptionToDomainMessage(it: Throwable): DomainMessage {
return when (it) {
is SQLTimeoutException -> DomainMessage.DatabaseTimeout
else -> DomainMessage.DatabaseError(it.message)
}
}
}

View File

@ -0,0 +1,44 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.example.model.domain.repository.CustomerRepository
import java.sql.SQLException
import java.sql.SQLTimeoutException
class InMemoryCustomerRepository : CustomerRepository {
private val table = mutableMapOf(
5L to CustomerEntity(5L, "Michael", "Bull", "example@email.com")
)
override fun findAll(): Collection<CustomerEntity> {
return table.values
}
override fun update(entity: CustomerEntity) {
val id = entity.id
if (id !in table) {
throw SQLException("No customer found for id $id")
} else {
setOrTimeout(id, entity)
}
}
override fun insert(entity: CustomerEntity) {
val id = entity.id
if (id in table) {
throw SQLException("Customer already exists with id $id")
} else {
setOrTimeout(id, entity)
}
}
private fun setOrTimeout(id: Long, entity: CustomerEntity) {
if (id == 42L) {
throw SQLTimeoutException()
} else {
table[id] = entity
}
}
}

View File

@ -0,0 +1,9 @@
ktor {
deployment {
port = 9000
}
application {
modules = [ com.github.michaelbull.result.example.ApplicationKt.main ]
}
}

View File

@ -0,0 +1,15 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT" />
</root>
<logger name="org.eclipse.jetty" level="INFO" />
<logger name="io.netty" level="INFO" />
</configuration>

View File

@ -1,3 +1,4 @@
rootProject.name = 'kotlin-result' rootProject.name = 'kotlin-result'
include 'js', 'jvm' include 'example', 'js', 'jvm'