Skip to main content

Smithy4s Transformations and generalisation

It is often the case that users may want to manipulate the generated interfaces in a generic way, be that to transform the context in which the interface operates, or to apply some generic behaviour when running methods.

The generated code provided by Smithy4s contains the required methods and instances to be able to write transformations very generically. In particular, all generated service interfaces come with an associated FunctorK5, which means they can be "mapped" by using a function that operates over higher-kinded types with 5 type parameters. Yes, this is scary, but indirections are present to make it easier for the end user.

$version: "2"

namespace smithy4s.example.greet

service GreetService {
operations: [Greet]
}

operation Greet {
input := {
@required
name: String
}
output := {
@required
message: String
}
}
import smithy4s._
import smithy4s.kinds.PolyFunction
import smithy4s.example.greet._

type Result[A] = Either[String, A]

// Assuming `GreetService` was generated by smithy4s.
val greetServiceEither: GreetService[Result] = new GreetService[Result]{
def greet(name: String): Result[GreetOutput] =
if (name.isEmpty) Left("What's your name ?")
else Right(GreetOutput(s"Hello $name!"))
}
// greetServiceEither: GreetService[Result] = repl.MdocSession$MdocApp$$anon$1@2e136176

// Creating a polymorphic function turning Either to Option :
val toOption: PolyFunction[Result, Option] = new PolyFunction[Result, Option]{
def apply[A](result: Result[A]): Option[A] = result.toOption
}
// toOption: PolyFunction[Result, Option] = repl.MdocSession$MdocApp$$anon$2@252d7bda

// transforming our service :
val greetServiceOption: GreetService[Option] = greetServiceEither.transform(toOption)
// greetServiceOption: GreetService[Option] = smithy4s.example.greet.GreetServiceOperation$Transformed@281a03ce

println(greetServiceOption.greet("John"))
// Some(GreetOutput(Hello John!))

Using transformations, it is possible to surface errors into the context a service operates, or, in the contrary, to absorb errors to make them disappear from the context. The generated interfaces contain the accurate information associated to each method, and the companion objects contain the necessary constructs to transform typed-errors into throwables and to recover type-errors from throwables.

$version: "2"

namespace smithy4s.example

service KVStore {
operations: [Get, Put, Delete],
errors: [UnauthorizedError]
}

operation Put {
input: KeyValue
}

operation Get {
input: Key,
output: Value,
errors: [KeyNotFoundError]
}

operation Delete {
input: Key,
errors: [KeyNotFoundError]
}

structure Key {
@required
key: String
}

structure KeyValue {
@required
key: String,
@required
value: String
}

structure Value {
@required
value: String
}

@error("client")
structure UnauthorizedError {
@required
reason: String
}

@error("client")
structure KeyNotFoundError {
@required
message: String
}

Surfacing errors

The smithy4s.Transformation.SurfaceError interface codifies the transformation of service implementations from contexts that represent errors as a generic Throwable, from contexts that have the awareness of the errors specified in the specifications. It is useful when you want to exhaustively handle the errors that are specified (as opposed to letting them propagate).

To make the ascription of such contexts easier, Smithy4s generates ErrorAware[F[_, _]] type aliases in the companion objects of services. This can be used conjointly with types that have "two" parameters, one for the error, one for the result. For instance type BIO[E, A] = EitherT[IO, E, A].

import smithy4s.example._
import smithy4s.example.KVStore
import smithy4s.Transformation
import scala.util.{Failure, Success, Try}

object kvStoreTry extends KVStore[Try] {
def delete(key: String): Try[Unit] = Success(())
def put(key: String, value: String): Try[Unit] = Success(())
def get(key: String): Failure[Value] = Failure(KeyNotFoundError(s"Key $key wasn't found"))
}

// SurfaceError allows to go from mono-functor to bi-functor, for instance, from
// IO[A] to EitherT[IO, E, A]
val toEither: Transformation.SurfaceError[Try, Either] =
new Transformation.SurfaceError[Try, Either] {
def apply[E, A](
value: Try[A],
catcher: Throwable => Option[E]
): Either[E, A] = value match {
case Success(value) => Right(value)
case Failure(error) =>
catcher(error) match {
case None => throw error // don't do this at work!
case Some(e) => Left(e)
}
}
}
// toEither: Transformation.SurfaceError[Try, Either] = repl.MdocSession$MdocApp$$anon$3@4d3af272

val kvStoreEither: KVStore.ErrorAware[Either] = kvStoreTry.transform(toEither)
// kvStoreEither: KVStore.ErrorAware[Either] = smithy4s.example.KVStoreOperation$Transformed@33b2b965
val result: Either[KVStore.GetError, Value] = kvStoreEither.get("foo")
// result: Either[KVStore.GetError, Value] = Left(
// value = KeyNotFoundErrorCase(
// keyNotFoundError = KeyNotFoundError(message = "Key foo wasn't found")
// )
// )

Absorbing errors

The smithy4s.Transformation.AbsorbErrors interface is the opposite as the SurfaceError: it codifies the absorption of errors known by the service into generic error channels.

It is useful to implement services in a way that leverages the type-checker to ensure that the returned errors have been specified in Smithy, before passing the implementation to a generic router that is only able to work against a monofunctor.

import smithy4s.example._
import smithy4s.example.KVStore
import smithy4s.Transformation
import scala.util.{Failure, Success, Try}

object kvStoreEither extends KVStore.ErrorAware[Either] {
def delete(key: String): Either[KVStore.DeleteError, Unit] = Right(())
def put(key: String, value: String): Either[Nothing, Unit] = Right(())
def get(key: String): Either[KVStore.GetError, Value] =
Left(
KVStore.GetError.KeyNotFoundErrorCase(
KeyNotFoundError(s"Key $key wasn't found")
)
)
}

val toTry: Transformation.AbsorbError[Either, Try] =
new Transformation.AbsorbError[Either, Try] {
def apply[E, A](
value: Either[E, A],
thrower: E => Throwable
): Try[A] = value match {
case Left(error) => Failure(thrower(error))
case Right(value) => Success(value)
}
}
// toTry: Transformation.AbsorbError[Either, Try] = repl.MdocSession$MdocApp3$$anon$4@16cf6be6

val kvStoreTry: KVStore[Try] = kvStoreEither.transform(toTry)
// kvStoreTry: KVStore[Try] = smithy4s.example.KVStoreOperation$Transformed@17930ff3
val result: Try[Value] = kvStoreTry.get("foo")
// result: Try[Value] = Failure(
// exception = KeyNotFoundError(message = "Key foo wasn't found")
// )