diff --git a/build.sbt b/build.sbt index dc27ae46d4..5254ea26ee 100644 --- a/build.sbt +++ b/build.sbt @@ -209,7 +209,7 @@ lazy val zioHttpBenchmarks = (project in file("zio-http-benchmarks")) // "com.softwaremill.sttp.tapir" %% "tapir-akka-http-server" % "1.1.0", "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % "1.5.1", "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "1.5.1", - "com.softwaremill.sttp.client3" %% "core" % "3.9.0", + "com.softwaremill.sttp.client3" %% "core" % "3.9.1", // "dev.zio" %% "zio-interop-cats" % "3.3.0", "org.slf4j" % "slf4j-api" % "2.0.9", "org.slf4j" % "slf4j-simple" % "2.0.9", diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 3d116e277d..2021be2583 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,10 +3,10 @@ import sbt.Keys.scalaVersion object Dependencies { val JwtCoreVersion = "9.1.1" - val NettyVersion = "4.1.100.Final" + val NettyVersion = "4.1.101.Final" val NettyIncubatorVersion = "0.0.20.Final" val ScalaCompactCollectionVersion = "2.11.0" - val ZioVersion = "2.0.18" + val ZioVersion = "2.0.19" val ZioCliVersion = "0.5.0" val ZioSchemaVersion = "0.4.15" val SttpVersion = "3.3.18" diff --git a/project/build.properties b/project/build.properties index 27430827bc..e8a1e246e8 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.6 +sbt.version=1.9.7 diff --git a/project/plugins.sbt b/project/plugins.sbt index 989c03b398..540cfc7eff 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,7 +3,7 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.6") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.3") addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") -addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.18.0") +addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.19.0") addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") addSbtPlugin("dev.zio" % "zio-sbt-website" % "0.3.10") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") diff --git a/zio-http/src/main/scala/zio/http/Handler.scala b/zio-http/src/main/scala/zio/http/Handler.scala index 52bb0ea89a..851a8be2af 100644 --- a/zio-http/src/main/scala/zio/http/Handler.scala +++ b/zio-http/src/main/scala/zio/http/Handler.scala @@ -367,6 +367,19 @@ sealed trait Handler[-R, +Err, -In, +Out] { self => ): Handler[R1, Err1, In, Out1] = self.foldHandler(err => Handler.fromZIO(f(err)), Handler.succeed(_)) + /** + * Transforms all failures of the handler effectfully except pure + * interruption. + */ + final def mapErrorCauseZIO[R1 <: R, Err1, Out1 >: Out]( + f: Cause[Err] => ZIO[R1, Err1, Out1], + )(implicit trace: Trace): Handler[R1, Err1, In, Out1] = + self.foldCauseHandler( + err => + if (err.isInterruptedOnly) Handler.failCause(err.asInstanceOf[Cause[Nothing]]) else Handler.fromZIO(f(err)), + Handler.succeed(_), + ) + /** * Returns a new handler where the error channel has been merged into the * success channel to their common combined type. diff --git a/zio-http/src/main/scala/zio/http/Middleware.scala b/zio-http/src/main/scala/zio/http/Middleware.scala index 5f5a9212e7..1324f9a81c 100644 --- a/zio-http/src/main/scala/zio/http/Middleware.scala +++ b/zio-http/src/main/scala/zio/http/Middleware.scala @@ -170,6 +170,68 @@ object Middleware extends HandlerAspects { } } + def logAnnotate(key: => String, value: => String)(implicit trace: Trace): Middleware[Any] = + logAnnotate(LogAnnotation(key, value)) + + def logAnnotate(logAnnotation: => LogAnnotation, logAnnotations: LogAnnotation*)(implicit + trace: Trace, + ): Middleware[Any] = + logAnnotate((logAnnotation +: logAnnotations).toSet) + + def logAnnotate(logAnnotations: => Set[LogAnnotation])(implicit trace: Trace): Middleware[Any] = + new Middleware[Any] { + def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = + routes.transform[Env1] { h => + handler((req: Request) => ZIO.logAnnotate(logAnnotations)(h(req))) + } + } + + /** + * Creates a middleware that will annotate log messages that are logged while + * a request is handled with log annotations derived from the request. + */ + def logAnnotate(fromRequest: Request => Set[LogAnnotation])(implicit trace: Trace): Middleware[Any] = + new Middleware[Any] { + def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = + routes.transform[Env1] { h => + handler((req: Request) => ZIO.logAnnotate(fromRequest(req))(h(req))) + } + } + + /** + * Creates a middleware that will annotate log messages that are logged while + * a request is handled with the names and the values of the specified + * headers. + */ + def logAnnotateHeaders(headerName: String, headerNames: String*)(implicit trace: Trace): Middleware[Any] = + new Middleware[Any] { + def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = { + val headers = headerName +: headerNames + routes.transform[Env1] { h => + handler((req: Request) => { + val annotations = Set.newBuilder[LogAnnotation] + annotations.sizeHint(headers.length) + var i = 0 + while (i < headers.length) { + val name = headers(i) + annotations += LogAnnotation(name, req.headers.get(name).mkString) + i += 1 + } + ZIO.logAnnotate(annotations.result())(h(req)) + }) + } + } + } + + /** + * Creates middleware that will annotate log messages that are logged while a + * request is handled with the names and the values of the specified headers. + */ + def logAnnotateHeaders(header: Header.HeaderType, headers: Header.HeaderType*)(implicit + trace: Trace, + ): Middleware[Any] = + logAnnotateHeaders(header.name, headers.map(_.name): _*) + def timeout(duration: Duration)(implicit trace: Trace): Middleware[Any] = new Middleware[Any] { def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = @@ -284,7 +346,8 @@ object Middleware extends HandlerAspects { } override def apply[Env1 <: Any, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = { - val mountpoint = Method.GET / path.segments.map(PathCodec.literal).reduceLeft(_ / _) + val mountpoint = + Method.GET / path.segments.map(PathCodec.literal).reduceLeftOption(_ / _).getOrElse(PathCodec.empty) val pattern = mountpoint / trailing val other = Routes( pattern -> Handler diff --git a/zio-http/src/main/scala/zio/http/Route.scala b/zio-http/src/main/scala/zio/http/Route.scala index 0e0ec8334e..c51d6fc207 100644 --- a/zio-http/src/main/scala/zio/http/Route.scala +++ b/zio-http/src/main/scala/zio/http/Route.scala @@ -83,6 +83,32 @@ sealed trait Route[-Env, +Err] { self => Handled(rpm.routePattern, handler2, location) } + final def handleErrorCauseZIO( + f: Cause[Err] => ZIO[Any, Nothing, Response], + )(implicit trace: Trace): Route[Env, Nothing] = + self match { + case Provided(route, env) => Provided(route.handleErrorCauseZIO(f), env) + case Augmented(route, aspect) => Augmented(route.handleErrorCauseZIO(f), aspect) + case Handled(routePattern, handler, location) => Handled(routePattern, handler, location) + + case Unhandled(rpm, handler, zippable, location) => + val handler2: Handler[Env, Response, Request, Response] = { + val paramHandler = + Handler.fromFunctionZIO[(rpm.Context, Request)] { case (ctx, request) => + rpm.routePattern.decode(request.method, request.path) match { + case Left(error) => ZIO.dieMessage(error) + case Right(value) => + val params = rpm.zippable.zip(value, ctx) + + handler(zippable.zip(params, request)) + } + } + rpm.aspect.applyHandlerContext(paramHandler.mapErrorCauseZIO(f)) + } + + Handled(rpm.routePattern, handler2, location) + } + /** * Determines if the route is defined for the specified request. */ diff --git a/zio-http/src/main/scala/zio/http/RoutePattern.scala b/zio-http/src/main/scala/zio/http/RoutePattern.scala index 2dc33c00f9..932605e0bf 100644 --- a/zio-http/src/main/scala/zio/http/RoutePattern.scala +++ b/zio-http/src/main/scala/zio/http/RoutePattern.scala @@ -222,16 +222,16 @@ object RoutePattern { */ val any: RoutePattern[Path] = RoutePattern(Method.ANY, PathCodec.trailing) + def apply(method: Method, path: Path): RoutePattern[Unit] = + path.segments.foldLeft[RoutePattern[Unit]](fromMethod(method)) { (pathSpec, segment) => + pathSpec./[Unit](PathCodec.Segment(SegmentCodec.literal(segment))) + } + /** * Constructs a route pattern from a method and a path literal. To match * against any method, use [[zio.http.Method.ANY]]. The specified string may * contain path segments, which are separated by slashes. */ - def apply(method: Method, value: String): RoutePattern[Unit] = { - val path = Path(value) - - path.segments.foldLeft[RoutePattern[Unit]](fromMethod(method)) { (pathSpec, segment) => - pathSpec./[Unit](PathCodec.Segment(SegmentCodec.literal(segment))) - } - } + def apply(method: Method, pathString: String): RoutePattern[Unit] = + apply(method, Path(pathString)) } diff --git a/zio-http/src/main/scala/zio/http/Routes.scala b/zio-http/src/main/scala/zio/http/Routes.scala index bc8845d1d1..3fb19c5781 100644 --- a/zio-http/src/main/scala/zio/http/Routes.scala +++ b/zio-http/src/main/scala/zio/http/Routes.scala @@ -76,6 +76,9 @@ final class Routes[-Env, +Err] private (val routes: Chunk[zio.http.Route[Env, Er def handleErrorCause(f: Cause[Err] => Response)(implicit trace: Trace): Routes[Env, Nothing] = new Routes(routes.map(_.handleErrorCause(f))) + def handleErrorCauseZIO(f: Cause[Err] => ZIO[Any, Nothing, Response])(implicit trace: Trace): Routes[Env, Nothing] = + new Routes(routes.map(_.handleErrorCauseZIO(f))) + /** * Returns new routes that have each been provided the specified environment, * thus eliminating their requirement for any specific environment. diff --git a/zio-http/src/test/scala/zio/http/LogAnnotationMiddlewareSpec.scala b/zio-http/src/test/scala/zio/http/LogAnnotationMiddlewareSpec.scala new file mode 100644 index 0000000000..f9f58b5a1f --- /dev/null +++ b/zio-http/src/test/scala/zio/http/LogAnnotationMiddlewareSpec.scala @@ -0,0 +1,72 @@ +package zio.http + +import zio._ +import zio.test._ + +object LogAnnotationMiddlewareSpec extends ZIOSpecDefault { + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("LogAnnotationMiddlewareSpec")( + test("add static log annotation") { + val response = Routes + .singleton( + handler(ZIO.logWarning("Oh!") *> ZIO.succeed(Response.text("Hey logging!"))), + ) + .@@(Middleware.logAnnotate("label", "value")) + .toHttpApp + .runZIO(Request.get("/")) + + for { + _ <- response + logs <- ZTestLogger.logOutput + log = logs.filter(_.message() == "Oh!").head + } yield assertTrue(log.annotations.get("label").contains("value")) + + }, + test("add request method and path as annotation") { + val response = Routes + .singleton( + handler(ZIO.logWarning("Oh!") *> ZIO.succeed(Response.text("Hey logging!"))), + ) + .@@( + Middleware.logAnnotate(req => + Set(LogAnnotation("method", req.method.name), LogAnnotation("path", req.path.encode)), + ), + ) + .toHttpApp + .runZIO(Request.get("/")) + + for { + _ <- response + logs <- ZTestLogger.logOutput + log = logs.filter(_.message() == "Oh!").head + } yield assertTrue( + log.annotations.get("method").contains("GET"), + log.annotations.get("path").contains("/"), + ) + }, + test("add headers as annotation") { + val response = Routes + .singleton( + handler(ZIO.logWarning("Oh!") *> ZIO.succeed(Response.text("Hey logging!"))), + ) + .@@(Middleware.logAnnotateHeaders("header")) + .@@(Middleware.logAnnotateHeaders(Header.UserAgent.name)) + .toHttpApp + .runZIO { + Request + .get("/") + .addHeader("header", "value") + .addHeader(Header.UserAgent.Product("zio-http", Some("3.0.0"))) + } + + for { + _ <- response + logs <- ZTestLogger.logOutput + log = logs.filter(_.message() == "Oh!").head + } yield assertTrue( + log.annotations.get("header").contains("value"), + log.annotations.get(Header.UserAgent.name).contains("zio-http/3.0.0"), + ) + }, + ) +} diff --git a/zio-http/src/test/scala/zio/http/RouteSpec.scala b/zio-http/src/test/scala/zio/http/RouteSpec.scala index 8555051a53..13b4baf74a 100644 --- a/zio-http/src/test/scala/zio/http/RouteSpec.scala +++ b/zio-http/src/test/scala/zio/http/RouteSpec.scala @@ -16,8 +16,6 @@ package zio.http -import scala.collection.Seq - import zio._ import zio.test._ @@ -64,5 +62,21 @@ object RouteSpec extends ZIOHttpSpec { } yield assertTrue(cnt == 2) }, ), + suite("error handle")( + test("handleErrorCauseZIO should execute a ZIO effect") { + val route = Method.GET / "endpoint" -> handler { (req: Request) => ZIO.fail(new Exception("hmm...")) } + for { + p <- zio.Promise.make[Exception, String] + + errorHandled = route + .handleErrorCauseZIO(c => p.failCause(c).as(Response.internalServerError)) + + request = Request.get(URL.decode("/endpoint").toOption.get) + response <- errorHandled.toHttpApp.runZIO(request) + result <- p.await.catchAllCause(c => ZIO.succeed(c.prettyPrint)) + + } yield assertTrue(extractStatus(response) == Status.InternalServerError, result.contains("hmm...")) + }, + ), ) }