Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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