diff --git a/build.sbt b/build.sbt index b4875c8..52631d6 100644 --- a/build.sbt +++ b/build.sbt @@ -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" ) ) diff --git a/docker/flipt/features.yaml b/docker/flipt/features.yaml index df1177c..a8813b6 100644 --- a/docker/flipt/features.yaml +++ b/docker/flipt/features.yaml @@ -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: @@ -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 diff --git a/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala b/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala index c682b79..8d42c27 100644 --- a/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala +++ b/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala @@ -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 @@ -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( @@ -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)) + } + } + } } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala index eabfd7f..8832b9b 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala @@ -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 @@ -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] diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala index a81cbdf..7f6161c 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala @@ -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 @@ -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) @@ -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) diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala new file mode 100644 index 0000000..45feea0 --- /dev/null +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala @@ -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 + } + +}