Rewrite example application

This commit is contained in:
Michael Bull 2020-08-26 22:02:22 +01:00
parent 41269f06d3
commit 67c1cd33ad
19 changed files with 274 additions and 256 deletions

View File

@ -217,19 +217,18 @@ user-facing errors.
#### Fetch customer information
```
$ curl -i -X GET 'http://localhost:9000/customers/5'
$ curl -i -X GET 'http://localhost:9000/customers/1'
```
```
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 93
Content-Length: 84
{
"id": 5,
"firstName": "Michael",
"lastName": "Bull",
"email": "example@email.com"
"email": "michael@example.com"
}
```
@ -242,7 +241,7 @@ $ curl -i -X POST \
'{
"firstName": "Your",
"lastName": "Name",
"email": "your@email.com"
"email": "email@example.com"
}' \
'http://localhost:9000/customers/200'
```

View File

@ -1,11 +1,8 @@
package com.github.michaelbull.result.example
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.example.model.domain.Customer
import com.github.michaelbull.result.example.model.domain.CustomerCreated
import com.github.michaelbull.result.example.model.domain.CustomerId
import com.github.michaelbull.result.example.model.domain.Created
import com.github.michaelbull.result.example.model.domain.CustomerIdMustBePositive
import com.github.michaelbull.result.example.model.domain.CustomerNotFound
import com.github.michaelbull.result.example.model.domain.CustomerRequired
@ -16,15 +13,20 @@ import com.github.michaelbull.result.example.model.domain.EmailAddressChanged
import com.github.michaelbull.result.example.model.domain.EmailInvalid
import com.github.michaelbull.result.example.model.domain.EmailRequired
import com.github.michaelbull.result.example.model.domain.EmailTooLong
import com.github.michaelbull.result.example.model.domain.Event
import com.github.michaelbull.result.example.model.domain.FirstNameChanged
import com.github.michaelbull.result.example.model.domain.FirstNameRequired
import com.github.michaelbull.result.example.model.domain.FirstNameTooLong
import com.github.michaelbull.result.example.model.domain.LastNameChanged
import com.github.michaelbull.result.example.model.domain.LastNameRequired
import com.github.michaelbull.result.example.model.domain.LastNameTooLong
import com.github.michaelbull.result.example.model.domain.SqlCustomerInvalid
import com.github.michaelbull.result.example.model.dto.CustomerDto
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.example.model.entity.CustomerId
import com.github.michaelbull.result.example.repository.InMemoryCustomerRepository
import com.github.michaelbull.result.example.service.CustomerService
import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.toResultOr
import io.ktor.application.Application
import io.ktor.application.call
@ -52,56 +54,49 @@ fun Application.main() {
}
}
val customers = setOf(
CustomerEntity(CustomerId(1L), "Michael", "Bull", "michael@example.com"),
CustomerEntity(CustomerId(2L), "Kevin", "Herron", "kevin@example.com"),
CustomerEntity(CustomerId(3L), "Markus", "Padourek", "markus@example.com"),
CustomerEntity(CustomerId(4L), "Tristan", "Hamilton", "tristan@example.com"),
)
val customersById = customers.associateBy(CustomerEntity::id).toMutableMap()
val customerRepository = InMemoryCustomerRepository(customersById)
val customerService = CustomerService(customerRepository)
routing {
get("/customers/{id}") {
call.parameters.readId()
.andThen(CustomerId.Companion::create)
.andThen(CustomerService::getById)
.mapError(::messageToResponse)
.mapBoth(
success = { customer ->
call.respond(HttpStatusCode.OK, CustomerDto.from(customer))
},
failure = { (status, message) ->
call.respond(status, message)
}
)
val (status, message) = call.parameters.readId()
.andThen(customerService::getById)
.mapBoth(::customerToResponse, ::messageToResponse)
call.respond(status, message)
}
post("/customers/{id}") {
call.parameters.readId()
.andThen { id ->
val dto = call.receive<CustomerDto>()
dto.id = id
Ok(dto)
}
.andThen(Customer.Companion::from)
.andThen(CustomerService::upsert)
.mapError(::messageToResponse)
.mapBoth(
success = { event ->
if (event == null) {
call.respond(HttpStatusCode.NotModified)
} else {
val (status, message) = messageToResponse(event)
call.respond(status, message)
}
},
failure = { (status, message) ->
call.respond(status, message)
}
)
val (status, message) = call.parameters.readId()
.andThen { customerService.save(it, call.receive()) }
.mapBoth(::eventToResponse, ::messageToResponse)
if (message != null) {
call.respond(status, message)
} else {
call.respond(status)
}
}
}
}
private fun Parameters.readId(): Result<Long, DomainMessage> {
return this["id"]
return get("id")
?.toLongOrNull()
.toResultOr { CustomerRequired }
}
private fun customerToResponse(customer: CustomerDto) = HttpStatusCode.OK to customer
private fun messageToResponse(message: DomainMessage) = when (message) {
CustomerRequired,
CustomerIdMustBePositive,
@ -112,23 +107,32 @@ private fun messageToResponse(message: DomainMessage) = when (message) {
EmailRequired,
EmailTooLong,
EmailInvalid ->
Pair(HttpStatusCode.BadRequest, "There is an error in your request")
// events
CustomerCreated ->
Pair(HttpStatusCode.Created, "Customer created")
is EmailAddressChanged ->
Pair(HttpStatusCode.OK, "Email address changed from ${message.old} to ${message.new}")
HttpStatusCode.BadRequest to "There is an error in your request"
// exposed errors
CustomerNotFound ->
Pair(HttpStatusCode.NotFound, "Unknown customer")
HttpStatusCode.NotFound to "Unknown customer"
// internal errors
SqlCustomerInvalid,
DatabaseTimeout,
is DatabaseError ->
Pair(HttpStatusCode.InternalServerError, "Internal server error occurred")
HttpStatusCode.InternalServerError to "Internal server error occurred"
}
private fun eventToResponse(event: Event?) = when (event) {
null ->
HttpStatusCode.NotModified to null
Created ->
HttpStatusCode.Created to "Customer created"
is FirstNameChanged ->
HttpStatusCode.OK to "First name changed from ${event.old} to ${event.new}"
is LastNameChanged ->
HttpStatusCode.OK to "First name changed from ${event.old} to ${event.new}"
is EmailAddressChanged ->
HttpStatusCode.OK to "Email address changed from ${event.old} to ${event.new}"
}

View File

@ -1,28 +1,6 @@
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

@ -1,14 +0,0 @@
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(CustomerRequired)
id < 1 -> Err(CustomerIdMustBePositive)
else -> Ok(CustomerId(id))
}
}
}

View File

@ -20,11 +20,6 @@ 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()

View File

@ -1,24 +1,5 @@
package com.github.michaelbull.result.example.model.domain
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
data class EmailAddress(
val address: String
) {
companion object {
private const val MAX_LENGTH = 20
private val PATTERN = ".+@.+\\..+".toRegex() // crude validation
fun create(address: String?): Result<EmailAddress, DomainMessage> {
return when {
address.isNullOrBlank() -> Err(EmailRequired)
address.length > MAX_LENGTH -> Err(EmailTooLong)
!address.matches(PATTERN) -> Err(EmailInvalid)
else -> Ok(EmailAddress(address))
}
}
}
}
)

View File

@ -0,0 +1,8 @@
package com.github.michaelbull.result.example.model.domain
sealed class Event
object Created : Event()
class FirstNameChanged(val old: String, val new: String) : Event()
class LastNameChanged(val old: String, val new: String) : Event()
class EmailAddressChanged(val old: String, val new: String) : Event()

View File

@ -7,18 +7,18 @@ import com.github.michaelbull.result.Result
data class PersonalName(
val first: String,
val last: String
) {
companion object {
private const val MAX_LENGTH = 10
)
fun create(first: String?, last: String?): Result<PersonalName, DomainMessage> {
return when {
first.isNullOrBlank() -> Err(FirstNameRequired)
last.isNullOrBlank() -> Err(LastNameRequired)
first.length > MAX_LENGTH -> Err(FirstNameTooLong)
last.length > MAX_LENGTH -> Err(LastNameTooLong)
else -> Ok(PersonalName(first, last))
}
}
private const val MAX_LENGTH = 10
fun Pair<String?, String?>.toPersonalName(): Result<PersonalName, DomainMessage> {
val (first, last) = this
return when {
first.isNullOrBlank() -> Err(FirstNameRequired)
last.isNullOrBlank() -> Err(LastNameRequired)
first.length > MAX_LENGTH -> Err(FirstNameTooLong)
last.length > MAX_LENGTH -> Err(LastNameTooLong)
else -> Ok(PersonalName(first, last))
}
}

View File

@ -1,9 +0,0 @@
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

@ -1,23 +1,7 @@
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
)
}
}
val firstName: String,
val lastName: String,
val email: String
)

View File

@ -1,23 +1,12 @@
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
)
}
}
val id: CustomerId,
val firstName: String,
val lastName: String,
val email: String
)

View File

@ -0,0 +1,3 @@
package com.github.michaelbull.result.example.model.entity
data class CustomerId(val id: Long)

View File

@ -0,0 +1,9 @@
package com.github.michaelbull.result.example.repository
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.example.model.entity.CustomerId
/**
* A repository that stores a [CustomerEntity] identified by a [CustomerId].
*/
interface CustomerRepository : Repository<CustomerEntity, CustomerId>

View File

@ -0,0 +1,28 @@
package com.github.michaelbull.result.example.repository
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.example.model.entity.CustomerId
import java.sql.SQLTimeoutException
class InMemoryCustomerRepository(
private val customers: MutableMap<CustomerId, CustomerEntity>
) : CustomerRepository {
override fun findById(id: CustomerId): CustomerEntity? {
return customers.entries.find { (key) -> key == id }?.value
}
override fun save(entity: CustomerEntity) {
val id = entity.id
if (id == TIMEOUT_CUSTOMER_ID) {
throw SQLTimeoutException()
} else {
customers[id] = entity
}
}
private companion object {
private val TIMEOUT_CUSTOMER_ID = CustomerId(42L)
}
}

View File

@ -1,10 +1,9 @@
package com.github.michaelbull.result.example.model.domain.repository
package com.github.michaelbull.result.example.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)
fun findById(id: ID): T?
fun save(entity: T)
}

View File

@ -1,69 +1,126 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.example.model.domain.Created
import com.github.michaelbull.result.example.model.domain.Customer
import com.github.michaelbull.result.example.model.domain.CustomerCreated
import com.github.michaelbull.result.example.model.domain.CustomerId
import com.github.michaelbull.result.example.model.domain.CustomerIdMustBePositive
import com.github.michaelbull.result.example.model.domain.CustomerNotFound
import com.github.michaelbull.result.example.model.domain.CustomerRequired
import com.github.michaelbull.result.example.model.domain.DatabaseError
import com.github.michaelbull.result.example.model.domain.DatabaseTimeout
import com.github.michaelbull.result.example.model.domain.DomainMessage
import com.github.michaelbull.result.example.model.domain.EmailAddressChanged
import com.github.michaelbull.result.example.model.domain.Event
import com.github.michaelbull.result.example.model.domain.FirstNameChanged
import com.github.michaelbull.result.example.model.domain.LastNameChanged
import com.github.michaelbull.result.example.model.dto.CustomerDto
import com.github.michaelbull.result.example.model.entity.CustomerEntity
import com.github.michaelbull.result.example.model.entity.CustomerId
import com.github.michaelbull.result.example.repository.CustomerRepository
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.map
import com.github.michaelbull.result.mapAll
import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching
import com.github.michaelbull.result.toResultOr
import com.github.michaelbull.result.zip
import java.sql.SQLTimeoutException
object CustomerService {
private val repository = InMemoryCustomerRepository()
class CustomerService(
private val repository: CustomerRepository
) {
fun getAll(): Result<Collection<Customer>, DomainMessage> {
return runCatching(repository::findAll)
.mapError(::exceptionToDomainMessage)
.mapAll(Customer.Companion::from)
fun getById(id: Long): Result<CustomerDto, DomainMessage> {
return parseCustomerId(id)
.andThen(::findById)
.map(::entityToDto)
}
fun getById(id: CustomerId): Result<Customer, DomainMessage> {
return getAll().andThen { customers -> customers.findCustomer(id) }
fun save(id: Long, dto: CustomerDto): Result<Event?, DomainMessage> {
return parseCustomerId(id)
.andThen { upsert(it, dto) }
}
fun upsert(customer: Customer): Result<DomainMessage?, DomainMessage> {
val entity = CustomerEntity.from(customer)
return getById(customer.id).mapBoth(
success = { existing -> updateCustomer(entity, existing, customer) },
failure = { createCustomer(entity) }
private fun parseCustomerId(id: Long?) = when {
id == null -> Err(CustomerRequired)
id < 1 -> Err(CustomerIdMustBePositive)
else -> Ok(CustomerId(id))
}
private fun entityToDto(entity: CustomerEntity): CustomerDto {
return CustomerDto(
firstName = entity.firstName,
lastName = entity.lastName,
email = entity.email
)
}
private fun updateCustomer(entity: CustomerEntity, old: Customer, new: Customer) =
runCatching { repository.update(entity) }
.map { differenceBetween(old, new) }
.mapError(::exceptionToDomainMessage)
private fun createCustomer(entity: CustomerEntity) =
runCatching { repository.insert(entity) }
.map { CustomerCreated }
.mapError(::exceptionToDomainMessage)
private fun Collection<Customer>.findCustomer(id: CustomerId): Result<Customer, CustomerNotFound> {
return find { it.id == id }.toResultOr { CustomerNotFound }
private fun findById(id: CustomerId): Result<CustomerEntity, CustomerNotFound> {
return repository.findById(id)
.toResultOr { CustomerNotFound }
}
private fun differenceBetween(old: Customer, new: Customer): EmailAddressChanged? {
return if (new.email != old.email) {
EmailAddressChanged(old.email.address, new.email.address)
private fun upsert(id: CustomerId, dto: CustomerDto): Result<Event?, DomainMessage> {
val existingCustomer = repository.findById(id)
return if (existingCustomer != null) {
update(existingCustomer, dto)
} else {
null
insert(id, dto)
}
}
private fun update(entity: CustomerEntity, dto: CustomerDto): Result<Event?, DomainMessage> {
val validated = validate(dto).getOrElse { return Err(it) }
val updated = entity.copy(
firstName = validated.name.first,
lastName = validated.name.last,
email = validated.email.address
)
return runCatching { repository.save(updated) }
.map { compare(entity, updated) }
.mapError(::exceptionToDomainMessage)
}
private fun insert(id: CustomerId, dto: CustomerDto): Result<Created, DomainMessage> {
val entity = createEntity(id, dto).getOrElse { return Err(it) }
return runCatching { repository.save(entity) }
.map { Created }
.mapError(::exceptionToDomainMessage)
}
private fun validate(dto: CustomerDto): Result<Customer, DomainMessage> {
return zip(
{ PersonalNameParser.parse(dto.firstName, dto.lastName) },
{ EmailAddressParser.parse(dto.email) },
::Customer
)
}
private fun createEntity(id: CustomerId, dto: CustomerDto): Result<CustomerEntity, DomainMessage> {
return zip(
{ PersonalNameParser.parse(dto.firstName, dto.lastName) },
{ EmailAddressParser.parse(dto.email) },
{ (first, last), (address) -> CustomerEntity(id, first, last, address) }
)
}
private fun exceptionToDomainMessage(t: Throwable) = when (t) {
is SQLTimeoutException -> DatabaseTimeout
else -> DatabaseError(t.message)
}
private fun compare(old: CustomerEntity, new: CustomerEntity): Event? {
return when {
new.firstName != old.firstName -> FirstNameChanged(old.firstName, new.firstName)
new.lastName != old.lastName -> LastNameChanged(old.lastName, new.lastName)
new.email != old.email -> EmailAddressChanged(old.email, new.email)
else -> null
}
}
}

View File

@ -0,0 +1,25 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.example.model.domain.DomainMessage
import com.github.michaelbull.result.example.model.domain.EmailAddress
import com.github.michaelbull.result.example.model.domain.EmailInvalid
import com.github.michaelbull.result.example.model.domain.EmailRequired
import com.github.michaelbull.result.example.model.domain.EmailTooLong
object EmailAddressParser {
private const val MAX_LENGTH = 20
private val PATTERN = ".+@.+\\..+".toRegex() // crude validation
fun parse(address: String?): Result<EmailAddress, DomainMessage> {
return when {
address.isNullOrBlank() -> Err(EmailRequired)
address.length > MAX_LENGTH -> Err(EmailTooLong)
!address.matches(this.PATTERN) -> Err(EmailInvalid)
else -> Ok(EmailAddress(address))
}
}
}

View File

@ -1,44 +0,0 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.example.model.domain.repository.CustomerRepository
import com.github.michaelbull.result.example.model.entity.CustomerEntity
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,26 @@
package com.github.michaelbull.result.example.service
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.example.model.domain.DomainMessage
import com.github.michaelbull.result.example.model.domain.FirstNameRequired
import com.github.michaelbull.result.example.model.domain.FirstNameTooLong
import com.github.michaelbull.result.example.model.domain.LastNameRequired
import com.github.michaelbull.result.example.model.domain.LastNameTooLong
import com.github.michaelbull.result.example.model.domain.PersonalName
object PersonalNameParser {
private const val MAX_LENGTH = 10
fun parse(first: String?, last: String?): Result<PersonalName, DomainMessage> {
return when {
first.isNullOrBlank() -> Err(FirstNameRequired)
last.isNullOrBlank() -> Err(LastNameRequired)
first.length > MAX_LENGTH -> Err(FirstNameTooLong)
last.length > MAX_LENGTH -> Err(LastNameTooLong)
else -> Ok(PersonalName(first, last))
}
}
}