Skip to content

Commit

Permalink
Replace odin with woof
Browse files Browse the repository at this point in the history
  • Loading branch information
usommerl committed Aug 26, 2024
1 parent fa5c85a commit b351e71
Show file tree
Hide file tree
Showing 9 changed files with 55 additions and 96 deletions.
1 change: 1 addition & 0 deletions .scalafix.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ OrganizeImports {
importsOrder = SymbolsFirst
groups = [ "re:(javax?\\.)|(scala\\.)", "*" ]
}
OrganizeImports.targetDialect = Scala3
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
9 changes: 4 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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")),
Expand Down
6 changes: 2 additions & 4 deletions src/main/scala/app/Api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
45 changes: 19 additions & 26 deletions src/main/scala/app/Config.scala
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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
})
}
48 changes: 26 additions & 22 deletions src/main/scala/app/Main.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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*))
}
}
27 changes: 0 additions & 27 deletions src/main/scala/app/OdinInterop.scala

This file was deleted.

8 changes: 0 additions & 8 deletions src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala

This file was deleted.

3 changes: 1 addition & 2 deletions src/test/scala/ApiSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down

0 comments on commit b351e71

Please sign in to comment.