diff --git a/.scalafix.conf b/.scalafix.conf index 4e88c05..6ec4a1f 100644 --- a/.scalafix.conf +++ b/.scalafix.conf @@ -8,3 +8,4 @@ OrganizeImports { importsOrder = SymbolsFirst groups = [ "re:(javax?\\.)|(scala\\.)", "*" ] } +OrganizeImports.targetDialect = Scala3 \ No newline at end of file diff --git a/README.md b/README.md index 6cd8c24..6d4b2c4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![codecov](https://img.shields.io/codecov/c/github/usommerl/graalnative4s?style=for-the-badge)](https://codecov.io/gh/usommerl/graalnative4s) [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=for-the-badge)](https://scala-steward.org) -This is a showcase for a combination of purely functional Scala libraries that can be used with GraalVM `native-image` without much effort. It employs [http4s][http4s] for general server functionality, [circe][circe] for JSON processing, [ciris][ciris] to load runtime configuration, [tapir][tapir] to describe HTTP endpoints and [odin][odin] for logging. Applications that were built with `native-image` have beneficial properties such as a lower memory footprint and fast startup. This makes them suitable for serverless applications. +This is a showcase for a combination of purely functional Scala libraries that can be used with GraalVM `native-image` without much effort. It employs [http4s][http4s] for general server functionality, [circe][circe] for JSON processing, [ciris][ciris] to load runtime configuration, [tapir][tapir] to describe HTTP endpoints and [woof][woof] for logging. Applications that were built with `native-image` have beneficial properties such as a lower memory footprint and fast startup. This makes them suitable for serverless applications. ### Build Use `sbt docker` to build a docker image with the native image binary. You don't need to install anything besides `docker` and `sbt`, the build process downloads all required GraalVM tooling. The [created image][image] will be as minimal as possible by using a multi-stage build. @@ -28,7 +28,7 @@ I have taken a lot of inspiration and knowledge from [this blog post by James Wa [http4s]: https://github.com/http4s/http4s [circe]: https://github.com/circe/circe [tapir]: https://github.com/softwaremill/tapir -[odin]: https://github.com/valskalla/odin +[woof]: https://github.com/LEGO/woof [ciris]: https://github.com/vlovgr/ciris [image]: https://github.com/users/usommerl/packages/container/package/graalnative4s diff --git a/build.sbt b/build.sbt index b9406bf..0bf5a80 100644 --- a/build.sbt +++ b/build.sbt @@ -9,10 +9,10 @@ val v = new { val circe = "0.14.9" val ciris = "3.6.0" val http4s = "0.23.27" - val odin = "0.13.0" val tapir = "1.11.1" val munit = "1.0.1" val munitCE = "2.0.0" + val woof = "0.7.0" } val upx = "UPX_COMPRESSION" @@ -29,9 +29,6 @@ lazy val graalnative4s = project "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % v.tapir, "com.softwaremill.sttp.tapir" %% "tapir-refined" % v.tapir, "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui" % v.tapir, - "com.github.valskalla" %% "odin-core" % v.odin, - "com.github.valskalla" %% "odin-json" % v.odin, - "com.github.valskalla" %% "odin-slf4j" % v.odin, "io.circe" %% "circe-core" % v.circe, "io.circe" %% "circe-generic" % v.circe, "io.circe" %% "circe-parser" % v.circe, @@ -41,13 +38,15 @@ lazy val graalnative4s = project "org.http4s" %% "http4s-ember-server" % v.http4s, "org.http4s" %% "http4s-circe" % v.http4s, "org.http4s" %% "http4s-dsl" % v.http4s, + "org.legogroup" %% "woof-core" % v.woof, + "org.legogroup" %% "woof-slf4j" % v.woof, "org.scalameta" %% "munit" % v.munit % Test, "org.typelevel" %% "munit-cats-effect" % v.munitCE % Test ), testFrameworks += new TestFramework("munit.Framework"), buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, Test / libraryDependencies), buildInfoPackage := organization.value, - buildInfoOptions ++= Seq[BuildInfoOption](BuildInfoOption.BuildTime), + buildInfoOptions ++= Seq[BuildInfoOption](BuildInfoOption.BuildTime, BuildInfoOption.ToMap), semanticdbEnabled := true, semanticdbVersion := scalafixSemanticdb.revision, docker / dockerfile := NativeDockerfile(file("Dockerfile")), diff --git a/src/main/scala/app/Api.scala b/src/main/scala/app/Api.scala index 8163074..288ea9a 100644 --- a/src/main/scala/app/Api.scala +++ b/src/main/scala/app/Api.scala @@ -12,12 +12,10 @@ import org.http4s.dsl.Http4sDsl import org.http4s.headers.Location import org.http4s.implicits.* import org.http4s.server.middleware.CORS -import sttp.model.StatusCode -import sttp.apispec.openapi.OpenAPI import sttp.apispec.Tag -import sttp.apispec.openapi.Info as OpenApiInfo -import sttp.apispec.openapi.Server +import sttp.apispec.openapi.{Info as OpenApiInfo, OpenAPI, Server} import sttp.apispec.openapi.circe.yaml.* +import sttp.model.StatusCode import sttp.tapir.* import sttp.tapir.codec.refined.* import sttp.tapir.docs.openapi.* diff --git a/src/main/scala/app/Config.scala b/src/main/scala/app/Config.scala index be472aa..5ee95c7 100644 --- a/src/main/scala/app/Config.scala +++ b/src/main/scala/app/Config.scala @@ -1,18 +1,19 @@ package app +import scala.util.Try + import cats.syntax.all.* import ciris.{Effect, *} import com.comcast.ip4s.{Host, Port} -import io.odin.Level -import io.odin.formatter.Formatter -import io.odin.json.{Formatter => JFormatter} import org.http4s.Uri import org.http4s.implicits.* +import org.legogroup.woof.{ColorPrinter, JsonPrinter, LogLevel, NoColorPrinter, Printer} +import org.legogroup.woof.LogLevel.Info case class Config(server: ServerConfig, logger: LoggerConfig) case class ServerConfig(host: Host, port: Port, apiDocs: ApiDocsConfig) case class ApiDocsConfig(server: Uri, description: Option[String]) -case class LoggerConfig(level: Level, formatter: Formatter) +case class LoggerConfig(level: LogLevel, printer: Printer) package object app { @@ -33,29 +34,21 @@ package object app { ).parMapN(ApiDocsConfig.apply) private lazy val loggerConfig: ConfigValue[Effect, LoggerConfig] = ( - env("LOG_LEVEL").as[Level].default(Level.Info), - env("LOG_FORMATTER").as[Formatter].default(Formatter.colorful) + env("LOG_LEVEL").as[LogLevel].default(Info), + env("LOG_FORMAT").as[Printer].default(ColorPrinter()) ).parMapN(LoggerConfig.apply) - given ConfigDecoder[String, Port] = ConfigDecoder[String, String].mapOption("Port")(Port.fromString) - given ConfigDecoder[String, Host] = ConfigDecoder[String, String].mapOption("Host")(Host.fromString) - given ConfigDecoder[String, Uri] = ConfigDecoder[String, String].mapOption("Uri")(Uri.fromString(_).toOption) - - given ConfigDecoder[String, Level] = - ConfigDecoder[String, String].mapOption("Level")(_.toLowerCase match { - case "trace" => Level.Trace.some - case "debug" => Level.Debug.some - case "info" => Level.Info.some - case "warn" => Level.Warn.some - case "error" => Level.Error.some - case _ => None - }) - - given ConfigDecoder[String, Formatter] = - ConfigDecoder[String, String].mapOption("Formatter")(_.toLowerCase match { - case "default" => Formatter.default.some - case "colorful" => Formatter.colorful.some - case "json" => JFormatter.json.some - case _ => None + given ConfigDecoder[String, Port] = ConfigDecoder[String, String].mapOption("Port")(Port.fromString) + given ConfigDecoder[String, Host] = ConfigDecoder[String, String].mapOption("Host")(Host.fromString) + given ConfigDecoder[String, Uri] = ConfigDecoder[String, String].mapOption("Uri")(Uri.fromString(_).toOption) + given ConfigDecoder[String, LogLevel] = + ConfigDecoder[String, String].mapOption("LogLevel")(s => Try(LogLevel.valueOf(s.toLowerCase.capitalize)).toOption) + + given ConfigDecoder[String, Printer] = + ConfigDecoder[String, String].mapOption("Printer")(_.toLowerCase match { + case "nocolor" => NoColorPrinter().some + case "color" => ColorPrinter().some + case "json" => JsonPrinter().some + case _ => None }) } diff --git a/src/main/scala/app/Main.scala b/src/main/scala/app/Main.scala index c8381ed..8685304 100644 --- a/src/main/scala/app/Main.scala +++ b/src/main/scala/app/Main.scala @@ -1,30 +1,36 @@ package app -import cats.arrow.FunctionK -import cats.effect.{Resource, *} -import cats.syntax.all.* -import cats.~> +import cats.effect.* +import cats.effect.std.Dispatcher import dev.usommerl.BuildInfo import fs2.io.net.Network -import io.odin.* import org.http4s.ember.server.EmberServerBuilder import org.http4s.server.{middleware, Server} +import org.legogroup.woof.{*, given} +import org.legogroup.woof.Logger.* +import org.legogroup.woof.slf4j.* object Main extends IOApp.Simple { - def run: IO[Unit] = app.config.resource[IO].flatMap(runF[IO](_, FunctionK.id)).useForever + def run: IO[Unit] = makeResources.useForever - def runF[F[_]: Async: Network](config: Config, functionK: F ~> IO): Resource[F, Unit] = + def makeResources: Resource[IO, Unit] = for - logger <- makeLogger[F](config.logger, functionK) - _ <- Resource.eval(logger.info(startMessage)) - _ <- makeServer[F](config.server) + config <- app.config.resource[IO] + given Logger[IO] <- makeIoLogger(config.logger) + _ <- logStart + _ <- makeServer[IO](config.server) yield () - private def makeLogger[F[_]: Async](config: LoggerConfig, functionK: F ~> IO): Resource[F, Logger[F]] = - Resource - .pure[F, Logger[F]](consoleLogger[F](config.formatter, config.level)) - .evalTap(logger => Sync[F].delay(OdinInterop.globalLogger.set(logger.mapK(functionK).some))) + private def makeIoLogger(config: LoggerConfig): Resource[IO, Logger[IO]] = + Dispatcher.sequential[IO].flatMap { implicit dispatcher => + given Printer = config.printer + given Filter = Filter.atLeastLevel(config.level) + for + logger <- Resource.eval(DefaultLogger.makeIo(Output.fromConsole)) + _ <- Resource.eval(logger.registerSlf4j) + yield logger + } private def makeServer[F[_]: Async: Network](config: ServerConfig): Resource[F, Server] = EmberServerBuilder @@ -34,13 +40,11 @@ object Main extends IOApp.Simple { .withHttpApp(middleware.Logger.httpApp(logHeaders = true, logBody = false)(Api[F](config.apiDocs))) .build - private lazy val startMessage: String = - "STARTED [ name: %s, version: %s, vmVersion: %s, scalaVersion: %s, sbtVersion: %s, builtAt: %s ]".format( - BuildInfo.name, - BuildInfo.version, - System.getProperty("java.vm.version"), - BuildInfo.scalaVersion, - BuildInfo.sbtVersion, - BuildInfo.builtAtString + def logStart(using logger: Logger[IO]): Resource[IO, Unit] = { + val keys = Set("name", "version", "scalaVersion", "sbtVersion", "builtAtString") + val context = BuildInfo.toMap.view.filterKeys(keys.contains).mapValues(_.toString).toSeq ++ Seq( + "vmVersion" -> System.getProperty("java.vm.version") ) + Resource.eval(logger.info("STARTED").withLogContext(context*)) + } } diff --git a/src/main/scala/app/OdinInterop.scala b/src/main/scala/app/OdinInterop.scala deleted file mode 100644 index a0a120f..0000000 --- a/src/main/scala/app/OdinInterop.scala +++ /dev/null @@ -1,27 +0,0 @@ -package app - -import cats.effect.IO -import cats.effect.kernel.Sync -import cats.effect.std.Dispatcher -import cats.effect.unsafe.implicits.* -import io.odin.Logger -import io.odin.slf4j.OdinLoggerBinder - -import java.util.concurrent.atomic.AtomicReference - -/** This implementation was stolen from: https://github.com/pitgull/pitgull/blob/v0.1.0/src/main/scala/io/pg/OdinInterop.scala - */ -class OdinInterop extends OdinLoggerBinder[IO] { - - implicit def F: Sync[IO] = IO.asyncForIO - implicit def dispatcher: Dispatcher[IO] = Dispatcher.parallel[IO].allocated.unsafeRunSync()._1 - - val loggers: PartialFunction[String, Logger[IO]] = { - val theLogger: String => Option[Logger[IO]] = _ => OdinInterop.globalLogger.get() - theLogger.unlift - } -} - -object OdinInterop { - val globalLogger = new AtomicReference[Option[Logger[IO]]](None) -} diff --git a/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala b/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala deleted file mode 100644 index 2a826a4..0000000 --- a/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala +++ /dev/null @@ -1,8 +0,0 @@ -package org.slf4j.impl - -class StaticLoggerBinder extends app.OdinInterop - -object StaticLoggerBinder extends StaticLoggerBinder { - val REQUESTED_API_VERSION: String = "1.7" - def getSingleton: StaticLoggerBinder = this -} diff --git a/src/test/scala/ApiSpec.scala b/src/test/scala/ApiSpec.scala index 1ae3971..c66bf3f 100644 --- a/src/test/scala/ApiSpec.scala +++ b/src/test/scala/ApiSpec.scala @@ -6,12 +6,11 @@ import dev.usommerl.BuildInfo import io.circe.Json import io.circe.literal.* import munit.CatsEffectSuite -import org.http4s.{Charset, Request, Response, Status} +import org.http4s.{Charset, Request, Response, Status, Uri} import org.http4s.MediaType.* import org.http4s.dsl.io.* import org.http4s.headers.{`Content-Type`, `Location`} import org.http4s.implicits.* -import org.http4s.Uri class ApiSpec extends ApiSuite {