Skip to content

Commit

Permalink
feat(flags): #39 Add ability to modify feature flags at runtime (#63)
Browse files Browse the repository at this point in the history
  • Loading branch information
rlemaitre authored Feb 17, 2024
1 parent ba6a282 commit 12e9fbe
Show file tree
Hide file tree
Showing 15 changed files with 222 additions and 95 deletions.
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

0 comments on commit 12e9fbe

Please sign in to comment.