diff --git a/zio-http-testkit/src/test/scala/zio/http/TestServerSpec.scala b/zio-http-testkit/src/test/scala/zio/http/TestServerSpec.scala index 084d9197e2..ccdb1ed0bc 100644 --- a/zio-http-testkit/src/test/scala/zio/http/TestServerSpec.scala +++ b/zio-http-testkit/src/test/scala/zio/http/TestServerSpec.scala @@ -106,11 +106,42 @@ object TestServerSpec extends ZIOHttpSpec { TestServer.layer, Scope.default, ), + suite("Environment updates")( + test("should use updated environment for each request") { + check(Gen.fromIterable(List(1, 2, 3, 4, 5))) { code => + for { + client <- ZIO.service[Client] + server <- ZIO.service[TestServer] + port <- server.port + url = URL.root.port(port) / "api" + request = Request + .get(url) + .addHeader(Header.Accept(MediaType.application.json)) + _ <- TestServer + .addRoutes( + Routes( + Method.GET / "api" -> handler(ZIO.serviceWith[TestEnv](env => Response.text(env.code.toString))), + ), + ) + .provideSomeLayer[TestServer](ZLayer.succeed(TestEnv(code))) + response <- client.request(request) + body <- response.body.asString + } yield assertTrue(body == code.toString) + } + }, + ).provideSome[Scope]( + ZLayer.succeed(Server.Config.default.onAnyOpenPort), + TestServer.layer, + Client.default, + NettyDriver.customized, + ZLayer.succeed(NettyConfig.defaultWithFastShutdown), + ), ).provide( ZLayer.succeed(Server.Config.default.onAnyOpenPort), Client.default, NettyDriver.customized, ZLayer.succeed(NettyConfig.defaultWithFastShutdown), + Scope.default, ) private def requestToCorrectPort = @@ -120,4 +151,5 @@ object TestServerSpec extends ZIOHttpSpec { .get(url = URL.root.port(port)) .addHeaders(Headers(Header.Accept(MediaType.text.`plain`))) + case class TestEnv(code: Int) } diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala b/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala index 334b186711..ec3c6cbd43 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala @@ -67,7 +67,7 @@ private[zio] final case class NettyDriver( while (loop) { val oldAppAndRt = appRef.get() val (oldApp, oldRt) = oldAppAndRt - val updatedApp = (oldApp ++ newApp).asInstanceOf[Routes[Any, Response]] + val updatedApp = (newApp ++ oldApp).asInstanceOf[Routes[Any, Response]] val updatedEnv = oldRt.environment.unionAll(env) // Update the fiberRefs with the new environment to avoid doing this every time we run / fork a fiber val updatedFibRefs = oldRt.fiberRefs.updatedAs(fiberId)(FiberRef.currentEnvironment, updatedEnv) diff --git a/zio-http/jvm/src/test/scala/zio/http/codec/HttpCodecSpec.scala b/zio-http/jvm/src/test/scala/zio/http/codec/HttpCodecSpec.scala index 372a578bca..e3eee9c5e4 100644 --- a/zio-http/jvm/src/test/scala/zio/http/codec/HttpCodecSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/codec/HttpCodecSpec.scala @@ -131,6 +131,14 @@ object HttpCodecSpec extends ZIOHttpSpec { for { result <- optional.decodeRequest(request).exit } yield assertTrue(result.isFailure) + // } + + // test("fallback for empty body") { + // val codec = HttpCodec.content[String].optional + // val requestWithEmptyBody = Request.post(url = URL.root, body = Body.empty) + + // for { + // result <- codec.decodeRequest(requestWithEmptyBody) + // } yield assertTrue(result.isEmpty) } } + suite("HeaderCodec") { diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index df546bd0da..118462f755 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -267,6 +267,22 @@ sealed trait HttpCodec[-AtomTypes, Value] { def named(named: Metadata.Named[Value]): HttpCodec[AtomTypes, Value] = HttpCodec.Annotated(self, Metadata.Named(named.name)) + def optionalBody[A](implicit schema: Schema[A]): HttpCodec[HttpCodecType.Content, Option[A]] = + Annotated( + HttpCodec + .Fallback( + ContentCodec.content[A], + HttpCodec.empty.asInstanceOf[HttpCodec[HttpCodecType.Content, Option[A]]], + Alternator.either, + HttpCodec.Fallback.Condition.isBodyEmptyOrMissing, + ) + .transform[Option[A]](either => either.fold(Some(_), _ => None)) { + case Some(value) => Right(Some(value)) + case None => Right(None) + }, + Metadata.Optional(), + ) + /** * Returns a new codec, where the value produced by this one is optional. */ @@ -275,7 +291,12 @@ sealed trait HttpCodec[-AtomTypes, Value] { if (self eq HttpCodec.Halt) HttpCodec.empty.asInstanceOf[HttpCodec[AtomTypes, Option[Value]]] else { HttpCodec - .Fallback(self, HttpCodec.empty, Alternator.either, HttpCodec.Fallback.Condition.isMissingDataOnly) + .Fallback( + self, + HttpCodec.empty, + Alternator.either, + HttpCodec.Fallback.Condition.isMissingDataOnly.combine(HttpCodec.Fallback.Condition.isBodyEmptyOrMissing), + ) .transform[Option[Value]](either => either.fold(Some(_), _ => None))(_.toLeft(())) }, Metadata.Optional(), @@ -826,16 +847,20 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with * recover from `MissingHeader` or `MissingQueryParam` errors. */ sealed trait Condition { self => - def apply(cause: Cause[Any]): Boolean = + def apply(cause: Cause[Any]): Boolean = self match { - case Condition.IsHttpCodecError => HttpCodecError.isHttpCodecError(cause) - case Condition.isMissingDataOnly => HttpCodecError.isMissingDataOnly(cause) + case Condition.IsHttpCodecError => HttpCodecError.isHttpCodecError(cause) + case Condition.isMissingDataOnly => HttpCodecError.isMissingDataOnly(cause) + case Condition.isBodyEmptyOrMissing => HttpCodecError.isMissingBodyOrEmpty(cause) // New condition + } def combine(that: Condition): Condition = (self, that) match { - case (Condition.isMissingDataOnly, _) => Condition.isMissingDataOnly - case (_, Condition.isMissingDataOnly) => Condition.isMissingDataOnly - case _ => Condition.IsHttpCodecError + case (Condition.isMissingDataOnly, _) => Condition.isMissingDataOnly + case (_, Condition.isMissingDataOnly) => Condition.isMissingDataOnly + case (Condition.isBodyEmptyOrMissing, _) => Condition.isBodyEmptyOrMissing + case (_, Condition.isBodyEmptyOrMissing) => Condition.isBodyEmptyOrMissing + case _ => Condition.IsHttpCodecError } def isHttpCodecError: Boolean = self match { case Condition.IsHttpCodecError => true @@ -845,10 +870,18 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with case Condition.isMissingDataOnly => true case _ => false } + def isBodyEmptyOrMissing: Boolean = self match { + case Condition.isBodyEmptyOrMissing => true + case _ => false + } } object Condition { - case object IsHttpCodecError extends Condition - case object isMissingDataOnly extends Condition + case object IsHttpCodecError extends Condition + case object isMissingDataOnly extends Condition + case object isBodyEmptyOrMissing extends Condition { + override def apply(cause: Cause[Any]): Boolean = + HttpCodecError.isMissingBodyOrEmpty(cause) + } } } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala index bcd97223d8..d649182895 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala @@ -63,6 +63,12 @@ object HttpCodecError { final case class InvalidEntity(details: String, cause: Chunk[ValidationError] = Chunk.empty) extends HttpCodecError { def message = s"A well-formed entity failed validation: $details" } + case object MissingBody extends HttpCodecError { + def message = "Request body is missing" + } + case object EmptyBody extends HttpCodecError { + def message: String = "Empty request body" + } object InvalidEntity { def wrap(errors: Chunk[ValidationError]): InvalidEntity = InvalidEntity( @@ -94,4 +100,14 @@ object HttpCodecError { !cause.isFailure && cause.defects.forall(e => e.isInstanceOf[HttpCodecError.MissingHeader] || e.isInstanceOf[HttpCodecError.MissingQueryParam], ) + + def isMissingBodyOrEmpty(cause: Cause[Any]): Boolean = { + !cause.isFailure && cause.defects.exists { + case HttpCodecError.MalformedBody(details, _) if details.contains("end of input") => true + case HttpCodecError.MissingBody => true + case HttpCodecError.EmptyBody => true + case _ => false + } + } + } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala index c0223e4fe6..7243640a0b 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala @@ -32,7 +32,11 @@ sealed trait HttpContentCodec[A] { self => lookup(contentType) match { case Some((_, codec)) => request.body.asChunk.flatMap { bytes => - ZIO.fromEither(codec.codec(config).decode(bytes)) + if (bytes.isEmpty) { + ZIO.fail(HttpCodecError.EmptyBody) + } else { + ZIO.fromEither(codec.codec(config).decode(bytes)) + } } case None => ZIO.fail(throw new IllegalArgumentException(s"No codec found for content type $contentType"))