diff --git a/example/build.gradle b/example/build.gradle new file mode 100644 index 0000000..c258ab9 --- /dev/null +++ b/example/build.gradle @@ -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" + } +} diff --git a/example/gradle.properties b/example/gradle.properties new file mode 100644 index 0000000..9ccadbf --- /dev/null +++ b/example/gradle.properties @@ -0,0 +1,2 @@ +ktorVersion=0.9.0 +logbackVersion=1.2.1 diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/Application.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/Application.kt new file mode 100644 index 0000000..27b7625 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/Application.kt @@ -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() + 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") + +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/Customer.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/Customer.kt new file mode 100644 index 0000000..4e4aeba --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/Customer.kt @@ -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 { + 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 { + 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) + } + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/CustomerId.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/CustomerId.kt new file mode 100644 index 0000000..9ba177d --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/CustomerId.kt @@ -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)) + } + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/DomainMessage.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/DomainMessage.kt new file mode 100644 index 0000000..1fd7959 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/DomainMessage.kt @@ -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() +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/EmailAddress.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/EmailAddress.kt new file mode 100644 index 0000000..5dbd8ae --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/EmailAddress.kt @@ -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)) + } + } +} + diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/PersonalName.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/PersonalName.kt new file mode 100644 index 0000000..2ecac77 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/PersonalName.kt @@ -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)) + } + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/CustomerRepository.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/CustomerRepository.kt new file mode 100644 index 0000000..0fb7656 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/CustomerRepository.kt @@ -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 diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/Repository.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/Repository.kt new file mode 100644 index 0000000..e22844e --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/domain/repository/Repository.kt @@ -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 { + fun findAll(): Collection + fun update(entity: T) + fun insert(entity: T) +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/dto/CustomerDto.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/dto/CustomerDto.kt new file mode 100644 index 0000000..70c5a88 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/dto/CustomerDto.kt @@ -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 + ) + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/model/entity/CustomerEntity.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/model/entity/CustomerEntity.kt new file mode 100644 index 0000000..1ea6b2d --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/model/entity/CustomerEntity.kt @@ -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 + ) + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/service/CustomerService.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/service/CustomerService.kt new file mode 100644 index 0000000..ca47094 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/service/CustomerService.kt @@ -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, DomainMessage> { + return Result.of(repository::findAll) + .mapError(this::exceptionToDomainMessage) + .andThen { result: Collection -> + Ok(result.map { + val customer = Customer.from(it) + when (customer) { + is Ok -> customer.value + is Err -> return customer + } + }) + } + } + + fun getById(id: CustomerId): Result { + return getAll().andThen { it.findCustomer(id) } + } + + fun upsert(customer: Customer): Result { + 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.findCustomer(id: CustomerId): Result { + 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) + } + } +} diff --git a/example/src/main/kotlin/com/github/michaelbull/result/example/service/InMemoryCustomerRepository.kt b/example/src/main/kotlin/com/github/michaelbull/result/example/service/InMemoryCustomerRepository.kt new file mode 100644 index 0000000..65fc169 --- /dev/null +++ b/example/src/main/kotlin/com/github/michaelbull/result/example/service/InMemoryCustomerRepository.kt @@ -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 { + 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 + } + } +} diff --git a/example/src/main/resources/application.conf b/example/src/main/resources/application.conf new file mode 100644 index 0000000..abb0d68 --- /dev/null +++ b/example/src/main/resources/application.conf @@ -0,0 +1,9 @@ +ktor { + deployment { + port = 9000 + } + + application { + modules = [ com.github.michaelbull.result.example.ApplicationKt.main ] + } +} diff --git a/example/src/main/resources/logback.xml b/example/src/main/resources/logback.xml new file mode 100644 index 0000000..9866d15 --- /dev/null +++ b/example/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index aceb36f..9c6c6b8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ rootProject.name = 'kotlin-result' -include 'js', 'jvm' +include 'example', 'js', 'jvm' +