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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import pillars.ApiServer
import pillars.App
import pillars.AppInfo
import pillars.Config.PillarsConfig
import pillars.Controller
import pillars.HttpServer
import pillars.Module
import pillars.Modules
import pillars.Observability
Expand Down Expand Up @@ -59,11 +61,13 @@ trait PillarsSuite extends CatsEffectSuite, TestContainersSuite:
override def appInfo: AppInfo = BuildInfo.toAppInfo
override def observability: Observability = obs
override def config: PillarsConfig = conf
override def apiServer: ApiServer = ApiServer.noop
override def apiServer: HttpServer = HttpServer.noop
override def logger: Scribe[IO] = scribe.cats.io
override def readConfig[T](using decoder: Decoder[T]): IO[T] =
IO.raiseError(new NotImplementedError("readConfig is not available in tests"))
override def module[T](key: Module.Key): T = modules.get(key)
override def adminServer: HttpServer = HttpServer.noop
override def adminControllers: List[Controller] = Nil
end for
end fromContainer

Expand Down
35 changes: 17 additions & 18 deletions modules/core/src/main/scala/pillars/AdminServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,31 @@
package pillars

import cats.effect.IO
import cats.effect.Resource.ExitCase
import cats.effect.Resource
import cats.syntax.all.*
import com.comcast.ip4s.*
import io.circe.Codec
import io.circe.derivation.Configuration
import pillars.AdminServer.Config
import scribe.cats.io.*
import sttp.tapir.*

final case class AdminServer(config: Config, infos: AppInfo, obs: Observability, controllers: List[Controller]):
def start(): IO[Unit] =
IO.whenA(config.enabled):
for
_ <- info(s"Starting admin server on ${config.http.host}:${config.http.port}")
_ <- HttpServer
.build("admin", config.http, config.openApi, infos, obs, controllers.flatten)
.onFinalizeCase:
case ExitCase.Errored(e) => error(s"Admin server stopped with error: $e")
case _ => info("Admin server stopped")
.useForever
yield ()
end for
end start
end AdminServer

object AdminServer:

def create(config: Config, infos: AppInfo, context: ModuleSupport.Context): Resource[IO, HttpServer] =
if config.enabled then
HttpServer(
HttpServer.Usage.Admin,
config.http,
config.openApi,
infos,
context.observability,
context.logger,
probes.livenessController
)
else
HttpServer.noop.pure[IO].toResource
end create

val baseEndpoint: Endpoint[Unit, Unit, HttpErrorResponse, Unit, Any] =
endpoint.in("admin").errorOut(PillarsError.View.output)

Expand Down
46 changes: 17 additions & 29 deletions modules/core/src/main/scala/pillars/ApiServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,33 @@
package pillars

import cats.effect.IO
import cats.effect.Resource.ExitCase
import cats.effect.Resource
import cats.syntax.all.*
import com.comcast.ip4s.*
import io.circe.Codec
import io.circe.derivation.Configuration
import io.github.iltotore.iron.*
import pillars.Controller.HttpEndpoint
import pillars.PillarsError.Code
import scala.annotation.targetName
import scribe.Scribe
import sttp.model.StatusCode

trait ApiServer:

def start(endpoints: List[HttpEndpoint]): IO[Unit]

@targetName("startWithEndpoints")
def start(endpoints: HttpEndpoint*): IO[Unit] = start(endpoints.toList)

@targetName("startWithControllers")
def start(controllers: Controller*): IO[Unit] = start(controllers.toList.flatten)

end ApiServer

def server(using p: Pillars): Run[ApiServer] = p.apiServer
def server(using p: Pillars): Run[HttpServer] = p.apiServer

object ApiServer:
def init(config: Config, infos: AppInfo, observability: Observability, logger: Scribe[IO]): ApiServer =
(endpoints: List[HttpEndpoint]) =>
IO.whenA(config.enabled):
for
_ <- logger.info(s"Starting API server on ${config.http.host}:${config.http.port}")
_ <- HttpServer.build("api", config.http, config.openApi, infos, observability, endpoints)
.onFinalizeCase:
case ExitCase.Errored(e) => logger.error(s"API server stopped with error: $e")
case _ => logger.info("API server stopped")
.useForever
yield ()
def create(config: Config, infos: AppInfo, context: ModuleSupport.Context): Resource[IO, HttpServer] =
if config.enabled then
HttpServer(
HttpServer.Usage.Api,
config.http,
config.openApi,
infos,
context.observability,
context.logger,
Nil
)
else
HttpServer.noop.pure[IO].toResource
end create

trait Error extends PillarsError:
override def status: StatusCode
final override def code: Code = Code("API")
Expand All @@ -59,5 +48,4 @@ object ApiServer:

private val defaultHttp = HttpServer.Config(host = host"0.0.0.0", port = port"9876", logging = Logging.HttpConfig())

