zio-http icon indicating copy to clipboard operation
zio-http copied to clipboard

Less strict declarative style Endpoint implementation

Open pocorall opened this issue 10 months ago • 4 comments

When I tried migrate my imperative style Route implementations to declarative style, I was frustrated by:

  1. cannot access Request object
  2. cannot return Response object
  3. exceptions must be handled using codec, it isn't falls through and catched with Routes.handleErrorCause later.

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.

pocorall avatar Feb 12 '25 08:02 pocorall

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.

987Nabil avatar Mar 15 '25 19:03 987Nabil

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.

pocorall avatar Mar 17 '25 04:03 pocorall

Can you show me, how you think error handling would look like? There should not be a lot of boilerplate

987Nabil avatar Mar 17 '25 19:03 987Nabil

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.

pocorall avatar Mar 18 '25 08:03 pocorall