diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 8849bfd..95820e7 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,4 +1,4 @@ -name-template: '$RESOLVED_VERSION' +name-template: 'v$RESOLVED_VERSION' categories: - title: 'Enhancements' label: 'enhancement' diff --git a/.github/workflows/ci_cd.yaml b/.github/workflows/ci_cd.yaml index b58f34e..6cc3f06 100644 --- a/.github/workflows/ci_cd.yaml +++ b/.github/workflows/ci_cd.yaml @@ -9,9 +9,10 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: - java-version: 1.11 + distribution: 'adopt' + java-version: 11 - name: Run tests run: sbt testCovered cd: @@ -22,14 +23,16 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: - java-version: 1.11 - - name: Docker login - env: - TOKEN: ${{ secrets.PAT_GHCR }} - USERNAME: ${{ github.repository_owner }} - run: echo $TOKEN | docker login ghcr.io -u $USERNAME --password-stdin + distribution: 'adopt' + java-version: 11 + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Create and publish a docker image with a UPX compressed executable run: export UPX_COMPRESSION='--best'; sbt ';docker;dockerPush' - name: Create and publish a docker image without compression @@ -43,7 +46,7 @@ jobs: docker tag $IMAGE_GHCR $IMAGE_GCR echo "image_gcr=$IMAGE_GCR" >> $GITHUB_ENV - name: Deploy to Cloud Run - uses: stefda/action-cloud-run@v1.2 + uses: stefda/action-cloud-run@v1.4 with: image: ${{ env.image_gcr }} service: graalnative4s diff --git a/.github/workflows/cloud_run_env_vars b/.github/workflows/cloud_run_env_vars index a634a86..508d284 100644 --- a/.github/workflows/cloud_run_env_vars +++ b/.github/workflows/cloud_run_env_vars @@ -1 +1 @@ -APIDOCS_SERVER_URL=https://graalnative4s.usommerl.dev,APIDOCS_DESCRIPTION="[github.com/usommerl/graalnative4s](https://github.com/usommerl/graalnative4s)" +APIDOCS_SERVER_URL=https://graalnative4s.usommerl.dev,APIDOCS_DESCRIPTION="[github.com/usommerl/graalnative4s](https://github.com/usommerl/graalnative4s)",LOG_FORMATTER="default" diff --git a/Dockerfile b/Dockerfile index a3257ca..846a155 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM ghcr.io/graalvm/graalvm-ce:java11-21.0.0.2 as builder + ARG upx_compression RUN gu install native-image RUN curl https://bintray.com/sbt/rpm/rpm | tee /etc/yum.repos.d/bintray-sbt-rpm.repo && microdnf install sbt git xz @@ -38,8 +39,12 @@ RUN if [ -n "${upx_compression}" ]; then \ upx-3.96-amd64_linux/upx "$upx_compression" /build/target/graalvm-native-image/graalnative4s; \ fi +RUN echo 'unprivileged:x:65534:65534:unprivileged:/:' > /etc/passwd.minimal + FROM scratch COPY --from=builder /build/target/graalvm-native-image/graalnative4s /server +COPY --from=builder /etc/passwd.minimal /etc/passwd +USER unprivileged ENV PATH "/" EXPOSE 8080 ENTRYPOINT [ "/server" ] diff --git a/build.sbt b/build.sbt index 841c69e..bbc31f8 100644 --- a/build.sbt +++ b/build.sbt @@ -1,15 +1,15 @@ -ThisBuild / scalaVersion := "2.13.4" +ThisBuild / scalaVersion := "2.13.5" ThisBuild / organization := "dev.usommerl" ThisBuild / scalafixDependencies += "com.github.liancheng" %% "organize-imports" % "0.5.0" val v = new { - val http4s = "0.21.19" + val http4s = "0.21.22" val circe = "0.13.0" val ciris = "1.2.1" - val tapir = "0.17.13" + val tapir = "0.17.19" val odin = "0.11.0" - val munit = "0.7.22" - val munitCE = "0.13.1" + val munit = "0.7.23" + val munitCE = "1.0.1" } val upx = "UPX_COMPRESSION" @@ -25,8 +25,10 @@ lazy val graalnative4s = project "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % v.tapir, "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % v.tapir, "com.softwaremill.sttp.tapir" %% "tapir-openapi-circe-yaml" % v.tapir, + "com.softwaremill.sttp.tapir" %% "tapir-refined" % v.tapir, "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-http4s" % 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, @@ -53,9 +55,7 @@ lazy val graalnative4s = project assembly / assemblyMergeStrategy := { case "META-INF/maven/org.webjars/swagger-ui/pom.properties" => MergeStrategy.singleOrError case x if x.endsWith("module-info.class") => MergeStrategy.discard - case x => - val oldStrategy = (assemblyMergeStrategy in assembly).value - oldStrategy(x) + case x => (assembly / assemblyMergeStrategy).value(x) } ) diff --git a/project/build.properties b/project/build.properties index 0b2e09c..e67343a 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.4.7 +sbt.version=1.5.0 diff --git a/project/plugins.sbt b/project/plugins.sbt index 5d30be0..4694735 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,10 +1,10 @@ -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") -addSbtPlugin("com.alejandrohdezma" % "sbt-codecov" % "0.2.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.27") +addSbtPlugin("com.alejandrohdezma" % "sbt-codecov" % "0.2.1") addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.10.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.0") -addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.16") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.17") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.8.2") diff --git a/src/main/scala/server/Api.scala b/src/main/scala/app/Api.scala similarity index 80% rename from src/main/scala/server/Api.scala rename to src/main/scala/app/Api.scala index ca868ad..6c7038a 100644 --- a/src/main/scala/server/Api.scala +++ b/src/main/scala/app/Api.scala @@ -1,11 +1,13 @@ -package server +package app import cats.Applicative import cats.data.Kleisli -import cats.effect.{Concurrent, ContextShift, Sync, Timer} +import cats.effect.{Concurrent, ContextShift, Timer} import cats.implicits._ import dev.usommerl.BuildInfo +import eu.timepit.refined.api.Refined import eu.timepit.refined.auto._ +import eu.timepit.refined.collection._ import io.circe.generic.auto._ import org.http4s.{HttpRoutes, Request, Response} import org.http4s.dsl.Http4sDsl @@ -15,6 +17,7 @@ import org.http4s.server.middleware.CORS import sttp.model.StatusCode import sttp.tapir._ import sttp.tapir.apispec.Tag +import sttp.tapir.codec.refined._ import sttp.tapir.docs.openapi._ import sttp.tapir.generic.auto._ import sttp.tapir.json.circe.jsonBody @@ -25,7 +28,7 @@ import sttp.tapir.server.http4s._ import sttp.tapir.swagger.http4s.SwaggerHttp4s object Api { - def apply[F[_]: Sync: Concurrent: ContextShift: Timer](config: ApiDocsConfig): Kleisli[F, Request[F], Response[F]] = { + def apply[F[_]: Concurrent: ContextShift: Timer](config: ApiDocsConfig): Kleisli[F, Request[F], Response[F]] = { val dsl = Http4sDsl[F] import dsl._ @@ -46,9 +49,10 @@ object Api { } object Examples { - def apply[F[_]: Sync: Concurrent: ContextShift: Timer]()(implicit F: Applicative[F]) = new TapirApi[F] { + def apply[F[_]: Concurrent: ContextShift: Timer]()(implicit F: Applicative[F]) = new TapirApi[F] { override val tag = Tag("Getting started", None) override lazy val serverEndpoints = List(info, hello) + type NonEmptyString = String Refined NonEmpty private val info: ServerEndpoint[Unit, StatusCode, Info, Any, F] = endpoint.get @@ -71,12 +75,12 @@ object Examples { ) ) - private val hello: ServerEndpoint[Option[String], StatusCode, String, Any, F] = + private val hello: ServerEndpoint[Option[NonEmptyString], StatusCode, String, Any, F] = endpoint.get .summary("The infamous hello world endpoint") .tag(tag.name) .in("hello") - .in(query[Option[String]]("name").description("Optional name to greet")) + .in(query[Option[NonEmptyString]]("name").description("Optional name to greet")) .out(stringBody) .errorOut(statusCode) .serverLogic(name => F.pure(s"Hello ${name.getOrElse("World")}!".asRight)) @@ -93,7 +97,7 @@ object Examples { } } -abstract class TapirApi[F[_]: Sync: Concurrent: ContextShift: Timer] { +abstract class TapirApi[F[_]: Concurrent: ContextShift: Timer] { def tag: Tag def serverEndpoints: List[ServerEndpoint[_, _, _, Any, F]] def endpoints: List[Endpoint[_, _, _, _]] = serverEndpoints.map(_.endpoint) diff --git a/src/main/scala/app/Config.scala b/src/main/scala/app/Config.scala new file mode 100644 index 0000000..7162776 --- /dev/null +++ b/src/main/scala/app/Config.scala @@ -0,0 +1,62 @@ +package app + +import cats.implicits._ +import ciris._ +import ciris.refined._ +import eu.timepit.refined.api.Refined +import eu.timepit.refined.auto._ +import eu.timepit.refined.string.Url +import eu.timepit.refined.types.net.PortNumber +import io.odin.Level +import io.odin.formatter.Formatter +import io.odin.json.{Formatter => JFormatter} + +import app.ServerUrl + +case class Config(server: ServerConfig, logger: LoggerConfig) +case class ServerConfig(port: PortNumber, apiDocs: ApiDocsConfig) +case class ApiDocsConfig(serverUrl: ServerUrl, description: Option[String]) +case class LoggerConfig(level: Level, formatter: Formatter) + +package object app { + + type ServerUrl = String Refined Url + + implicit val logLevelDecoder: 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 + }) + + implicit val logFormatterDecoder: 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 + }) + + private val loggerConfig: ConfigValue[LoggerConfig] = ( + env("LOG_LEVEL").as[Level].default(Level.Info), + env("LOG_FORMATTER").as[Formatter].default(Formatter.colorful) + ).parMapN(LoggerConfig) + + private val apiDocsConfig: ConfigValue[ApiDocsConfig] = ( + env("APIDOCS_SERVER_URL").as[ServerUrl].default("http://localhost:8080"), + env("APIDOCS_DESCRIPTION").option + ).parMapN(ApiDocsConfig) + + private val serverConfig: ConfigValue[ServerConfig] = ( + env("PORT").as[PortNumber].default(8080), + apiDocsConfig + ).parMapN(ServerConfig) + + val config: ConfigValue[Config] = ( + serverConfig, + loggerConfig + ).parMapN(Config) +} diff --git a/src/main/scala/app/Main.scala b/src/main/scala/app/Main.scala new file mode 100644 index 0000000..62e0b1e --- /dev/null +++ b/src/main/scala/app/Main.scala @@ -0,0 +1,46 @@ +package app + +import scala.concurrent.ExecutionContext.global + +import cats.effect.{Resource, _} +import cats.implicits._ +import dev.usommerl.BuildInfo +import eu.timepit.refined.auto._ +import io.odin._ +import org.http4s.server.{middleware, Server} +import org.http4s.server.blaze.BlazeServerBuilder + +object Main extends IOApp { + + def run(args: List[String]): IO[ExitCode] = + runF[IO].use(_ => IO.never) + + private def runF[F[_]: ContextShift: ConcurrentEffect: Timer]: Resource[F, Unit] = + for { + config <- app.config.resource[F] + logger <- createLogger[F](config.logger) + _ <- Resource.eval(logger.info(startMessage)) + _ <- serve[F](config.server) + } yield () + + private def createLogger[F[_]: ConcurrentEffect: Timer](config: LoggerConfig): 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(Effect.toIOK).some))) + + private def serve[F[_]: ContextShift: ConcurrentEffect: Timer](config: ServerConfig): Resource[F, Server[F]] = + BlazeServerBuilder[F](global) + .bindHttp(config.port, "0.0.0.0") + .withHttpApp(middleware.Logger.httpApp(logHeaders = true, logBody = false)(Api[F](config.apiDocs))) + .resource + + 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 + ) +} diff --git a/src/main/scala/app/OdinInterop.scala b/src/main/scala/app/OdinInterop.scala new file mode 100644 index 0000000..d074154 --- /dev/null +++ b/src/main/scala/app/OdinInterop.scala @@ -0,0 +1,26 @@ +package app + +import java.util.concurrent.atomic.AtomicReference + +import cats.effect.Clock +import cats.effect.Effect +import cats.effect.IO +import io.odin.Logger +import io.odin.slf4j.OdinLoggerBinder + +/** This implementation was stolen from here: + * https://github.com/pitgull/pitgull/blob/v0.0.7/src/main/scala/io/pg/OdinInterop.scala + */ +class OdinInterop extends OdinLoggerBinder[IO] { + implicit val F: Effect[IO] = IO.ioEffect + implicit val clock: Clock[IO] = Clock.create[IO] + + 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 index 15e01b7..2a826a4 100644 --- a/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala +++ b/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala @@ -1,33 +1,8 @@ package org.slf4j.impl -import scala.concurrent.ExecutionContext - -import cats.effect.{Clock, Effect, IO, Timer} -import io.odin.{consoleLogger, Level, Logger} -import io.odin.formatter.Formatter -import io.odin.slf4j.OdinLoggerBinder - -/** This is bridge is needed for project dependencies that require an SLF4J API - * See: https://github.com/valskalla/odin/tree/v0.9.1#slf4j-bridge - * - * This particular implementation was stolen from here: - * https://github.com/pitgull/pitgull/blob/v0.0.2/src/main/scala/org/slf4j/impl/StaticLoggerBinder.scala - */ -class StaticLoggerBinder extends OdinLoggerBinder[IO] { - implicit val F: Effect[IO] = IO.ioEffect - implicit val clock: Clock[IO] = Clock.create[IO] - - import StaticLoggerBinder.baseLogger - - val loggers: PartialFunction[String, Logger[IO]] = { case _ => - baseLogger.withMinimalLevel(Level.Info) - } -} +class StaticLoggerBinder extends app.OdinInterop object StaticLoggerBinder extends StaticLoggerBinder { - //EC isn't used - only Clock is required - implicit val timer: Timer[IO] = IO.timer(ExecutionContext.parasitic) - val baseLogger = consoleLogger[IO](formatter = Formatter.colorful) val REQUESTED_API_VERSION: String = "1.7" def getSingleton: StaticLoggerBinder = this } diff --git a/src/main/scala/server/Config.scala b/src/main/scala/server/Config.scala deleted file mode 100644 index 10d7ecd..0000000 --- a/src/main/scala/server/Config.scala +++ /dev/null @@ -1,27 +0,0 @@ -package server - -import cats.implicits._ -import ciris._ -import ciris.refined._ -import eu.timepit.refined.api.Refined -import eu.timepit.refined.auto._ -import eu.timepit.refined.string.Url -import eu.timepit.refined.types.net.PortNumber - -import app.ServerUrl - -case class Config(port: PortNumber, apiDocs: ApiDocsConfig) -case class ApiDocsConfig(serverUrl: ServerUrl, description: Option[String]) - -package object app { - - type ServerUrl = String Refined Url - - val config: ConfigValue[Config] = - ( - env("PORT").as[PortNumber].default(8080), - env("APIDOCS_SERVER_URL").as[ServerUrl].default("http://localhost:8080"), - env("APIDOCS_DESCRIPTION").option - ).parMapN { case (port, url, description) => Config(port, ApiDocsConfig(url, description)) } - -} diff --git a/src/main/scala/server/Main.scala b/src/main/scala/server/Main.scala deleted file mode 100644 index ccb2059..0000000 --- a/src/main/scala/server/Main.scala +++ /dev/null @@ -1,48 +0,0 @@ -package server - -import scala.concurrent.ExecutionContext.global - -import cats.effect._ -import cats.implicits._ -import dev.usommerl.BuildInfo -import eu.timepit.refined.auto._ -import eu.timepit.refined.types.net.PortNumber -import io.odin._ -import org.http4s.HttpApp -import org.http4s.server.blaze.BlazeServerBuilder -import org.http4s.server.middleware.{Logger => Log} -import org.slf4j.impl.StaticLoggerBinder - -object Main extends IOApp { - - def run(args: List[String]): IO[ExitCode] = { - implicit val logger = StaticLoggerBinder.baseLogger.withMinimalLevel(Level.Info) - runF[IO] - } - - private def runF[F[_]: Sync: ContextShift: ConcurrentEffect: Timer: Logger]: F[ExitCode] = - for { - _ <- Logger[F].info(startMessage) - config <- app.config.load[F] - httpApp = Log.httpApp(logHeaders = true, logBody = false)(Api[F](config.apiDocs)) - exitCode <- serve[F](config.port, httpApp).as(ExitCode.Success) - } yield exitCode - - private def serve[F[_]: ConcurrentEffect: Timer](port: PortNumber, httpApp: HttpApp[F]): F[Unit] = - BlazeServerBuilder[F](global) - .bindHttp(port, "0.0.0.0") - .withHttpApp(httpApp) - .serve - .compile - .drain - - 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 - ) -} diff --git a/src/test/scala/ApiSpec.scala b/src/test/scala/ApiSpec.scala index ea4b7e2..fa3d950 100644 --- a/src/test/scala/ApiSpec.scala +++ b/src/test/scala/ApiSpec.scala @@ -1,4 +1,4 @@ -package server +package app import cats.data.Kleisli import cats.effect.IO @@ -27,6 +27,11 @@ class ApiSpec extends ApiSuite { check(response, Ok, "Hello John Doe!") } + test("GET /hello not accept empty string as name parameter") { + val response = api().run(Request[IO](method = GET, uri = uri"/hello?name=")) + check(response, BadRequest, evaluateBody = false) + } + test("GET /info should respond with application information") { val response = api().run(Request[IO](method = GET, uri = uri"/info")) check( @@ -66,10 +71,15 @@ trait ApiSuite extends CatsEffectSuite { io: IO[Response[IO]], expectedStatus: Status, expectedBody: Option[String] = None, - expectedContentType: Option[`Content-Type`] = None + expectedContentType: Option[`Content-Type`] = None, + evaluateBody: Boolean = true ): IO[Unit] = io.flatMap { response => assertEquals(response.status, expectedStatus) - assertEquals(response.headers.get(`Content-Type`), expectedContentType) - response.as[String].assertEquals(expectedBody.getOrElse("")) + if (evaluateBody) { + assertEquals(response.headers.get(`Content-Type`), expectedContentType) + response.as[String].assertEquals(expectedBody.getOrElse("")) + } else { + IO.unit + } } }