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

feat(flags): #39 Add ability to modify feature flags at runtime #63

Merged
merged 3 commits into from
Feb 17, 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
66 changes: 62 additions & 4 deletions modules/docs/src/docs/user-guide/30_modules/30_flags.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,42 @@ yield ()
=== Endpoints

Feature flags are exposed on the xref:../20_features/60_admin-server.adoc[admin server].
The defined endpoints are:

* `GET /flags` - Get all feature flags.
* `GET /flags/+{name}+` - Get a specific feature flag.
==== Get all feature flags

Feature flags are returned in the following format:
The `GET /admin/flags` endpoint returns all feature flags.

[source,shell]
----
curl -X GET http://localhost:19876/admin/flags
----

The response is a JSON array of feature flags.

[source,json]
--
[
{
"name": "feature-1",
"status": "enabled"
},
{
"name": "feature-2",
"status": "disabled"
}
]
--

==== Get a specific feature flag

The `GET /admin/flags/+{name}+` endpoint returns a specific feature flag.

[source,shell]
----
curl -X GET http://localhost:19876/admin/flags/feature-1
----

The response is a JSON object with the name and status of the feature flag.

[source,json]
--
Expand All @@ -84,3 +114,31 @@ Feature flags are returned in the following format:
"status": "enabled"
}
--

==== Update a specific feature flag

The `PUT /admin/flags/+{name}+` endpoint updates a specific feature flag.

[source,shell]
----
curl -X PUT -H "Content-Type: application/json" -d '{"status": "disabled"}' http://localhost:19876/admin/flags/feature-1
----

The request body should be a JSON object with the new status of the feature flag.

[source,json]
--
{
"status": "disabled"
}
--

The response is a JSON object with the name and status of the feature flag.

[source,json]
--
{
"name": "feature-1",
"status": "disabled"
}
--
4 changes: 2 additions & 2 deletions modules/example/src/main/rest/admin.http
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ GET {{ host }}/admin/flags/
GET {{ host }}/admin/flags/{{featureId}}

###
#
# Liveness probe

GET {{ host }}/admin/probes/healthz

###
#
# Readiness probe

GET {{ host }}/admin/probes/health
8 changes: 6 additions & 2 deletions modules/example/src/main/scala/example/app.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ object app extends pillars.EntryPoint: // // <1>
_ <- Logger[IO].info(s"The current date is $date.")
yield ()
_ <- HttpClient[IO].get("https://pillars.rlemaitre.com"): response =>
Logger[IO].info(s"Response: ${response.status}")
_ <- ApiServer[IO].start(endpoints.all)
for
_ <- Logger[IO].info(s"Response: ${response.status}")
size <- response.body.compile.count
_ <- Logger[IO].info(s"Body: $size bytes")
yield ()
_ <- ApiServer[IO].start(TodoController().endpoints)
yield ()
end for
end run
Expand Down
7 changes: 5 additions & 2 deletions modules/example/src/main/scala/example/endpoints.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package example

import cats.effect.IO
import cats.syntax.all.*
import pillars.Controller
import pillars.Controller.HttpEndpoint
import sttp.tapir.*

object endpoints:
def all: List[HttpEndpoint[IO]] = endpoint.get.out(stringBody).serverLogicSuccess(_ => "OK".pure[IO]) :: Nil
final case class TodoController() extends Controller[IO]:
def list: HttpEndpoint[IO] = endpoint.get.out(stringBody).serverLogicSuccess(_ => "OK".pure[IO])
val endpoints = List(list)
end TodoController
23 changes: 23 additions & 0 deletions modules/flags/src/main/rest/flags.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@host = http://localhost:19876

###
# @name: List all flags
#
GET {{ host }}/admin/flags/

###
# @name: Get flag by id

@featureId = feature-2

GET {{ host }}/admin/flags/{{featureId}}

###
# @name: Update flag by id

