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]