Skip to content

Commit

Permalink
Add JSON structured variants (#9)
Browse files Browse the repository at this point in the history
- Add method for deserialisation variant attachments when a variant
match is received
  • Loading branch information
alexcardell authored May 9, 2024
1 parent 923d942 commit 0349f07
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 7 deletions.
2 changes: 2 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ lazy val `flipt-sdk-server` =
"org.http4s" %%% "http4s-client" % "0.23.26",
"org.http4s" %%% "http4s-ember-client" % "0.23.26",
"org.http4s" %%% "http4s-circe" % "0.23.26",
"io.circe" %%% "circe-core" % "0.14.7",
"io.circe" %%% "circe-parser" % "0.14.7",
"io.circe" %%% "circe-generic" % "0.14.7"
)
)
Expand Down
7 changes: 4 additions & 3 deletions docker/flipt/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ flags:
enabled: true
variants:
- key: key-1
attachment:
jsonKey: value
attachment:
field: string
intField: 33
rules:
- segment: default-ns-segment-1
distributions:
Expand All @@ -25,5 +26,5 @@ segments:
- type: STRING_COMPARISON_TYPE
property: test-property
operator: eq
value: test-property-value
value: matched-property-value
match_type: ALL_MATCH_TYPE
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import com.dimafeng.testcontainers.DockerComposeContainer
import com.dimafeng.testcontainers.ExposedService
import com.dimafeng.testcontainers.munit.TestContainerForAll
import io.cardell.ff4s.flipt.auth.AuthenticationStrategy
import io.circe.Decoder
import io.circe.generic.semiauto.deriveDecoder
import munit.CatsEffectSuite
import org.http4s.Uri
import org.http4s.ember.client.EmberClientBuilder
Expand Down Expand Up @@ -81,7 +83,7 @@ class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll {
test("receives variant match when in segment rule") {
withContainers { containers =>
api(containers).use { flipt =>
val segmentContext = Map("test-property" -> "test-property-value")
val segmentContext = Map("test-property" -> "matched-property-value")
for {
res <- flipt.evaluateVariant(
EvaluationRequest(
Expand Down Expand Up @@ -115,4 +117,53 @@ class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll {
}
}
}

case class TestVariant(field: String, intField: Int)
object TestVariant {
implicit val decoder: Decoder[TestVariant] = deriveDecoder
}

test("can deserialise variant match") {
withContainers { containers =>
api(containers).use { flipt =>
val segmentContext = Map("test-property" -> "matched-property-value")

for {
res <- flipt.evaluateStructuredVariant[TestVariant](
EvaluationRequest(
"default",
"variant-flag-1",
None,
segmentContext,
None
)
)
_ <- IO.println(res)
result = res.map(_.variantAttachment)
} yield assertEquals(result, Right(Some(TestVariant("string", 33))))
}
}
}

test("does not attempt variant deserialisation without a match") {
withContainers { containers =>
api(containers).use { flipt =>
val segmentContext = Map("test-property" -> "unmatched-property-value")

for {
res <- flipt.evaluateStructuredVariant[TestVariant](
EvaluationRequest(
"default",
"variant-flag-1",
None,
segmentContext,
None
)
)
_ <- IO.println(res)
result = res.map(_.variantAttachment)
} yield assertEquals(result, Right(None))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ package io.cardell.ff4s.flipt
import cats.effect.Concurrent
import io.cardell.ff4s.flipt.auth.AuthMiddleware
import io.cardell.ff4s.flipt.auth.AuthenticationStrategy
import io.cardell.ff4s.flipt.model.AttachmentDecodingError
import io.cardell.ff4s.flipt.model.BatchEvaluationRequest
import io.cardell.ff4s.flipt.model.BatchEvaluationResponse
import io.cardell.ff4s.flipt.model.BooleanEvaluationResponse
import io.cardell.ff4s.flipt.model.StructuredVariantEvaluationResponse
import io.cardell.ff4s.flipt.model.VariantEvaluationResponse
import io.circe.Decoder
import org.http4s.Uri
import org.http4s.client.Client

Expand All @@ -33,6 +36,15 @@ trait FliptApi[F[_]] {
def evaluateVariant(
request: EvaluationRequest
): F[VariantEvaluationResponse]

/** If a variant matches, attempt to deserialise a variant attachment
*
* This method assumes all variant attachments match the JSON model of the
* type parameter
*/
def evaluateStructuredVariant[A: Decoder](
request: EvaluationRequest
): F[Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]]]
def evaluateBatch(
request: BatchEvaluationRequest
): F[BatchEvaluationResponse]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@
package io.cardell.ff4s.flipt

import cats.effect.Concurrent
import cats.syntax.all.*
import io.cardell.ff4s.flipt.model.AttachmentDecodingError
import io.cardell.ff4s.flipt.model.BatchEvaluationRequest
import io.cardell.ff4s.flipt.model.BatchEvaluationResponse
import io.cardell.ff4s.flipt.model.BooleanEvaluationResponse
import io.cardell.ff4s.flipt.model.StructuredVariantEvaluationResponse
import io.cardell.ff4s.flipt.model.VariantEvaluationResponse
import io.circe.Decoder
import org.http4s.Method
import org.http4s.Request
import org.http4s.Uri
Expand All @@ -32,12 +36,14 @@ protected[flipt] class FliptApiImpl[F[_]: Concurrent](
baseUri: Uri
) extends FliptApi[F] {

private val evalUri = baseUri / "evaluate" / "v1"

override def evaluateBoolean(
request: EvaluationRequest
): F[BooleanEvaluationResponse] = {
val req = Request[F](
method = Method.POST,
uri = baseUri / "evaluate" / "v1" / "boolean"
uri = evalUri / "boolean"
).withEntity(request)

client.expect[BooleanEvaluationResponse](req)
Expand All @@ -48,18 +54,26 @@ protected[flipt] class FliptApiImpl[F[_]: Concurrent](
): F[VariantEvaluationResponse] = {
val req = Request[F](
method = Method.POST,
uri = baseUri / "evaluate" / "v1" / "variant"
uri = evalUri / "variant"
).withEntity(request)

client.expect[VariantEvaluationResponse](req)
}

override def evaluateStructuredVariant[A: Decoder](
request: EvaluationRequest
): F[
Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]]
] = {
evaluateVariant(request).map(StructuredVariantEvaluationResponse[A](_))
}

override def evaluateBatch(
request: BatchEvaluationRequest
): F[BatchEvaluationResponse] = {
val req = Request[F](
method = Method.POST,
uri = baseUri / "evaluate" / "v1" / "batch"
uri = evalUri / "batch"
).withEntity(request)

client.expect[BatchEvaluationResponse](req)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2023 Alex Cardell
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.cardell.ff4s.flipt.model

import cats.syntax.all.*
import io.circe.Decoder
import io.circe.parser

sealed trait AttachmentDecodingError
object AttachmentDecodingError {
case object AttachmentJsonParsingError extends AttachmentDecodingError
case object AttachmentDeserialisationError extends AttachmentDecodingError
}

case class StructuredVariantEvaluationResponse[A](
`match`: Boolean,
segmentKeys: List[String],
reason: EvaluationReason,
flagKey: String,
variantKey: String,
variantAttachment: Option[A],
requestDurationMillis: Float,
timestamp: String
)

object StructuredVariantEvaluationResponse {
def apply[A: Decoder](
variant: VariantEvaluationResponse
): Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]] = {
val maybeAttachment = if (variant.`match`) {
decodeJsonAttachment(variant.variantAttachment).map(Some(_))
} else {
Option.empty[A].asRight[AttachmentDecodingError]
}

maybeAttachment.map { attachment =>
StructuredVariantEvaluationResponse[A](
`match` = variant.`match`,
segmentKeys = variant.segmentKeys,
reason = variant.reason,
flagKey = variant.flagKey,
variantKey = variant.variantKey,
variantAttachment = attachment,
requestDurationMillis = variant.requestDurationMillis,
timestamp = variant.timestamp
)
}
}

private def decodeJsonAttachment[A: Decoder](
string: String
): Either[AttachmentDecodingError, A] = {
import AttachmentDecodingError.*
for {
json <- parser
.parse(string)
.leftMap(_ => AttachmentJsonParsingError)
attachment <- json
.as[A]
.leftMap(_ => AttachmentDeserialisationError)
} yield attachment
}

}

0 comments on commit 0349f07

Please sign in to comment.