PUT {{ host }}/admin/flags/{{featureId}}
Content-Type: application/json

{
"status": "enabled"
}
21 changes: 0 additions & 21 deletions modules/flags/src/main/scala/pillars/flags/FeatureFlag.scala

This file was deleted.

32 changes: 27 additions & 5 deletions modules/flags/src/main/scala/pillars/flags/FlagController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,38 @@ package pillars.flags
import cats.Functor
import cats.syntax.all.*
import io.github.iltotore.iron.*
import pillars.AdminServer.baseEndpoint
import pillars.Controller
import pillars.Controller.HttpEndpoint
import pillars.PillarsError
import pillars.PillarsError.Code
import pillars.PillarsError.ErrorNumber
import pillars.PillarsError.Message
import pillars.flags.FlagController.FlagEndpoints
import pillars.flags.FlagController.FlagError
import pillars.flags.endpoints.*
import sttp.model.StatusCode
import sttp.tapir.*
import sttp.tapir.codec.iron.given
import sttp.tapir.json.circe.jsonBody

final case class FlagController[F[_]: Functor](manager: FlagManager[F]) extends Controller[F]:
private val listAll = list.serverLogicSuccess(_ => manager.flags)
private val listAll = FlagEndpoints.list.serverLogicSuccess(_ => manager.flags)
private val getOne =
get.serverLogic: name =>
FlagEndpoints.get.serverLogic: name =>
manager
.getFlag(name)
.map:
case Some(flag) => Right(flag)
case None => FlagError.FlagNotFound(name).view
private val modify =
FlagEndpoints.edit.serverLogic: (name, flag) =>
manager
.setStatus(name, flag.status)
.map:
case Some(flag) => Right(flag)
case None => FlagError.FlagNotFound(name).view

override def endpoints: List[HttpEndpoint[F]] = List(listAll, getOne)
override def endpoints: List[HttpEndpoint[F]] = List(listAll, getOne, modify)
end FlagController

object FlagController:
Expand All @@ -34,7 +45,18 @@ object FlagController:
) extends PillarsError:
override def code: Code = Code("FLAG")

case FlagNotFound(name: FeatureFlag.Name)
case FlagNotFound(name: Flag)
extends FlagError(ErrorNumber(1), StatusCode.NotFound, Message(s"Flag ${name}not found".assume))
end FlagError

object FlagEndpoints:
private val prefix = baseEndpoint.in("flags")

def list = prefix.get.out(jsonBody[List[FeatureFlag]])

def get = prefix.get.in(path[Flag]("name")).out(jsonBody[FeatureFlag])

def edit = prefix.put.in(path[Flag]("name")).in(jsonBody[FlagDetails]).out(jsonBody[FeatureFlag])
end FlagEndpoints

end FlagController
40 changes: 23 additions & 17 deletions modules/flags/src/main/scala/pillars/flags/FlagManager.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package pillars.flags

import FeatureFlag.Name
import cats.effect.Async
import cats.effect.Ref
import cats.effect.Resource
Expand All @@ -16,32 +15,32 @@ import pillars.Modules
import pillars.Pillars

trait FlagManager[F[_]: Sync] extends Module[F]:
def isEnabled(flag: FeatureFlag.Name): F[Boolean]
def getFlag(name: FeatureFlag.Name): F[Option[FeatureFlag]]
def isEnabled(flag: Flag): F[Boolean]
def getFlag(name: Flag): F[Option[FeatureFlag]]
def flags: F[List[FeatureFlag]]
override def key: Module.Key =
FlagManager.Key

def when[A](flag: FeatureFlag.Name)(thunk: => F[A]): F[Unit] =
private[flags] def setStatus(flag: Flag, status: Status): F[Option[FeatureFlag]]
def when[A](flag: Flag)(thunk: => F[A]): F[Unit] =
isEnabled(flag).flatMap:
case true => thunk.void
case false => Sync[F].unit

