Less strict declarative style Endpoint implementation
When I tried migrate my imperative style Route implementations to declarative style, I was frustrated by:
- cannot access
Requestobject - cannot return
Responseobject - exceptions must be handled using codec, it isn't falls through and catched with
Routes.handleErrorCauselater.
I know that this is intentional design decision. Accessing directly to Request and Response is discouraged. However, access to these objects is necessary at least for migration purpose.
As a motivating toy example, refer to this code:
object EndpointExample extends ZIOAppDefault:
class NoProblemException(message: String) extends Exception(message)
private val myEndpoint1 =
Endpoint(Method.GET / "my-endpoint1").query(HttpCodec.query[Option[String]]("id"))
.query(HttpCodec.query[String]("name")).out[String]
private def myRoutes = Routes(
myEndpoint1.looseImplement { (maybeId: Option[String], name: String, req: Request) => // I hope this work
ZIO.fail(new NoProblemException(s"my-endpoint1 $maybeId $name, requested from ${req.remoteAddress}"))
// alternatively, the code below should also work
// ZIO.succeed(Response.text("OK with Response object"))
})
def errorProcessor(cause: Cause[Any]): Response =
cause.failureOrCause match
case Left(failure) => failure match
case i: NoProblemException => Response.json(s"""{"message": "${i.getMessage}"}""")
case i: Throwable => Response.text("My custom response")
case Right(cause) => Response.fromCause(cause)
def run: ZIO[Any, Throwable, Any] =
Server.serve(myRoutes.handleErrorCause(errorProcessor)).provide(Server.default)
I hope a new method Endpoint.looseImplement would provide this generous access to Request, Response, and ZIO.fail handling from Routes.handleErrorCause.
I am sorry, but this would require quite some internal change that has performance challenges. And it seems like a fringe use case. I don't even understand why it is hard to just migrate an endpoint fully. Maybe you can use a middleware to solve your problem? Please do not expect that we will implement this. It also encourages bad style for the endpoint API.
For me, just stay in imperative style would works.
Existing codes have access to Request, Response and relies on existing error handler. When I tried to move to declarative style, I found that I need to overhaul existing code fundamentally, and all the error handling need to be done for each endpoint, not in centralized way, which adds another boilerplate.
What I wanted is a middle ground for the migration, which helps adoption of endpoint API.
Can you show me, how you think error handling would look like? There should not be a lot of boilerplate
OK. Here I show another example demonstrate the problem:
import zio.dynamodb.DynamoDBError
import zio.http.Status.{InternalServerError, UnprocessableEntity}
import zio.http.*
import zio.http.codec.HttpCodec
import zio.http.endpoint.Endpoint
import zio.schema.{DeriveSchema, Schema}
import zio.{Cause, ZIO, ZIOAppDefault}
class DontWorryException(message: String) extends Exception(message)
val OkResponse: Response = Response.json("""{"message": "OK"}""")
def getUserFromId(id: String): ZIO[Any, Throwable, String] = ZIO.fail(DynamoDBError.ItemError.ValueNotFound("simulated failure")) // mock function
def errorResponse(status: Status.Error, message: String): Response =
Response(status = status, headers = Headers(zio.http.Header.ContentType(MediaType.application.json).untyped), Body.fromCharSequence(s"""{"message": $message}"""))
def checkGoodToStop()(using req: Request) = true // mock function
def errorProcessor(cause: Cause[Any]): Response =
cause.failureOrCause match
case Left(failure) => failure match
case i: DontWorryException => OkResponse
case i: DynamoDBError.ItemError.ValueNotFound => errorResponse(UnprocessableEntity, "VALUE_NOT_FOUND")
case i: DynamoDBError.ItemError => errorResponse(InternalServerError, "ITEM_ERROR")
case i: DynamoDBError => errorResponse(InternalServerError, "DB_ERROR")
case i: Throwable => errorResponse(InternalServerError, "INTERNAL_SERVER_ERROR")
case Right(cause) => Response.fromCause(cause)
object ImperativeExample extends ZIOAppDefault:
private def myRoutes = Routes(
Method.GET / "doSomething1" / string("id") -> handler { (id: String, req: Request) =>
given Request = req
for
user <- getUserFromId(id)
cond = checkGoodToStop()
_ <- ZIO.when(cond)(ZIO.fail(new DontWorryException("Will not run the code below. OK to stop.")))
// do some other stuff
yield OkResponse
})
def run: ZIO[Any, Throwable, Any] =
Server.serve(myRoutes.handleErrorCause(errorProcessor)).provide(Server.default)
The code above is written in imperative style.
Here I show my first failed attempt to migrate it to declarative style:
object EndpointExample extends ZIOAppDefault:
private val doSomething1Endpoint =
Endpoint(Method.GET / "doSomething1").query(HttpCodec.query[String]("id"))
.out[String]
.outError[DontWorryToken](Status.Ok)
.outError[DynamoDBErrorItemErrorValueNotFound](Status.UnprocessableEntity)
.outError[DynamoDBErrorItemError](Status.InternalServerError)
.outError[DynamoDBErrorToken](Status.InternalServerError)
.outError[ThrowableToken](Status.InternalServerError)
private def myRoutes = Routes(
doSomething1Endpoint.implement: (id: String) =>
(for
user <- getUserFromId(id)
cond = true // checkGoodToStop() requires access to Request
_ <- ZIO.when(cond)(ZIO.fail(new DontWorryException("Will not run the code below. OK to stop.")))
// do some other stuff
yield """{"message": "OK"}""").mapErrorCause(translateError) // Type mismatch error
)
case class DynamoDBErrorItemErrorValueNotFound()
object DynamoDBErrorItemErrorValueNotFound {
implicit val schema: Schema[DynamoDBErrorItemErrorValueNotFound] = DeriveSchema.gen
}
case class DynamoDBErrorItemError()
object DynamoDBErrorItemError {
implicit val schema: Schema[DynamoDBErrorItemError] = DeriveSchema.gen
}
case class DynamoDBErrorToken()
object DynamoDBErrorToken {
implicit val schema: Schema[DynamoDBErrorToken] = DeriveSchema.gen
}
case class ThrowableToken()
object ThrowableToken {
implicit val schema: Schema[ThrowableToken] = DeriveSchema.gen
}
case class DontWorryToken()
object DontWorryToken {
implicit val schema: Schema[DontWorryToken] = DeriveSchema.gen
}
def translateError(cause: Cause[Any]) =
cause.failureOrCause match
case Left(failure) => failure match
case i: DontWorryException => Cause.fail(DontWorryToken())
case i: DynamoDBError.ItemError.ValueNotFound => Cause.fail(DynamoDBErrorItemErrorValueNotFound())
case i: DynamoDBError.ItemError => Cause.fail(DynamoDBErrorItemError())
case i: DynamoDBError => Cause.fail(DynamoDBErrorToken())
case i: Throwable => Cause.fail(ThrowableToken())
case Right(cause) => cause
def run: ZIO[Any, Throwable, Any] =
Server.serve(myRoutes.handleErrorCause(errorProcessor)).provide(Server.default)
The code above have following problems:
- All the errors must be explicitly listed
- All Error classes must have schema
- Schema auto generator works only for case classes. Unfortunately
DynamoDBError, which is provided by zio-dynamodb package, is not a case class. Thus I tried to write bridge classes (e.g.DynamoDBErrorToken). There may be a little better way to solve this problem. Nevertheless, we need schema after all. - Existing business logic need to have translator for errors (see
.mapErrorCause(translateError)). I just demonstrated one endpoint. This inevitably repeats for each endpoint implementations. This is the boilerplate that I previously mentioned. - Actually, this is a failed attempt, as the endpoint API requires inflexible type for error,
Either[DontWorryToken, Either[DynamoDBErrorItemErrorValueNotFound, Either[...]]], which is highly impractical to fit its type like this:Right(Right(Left(...)))
At this moment, I found that the migration is not worth trouble.