def noop: ApiServer = _ => IO.unit
end ApiServer
2 changes: 1 addition & 1 deletion modules/core/src/main/scala/pillars/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ trait Config

object Config:
val defaultCirceConfig: Configuration =
Configuration.default.withSnakeCaseMemberNames.withSnakeCaseConstructorNames.withDefaults
Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
case class PillarsConfig(
name: App.Name,
log: Logging.Config = Logging.Config(),
Expand Down
58 changes: 51 additions & 7 deletions modules/core/src/main/scala/pillars/HttpServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package pillars

import cats.effect.IO
import cats.effect.Resource
import cats.effect.std.Hotswap
import com.comcast.ip4s.*
import io.circe.Codec
import io.circe.derivation.Configuration
Expand All @@ -24,6 +25,8 @@ import org.typelevel.otel4s.trace.Tracer
import pillars.Controller.HttpEndpoint
import pillars.codec.given
import pillars.syntax.all.*
import scala.concurrent.duration.*
import scribe.Scribe
import sttp.capabilities.StreamMaxLengthExceededException
import sttp.monad.MonadError
import sttp.tapir.*
Expand All @@ -37,8 +40,44 @@ import sttp.tapir.server.model.ValuedEndpointOutput
import sttp.tapir.swagger.SwaggerUIOptions
import sttp.tapir.swagger.bundle.SwaggerInterpreter

trait HttpServer:
def restartWith(endpoints: List[Controller]): IO[Unit]

object HttpServer:
def build(
enum Usage(val name: String):
case Admin extends Usage("admin")
case Api extends Usage("api")
case Custom(override val name: String) extends Usage(name)

def apply(
usage: Usage,
config: Config,
openApi: Config.OpenAPI,
infos: AppInfo,
observability: Observability,
logger: Scribe[IO],
initialEndpoints: List[HttpEndpoint]
): Resource[IO, HttpServer] =
for
_ <- logger.info(s"Creating ${usage.name} server on ${config.host}:${config.port}").toResource
_ <- logger.debug(s"${usage.name} server config: $config").toResource
_ <- logger.debug(s"App info: $infos").toResource
hotswap <- Hotswap.create[IO, Server]
yield new HttpServer:
override def restartWith(endpoints: List[Controller]): IO[Unit] =
for
_ <- logger.info(s"Stopping ${usage.name} server")
_ <- hotswap.clear
_ <- logger.debug(s"${usage.name} server stopped")
_ <- IO.sleep(100.millis)
_ <- hotswap.swap(build(usage.name, config, openApi, infos, observability, endpoints.flatten))
_ <- logger.info(s"${usage.name} server restarted")
Comment on lines +65 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the server can be initialized at startup the hotswap can be created with an initial value and using .clear + sleep won't nbe necessary

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I put the IO.sleep to test but it shouldn't be necessary, I remove it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I fear that the OS doesn't release the port fast enough

yield ()
end apply

val noop: HttpServer = (endpoints: List[Controller]) => IO.unit

private def build(
name: String,
config: Config,
openApi: Config.OpenAPI,
Expand Down Expand Up @@ -94,11 +133,15 @@ object HttpServer:

val app: HttpApp[IO] = routes |> logging |> errorHandling |> cors

NettyServerBuilder[IO].withoutSsl.withNioTransport
.bindHttp(config.port.value, config.host.toString)
.withHttpApp(app)
.withoutBanner
.resource
for
_ <- scribe.cats.io.info(s"Starting $name server on ${config.host}:${config.port}").toResource
server <- NettyServerBuilder[IO].withoutSsl.withNioTransport
.bindHttp(config.port.value, config.host.toString)
.withHttpApp(app)
.withoutBanner
.resource
yield server
end for
end build

private def exceptionHandler(tracer: Tracer[IO]): ExceptionHandler[IO] =
Expand Down Expand Up @@ -126,7 +169,8 @@ object HttpServer:
extends pillars.Config

object Config:
given Configuration = pillars.Config.defaultCirceConfig
given Configuration =
Configuration.default.withKebabCaseMemberNames.withKebabCaseMemberNames.withDefaults.withStrictDecoding
given Codec[Config.OpenAPI] = Codec.AsObject.derivedConfigured
given Codec[Config] = Codec.AsObject.derivedConfigured

Expand Down
39 changes: 28 additions & 11 deletions modules/core/src/main/scala/pillars/Pillars.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ trait Pillars:
/**
* The API server for the application.
*
* It has to be manually started by calling the `start` method in the application.
*/
def apiServer: ApiServer
def apiServer: HttpServer

/**
* The admin server for the application.
*
*/
def adminServer: HttpServer

/**
* The logger for the application.
Expand All @@ -61,6 +66,21 @@ trait Pillars:
* @return the module.
*/
def module[T](key: Module.Key): T

/**
* The admin controllers for the application.
*
* @return the admin controllers.
*/
def adminControllers: List[Controller]

/**
* The API controllers for the application.
*
* @return the API controllers.
*/
def apiControllers: List[Controller] = Nil

end Pillars

/**
Expand Down Expand Up @@ -88,23 +108,23 @@ object Pillars:
_ <- Resource.eval(Logging.init(_config.log))
_logger = scribe.cats.io
context = ModuleSupport.Context(obs, configReader, _logger)
_adminServer <- AdminServer.create(_config.admin, infos, context)
_ <- Resource.eval(_logger.info("Loading modules..."))
_modules <- loadModules(modules, context)
_ <- Resource.eval(_logger.debug(s"Loaded ${_modules.size} modules"))
probes <- ProbeManager.build(_modules, obs)
_ <- Spawn[IO].background(probes.start())
_ <- Spawn[IO].background:
AdminServer(_config.admin, infos, obs, _modules.adminControllers :+ probesController(probes))
.start()
_apiServer <- ApiServer.create(_config.api, infos, context)
yield new Pillars:
override def appInfo: AppInfo = infos
override def observability: Observability = obs
override def config: PillarsConfig = _config
override def apiServer: ApiServer =
ApiServer.init(config.api, infos, observability, logger)
override def apiServer: HttpServer = _apiServer
override def adminServer: HttpServer = _adminServer
override def logger: Scribe[IO] = _logger
override def readConfig[T](using Decoder[T]): IO[T] = configReader.read[T]
override def module[T](key: Module.Key): T = _modules.get(key)
override def adminControllers: List[Controller] = _modules.adminControllers :+ probesController(probes)
end for
end apply

Expand All @@ -116,10 +136,7 @@ object Pillars:
* @param context The context for loading the modules.
* @return a resource that will instantiate the modules.
*/
private def loadModules(
modules: Seq[ModuleSupport],
context: ModuleSupport.Context
): Resource[IO, Modules] =
private def loadModules(modules: Seq[ModuleSupport], context: ModuleSupport.Context): Resource[IO, Modules] =
scribe.info(s"Found ${modules.size} modules: ${modules.map(_.key).map(_.name).mkString(", ")}")
modules.topologicalSort(_.dependsOn) match
case Left(value) => throw value
Expand Down
14 changes: 11 additions & 3 deletions modules/core/src/main/scala/pillars/app.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import pillars.probes.Probe

abstract class App(val modules: ModuleSupport*):
def infos: AppInfo
def probes: List[Probe] = Nil
def adminControllers: List[Controller] = Nil
def probes: List[Probe] = Nil
def adminControllers: Run[List[Controller]] = Nil
def controllers: Run[List[Controller]] = Nil
def run: Run[IO[Unit]]

import pillars.given
Expand All @@ -28,7 +29,14 @@ abstract class App(val modules: ModuleSupport*):
Opts.option[Path]("config", "Path to the configuration file").map: configPath =>
Pillars(infos, modules, configPath).use: pillars =>
given Pillars = pillars
run.as(ExitCode.Success)

(for
_ <- run.toResource
_ <- Spawn[IO].background(
pillars.adminServer.restartWith(pillars.adminControllers ++ adminControllers)
)
_ <- Spawn[IO].background(pillars.apiServer.restartWith(controllers))
yield ()).useForever.as(ExitCode.Success)

command.parse(args, sys.env) match
case Left(help) => Console[IO].errorln(help).as(ExitCode.Error)
Expand Down
5 changes: 3 additions & 2 deletions modules/core/src/main/scala/pillars/probes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,16 @@ object probes:
given Codec[ProbeConfig] = Codec.AsObject.derivedConfigured
end ProbeConfig

val livenessController: Controller = List(liveness.serverLogicSuccess(_ => "OK".pure[IO]))

def probesController(manager: ProbeManager): Controller =
val alive = liveness.serverLogicSuccess(_ => "OK".pure[IO])
val ready = readiness.serverLogicSuccess: _ =>
manager.status.map: statuses =>
val checks = statuses.map: (component, status) =>
CheckStatus(component.name, component.`type`, status)
val globalStatus = statuses.values.foldLeft(Status.pass)(_ |+| _)
HealthStatus(globalStatus, checks.toList)
List(alive, ready)
livenessController :+ ready
end probesController

object endpoints:
Expand Down
4 changes: 2 additions & 2 deletions modules/example/src/main/resources/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ api:
body: true
level: info
open-api:
enabled: false
enabled: true
path-prefix: ["docs"]
yaml-name: "pillars-example.yaml"
context-path: []
Expand All @@ -66,7 +66,7 @@ admin:
body: true
level: debug
open-api:
enabled: false
enabled: true
Comment thread
rlemaitre marked this conversation as resolved.
path-prefix: ["docs"]
yaml-name: "pillars-example.yaml"
context-path: []
Expand Down
Loading