extension (pillars: Pillars[F])
def flags: FlagManager[F] = this

def when(flag: FeatureFlag.Name)(thunk: => F[Unit]): F[Unit] = this.when(flag)(thunk)
def flags: FlagManager[F] = this
def when(flag: Flag)(thunk: => F[Unit]): F[Unit] = this.when(flag)(thunk)
end extension
end FlagManager

object FlagManager:
case object Key extends Module.Key
def noop[F[_]: Sync]: FlagManager[F] =
new FlagManager[F]:
override def isEnabled(flag: Name): F[Boolean] = false.pure[F]
override def getFlag(name: FeatureFlag.Name): F[Option[FeatureFlag]] = None.pure[F]
override def flags: F[List[FeatureFlag]] = List.empty.pure[F]

def isEnabled(flag: Flag): F[Boolean] = false.pure[F]
def getFlag(name: Flag): F[Option[FeatureFlag]] = None.pure[F]
def flags: F[List[FeatureFlag]] = List.empty.pure[F]
private[flags] def setStatus(flag: Flag, status: Status) = None.pure[F]
end FlagManager

class FlagManagerLoader extends Loader:
Expand All @@ -56,29 +55,36 @@ class FlagManagerLoader extends Loader:
Resource.eval:
for
_ <- logger.info("Loading Feature flags module")
config <- configReader.read[FeatureFlagsConfig](name)
config <- configReader.read[FlagsConfig](name)
manager <- createManager(config)
_ <- logger.info("Feature flags module loaded")
yield manager
end load

private[flags] def createManager[F[_]: Async: Network: Tracer: Console](config: FeatureFlagsConfig)
: F[FlagManager[F]] =
private[flags] def createManager[F[_]: Async: Network: Tracer: Console](config: FlagsConfig): F[FlagManager[F]] =
if !config.enabled then Sync[F].pure(FlagManager.noop[F])
else
val flags = config.flags.groupBy(_.name).map((name, flags) => name -> flags.head)
Ref
.of[F, Map[Name, FeatureFlag]](flags)
.of[F, Map[Flag, FeatureFlag]](flags)
.map: ref =>
new FlagManager[F]:
def flags: F[List[FeatureFlag]] = ref.get.map(_.values.toList)

def getFlag(name: Name): F[Option[FeatureFlag]] =
def getFlag(name: Flag): F[Option[FeatureFlag]] =
ref.get.map(_.get(name))

def isEnabled(flag: Name): F[Boolean] =
def isEnabled(flag: Flag): F[Boolean] =
ref.get.map(_.get(flag).exists(_.isEnabled))

private[flags] def setStatus(flag: Flag, status: Status) =
ref
.updateAndGet: flags =>
flags.updatedWith(flag):
case Some(f) => Some(f.copy(status = status))
case None => None
.map(_.get(flag))

override def adminControllers: List[Controller[F]] = FlagController(this).pure[List]
end if
end createManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package pillars.flags

import io.circe.Codec

final case class FeatureFlagsConfig(
final case class FlagsConfig(
enabled: Boolean = true,
flags: List[FeatureFlag] = List.empty
) derives Codec.AsObject
12 changes: 0 additions & 12 deletions modules/flags/src/main/scala/pillars/flags/endpoints.scala

This file was deleted.

20 changes: 20 additions & 0 deletions modules/flags/src/main/scala/pillars/flags/model.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package pillars.flags

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

final case class FeatureFlag(name: Flag, status: Status):
def isEnabled: Boolean = status.isEnabled

private type FlagConstraint = Not[Blank] DescribedAs "Name must not be blank"
opaque type Flag <: String = String :| FlagConstraint

object Flag extends RefinedTypeOps[String, FlagConstraint, Flag]

enum Status:
case Enabled, Disabled

def isEnabled: Boolean = this match
case Enabled => true
case Disabled => false
end Status
Loading
Loading