Add example application
This commit is contained in:
parent
389a87ffe2
commit
cfaa57dd1f
33
example/build.gradle
Normal file
33
example/build.gradle
Normal 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"
|
||||
}
|
||||
}
|
2
example/gradle.properties
Normal file
2
example/gradle.properties
Normal file
@ -0,0 +1,2 @@
|
||||
ktorVersion=0.9.0
|
||||
logbackVersion=1.2.1
|
@ -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")
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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)
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
9
example/src/main/resources/application.conf
Normal file
9
example/src/main/resources/application.conf
Normal file
@ -0,0 +1,9 @@
|
||||
ktor {
|
||||
deployment {
|
||||
port = 9000
|
||||
}
|
||||
|
||||
application {
|
||||
modules = [ com.github.michaelbull.result.example.ApplicationKt.main ]
|
||||
}
|
||||
}
|
15
example/src/main/resources/logback.xml
Normal file
15
example/src/main/resources/logback.xml
Normal 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>
|
@ -1,3 +1,4 @@
|
||||
rootProject.name = 'kotlin-result'
|
||||
|
||||
include 'js', 'jvm'
|
||||
include 'example', 'js', 'jvm'
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user