A library that provides a FutureValidation
type that wraps scala.concurrent.Future[scalaz.Validation[Failure, Result]]
.
It allows to design strictly typed APIs, and take advantage of to the monadic nature of FutureValidation
to perform serial or parallel computations, errors accumulations and other functional programming tasks using features provided by Scalaz.
A simple API contract, using a database client as an example, can be defined as follows:
package object db {
type DatabaseResult[T] extends FutureValidation[DatabaseError, T]
object DatabaseResult extends TypedFutureValidation[DatabaseError]
}
case class DatabaseError(code: Int, message: String)
Then an example database client would look like the following:
import db._
class DatabaseClient {
// Handles future exceptions and converts them to the defined error for the API
def exceptionHandler[T]: ExceptionHandler[DatabaseError, T] = {
case ex: DuplicateKeyException => Validation.failure(DatabaseError(11000, ex.getMessage))
case ex => Validation.failure(DatabaseError(0, ex.getMessage))
}
def findById(id: String): DatabaseResult[Entity] =
DatabaseResult {
Future(???)
} recover exceptionHandler
def insert(entity: Entity): DatabaseResult[Unit] =
DatabaseResult {
Future(???)
} recover exceptionHandler
}
DatabaseResult
can be used in for comprehensions and supports various operations:
val client = new DatabaseClient
val result = for {
a <- findById("1234")
b <- findById(a)
} yield b
val resultWithMappedError = result.leftMap {
case DatabaseError(0, _) => DatabaseError(0, "Something went wrong")
}
val insertResult = result.flatMap { entity =>
client.insert(entity)
}
For more details on the supported features check the Usage section below.
Future Validation is available at my Nexus Repository, it is available for Scala 2.10 and Scala 2.11.
Using SBT, add the following dependency to your build file:
libraryDependencies ++= Seq(
"com.bfil" %% "future-validation" % "0.2.0"
)
Don't forget to add the following resolver:
resolvers += "BFil Nexus Releases" at "http://nexus.b-fil.com/nexus/content/repositories/releases/"
If you need a snapshot dependency:
libraryDependencies ++= Seq(
"com.bfil" %% "future-validation" % "0.3.0-SNAPSHOT"
)
resolvers += "BFil Nexus Snapshots" at "http://nexus.b-fil.com/nexus/content/repositories/snapshots/";
APIs can be defined by simply choosing a name for the async result to use. F example, for a database client it would make sense to have a result type called DatabaseResult
, that can either be successful and containing the expected result or it could return a DatabaseError
.
Using the library, a simple definition would be defined as follows:
package object db {
type DatabaseResult[T] extends FutureValidation[DatabaseError, T]
object DatabaseResult extends TypedFutureValidation[DatabaseError]
}
case class DatabaseError(code: Int, message: String)
We defined a custom type that is a type alias for FutureValidation[FailureType, T]
, and we define the companion object, usually keeping the same name is a good idea to avoid confusion.
The companion object can extend two different traits: TypedFutureValidation
and SealedFutureValidation
, they both take a type parameter, which is basically the failure type, in our example it's a simple case class DatabaseError
.
Extending TypedFutureValidation
leaves the exception handling up to the user, it is useful when the async operations require different exception handlers depending on the operations to perform.
If that's the case we would wrap the asynchronous client with something like the following:
import db._
class DatabaseClient {
def readExceptionHandler[T]: ExceptionHandler[DatabaseError, T] = {
case ex: QueryParseException => Validation.failure(DatabaseError(500, ex.getMessage))
case ex => Validation.failure(DatabaseError(0, ex.getMessage))
}
def findById(id: String): DatabaseResult[Entity] =
DatabaseResult {
Future(???)
} recover readExceptionHandler
def writeExceptionHandler[T]: ExceptionHandler[DatabaseError, T] = {
case ex: DuplicateKeyException => Validation.failure(DatabaseError(11000, ex.getMessage))
case ex => Validation.failure(DatabaseError(0, ex.getMessage))
}
def insert(entity: Entity): DatabaseResult[Unit] =
DatabaseResult {
Future(???)
} recover writeExceptionHandler
}
In our example case let's assume we are wrapping a database library that returns well defined exceptions that are common for all operations, in this case we can rewrite the above API definition like this:
package object db {
type DatabaseResult[T] extends FutureValidation[DatabaseError, T]
object DatabaseResult extends SealedFutureValidation[DatabaseError] {
def exceptionHandler[T]: ExceptionHandler[DatabaseError, T] = {
case ex: DuplicateKeyException => Validation.failure(DatabaseError(11000, ex.getMessage))
case ex: QueryParseException => Validation.failure(DatabaseError(500, ex.getMessage))
case ex => Validation.failure(DatabaseError(0, ex.getMessage))
}
}
}
case class DatabaseError(code: Int, message: String)
The above just emplies that wrapping a Future[T]
within a DatabaseResult
will always catch the exceptions and turn them into the custom error type DatabaseError
.
The methods documentation uses as an example the DatabaseResult
API type defined above.
Maps the successful value of the future validation
val result = client.findById("1234") map { entity =>
entity.copy(???)
}
FlatMaps the successful value of the future validation into a successful/failure future validation
val stringResult: DatabaseResult[String] =
client.findById("1234") flatMap { entity =>
DatabaseResult.success("stubbed")
}
val errorResult: DatabaseResult[String] =
client.findById("1234") flatMap { entity =>
DatabaseResult.failure(DatabaseError(100, "Some error"))
}
Maps the failure value of the future validation
val result = client.findById("1234") leftMap { error =>
DatabaseError(200, "Mapped error")
}
val result2 = client.findById("1234") mapError { error =>
DatabaseError(200, "Mapped error")
}
FlatMaps the failure value of the future validation into a successful/failure future validation
val errorResult = client.findById("1234") leftFlatMap { error =>
DatabaseResult.failure(DatabaseError(200, "Mapped error"))
}
val stringResult = client.findById("1234") leftFlatMap { error =>
DatabaseResult.success("stubbed")
}
val stringResult = client.findById("1234") recoverError { error =>
DatabaseResult.success("stubbed")
}
Folds the future validation into a future using the provided failure and success mapping functions
val result: Future[Int] = client.findById("1234").fold(error => 0, result => 1)
Handles the future exceptions
DatabaseResult {
Future.failed(new Exception("An exception occurred"))
} recover {
case ex => Validation.failure(DatabaseError(0, ex.getMessage))
}
Allows to define a default future validation value to use instead of the future validation failure
val result = client.findById("1234").orElse(DatabaseResult.success(???))
Zips future validations together
val result: DatabaseResult[(Entity, Entity)] = client.findById("1234") zip client.findById("5678")
To zip more than 2 future validations together us the singleton object:
val result: DatabaseResult[(Entity, Entity, Entity)] =
DatabaseResult.zip(
client.findById("1234"),
client.findById("3456"),
client.findById("5678")
)
Turns a List[FutureValidation[F, T]]
into a FutureValidation[F, List[T]]
val result: DatabaseResult[List[Entity]] =
DatabaseResult.sequence(
List(client.findById("1234"), client.findById("5678"))
)
Can be used to traverse lists to turn into future validations
val result: DatabaseResult[List[Entity]] =
DatabaseResult.traverse(List("1234", "5678")) { id =>
client.findById(id)
}
Transforms a future validation error type to a NonEmptyList
type, useful to do error accumulation
val result = client.findById("1234").toFutureValidationNel
To be able to use Scalaz applicative building for error accumulation we need to have an instance of the Apply
type for our FutureValidation
type in place.
To facilitate this use case, the object defining the API can be extended with TypedFutureValidationApplyInstances
like this:
package object db extends TypedFutureValidationApplyInstances[DatabaseError] {
type DatabaseResult[T] extends FutureValidation[DatabaseError, T]
type DatabaseResultNel[T] extends FutureValidationNel[DatabaseError, T] // this is just to alias the complex type
object DatabaseResult extends TypedFutureValidation[DatabaseError]
}
So an import db._
will also provide the instance of Apply
for our API type, so that we can accumulate errors like so:
import scalaz._
import Scalaz._
val result1 = client.findById("1234").toFutureValidationNel
val result2 = client.findById("3456").toFutureValidationNel
val result3 = client.findById("5678").toFutureValidationNel
val result: DatabaseResultNel[(Entity, Entity, Entity)] = result1 |@| result2 |@| result3 {
case (res1, res2, res3) => (res1, res2, res3)
}
If at least one of the operations fails, the errors will be accumulated in a NonEmptyList[DatabaseError]
.
An instance of the Scalaz Monoid
can be included in the API by extending TypedFutureValidationMonoidInstances
:
package object db extends TypedFutureValidationMonoidInstances[DatabaseError] {
type DatabaseResult[T] extends FutureValidation[DatabaseError, T]
object DatabaseResult extends TypedFutureValidation[DatabaseError]
def zero = DatabaseError(0, "Default error")
}
Please note a zero/identity value should be provided, having the instance in scope would allow the use of if conditions or destructuring in for comprehensions. The append
method can also be overridden if needed, by default it always uses the first semigroup.
Both instances of Apply
and Monoid
can be provided by extending a single trait, called TypedFutureValidationInstances
.
This software is licensed under the Apache 2 license, quoted below.
Copyright © 2015 Bruno Filippone http://b-fil.com
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
[http://www.apache.org/licenses/LICENSE-2.0]
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.