diff --git a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientService.java b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientService.java index 78184c9..69d3030 100644 --- a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientService.java +++ b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientService.java @@ -14,6 +14,7 @@ import io.github.lavenderses.aws_app_config_openfeature_provider.evaluation_value.EvaluationValue; import io.github.lavenderses.aws_app_config_openfeature_provider.parser.BooleanAttributeParser; import io.github.lavenderses.aws_app_config_openfeature_provider.parser.ObjectAttributeParser; +import io.github.lavenderses.aws_app_config_openfeature_provider.parser.StringAttributeParser; import io.github.lavenderses.aws_app_config_openfeature_provider.utils.AwsAppConfigClientBuilder; import org.intellij.lang.annotations.Language; import org.jetbrains.annotations.NotNull; @@ -54,6 +55,9 @@ final class AwsAppConfigClientService { @NotNull private final BooleanAttributeParser booleanAttributeParser; + @NotNull + private final StringAttributeParser stringAttributeParser; + @NotNull private final ObjectAttributeParser objectAttributeParser; @@ -69,6 +73,7 @@ final class AwsAppConfigClientService { @NotNull final AwsAppConfigParser awsAppConfigParser, @NotNull final AppConfigValueConverter appConfigValueConverter, @NotNull final BooleanAttributeParser booleanAttributeParser, + @NotNull final StringAttributeParser stringAttributeParser, @NotNull final ObjectAttributeParser objectAttributeParser ) { this.client = requireNonNull(client, "AppConfigDataClient"); @@ -76,6 +81,7 @@ final class AwsAppConfigClientService { this.awsAppConfigParser = requireNonNull(awsAppConfigParser, "AwsAppConfigParse"); this.appConfigValueConverter = requireNonNull(appConfigValueConverter, "appConfigValueConverter"); this.booleanAttributeParser = requireNonNull(booleanAttributeParser, "booleanAttributeParser"); + this.stringAttributeParser = requireNonNull(stringAttributeParser, "stringAttributeParser"); this.objectAttributeParser = requireNonNull(objectAttributeParser, "objectAttributeParser"); } @@ -95,6 +101,7 @@ final class AwsAppConfigClientService { awsAppConfigParser = new AwsAppConfigParser(); appConfigValueConverter = new AppConfigValueConverter(); booleanAttributeParser = new BooleanAttributeParser(); + stringAttributeParser = new StringAttributeParser(); objectAttributeParser = new ObjectAttributeParser(); } @@ -129,8 +136,25 @@ EvaluationValue getBoolean( } @NotNull - EvaluationValue getString(@NotNull final String key) { - return null; + EvaluationValue getString( + @NotNull final String key, + @NotNull final String defaultValue + ) { + try { + return getInternal( + /* key = */ key, + /* defaultValue = */ defaultValue, + /* asPrimitive = */ true, + /* parseFromResponseBody = */ (@Language("json") final String responseBody) -> awsAppConfigParser.parse( + /* key = */ key, + /* value = */ responseBody, + /* buildAppConfigValue = */ stringAttributeParser + ) + ); + } catch (final AppConfigValueParseException e) { + log.error("Failed to parseFromResponseBody object from AWS AppConfig response. Fall back to default flag value", e); + return e.asErrorEvaluationResult(); + } } @NotNull diff --git a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigFeatureProvider.java b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigFeatureProvider.java index 87a9104..3d076b1 100644 --- a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigFeatureProvider.java +++ b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigFeatureProvider.java @@ -108,7 +108,10 @@ public ProviderEvaluation getStringEvaluation( requireNonNull(defaultValue, "defaultValue"); // Get boolean value from AppConfig by key - final EvaluationValue evaluationValue = awsAppConfigClientService.getString(key); + final EvaluationValue evaluationValue = awsAppConfigClientService.getString( + /* key = */ key, + /* defaultValue = */ defaultValue + ); return evaluationValue.providerEvaluation(); } diff --git a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValue.java b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValue.java new file mode 100644 index 0000000..7dea30b --- /dev/null +++ b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValue.java @@ -0,0 +1,31 @@ +package io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.NotNull; + +import static java.util.Objects.requireNonNull; + +/** + * String type in AWS AppConfig's Attribute.
+ * This feature flag will be mapped as string in OpenFeature requirements. + */ +@Getter +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +public final class AppConfigStringValue extends AppConfigValue { + + public AppConfigStringValue( + @NotNull Boolean enabled, + @NotNull String value, + @Language("json") @NotNull String jsonFormat + ) { + super( + /* enabled = */ requireNonNull(enabled, "enabled"), + /* value = */ requireNonNull(value, "value"), + /* jsonFormat = */ requireNonNull(jsonFormat, "jsonFormat") + ); + } +} diff --git a/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParser.java b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParser.java new file mode 100644 index 0000000..351522d --- /dev/null +++ b/src/main/java/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParser.java @@ -0,0 +1,37 @@ +package io.github.lavenderses.aws_app_config_openfeature_provider.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigBooleanValue; +import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigStringValue; +import org.jetbrains.annotations.NotNull; + +/** + * Parser implementation for boolean-type feature flag value. + */ +public final class StringAttributeParser extends AbstractAttributeParser { + + /** + * Extract "Attribute" as Boolean in JSON response from AWS AppConfig. + * + * @param keyNode a response JSON string from AWS AppConfig + * @return {@link AppConfigBooleanValue} if the response schema is valid + * @throws AppConfigValueParseException when {@param keyNode} is invalid schema + */ + @Override + public AppConfigStringValue apply( + @NotNull JsonNode responseNode, + @NotNull JsonNode keyNode + ) { + final JsonNode flagValueNode = getValidFlagValueNode( + /* keyNode = */ keyNode, + /* expectedNodeType = */ JsonNodeType.STRING + ); + + return new AppConfigStringValue( + /* enabled = */ enabled(keyNode), + /* value = */ flagValueNode.asText(), + /* responseNode = */ responseNode.toString() + ); + } +} diff --git a/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientServiceTest.kt b/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientServiceTest.kt index d0ca7e2..e45fcee 100644 --- a/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientServiceTest.kt +++ b/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/AwsAppConfigClientServiceTest.kt @@ -7,6 +7,7 @@ import dev.openfeature.sdk.Reason import dev.openfeature.sdk.Value import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigBooleanValue import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigObjectValue +import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigStringValue import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.fixture import io.github.lavenderses.aws_app_config_openfeature_provider.converter.AppConfigValueConverter import io.github.lavenderses.aws_app_config_openfeature_provider.evaluation_value.ErrorEvaluationValue @@ -17,6 +18,7 @@ import io.github.lavenderses.aws_app_config_openfeature_provider.parser.AppConfi import io.github.lavenderses.aws_app_config_openfeature_provider.parser.AwsAppConfigParser import io.github.lavenderses.aws_app_config_openfeature_provider.parser.BooleanAttributeParser import io.github.lavenderses.aws_app_config_openfeature_provider.parser.ObjectAttributeParser +import io.github.lavenderses.aws_app_config_openfeature_provider.parser.StringAttributeParser import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -59,6 +61,9 @@ class AwsAppConfigClientServiceTest { @Mock private lateinit var booleanAttributeParser: BooleanAttributeParser + @Mock + private lateinit var stringAttributeParser: StringAttributeParser + @Mock private lateinit var objectAttributeParser: ObjectAttributeParser @@ -121,6 +126,187 @@ class AwsAppConfigClientServiceTest { } } + @Nested + inner class GetString { + + @Test + fun normal() { + // prepare + val key = "key" + val defaultValue = "defaultValue" + val request = GetLatestConfigurationRequest.builder() + .configurationToken("token") + .build() + val configuration = mock { + on { asByteArray() } doReturn """{ "foo": "bar" }""".toByteArray() + on { asUtf8String() } doReturn """{ "foo": "bar" }""" + } + val response = mock { + on { configuration() } doReturn configuration + } + val flagValue = AppConfigStringValue::class.fixture() + val expected = PrimitiveEvaluationValue( + /* rawValue = */ "test", + /* reason = */ Reason.TARGETING_MATCH, + ) + + doReturn(response) + .whenever(client) + .getLatestConfiguration( + /* getLatestConfigurationRequest = */ request, + ) + doReturn(flagValue) + .whenever(awsAppConfigParser) + .parse( + /* key = */ key, + /* value = */ """{ "foo": "bar" }""", + /* buildAppConfigValue = */ stringAttributeParser, + ) + doReturn( + PrimitiveEvaluationValue( + /* rawValue = */ "test", + /* reason = */ Reason.TARGETING_MATCH, + ), + ) + .whenever(appConfigValueConfigValueConverter) + .toEvaluationValue( + /* defaultValue = */ defaultValue, + /* appConfigValue = */ flagValue, + /* asPrimitive = */ true, + ) + + // do & verify + assertThat( + awsAppConfigClientService.getString( + /* key = */ key, + /* defaultValue = */ defaultValue, + ), + ).isEqualTo(expected) + } + + @Test + fun `failed to request to AWS AppConfig`() { + // prepare + val key = "key" + val defaultValue = "defaultValue" + val request = GetLatestConfigurationRequest.builder() + .configurationToken("token") + .build() + val expected = ErrorEvaluationValue( + /* errorCode = */ ErrorCode.FLAG_NOT_FOUND, + /* errorMessage = */ null, + /* reason = */ Reason.DEFAULT, + ) + + doThrow(RuntimeException("error")) + .whenever(client) + .getLatestConfiguration( + /* getLatestConfigurationRequest = */ request, + ) + + // do + assertThat( + awsAppConfigClientService.getString( + /* key = */ key, + /* defaultValue = */ defaultValue, + ), + ).isEqualTo(expected) + + // verify + verifyNoInteractions(awsAppConfigParser, appConfigValueConfigValueConverter) + } + + @Test + fun `response body is null`() { + // prepare + val key = "key" + val defaultValue = "defaultValue" + val request = GetLatestConfigurationRequest.builder() + .configurationToken("token") + .build() + val configuration = mock { + on { asByteArray() } doReturn "".toByteArray() + } + val response = mock { + on { configuration() } doReturn configuration + } + val expected = ErrorEvaluationValue( + /* errorCode = */ ErrorCode.PARSE_ERROR, + /* errorMessage = */ null, + /* reason = */ Reason.ERROR, + ) + + doReturn(response) + .whenever(client) + .getLatestConfiguration( + /* getLatestConfigurationRequest = */ request, + ) + + // do + assertThat( + awsAppConfigClientService.getString( + /* key = */ key, + /* defaultValue = */ defaultValue, + ), + ).isEqualTo(expected) + + // verify + verifyNoInteractions(awsAppConfigParser, appConfigValueConfigValueConverter) + } + + @Test + fun `failed to call parse`() { + // prepare + val key = "key" + val defaultValue = "defaultValue" + val request = GetLatestConfigurationRequest.builder() + .configurationToken("token") + .build() + val configuration = mock { + on { asByteArray() } doReturn """{ "foo": "bar" }""".toByteArray() + on { asUtf8String() } doReturn """{ "foo": "bar" }""" + } + val response = mock { + on { configuration() } doReturn configuration + } + val expected = ErrorEvaluationValue( + /* errorCode = */ ErrorCode.PARSE_ERROR, + /* errorMessage = */ """errorMessage. Response from AWS AppConfig: { "foo": "bar" }""", + /* reason = */ Reason.ERROR, + ) + + doReturn(response) + .whenever(client) + .getLatestConfiguration( + /* getLatestConfigurationRequest = */ request, + ) + doThrow( + AppConfigValueParseException( + /* response = */ """{ "foo": "bar" }""", + /* errorMessage = */ "errorMessage", + /* evaluationResult = */ EvaluationResult.INVALID_ATTRIBUTE_FORMAT, + ), + ) + .whenever(awsAppConfigParser) + .parse( + /* key = */ key, + /* value = */ """{ "foo": "bar" }""", + /* buildAppConfigValue = */ stringAttributeParser, + ) + + // do + assertThat( + awsAppConfigClientService.getString( + /* key = */ key, + /* defaultValue = */ defaultValue, + ), + ).isEqualTo(expected) + + // verify + verifyNoInteractions(appConfigValueConfigValueConverter) + } + } + @Nested inner class GetObject { diff --git a/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParserTest.kt b/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParserTest.kt new file mode 100644 index 0000000..19f472e --- /dev/null +++ b/src/test/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/parser/StringAttributeParserTest.kt @@ -0,0 +1,134 @@ +package io.github.lavenderses.aws_app_config_openfeature_provider.parser + +import assertk.assertThat +import assertk.assertions.isEqualTo +import io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model.AppConfigStringValue +import io.github.lavenderses.aws_app_config_openfeature_provider.evaluation_value.EvaluationResult +import io.github.lavenderses.aws_app_config_openfeature_provider.utils.ObjectMapperBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class StringAttributeParserTest { + + companion object { + private val OBJECT_MAPPER = ObjectMapperBuilder.build() + } + + @InjectMocks + private lateinit var stringAttributeParser: StringAttributeParser + + @Test + fun normal() { + // prepare + val responseNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "key": { + "enabled": true, + "flag_value": "test" + } + } + """.trimIndent(), + ) + val keyNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "enabled": true, + "flag_value": "test" + } + """.trimIndent(), + ) + val expected = AppConfigStringValue( + /* enabled = */ true, + /* value = */ "test", + /* jsonFormat = */ """{"key":{"enable":true,"flag_value":"test"}}""", + ) + + // do & verify + assertThat( + stringAttributeParser.apply( + /* responseNode = */ responseNode, + /* keyNode = */ keyNode, + ), + ).isEqualTo(expected) + } + + @Test + fun `enable is false`() { + // prepare + // language=JSON + val responseNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "key": { + "enabled": false, + "flag_value": "test" + } + } + """.trimIndent(), + ) + val keyNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "enabled": false, + "flag_value": "test" + } + """.trimIndent(), + ) + val expected = AppConfigStringValue( + /* enabled = */ false, + /* value = */ "test", + /* jsonFormat = */ """{"key":{"enable":true,"flag_value":"test"}}""", + ) + + // do & verify + assertThat( + stringAttributeParser.apply( + /* responseNode = */ responseNode, + /* keyNode = */ keyNode, + ), + ).isEqualTo(expected) + } + + @Test + fun `flag_value is null`() { + // prepare + val responseNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "key": { + "enabled": true + } + } + """.trimIndent(), + ) + val keyNode = OBJECT_MAPPER.readTree( + // language=JSON + """ + { + "enabled": true + } + """.trimIndent(), + ) + + // do + val e = assertThrows { + stringAttributeParser.apply( + /* responseNode = */ responseNode, + /* keyNode = */ keyNode, + ) + } + + // verify + assertThat(e.evaluationResult).isEqualTo(EvaluationResult.INVALID_ATTRIBUTE_FORMAT) + } +} diff --git a/src/testFixtures/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValueFixtures.kt b/src/testFixtures/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValueFixtures.kt new file mode 100644 index 0000000..bc8582a --- /dev/null +++ b/src/testFixtures/kotlin/io/github/lavenderses/aws_app_config_openfeature_provider/app_config_model/AppConfigStringValueFixtures.kt @@ -0,0 +1,15 @@ +package io.github.lavenderses.aws_app_config_openfeature_provider.app_config_model + +import org.intellij.lang.annotations.Language +import kotlin.reflect.KClass + +fun KClass.fixture( + enabled: Boolean = true, + value: String = "text", + @Language("json") + jsonFormat: String = "{}", +) = AppConfigStringValue( + /* enabled = */ enabled, + /* value = */ value, + /* jsonFormat = */ jsonFormat, +)