From d2709c4b61c94782bd485656c3a2ffe4e0ab99a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Lemaitre?= Date: Fri, 4 Apr 2025 16:13:30 +0200 Subject: [PATCH] fix!: expose AdminServer controller from application context This change ensures that the AdminServer controller is properly exposed from the application context, fixing the configuration setup needed for the admin endpoints to be accessible. BREAKING CHANGE: Changes how API controllers are defined and started. Controllers are now defined in the `controllers` field on `App` instead of being manually started in `Pillars.run`. The API server is now automatically started. Fixes #157 --- .../src/main/scala/pillars/tests/suite.scala | 6 +- .../src/main/scala/pillars/AdminServer.scala | 35 ++++++----- .../src/main/scala/pillars/ApiServer.scala | 46 ++++++--------- .../core/src/main/scala/pillars/Config.scala | 2 +- .../src/main/scala/pillars/HttpServer.scala | 58 ++++++++++++++++--- .../core/src/main/scala/pillars/Pillars.scala | 39 +++++++++---- modules/core/src/main/scala/pillars/app.scala | 14 ++++- .../core/src/main/scala/pillars/probes.scala | 5 +- .../example/src/main/resources/config.yaml | 4 +- .../example/src/main/scala/example/app.scala | 3 +- .../pillars/rabbitmq/fs2/RabbitMQTests.scala | 2 + 11 files changed, 139 insertions(+), 75 deletions(-) diff --git a/modules/core-tests/src/main/scala/pillars/tests/suite.scala b/modules/core-tests/src/main/scala/pillars/tests/suite.scala index f027b07e1..497a78bb7 100644 --- a/modules/core-tests/src/main/scala/pillars/tests/suite.scala +++ b/modules/core-tests/src/main/scala/pillars/tests/suite.scala @@ -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 @@ -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 diff --git a/modules/core/src/main/scala/pillars/AdminServer.scala b/modules/core/src/main/scala/pillars/AdminServer.scala index 4c6024723..249dc4a98 100644 --- a/modules/core/src/main/scala/pillars/AdminServer.scala +++ b/modules/core/src/main/scala/pillars/AdminServer.scala @@ -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) diff --git a/modules/core/src/main/scala/pillars/ApiServer.scala b/modules/core/src/main/scala/pillars/ApiServer.scala index 1c4e37d91..3317d4d6c 100644 --- a/modules/core/src/main/scala/pillars/ApiServer.scala +++ b/modules/core/src/main/scala/pillars/ApiServer.scala @@ -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") @@ -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 diff --git a/modules/core/src/main/scala/pillars/Config.scala b/modules/core/src/main/scala/pillars/Config.scala index bf1be3aa3..dc7019b1c 100644 --- a/modules/core/src/main/scala/pillars/Config.scala +++ b/modules/core/src/main/scala/pillars/Config.scala @@ -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(), diff --git a/modules/core/src/main/scala/pillars/HttpServer.scala b/modules/core/src/main/scala/pillars/HttpServer.scala index e9dade632..58a11dc02 100644 --- a/modules/core/src/main/scala/pillars/HttpServer.scala +++ b/modules/core/src/main/scala/pillars/HttpServer.scala @@ -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 @@ -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.* @@ -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") + yield () + end apply + + val noop: HttpServer = (endpoints: List[Controller]) => IO.unit + + private def build( name: String, config: Config, openApi: Config.OpenAPI, @@ -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] = @@ -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 diff --git a/modules/core/src/main/scala/pillars/Pillars.scala b/modules/core/src/main/scala/pillars/Pillars.scala index 63c2299da..a2ddb3f05 100644 --- a/modules/core/src/main/scala/pillars/Pillars.scala +++ b/modules/core/src/main/scala/pillars/Pillars.scala @@ -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. @@ -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 /** @@ -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 @@ -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 diff --git a/modules/core/src/main/scala/pillars/app.scala b/modules/core/src/main/scala/pillars/app.scala index 6ff25227b..af3caecc3 100644 --- a/modules/core/src/main/scala/pillars/app.scala +++ b/modules/core/src/main/scala/pillars/app.scala @@ -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 @@ -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) diff --git a/modules/core/src/main/scala/pillars/probes.scala b/modules/core/src/main/scala/pillars/probes.scala index e9acacd1d..584d786c9 100644 --- a/modules/core/src/main/scala/pillars/probes.scala +++ b/modules/core/src/main/scala/pillars/probes.scala @@ -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: diff --git a/modules/example/src/main/resources/config.yaml b/modules/example/src/main/resources/config.yaml index 5bca9fe6f..90badc123 100644 --- a/modules/example/src/main/resources/config.yaml +++ b/modules/example/src/main/resources/config.yaml @@ -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: [] @@ -66,7 +66,7 @@ admin: body: true level: debug open-api: - enabled: false + enabled: true path-prefix: ["docs"] yaml-name: "pillars-example.yaml" context-path: [] diff --git a/modules/example/src/main/scala/example/app.scala b/modules/example/src/main/scala/example/app.scala index d07ec7db2..ec8568da7 100644 --- a/modules/example/src/main/scala/example/app.scala +++ b/modules/example/src/main/scala/example/app.scala @@ -36,9 +36,10 @@ object app extends pillars.IOApp(DB, DBMigration, FeatureFlags, HttpClient): // size <- response.body.compile.count _ <- logger.info(s"Body: $size bytes") yield () - _ <- server.start(homeController, userController) // // <5> yield () end for end run + + override def controllers: Run[List[Controller]] = List(homeController, userController) end app // end::quick-start[] diff --git a/modules/rabbitmq-fs2/src/test/scala/pillars/rabbitmq/fs2/RabbitMQTests.scala b/modules/rabbitmq-fs2/src/test/scala/pillars/rabbitmq/fs2/RabbitMQTests.scala index 45269d37c..43a9cc501 100644 --- a/modules/rabbitmq-fs2/src/test/scala/pillars/rabbitmq/fs2/RabbitMQTests.scala +++ b/modules/rabbitmq-fs2/src/test/scala/pillars/rabbitmq/fs2/RabbitMQTests.scala @@ -39,6 +39,8 @@ class RabbitMQTests extends CatsEffectSuite, TestContainerForEach: def logger = scribe.cats.io def readConfig[T](using Decoder[T]) = ??? def module[T](key: Module.Key): T = ??? + def adminServer = ??? + def adminControllers = ??? given Tracer[IO] = Tracer.noop[IO]