Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Direct-style Bootzooka in Scala 3 #1274

Merged
merged 11 commits into from
Aug 2, 2024
Merged
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
2 changes: 1 addition & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=3.8.3
maxColumn = 140
runner.dialect = scala213
runner.dialect = scala3
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

### Backend: PostgreSQL & API

In order to run Bootzooka, you'll need a running instance of the PostgreSQL with a `bootzooka` database. You can spin
In order to run Bootzooka, you'll need a running instance of PostgreSQL with a `bootzooka` database. You can spin
up one easily using docker:

```sh
Expand Down Expand Up @@ -45,4 +45,4 @@ We offer commercial support for Bootzooka and related technologies, as well as d

## Copyright

Copyright (C) 2013-2022 SoftwareMill [https://softwaremill.com](https://softwaremill.com).
Copyright (C) 2013-2024 SoftwareMill [https://softwaremill.com](https://softwaremill.com).
2 changes: 1 addition & 1 deletion backend/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ db {
username = "postgres"
username = ${?SQL_USERNAME}

password = ""
password = "bootzooka"
password = ${?SQL_PASSWORD}

name = "bootzooka"
Expand Down
24 changes: 13 additions & 11 deletions backend/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>

<configuration scan="true">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<if condition='p("LOGBACK_JSON_ENCODE").equals("true")'>
<then>
<if condition='p("LOGBACK_JSON_ENCODE").equals("true")'>
<then>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</then>
<else>
<encoder>
<pattern>%d{HH:mm:ss.SSS}%boldYellow(%replace( [%X{cid}] ){' \[\] ', ' '})[%thread] %-5level %logger{5} - %msg%n%rEx</pattern>
</encoder>
</else>
</if>
</appender>
</appender>
</then>
<else>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS}%boldYellow(%replace( [%X{cid}] ){' \[\] ', ' '})[%thread] %-5level %logger{5} - %msg%n%rEx</pattern>
</encoder>
</appender>
</else>
</if>

<logger name="com.softwaremill.bootzooka" level="${LOG_LEVEL:-DEBUG}" additivity="false">
<appender-ref ref="STDOUT"/>
Expand Down
126 changes: 69 additions & 57 deletions backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala
Original file line number Diff line number Diff line change
@@ -1,63 +1,75 @@
package com.softwaremill.bootzooka

import cats.data.NonEmptyList
import cats.effect.{IO, Resource}
import com.softwaremill.bootzooka.config.Config
import com.softwaremill.bootzooka.email.EmailService
import com.softwaremill.bootzooka.email.sender.EmailSender
import com.softwaremill.bootzooka.http.{Http, HttpApi, HttpConfig}
import com.softwaremill.bootzooka.metrics.VersionApi
import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken}
import com.softwaremill.bootzooka.security.ApiKeyAuthToken
import com.softwaremill.bootzooka.user.UserApi
import com.softwaremill.bootzooka.util.{Clock, DefaultIdGenerator}
import com.softwaremill.macwire.autocats.autowire
import doobie.util.transactor.Transactor
import io.prometheus.metrics.model.registry.PrometheusRegistry
import sttp.client3.SttpBackend
import sttp.tapir.server.metrics.prometheus.PrometheusMetrics
import com.softwaremill.bootzooka.email.{EmailModel, EmailService, EmailTemplates}
import com.softwaremill.bootzooka.http.{Http, HttpApi}
import com.softwaremill.bootzooka.infrastructure.{DB, SetCorrelationIdBackend}
import com.softwaremill.bootzooka.metrics.{Metrics, VersionApi}
import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken, PasswordResetCodeModel, PasswordResetService}
import com.softwaremill.bootzooka.security.{ApiKeyAuthToken, ApiKeyModel, ApiKeyService, Auth}
import com.softwaremill.bootzooka.user.{UserApi, UserModel, UserService}
import com.softwaremill.bootzooka.util.{Clock, DefaultClock, DefaultIdGenerator, IdGenerator}
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter
import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.metrics.SdkMeterProvider
import io.opentelemetry.sdk.metrics.`export`.PeriodicMetricReader
import ox.{IO, Ox, tap, useCloseableInScope, useInScope}
import sttp.client3.logging.slf4j.Slf4jLoggingBackend
import sttp.client3.opentelemetry.OpenTelemetryMetricsBackend
import sttp.client3.{HttpClientSyncBackend, SttpBackend}
import sttp.shared.Identity

case class Dependencies(httpApi: HttpApi, emailService: EmailService)
trait Dependencies(using Ox, IO):
// TODO use macwire/autowire once available for Scala3
lazy val config: Config = Config.read.tap(Config.log)
lazy val otel: OpenTelemetry = createOtel()
lazy val metrics = new Metrics(otel)
lazy val sttpBackend: SttpBackend[Identity, Any] =
useInScope(
Slf4jLoggingBackend(OpenTelemetryMetricsBackend(new SetCorrelationIdBackend(HttpClientSyncBackend()), otel), includeTiming = true)
)(_.close())
lazy val db: DB = useCloseableInScope(DB.createTestMigrate(config.db))
lazy val idGenerator: IdGenerator = DefaultIdGenerator
lazy val clock: Clock = DefaultClock
lazy val http = new Http
lazy val emailTemplates = new EmailTemplates
lazy val emailModel = new EmailModel
lazy val emailSender: EmailSender = EmailSender.create(sttpBackend, config.email)
lazy val emailService = new EmailService(emailModel, idGenerator, emailSender, config.email, db, metrics)
lazy val apiKeyModel = new ApiKeyModel
lazy val apiKeyAuthToken = new ApiKeyAuthToken(apiKeyModel)
lazy val apiKeyService = new ApiKeyService(apiKeyModel, idGenerator, clock)
lazy val apiKeyAuth = new Auth(apiKeyAuthToken, db, clock)
lazy val passwordResetCodeModel = new PasswordResetCodeModel
lazy val passwordResetAuthToken = new PasswordResetAuthToken(passwordResetCodeModel)
lazy val passwordResetAuth = new Auth(passwordResetAuthToken, db, clock)
lazy val userModel = new UserModel
lazy val userService = new UserService(userModel, emailService, emailTemplates, apiKeyService, idGenerator, clock, config.user)
lazy val userApi = new UserApi(http, apiKeyAuth, userService, db, metrics)
lazy val passwordResetService = new PasswordResetService(
userModel,
passwordResetCodeModel,
emailService,
emailTemplates,
passwordResetAuth,
idGenerator,
config.passwordReset,
clock,
db
)
lazy val passwordResetApi = new PasswordResetApi(http, passwordResetService, db)
lazy val versionApi = new VersionApi(http)
lazy val httpApi =
new HttpApi(http, userApi.endpoints ++ passwordResetApi.endpoints, List(versionApi.versionEndpoint), otel, config.api)

object Dependencies {
def wire(
config: Config,
sttpBackend: Resource[IO, SttpBackend[IO, Any]],
xa: Resource[IO, Transactor[IO]],
clock: Clock,
collectorRegistry: PrometheusRegistry
): Resource[IO, Dependencies] = {
def buildHttpApi(
http: Http,
userApi: UserApi,
passwordResetApi: PasswordResetApi,
versionApi: VersionApi,
cfg: HttpConfig
) = {
val prometheusMetrics = PrometheusMetrics.default[IO](registry = collectorRegistry)
new HttpApi(
http,
userApi.endpoints concatNel passwordResetApi.endpoints,
NonEmptyList.of(versionApi.versionEndpoint),
prometheusMetrics,
cfg
)
}

autowire[Dependencies](
config.api,
config.user,
config.passwordReset,
config.email,
DefaultIdGenerator,
clock,
sttpBackend,
xa,
buildHttpApi _,
new EmailService(_, _, _, _, _),
EmailSender.create _,
new ApiKeyAuthToken(_),
new PasswordResetAuthToken(_)
)
}
}
private def createOtel(): OpenTelemetry =
// An exporter that sends metrics to a collector over gRPC
val grpcExporter = OtlpGrpcMetricExporter.builder().build()
// A metric reader that exports using the gRPC exporter
val metricReader: PeriodicMetricReader = PeriodicMetricReader.builder(grpcExporter).build()
// A meter registry whose meters are read by the above reader
val meterProvider: SdkMeterProvider = SdkMeterProvider.builder().registerMetricReader(metricReader).build()
// An instance of OpenTelemetry using the above meter registry
OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build()
3 changes: 1 addition & 2 deletions backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ package com.softwaremill.bootzooka
*/
abstract class Fail extends Exception

object Fail {
object Fail:
case class NotFound(what: String) extends Fail
case class Conflict(msg: String) extends Fail
case class IncorrectInput(msg: String) extends Fail
case class Unauthorized(msg: String) extends Fail
case object Forbidden extends Fail
}
49 changes: 11 additions & 38 deletions backend/src/main/scala/com/softwaremill/bootzooka/Main.scala
Original file line number Diff line number Diff line change
@@ -1,45 +1,18 @@
package com.softwaremill.bootzooka

import cats.effect.{IO, Resource, ResourceApp}
import com.softwaremill.bootzooka.config.Config
import com.softwaremill.bootzooka.infrastructure.{CorrelationId, DB, Doobie, SetCorrelationIdBackend}
import com.softwaremill.bootzooka.metrics.Metrics
import com.softwaremill.bootzooka.util.DefaultClock
import com.typesafe.scalalogging.StrictLogging
import io.prometheus.metrics.model.registry.PrometheusRegistry
import sttp.capabilities.WebSockets
import sttp.capabilities.fs2.Fs2Streams
import sttp.client3.SttpBackend
import sttp.client3.asynchttpclient.fs2.AsyncHttpClientFs2Backend
import sttp.client3.logging.slf4j.Slf4jLoggingBackend
import sttp.client3.prometheus.PrometheusBackend
import com.softwaremill.bootzooka.logging.{Logging, InheritableMDC}
import ox.{IO, Ox, OxApp, never}

object Main extends ResourceApp.Forever with StrictLogging {
Metrics.init()
object Main extends OxApp.Simple with Logging:
InheritableMDC.init
Thread.setDefaultUncaughtExceptionHandler((t, e) => logger.error("Uncaught exception in thread: " + t, e))

val sttpBackend: Resource[IO, SttpBackend[IO, Fs2Streams[IO] with WebSockets]] =
AsyncHttpClientFs2Backend
.resource[IO]()
.map(baseSttpBackend => Slf4jLoggingBackend(PrometheusBackend(new SetCorrelationIdBackend(baseSttpBackend)), includeTiming = true))
override def run(using Ox, IO): Unit =
val deps = new Dependencies() {}

val config: Config = Config.read
Config.log(config)
deps.emailService.startProcesses()
val binding = deps.httpApi.start()
logger.info(s"Started Bootzooka on ${binding.hostName}:${binding.port}.")

val xa: Resource[IO, Doobie.Transactor[IO]] = new DB(config.db).transactorResource.map(CorrelationId.correlationIdTransactor)

/** Creating a resource which combines three resources in sequence:
*
* - the first creates the object graph and allocates the dependencies
* - the second starts the background processes (here, an email sender)
* - the third allocates the http api resource
*
* Thanks to ResourceApp.Forever the result of the allocation is used by a non-terminating process (so that the http server is available
* as long as our application runs).
*/
override def run(list: List[String]): Resource[IO, Unit] = for {
deps <- Dependencies.wire(config, sttpBackend, xa, DefaultClock, PrometheusRegistry.defaultRegistry)
_ <- deps.emailService.startProcesses().background
_ <- deps.httpApi.resource
} yield ()
}
// blocking until the application is shut down
never
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ package com.softwaremill.bootzooka.config
import com.softwaremill.bootzooka.email.EmailConfig
import com.softwaremill.bootzooka.http.HttpConfig
import com.softwaremill.bootzooka.infrastructure.DBConfig
import com.softwaremill.bootzooka.logging.Logging
import com.softwaremill.bootzooka.passwordreset.PasswordResetConfig
import com.softwaremill.bootzooka.user.UserConfig
import com.softwaremill.bootzooka.version.BuildInfo
import com.typesafe.scalalogging.StrictLogging
import pureconfig.ConfigSource
import pureconfig.generic.auto._
import pureconfig.{ConfigReader, ConfigSource}
import pureconfig.generic.derivation.default.*

import scala.collection.immutable.TreeMap

/** Maps to the `application.conf` file. Configuration for all modules of the application. */
case class Config(db: DBConfig, api: HttpConfig, email: EmailConfig, passwordReset: PasswordResetConfig, user: UserConfig)
derives ConfigReader

object Config extends StrictLogging {
def log(config: Config): Unit = {
object Config extends Logging:
def log(config: Config): Unit =
val baseInfo = s"""
|Bootzooka configuration:
|-----------------------
Expand All @@ -35,7 +36,6 @@ object Config extends StrictLogging {
}

logger.info(info)
}
end log

def read: Config = ConfigSource.default.loadOrThrow[Config]
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.softwaremill.bootzooka.config

case class Sensitive(value: String) extends AnyVal {
import pureconfig.ConfigReader

case class Sensitive(value: String):
override def toString: String = "***"
}

object Sensitive:
given ConfigReader[Sensitive] = pureconfig.ConfigReader[String].map(Sensitive(_))
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.softwaremill.bootzooka.email

import com.softwaremill.bootzooka.config.Sensitive
import pureconfig.ConfigReader
import pureconfig.generic.derivation.default.*

import scala.concurrent.duration.FiniteDuration

Expand All @@ -9,7 +11,7 @@ case class EmailConfig(
smtp: SmtpConfig,
batchSize: Int,
emailSendInterval: FiniteDuration
)
) derives ConfigReader

case class SmtpConfig(
enabled: Boolean,
Expand All @@ -21,6 +23,7 @@ case class SmtpConfig(
verifySslCertificate: Boolean,
from: String,
encoding: String
)
) derives ConfigReader

case class MailgunConfig(enabled: Boolean, apiKey: Sensitive, url: String, domain: String, senderName: String, senderDisplayName: String)
derives ConfigReader
Loading
Loading