diff --git a/README.md b/README.md index 8705060ff..45a9facd3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Scala Server Toolkit [![Build Status](https://travis-ci.org/avast/scala-server-toolkit.svg?branch=master)](https://travis-ci.org/avast/scala-server-toolkit) -[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-pureconfig_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-pureconfig_2.12/) +[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-http4s-blaze-server_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-http4s-blaze-server_2.12/) [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-brightgreen.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) This project is a culmination of years of Scala development at Avast and tries to represent the best practices of Scala server development diff --git a/build.sbt b/build.sbt index fecf46b04..1a78cd5dc 100644 --- a/build.sbt +++ b/build.sbt @@ -16,21 +16,22 @@ lazy val commonSettings = Seq( libraryDependencies ++= Seq( compilerPlugin(Dependencies.kindProjector), Dependencies.catsEffect, - Dependencies.Test.scalaTest + Dependencies.logbackClassic % Test, + Dependencies.scalaTest % Test ), Test / publishArtifact := false ) lazy val root = project .in(file(".")) - .aggregate(example, jvmExecution, jvmSsl, jvmSystem, pureconfig) + .aggregate(example, http4sBlazeClient, http4sBlazeServer, jvmExecution, jvmSsl, jvmSystem, pureconfig) .settings( name := "scala-server-toolkit", publish / skip := true ) lazy val example = project - .dependsOn(jvmExecution, jvmSsl, jvmSystem, pureconfig) + .dependsOn(jvmExecution, http4sBlazeClient, http4sBlazeServer, jvmSsl, jvmSystem, pureconfig) .enablePlugins(MdocPlugin) .settings( commonSettings, @@ -46,6 +47,28 @@ lazy val example = project ) ) +lazy val http4sBlazeClient = project + .in(file("http4s-blaze-client")) + .dependsOn(jvmSsl) + .settings(commonSettings) + .settings( + name := "scala-server-toolkit-http4s-blaze-client", + libraryDependencies += Dependencies.http4sBlazeClient + ) + +lazy val http4sBlazeServer = project + .in(file("http4s-blaze-server")) + .dependsOn(http4sBlazeClient % Test) + .settings(commonSettings) + .settings( + name := "scala-server-toolkit-http4s-blaze-server", + libraryDependencies ++= Seq( + Dependencies.http4sBlazeServer, + Dependencies.http4sDsl, + Dependencies.slf4jApi + ) + ) + lazy val jvmExecution = project .in(file("jvm-execution")) .settings( diff --git a/docs/http4s.md b/docs/http4s.md new file mode 100644 index 000000000..684b72d72 --- /dev/null +++ b/docs/http4s.md @@ -0,0 +1,92 @@ +# Module http4s + +[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-http4s-blaze-server_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-http4s-blaze-server_2.12/) + +`libraryDependencies += "com.avast" %% "scala-server-toolkit-http4s-blaze-server" % ""` + +There are `http4s-*` modules that provide easy initialization of a server and a client. Http4s is an interface with multiple possible +implementations - for now we provide only implementations based on [Blaze](https://github.com/http4s/blaze). + +Both server and client are configured via configuration `case class` which contains default values taken from the underlying implementations. + +```scala +import cats.effect._ +import com.avast.server.toolkit.execution.ExecutorModule +import com.avast.server.toolkit.http4s._ +import com.avast.server.toolkit.system.console.ConsoleModule +import org.http4s.dsl.Http4sDsl +import org.http4s.HttpRoutes +import zio.DefaultRuntime +import zio.interop.catz._ +import zio.interop.catz.implicits._ +import zio.Task + +implicit val runtime = new DefaultRuntime {} // this is just needed in example + +val dsl = Http4sDsl[Task] // this is just needed in example +import dsl._ + +val routes = Http4sRouting.make { + HttpRoutes.of[Task] { + case GET -> Root / "hello" => Ok("Hello World!") + } +} + +val resource = for { + executorModule <- ExecutorModule.makeDefault[Task] + console = ConsoleModule.make[Task] + server <- Http4sBlazeServerModule.make[Task](Http4sBlazeServerConfig("127.0.0.1", 0), routes, executorModule.executionContext) + client <- Http4sBlazeClient.make[Task](Http4sBlazeClientConfig(), executorModule.executionContext) +} yield (server, client, console) + +val program = resource + .use { + case (server, client, console) => + client + .expect[String](s"http://127.0.0.1:${server.address.getPort}/hello") + .flatMap(console.printLine) + } +``` + +```scala +runtime.unsafeRun(program) +// Hello World! +``` + +## Middleware + +### Correlation ID Middleware + +```scala +import cats.effect._ +import com.avast.server.toolkit.execution.ExecutorModule +import com.avast.server.toolkit.http4s._ +import com.avast.server.toolkit.http4s.middleware.CorrelationIdMiddleware +import org.http4s.dsl.Http4sDsl +import org.http4s.HttpRoutes +import zio.DefaultRuntime +import zio.interop.catz._ +import zio.interop.catz.implicits._ +import zio.Task + +val dsl = Http4sDsl[Task] // this is just needed in example +import dsl._ + +implicit val runtime = new DefaultRuntime {} // this is just needed in example + +for { + middleware <- Resource.liftF(CorrelationIdMiddleware.default[Task]) + executorModule <- ExecutorModule.makeDefault[Task] + routes = Http4sRouting.make { + middleware.wrap { + HttpRoutes.of[Task] { + case req @ GET -> Root => + // val correlationId = middleware.retrieveCorrelationId(req) + ??? + } + } + } + server <- Http4sBlazeServerModule.make[Task](Http4sBlazeServerConfig.localhost8080, routes, executorModule.executionContext) +} yield server +``` + diff --git a/docs/index.md b/docs/index.md index 07152b355..570116bca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,7 @@ * [Getting Started](#getting-started) * [Rationale](rationale.md) +* [Modules http4s](http4s.md) * [Modules JVM](jvm.md) * [Module PureConfig](pureconfig.md) @@ -11,6 +12,6 @@ Creating a simple HTTP server is as easy as this: #### build.sbt -[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-pureconfig_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-pureconfig_2.12/) +[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-http4s-blaze-server_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-http4s-blaze-server_2.12/) -`libraryDependencies += "com.avast" %% "scala-server-toolkit-pureconfig" % ""` +`libraryDependencies += "com.avast" %% "scala-server-toolkit-http4s-blaze-server" % ""` diff --git a/docs/jvm.md b/docs/jvm.md index 6ad62f50f..654375ad5 100644 --- a/docs/jvm.md +++ b/docs/jvm.md @@ -23,11 +23,11 @@ val program = for { console = ConsoleModule.make[Task] _ <- console.printLine(s"Random number: $randomNumber") } yield () -// program: zio.ZIO[Any, Throwable, Unit] = zio.ZIO$FlatMap@2f1f9515 +// program: zio.ZIO[Any, Throwable, Unit] = zio.ZIO$FlatMap@4cc26df -val runtime = new DefaultRuntime {} // this is just in example -// runtime: AnyRef with DefaultRuntime = repl.Session$App$$anon$1@33ebe4f0 // this is just in example +val runtime = new DefaultRuntime {} // this is just needed in example +// runtime: AnyRef with DefaultRuntime = repl.Session$App$$anon$1@3bab95ca // this is just needed in example runtime.unsafeRun(program) -// Random number: 776310297 +// Random number: 1797916077 ``` diff --git a/docs/pureconfig.md b/docs/pureconfig.md index 4cf335a14..61724ee15 100644 --- a/docs/pureconfig.md +++ b/docs/pureconfig.md @@ -20,9 +20,7 @@ import zio.Task final case class ServerConfiguration(listenAddress: String, listenPort: Int) implicit val serverConfigurationReader: ConfigReader[ServerConfiguration] = deriveReader -// serverConfigurationReader: ConfigReader[ServerConfiguration] = pureconfig.generic.DerivedConfigReader1$$anon$3@2a8eed58 val maybeConfiguration = PureConfigModule.make[Task, ServerConfiguration] -// maybeConfiguration: Task[Either[cats.data.NonEmptyList[String], ServerConfiguration]] = zio.ZIO$EffectPartial@352bea0e ``` diff --git a/example/src/main/mdoc/http4s.md b/example/src/main/mdoc/http4s.md new file mode 100644 index 000000000..b65f4dcfa --- /dev/null +++ b/example/src/main/mdoc/http4s.md @@ -0,0 +1,90 @@ +# Module http4s + +[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-http4s-blaze-server_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-http4s-blaze-server_2.12/) + +`libraryDependencies += "com.avast" %% "scala-server-toolkit-http4s-blaze-server" % ""` + +There are `http4s-*` modules that provide easy initialization of a server and a client. Http4s is an interface with multiple possible +implementations - for now we provide only implementations based on [Blaze](https://github.com/http4s/blaze). + +Both server and client are configured via configuration `case class` which contains default values taken from the underlying implementations. + +```scala mdoc:silent:reset-class +import cats.effect._ +import com.avast.server.toolkit.execution.ExecutorModule +import com.avast.server.toolkit.http4s._ +import com.avast.server.toolkit.system.console.ConsoleModule +import org.http4s.dsl.Http4sDsl +import org.http4s.HttpRoutes +import zio.DefaultRuntime +import zio.interop.catz._ +import zio.interop.catz.implicits._ +import zio.Task + +implicit val runtime = new DefaultRuntime {} // this is just needed in example + +val dsl = Http4sDsl[Task] // this is just needed in example +import dsl._ + +val routes = Http4sRouting.make { + HttpRoutes.of[Task] { + case GET -> Root / "hello" => Ok("Hello World!") + } +} + +val resource = for { + executorModule <- ExecutorModule.makeDefault[Task] + console = ConsoleModule.make[Task] + server <- Http4sBlazeServerModule.make[Task](Http4sBlazeServerConfig("127.0.0.1", 0), routes, executorModule.executionContext) + client <- Http4sBlazeClient.make[Task](Http4sBlazeClientConfig(), executorModule.executionContext) +} yield (server, client, console) + +val program = resource + .use { + case (server, client, console) => + client + .expect[String](s"http://127.0.0.1:${server.address.getPort}/hello") + .flatMap(console.printLine) + } +``` + +```scala mdoc +runtime.unsafeRun(program) +``` + +## Middleware + +### Correlation ID Middleware + +```scala mdoc:silent:reset +import cats.effect._ +import com.avast.server.toolkit.execution.ExecutorModule +import com.avast.server.toolkit.http4s._ +import com.avast.server.toolkit.http4s.middleware.CorrelationIdMiddleware +import org.http4s.dsl.Http4sDsl +import org.http4s.HttpRoutes +import zio.DefaultRuntime +import zio.interop.catz._ +import zio.interop.catz.implicits._ +import zio.Task + +val dsl = Http4sDsl[Task] // this is just needed in example +import dsl._ + +implicit val runtime = new DefaultRuntime {} // this is just needed in example + +for { + middleware <- Resource.liftF(CorrelationIdMiddleware.default[Task]) + executorModule <- ExecutorModule.makeDefault[Task] + routes = Http4sRouting.make { + middleware.wrap { + HttpRoutes.of[Task] { + case req @ GET -> Root => + // val correlationId = middleware.retrieveCorrelationId(req) + ??? + } + } + } + server <- Http4sBlazeServerModule.make[Task](Http4sBlazeServerConfig.localhost8080, routes, executorModule.executionContext) +} yield server +``` \ No newline at end of file diff --git a/example/src/main/mdoc/index.md b/example/src/main/mdoc/index.md index 07152b355..570116bca 100644 --- a/example/src/main/mdoc/index.md +++ b/example/src/main/mdoc/index.md @@ -2,6 +2,7 @@ * [Getting Started](#getting-started) * [Rationale](rationale.md) +* [Modules http4s](http4s.md) * [Modules JVM](jvm.md) * [Module PureConfig](pureconfig.md) @@ -11,6 +12,6 @@ Creating a simple HTTP server is as easy as this: #### build.sbt -[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-pureconfig_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-pureconfig_2.12/) +[![Maven Central](https://img.shields.io/maven-central/v/com.avast/scala-server-toolkit-http4s-blaze-server_2.12)](https://repo1.maven.org/maven2/com/avast/scala-server-toolkit-http4s-blaze-server_2.12/) -`libraryDependencies += "com.avast" %% "scala-server-toolkit-pureconfig" % ""` +`libraryDependencies += "com.avast" %% "scala-server-toolkit-http4s-blaze-server" % ""` diff --git a/example/src/main/mdoc/jvm.md b/example/src/main/mdoc/jvm.md index a85d0dbcf..79254a03c 100644 --- a/example/src/main/mdoc/jvm.md +++ b/example/src/main/mdoc/jvm.md @@ -24,6 +24,6 @@ val program = for { _ <- console.printLine(s"Random number: $randomNumber") } yield () -val runtime = new DefaultRuntime {} // this is just in example +val runtime = new DefaultRuntime {} // this is just needed in example runtime.unsafeRun(program) ``` diff --git a/example/src/main/mdoc/pureconfig.md b/example/src/main/mdoc/pureconfig.md index 776c02c67..a157320ac 100644 --- a/example/src/main/mdoc/pureconfig.md +++ b/example/src/main/mdoc/pureconfig.md @@ -10,7 +10,7 @@ that your application's configuration will be in [HOCON](https://github.com/ligh Loading of configuration is side-effectful so it is wrapped in `F` which is `Sync`. This module also tweaks the error messages a little. -```scala mdoc +```scala mdoc:silent import com.avast.server.toolkit.pureconfig._ import pureconfig.ConfigReader import pureconfig.generic.semiauto.deriveReader diff --git a/example/src/main/scala/com/avast/server/toolkit/example/Main.scala b/example/src/main/scala/com/avast/server/toolkit/example/Main.scala index f42414a2d..5790746a7 100644 --- a/example/src/main/scala/com/avast/server/toolkit/example/Main.scala +++ b/example/src/main/scala/com/avast/server/toolkit/example/Main.scala @@ -13,6 +13,7 @@ import zio.{Task, ZIO} object Main extends CatsApp { def program: Resource[Task, Unit] = { + for { configuration <- Resource.liftF(PureConfigModule.makeOrRaise[Task, Configuration]) executorModule <- ExecutorModule.makeFromExecutionContext[Task](runtime.Platform.executor.asEC) diff --git a/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClient.scala b/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClient.scala new file mode 100644 index 000000000..f383ba5e7 --- /dev/null +++ b/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClient.scala @@ -0,0 +1,50 @@ +package com.avast.server.toolkit.http4s + +import cats.Traverse +import cats.effect.{ConcurrentEffect, Resource, Sync} +import cats.implicits._ +import com.avast.server.toolkit.ssl.{SslContextConfig, SslContextModule} +import javax.net.ssl.SSLContext +import org.http4s.client.Client +import org.http4s.client.blaze.BlazeClientBuilder + +import scala.concurrent.ExecutionContext +import scala.language.higherKinds + +object Http4sBlazeClient { + + /** Makes [[org.http4s.client.Client]] (Blaze) initialized with the given config. + * + * @param executionContext callback handling [[scala.concurrent.ExecutionContext]] + */ + def make[F[_]: ConcurrentEffect](config: Http4sBlazeClientConfig, executionContext: ExecutionContext): Resource[F, Client[F]] = { + for { + maybeSslContext <- Resource.liftF(sslContext(config.sslContext)) + client <- { + val builder = BlazeClientBuilder[F](executionContext) + .withResponseHeaderTimeout(config.responseHeaderTimeout) + .withIdleTimeout(config.idleTimeout) + .withRequestTimeout(config.requestTimeout) + .withConnectTimeout(config.connectTimeout) + .withUserAgent(config.userAgent) + .withMaxTotalConnections(config.maxTotalConnections) + .withMaxWaitQueueLimit(config.maxWaitQueueLimit) + .withMaxConnectionsPerRequestKey(Function.const(config.maxConnectionsPerRequestkey)) + .withCheckEndpointAuthentication(config.checkEndpointIdentification) + .withMaxResponseLineSize(config.maxResponseLineSize) + .withMaxHeaderLength(config.maxHeaderLength) + .withMaxChunkSize(config.maxChunkSize) + .withChunkBufferMaxSize(config.chunkBufferMaxSize) + .withParserMode(config.parserMode) + .withBufferSize(config.bufferSize) + + maybeSslContext.map(builder.withSslContext).getOrElse(builder).resource + } + } yield client + } + + private def sslContext[F[_]: Sync](maybeSslContextConfig: Option[SslContextConfig]): F[Option[SSLContext]] = { + Traverse[Option].traverse(maybeSslContextConfig)(SslContextModule.make[F]) + } + +} diff --git a/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClientConfig.scala b/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClientConfig.scala new file mode 100644 index 000000000..3aa1bf2c5 --- /dev/null +++ b/http4s-blaze-client/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeClientConfig.scala @@ -0,0 +1,30 @@ +package com.avast.server.toolkit.http4s + +import java.util.concurrent.TimeUnit + +import com.avast.server.toolkit.ssl.SslContextConfig +import org.http4s.BuildInfo +import org.http4s.client.blaze.ParserMode +import org.http4s.client.defaults +import org.http4s.headers.{`User-Agent`, AgentComment, AgentProduct} + +import scala.concurrent.duration.{Duration, FiniteDuration} + +final case class Http4sBlazeClientConfig( + responseHeaderTimeout: Duration = Duration.Inf, + idleTimeout: FiniteDuration = Duration(1, TimeUnit.MINUTES), + requestTimeout: FiniteDuration = defaults.RequestTimeout, + connectTimeout: FiniteDuration = defaults.ConnectTimeout, + userAgent: `User-Agent` = `User-Agent`(AgentProduct("http4s-blaze-client", Some(BuildInfo.version)), List(AgentComment("Server"))), + maxTotalConnections: Int = 10, + maxWaitQueueLimit: Int = 256, + maxConnectionsPerRequestkey: Int = 256, + sslContext: Option[SslContextConfig] = None, + checkEndpointIdentification: Boolean = true, + maxResponseLineSize: Int = 4 * 1024, + maxHeaderLength: Int = 40 * 1024, + maxChunkSize: Int = Int.MaxValue, + chunkBufferMaxSize: Int = 1024 * 1024, + parserMode: ParserMode = ParserMode.Strict, + bufferSize: Int = 8192 +) diff --git a/http4s-blaze-client/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeClientTest.scala b/http4s-blaze-client/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeClientTest.scala new file mode 100644 index 000000000..b51191dcf --- /dev/null +++ b/http4s-blaze-client/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeClientTest.scala @@ -0,0 +1,32 @@ +package com.avast.server.toolkit.http4s + +import cats.effect._ +import org.http4s.headers.{`User-Agent`, AgentComment, AgentProduct} +import org.scalatest.AsyncFunSuite + +import scala.concurrent.ExecutionContext + +class Http4SBlazeClientTest extends AsyncFunSuite { + + implicit private val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) + + test("Initialization of HTTP client and simple GET") { + val expected = """|{ + | "user-agent": "http4s-client/1.2.3 (Test)" + |} + |""".stripMargin + + val test = for { + client <- Http4sBlazeClient.make[IO]( + Http4sBlazeClientConfig( + userAgent = `User-Agent`(AgentProduct("http4s-client", Some("1.2.3")), List(AgentComment("Test"))) + ), + ExecutionContext.global + ) + response <- Resource.liftF(client.expect[String]("https://httpbin.org/user-agent")) + } yield assert(response === expected) + + test.use(IO.pure).unsafeToFuture() + } + +} diff --git a/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerConfig.scala b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerConfig.scala new file mode 100644 index 000000000..cf104a4ab --- /dev/null +++ b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerConfig.scala @@ -0,0 +1,33 @@ +package com.avast.server.toolkit.http4s + +import java.util.concurrent.TimeUnit + +import com.avast.server.toolkit.http4s.Http4sBlazeServerConfig.SocketOptions +import org.http4s.blaze.channel +import org.http4s.server.defaults + +import scala.concurrent.duration.{Duration, FiniteDuration} + +final case class Http4sBlazeServerConfig( + listenAddress: String, + listenPort: Int, + nio2Enabled: Boolean = true, + webSocketsEnabled: Boolean = false, + http2Enabled: Boolean = false, + responseHeaderTimeout: FiniteDuration = Duration(defaults.ResponseTimeout.toNanos, TimeUnit.NANOSECONDS), + idleTimeout: FiniteDuration = Duration(defaults.IdleTimeout.toNanos, TimeUnit.NANOSECONDS), + bufferSize: Int = 64 * 1024, + maxRequestLineLength: Int = 4 * 1024, + maxHeadersLength: Int = 40 * 1024, + chunkBufferMaxSize: Int = 1024 * 1024, + connectorPoolSize: Int = channel.DefaultPoolSize, + socketOptions: SocketOptions = SocketOptions() +) + +object Http4sBlazeServerConfig { + + def localhost8080: Http4sBlazeServerConfig = Http4sBlazeServerConfig("127.0.0.1", 8080) + + final case class SocketOptions(tcpNoDelay: Boolean = true) + +} diff --git a/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerModule.scala b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerModule.scala new file mode 100644 index 000000000..850ac4c32 --- /dev/null +++ b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sBlazeServerModule.scala @@ -0,0 +1,42 @@ +package com.avast.server.toolkit.http4s + +import java.net.{InetSocketAddress, StandardSocketOptions} + +import cats.effect.{ConcurrentEffect, Resource, Timer} +import org.http4s.HttpApp +import org.http4s.server.Server +import org.http4s.server.blaze.BlazeServerBuilder + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration.Duration +import scala.language.higherKinds + +object Http4sBlazeServerModule { + + /** Makes [[org.http4s.server.Server]] (Blaze) initialized with the given config and [[org.http4s.HttpApp]]. + * + * @param executionContext callback handling [[scala.concurrent.ExecutionContext]] + */ + def make[F[_]: ConcurrentEffect: Timer](config: Http4sBlazeServerConfig, + httpApp: HttpApp[F], + executionContext: ExecutionContext): Resource[F, Server[F]] = { + BlazeServerBuilder[F] + .bindSocketAddress(InetSocketAddress.createUnresolved(config.listenAddress, config.listenPort)) + .withHttpApp(httpApp) + .withExecutionContext(executionContext) + .withoutBanner + .withNio2(config.nio2Enabled) + .withWebSockets(config.webSocketsEnabled) + .enableHttp2(config.http2Enabled) + .withResponseHeaderTimeout(Duration.fromNanos(config.responseHeaderTimeout.toNanos)) + .withIdleTimeout(Duration.fromNanos(config.idleTimeout.toNanos)) + .withBufferSize(config.bufferSize) + .withMaxRequestLineLength(config.maxRequestLineLength) + .withMaxHeadersLength(config.maxHeadersLength) + .withChunkBufferMaxSize(config.chunkBufferMaxSize) + .withConnectorPoolSize(config.connectorPoolSize) + .withChannelOption[java.lang.Boolean](StandardSocketOptions.TCP_NODELAY, config.socketOptions.tcpNoDelay) + .resource + } + +} diff --git a/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sRouting.scala b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sRouting.scala new file mode 100644 index 000000000..32bd40140 --- /dev/null +++ b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/Http4sRouting.scala @@ -0,0 +1,24 @@ +package com.avast.server.toolkit.http4s + +import cats.Monad +import cats.data.{Kleisli, OptionT} +import org.http4s.syntax.kleisli._ +import org.http4s.{HttpApp, HttpRoutes, Request} + +import scala.language.higherKinds + +object Http4sRouting { + + /** Makes [[org.http4s.HttpApp]] from [[org.http4s.HttpRoutes]] */ + def make[F[_]: Monad](routes: HttpRoutes[F], more: HttpRoutes[F]*): HttpApp[F] = { + val semigroup = Kleisli.catsDataSemigroupKForKleisli[OptionT[F, *], Request[F]](OptionT.catsDataSemigroupKForOptionT[F]) + + more + .foldLeft[HttpRoutes[F]](routes) { + case (acc, moreRoutes) => + semigroup.combineK(acc, moreRoutes) + } + .orNotFound + } + +} diff --git a/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddleware.scala b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddleware.scala new file mode 100644 index 000000000..3fde83b31 --- /dev/null +++ b/http4s-blaze-server/src/main/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddleware.scala @@ -0,0 +1,67 @@ +package com.avast.server.toolkit.http4s.middleware + +import java.util.UUID + +import cats.data.{Kleisli, OptionT} +import cats.effect.Sync +import cats.syntax.functor._ +import com.avast.server.toolkit.http4s.middleware.CorrelationIdMiddleware.CorrelationId +import io.chrisdavenport.vault.Key +import org.http4s.util.CaseInsensitiveString +import org.http4s.{Header, HttpRoutes, Request, Response} +import org.slf4j.LoggerFactory + +import scala.language.higherKinds + +/** Provides correlation ID functionality. Either generates new correlation ID for a request or takes the one sent in HTTP header + * and puts it to [[org.http4s.Request]] attributes. It is also filled into HTTP response header. + * + * Use method `retrieveCorrelationId` to get the value from request attributes. + */ +class CorrelationIdMiddleware[F[_]: Sync](correlationIdHeaderName: CaseInsensitiveString, + attributeKey: Key[CorrelationId], + generator: () => String) { + + private val logger = LoggerFactory.getLogger("CorrelationIdMiddleware") + + private val F = Sync[F] + + def wrap(routes: HttpRoutes[F]): HttpRoutes[F] = Kleisli[OptionT[F, *], Request[F], Response[F]] { request => + request.headers.get(correlationIdHeaderName) match { + case Some(header) => + val requestWithAttribute = request.withAttribute(attributeKey, CorrelationId(header.value)) + routes(requestWithAttribute).map(r => r.withHeaders(r.headers.put(header))) + case None => + for { + newCorrelationId <- OptionT.liftF(F.delay(generator())) + _ <- log(newCorrelationId) + requestWithAttribute = request.withAttribute(attributeKey, CorrelationId(newCorrelationId)) + response <- routes(requestWithAttribute) + } yield response.withHeaders(response.headers.put(Header(correlationIdHeaderName.value, newCorrelationId))) + } + } + + def retrieveCorrelationId(request: Request[F]): Option[CorrelationId] = request.attributes.lookup(attributeKey) + + private def log(newCorrelationId: String) = { + OptionT.liftF { + F.delay { + if (logger.isDebugEnabled()) { + logger.debug(s"Generated new correlation ID: $newCorrelationId") + } + } + } + } +} + +object CorrelationIdMiddleware { + + final case class CorrelationId(value: String) extends AnyVal + + def default[F[_]: Sync]: F[CorrelationIdMiddleware[F]] = { + Key.newKey[F, CorrelationId].map { attributeKey => + new CorrelationIdMiddleware(CaseInsensitiveString("Correlation-ID"), attributeKey, () => UUID.randomUUID().toString) + } + } + +} diff --git a/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeServerModuleTest.scala b/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeServerModuleTest.scala new file mode 100644 index 000000000..5bd54cffb --- /dev/null +++ b/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/Http4SBlazeServerModuleTest.scala @@ -0,0 +1,34 @@ +package com.avast.server.toolkit.http4s + +import cats.effect.{ContextShift, IO, Timer} +import org.http4s.HttpRoutes +import org.http4s.dsl.Http4sDsl +import org.scalatest.AsyncFunSuite + +import scala.concurrent.ExecutionContext + +class Http4SBlazeServerModuleTest extends AsyncFunSuite with Http4sDsl[IO] { + + implicit private val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) + implicit private val timer: Timer[IO] = IO.timer(ExecutionContext.global) + + test("Simple HTTP server") { + val routes = Http4sRouting.make(HttpRoutes.of[IO] { + case GET -> Root / "test" => Ok("test") + }) + val test = for { + server <- Http4sBlazeServerModule.make[IO](Http4sBlazeServerConfig("127.0.0.1", 0), routes, ExecutionContext.global) + client <- Http4sBlazeClient.make[IO](Http4sBlazeClientConfig(), ExecutionContext.global) + } yield (server, client) + + test + .use { + case (server, client) => + client + .expect[String](s"http://${server.address.getHostString}:${server.address.getPort}/test") + .map(response => assert(response === "test")) + } + .unsafeToFuture() + } + +} diff --git a/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddlewareTest.scala b/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddlewareTest.scala new file mode 100644 index 000000000..1454a4e56 --- /dev/null +++ b/http4s-blaze-server/src/test/scala/com/avast/server/toolkit/http4s/middleware/CorrelationIdMiddlewareTest.scala @@ -0,0 +1,56 @@ +package com.avast.server.toolkit.http4s.middleware + +import cats.effect.{ContextShift, IO, Resource, Timer} +import com.avast.server.toolkit.http4s.{ + Http4sBlazeClient, + Http4sBlazeClientConfig, + Http4sBlazeServerConfig, + Http4sBlazeServerModule, + Http4sRouting +} +import org.http4s.dsl.Http4sDsl +import org.http4s.util.CaseInsensitiveString +import org.http4s.{Header, HttpRoutes, Request, Uri} +import org.scalatest.AsyncFunSuite + +import scala.concurrent.ExecutionContext + +class CorrelationIdMiddlewareTest extends AsyncFunSuite with Http4sDsl[IO] { + + implicit private val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) + implicit private val timer: Timer[IO] = IO.timer(ExecutionContext.global) + + test("CorrelationIdMiddleware fills Request attributes and HTTP response header") { + val test = for { + middleware <- Resource.liftF(CorrelationIdMiddleware.default[IO]) + routes = Http4sRouting.make { + middleware.wrap { + HttpRoutes.of[IO] { + case req @ GET -> Root / "test" => + val id = middleware.retrieveCorrelationId(req) + Ok("test").map(_.withHeaders(Header("Attribute-Value", id.toString))) + } + } + } + server <- Http4sBlazeServerModule.make[IO](Http4sBlazeServerConfig("127.0.0.1", 0), routes, ExecutionContext.global) + client <- Http4sBlazeClient.make[IO](Http4sBlazeClientConfig(), ExecutionContext.global) + } yield (server, client) + + test + .use { + case (server, client) => + client + .fetch( + Request[IO](uri = Uri.unsafeFromString(s"http://${server.address.getHostString}:${server.address.getPort}/test")) + .withHeaders(Header("Correlation-Id", "test-value")) + ) { response => + IO.delay { + assert(response.headers.get(CaseInsensitiveString("Correlation-Id")).get.value === "test-value") + assert(response.headers.get(CaseInsensitiveString("Attribute-Value")).get.value === "Some(CorrelationId(test-value))") + } + } + } + .unsafeToFuture() + } + +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index b067e2ccd..acb77450c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,15 +3,20 @@ import sbt._ object Dependencies { val catsEffect = "org.typelevel" %% "cats-effect" % "2.0.0" + val http4sBlazeClient = "org.http4s" %% "http4s-blaze-client" % Versions.http4s + val http4sBlazeServer = "org.http4s" %% "http4s-blaze-server" % Versions.http4s + val http4sDsl = "org.http4s" %% "http4s-dsl" % Versions.http4s val kindProjector = "org.typelevel" %% "kind-projector" % "0.10.3" + val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.2.3" val pureConfig = "com.github.pureconfig" %% "pureconfig" % "0.12.1" + val scalaTest = "org.scalatest" %% "scalatest" % "3.0.8" val slf4jApi = "org.slf4j" % "slf4j-api" % "1.7.28" val zio = "dev.zio" %% "zio" % "1.0.0-RC13" val zioInteropCats = "dev.zio" %% "zio-interop-cats" % "2.0.0.0-RC4" - object Test { + object Versions { - val scalaTest = "org.scalatest" %% "scalatest" % "3.0.8" % sbt.Test + val http4s = "0.20.11" }