Skip to content

Commit

Permalink
Explicit and customizable error messages for Endpoint BadRequest (#2650)
Browse files Browse the repository at this point in the history
  • Loading branch information
987Nabil committed Mar 4, 2024
1 parent 28c9de1 commit d20cd21
Show file tree
Hide file tree
Showing 11 changed files with 541 additions and 221 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ object EndpointGen {
doc: Doc,
input: HttpCodec[CodecType, Input],
): Endpoint[Path, Input, ZNothing, ZNothing, EndpointMiddleware.None] =
Endpoint(RoutePattern.any, input, HttpCodec.unused, HttpCodec.unused, doc, EndpointMiddleware.None)
Endpoint(
RoutePattern.any,
input,
HttpCodec.unused,
HttpCodec.unused,
CodecErrorHandler.default,
doc,
EndpointMiddleware.None,
)

lazy val anyCliEndpoint: Gen[Any, CliReprOf[CliEndpoint]] =
anyCodec.map(
Expand Down
104 changes: 104 additions & 0 deletions zio-http/jvm/src/test/scala/zio/http/endpoint/BadRequestSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package zio.http.endpoint

import zio.test._

import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.HttpCodecError.CustomError
import zio.http.codec.{ContentCodec, QueryCodec}
import zio.http.template._

object BadRequestSpec extends ZIOSpecDefault {

override def spec =
suite("BadRequestSpec")(
test("should return html rendered error message by default for html accept header") {
val endpoint = Endpoint(Method.GET / "test")
.query(QueryCodec.queryInt("age"))
.out[Unit]
val route = endpoint.implement(handler((_: Int) => ()))
val app = route.toHttpApp
val request =
Request(method = Method.GET, url = url"/test?age=1&age=2").addHeader(Header.Accept(MediaType.text.`html`))
val expectedBody =
html(
body(
h1("Bad Request"),
p("There was an error decoding the request"),
p("Expected single value for query parameter age, but got 2 instead"),
),
)
for {
response <- app.runZIO(request)
body <- response.body.asString
} yield assertTrue(body == expectedBody.encode.toString)
},
test("should return json rendered error message by default for json accept header") {
val endpoint = Endpoint(Method.GET / "test")
.query(QueryCodec.queryInt("age"))
.out[Unit]
val route = endpoint.implement(handler((_: Int) => ()))
val app = route.toHttpApp
val request =
Request(method = Method.GET, url = url"/test?age=1&age=2")
.addHeader(Header.Accept(MediaType.application.json))
val expectedBody = """{"message":"Expected single value for query parameter age, but got 2 instead"}"""
for {
response <- app.runZIO(request)
body <- response.body.asString
} yield assertTrue(body == expectedBody)
},
test("should return json rendered error message by default as fallback for unsupported accept header") {
val endpoint = Endpoint(Method.GET / "test")
.query(QueryCodec.queryInt("age"))
.out[Unit]
val route = endpoint.implement(handler((_: Int) => ()))
val app = route.toHttpApp
val request =
Request(method = Method.GET, url = url"/test?age=1&age=2")
.addHeader(Header.Accept(MediaType.application.`atf`))
val expectedBody = """{"message":"Expected single value for query parameter age, but got 2 instead"}"""
for {
response <- app.runZIO(request)
body <- response.body.asString
} yield assertTrue(body == expectedBody)
},
test("should return empty body after calling Endpoint#codecErrorEmptyResponse") {
val endpoint = Endpoint(Method.GET / "test")
.query(QueryCodec.queryInt("age"))
.out[Unit]
.codecErrorEmptyResponse
val route = endpoint.implement(handler((_: Int) => ()))
val app = route.toHttpApp
val request =
Request(method = Method.GET, url = url"/test?age=1&age=2")
.addHeader(Header.Accept(MediaType.application.`atf`))
val expectedBody = ""
for {
response <- app.runZIO(request)
body <- response.body.asString
} yield assertTrue(body == expectedBody)
},
test("should return custom error message") {
val endpoint = Endpoint(Method.GET / "test")
.query(QueryCodec.queryInt("age"))
.out[Unit]
.codecErrorAs((err, _) => CustomError(err.getMessage()))(ContentCodec.content[CustomError])
val route = endpoint.implement(handler((_: Int) => ()))
val app = route.toHttpApp
val request =
Request(method = Method.GET, url = url"/test?age=1&age=2")
.addHeader(Header.Accept(MediaType.application.json))
val expectedBody = """{"error":"Expected single value for query parameter age, but got 2 instead"}"""
for {
response <- app.runZIO(request)
body <- response.body.asString
} yield assertTrue(body == expectedBody)
},
)

final case class CustomError(error: String)
implicit val schema: Schema[CustomError] = DeriveSchema.gen[CustomError]

}
4 changes: 2 additions & 2 deletions zio-http/shared/src/main/scala/zio/http/MediaType.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ final case class MediaType(
) {
lazy val fullType: String = s"$mainType/$subType"

def matches(other: MediaType): Boolean =
def matches(other: MediaType, ignoreParameters: Boolean = false): Boolean =
(mainType == "*" || other.mainType == "*" || mainType.equalsIgnoreCase(other.mainType)) &&
(subType == "*" || other.subType == "*" || subType.equalsIgnoreCase(other.subType)) &&
parameters.forall { case (key, value) => other.parameters.get(key).contains(value) }
(ignoreParameters || parameters.forall { case (key, value) => other.parameters.get(key).contains(value) })
}

object MediaType extends MediaTypes {
Expand Down
8 changes: 8 additions & 0 deletions zio-http/shared/src/main/scala/zio/http/Route.scala
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ sealed trait Route[-Env, +Err] { self =>
final def handleError(f: Err => Response)(implicit trace: Trace): Route[Env, Nothing] =
self.handleErrorCause(Response.fromCauseWith(_)(f))

final def handleErrorZIO(f: Err => ZIO[Any, Nothing, Response])(implicit trace: Trace): Route[Env, Nothing] =
self.handleErrorCauseZIO { cause =>
cause.failureOrCause match {
case Left(err) => f(err)
case Right(cause) => ZIO.succeed(Response.fromCause(cause))
}
}

/**
* Handles all typed errors, as well as all non-recoverable errors, by
* converting them into responses. This method can be used to convert a route
Expand Down
3 changes: 3 additions & 0 deletions zio-http/shared/src/main/scala/zio/http/Routes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ final class Routes[-Env, +Err] private (val routes: Chunk[zio.http.Route[Env, Er
def handleError(f: Err => Response)(implicit trace: Trace): Routes[Env, Nothing] =
new Routes(routes.map(_.handleError(f)))

def handleErrorZIO(f: Err => ZIO[Any, Nothing, Response])(implicit trace: Trace): Routes[Env, Nothing] =
new Routes(routes.map(_.handleErrorZIO(f)))

/**
* Handles all typed errors, as well as all non-recoverable errors, by
* converting them into responses. This method can be used to convert routes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import zio.schema.codec._
import zio.http.Header.Accept.MediaTypeWithQFactor
import zio.http._
import zio.http.internal.HeaderOps
import zio.http.template.Dom

final case class HttpContentCodec[A](
choices: ListMap[MediaType, BinaryCodec[A]],
Expand Down Expand Up @@ -105,12 +106,10 @@ final case class HttpContentCodec[A](
} else {
var i = 0
var result: (MediaType, BinaryCodec[A]) = null
while (i < mediaTypes.size) {
val mediaType = mediaTypes(i)
if (choices.contains(mediaType.mediaType)) {
result = (mediaType.mediaType, choices(mediaType.mediaType))
i = mediaTypes.size
}
while (i < mediaTypes.size && result == null) {
val mediaType = mediaTypes(i)
val lookupResult = lookup(mediaType.mediaType)
if (lookupResult.isDefined) result = (mediaType.mediaType, lookupResult.get)
i += 1
}
if (result == null) {
Expand Down Expand Up @@ -181,6 +180,18 @@ object HttpContentCodec {
}
}

object html {
implicit val htmlCodec: HttpContentCodec[Dom] = {
HttpContentCodec(
ListMap(
MediaType.text.`html` ->
zio.http.codec.internal.TextCodec.fromSchema(Schema[Dom]),
),
Schema[Dom],
)
}
}

private val ByteChunkBinaryCodec: BinaryCodec[Chunk[Byte]] = new BinaryCodec[Chunk[Byte]] {

override def encode(value: Chunk[Byte]): Chunk[Byte] = value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ private[codec] object EncoderDecoder {

val decodeErrorMessage =
"""
|Trying to decode with Undefined codec. That means that encode was invoked for object of type Nothing - which cannot exist.
|Trying to decode with Undefined codec. That means that decode was invoked for object of type Nothing - which cannot exist.
|Verify that middleware and endpoint have proper types or submit bug report at https://github.com/zio/zio-http/issues
""".stripMargin.trim()

Expand Down
Loading

0 comments on commit d20cd21

Please sign in to comment.