From 4ddb301710b9f5d1facef544345081feb61545f6 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Mon, 20 May 2024 16:21:17 +0200 Subject: [PATCH] feat: Native Flag Provider APIs --- pom.xml | 6 ++ .../com/spotify/confidence/Confidence.java | 99 ++++++++++++++++++- .../confidence/ConfidenceFeatureProvider.java | 76 +------------- .../com/spotify/confidence/ErrorType.java | 8 ++ .../spotify/confidence/FlagEvaluation.java | 50 ++++++++++ .../java/com/spotify/confidence/SdkUtils.java | 78 +++++++++++++++ 6 files changed, 244 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/spotify/confidence/ErrorType.java create mode 100644 src/main/java/com/spotify/confidence/FlagEvaluation.java diff --git a/pom.xml b/pom.xml index 676091bd..2c43eddf 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,12 @@ + + org.projectlombok + lombok + 1.18.30 + provided + io.grpc grpc-netty-shaded diff --git a/src/main/java/com/spotify/confidence/Confidence.java b/src/main/java/com/spotify/confidence/Confidence.java index 6e66e5b3..e559473e 100644 --- a/src/main/java/com/spotify/confidence/Confidence.java +++ b/src/main/java/com/spotify/confidence/Confidence.java @@ -1,11 +1,15 @@ package com.spotify.confidence; +import static com.spotify.confidence.SdkUtils.getValueForPath; + import com.google.common.annotations.Beta; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.common.io.Closer; +import com.spotify.confidence.SdkUtils.FlagPath; import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; +import com.spotify.confidence.shaded.flags.resolver.v1.ResolvedFlag; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import java.io.Closeable; @@ -16,17 +20,19 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Collector; import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import org.slf4j.Logger; @Beta public abstract class Confidence implements EventSender, Closeable { - private static final int FLUSH_TIMEOUT_MILLISECONDS = 500; protected Map context = Maps.newHashMap(); + private static final Logger log = org.slf4j.LoggerFactory.getLogger(Confidence.class); private Confidence() {} @@ -98,6 +104,97 @@ public void send(String eventName, ConfidenceValue.Struct message) { } } + public T getValue(String key, T defaultValue) { + return getEvaluation(key, defaultValue).getValue(); + } + + public FlagEvaluation getEvaluation(String key, T defaultValue) { + try { + final FlagPath flagPath = SdkUtils.getPath(key); + final String requestFlagName = "flags/" + flagPath.getFlag(); + final ResolveFlagsResponse response = resolveFlags(requestFlagName).get(); + if (response.getResolvedFlagsList().isEmpty()) { + final String errorMessage = String.format("No active flag '%s' was found", flagPath.getFlag()); + log.warn(errorMessage); + return new FlagEvaluation<>( + defaultValue, + "", + "ERROR", + ErrorType.FLAG_NOT_FOUND, + errorMessage); + } + + final String responseFlagName = response.getResolvedFlags(0).getFlag(); + if (!requestFlagName.equals(responseFlagName)) { + final String errorMessage = String.format("Unexpected flag '%s' from remote", + responseFlagName.replaceFirst("^flags/", "")); + log.warn(errorMessage); + return new FlagEvaluation<>( + defaultValue, + "", + "ERROR", + ErrorType.INTERNAL_ERROR, + errorMessage); + } + final ResolvedFlag resolvedFlag = response.getResolvedFlags(0); + if (resolvedFlag.getVariant().isEmpty()) { + final String errorMessage = String.format( + "The server returned no assignment for the flag '%s'. Typically, this happens " + + "if no configured rules matches the given evaluation context.", + flagPath.getFlag()); + log.debug(errorMessage); + return new FlagEvaluation<>( + defaultValue, + "", + resolvedFlag.getReason().toString()); + } else { + // TODO Convert proto to Confidence directly + final ConfidenceValue confidenceValue = + ConfidenceValue.fromProto( + TypeMapper.from( + getValueForPath( + flagPath.getPath(), + TypeMapper.from(resolvedFlag.getValue(), resolvedFlag.getFlagSchema())))); + + // regular resolve was successful + return new FlagEvaluation<>( + getTyped(confidenceValue, defaultValue), + resolvedFlag.getVariant(), + resolvedFlag.getReason().toString()); + } + } catch (InterruptedException | ExecutionException e) { + return new FlagEvaluation<>( + defaultValue, + "", + "ERROR", + ErrorType.NETWORK_ERROR, + "Error while fetching data from backend"); + } + } + + private T getTyped(ConfidenceValue value, T defaultValue) { + if (value.isString()) { + return (T) value.asString(); + } else if (value.isDouble()) { + return (T) Double.valueOf(value.asDouble()); + } else if (value.isInteger()) { + return (T) Integer.valueOf(value.asInteger()); + } else if (value.isBoolean()) { + return (T) Boolean.valueOf(value.asBoolean()); + } else if (value.isDate()) { + return (T) value.asLocalDate(); + } else if (value.isTimestamp()) { + return (T) value.asInstant(); + } else if (value.isStruct()) { + return (T) value.asStruct(); + } else if (value.isList()) { + return (T) value.asList(); + } else { + // Empty value from backend signals to use client defaults + return defaultValue; + } + } + CompletableFuture resolveFlags(String flagName) { return client().resolveFlags(flagName, getContext()); } diff --git a/src/main/java/com/spotify/confidence/ConfidenceFeatureProvider.java b/src/main/java/com/spotify/confidence/ConfidenceFeatureProvider.java index 4bdb41bf..f0a5ee66 100644 --- a/src/main/java/com/spotify/confidence/ConfidenceFeatureProvider.java +++ b/src/main/java/com/spotify/confidence/ConfidenceFeatureProvider.java @@ -1,13 +1,16 @@ package com.spotify.confidence; +import static com.spotify.confidence.SdkUtils.getPath; +import static com.spotify.confidence.SdkUtils.getValueForPath; + import com.google.protobuf.Struct; +import com.spotify.confidence.SdkUtils.FlagPath; import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; import com.spotify.confidence.shaded.flags.resolver.v1.ResolvedFlag; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; @@ -16,12 +19,9 @@ import io.grpc.ManagedChannelBuilder; import io.grpc.Status.Code; import io.grpc.StatusRuntimeException; -import java.util.Arrays; -import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.function.Function; -import java.util.regex.Pattern; import org.slf4j.Logger; /** OpenFeature Provider for feature flagging with the Confidence platform */ @@ -240,72 +240,4 @@ private static void handleStatusRuntimeException(StatusRuntimeException e) { e.getMessage())); } } - - private static Value getValueForPath(List path, Value fullValue) { - Value value = fullValue; - for (String fieldName : path) { - final Structure structure = value.asStructure(); - if (structure == null) { - // value's inner object actually is no structure - log.warn( - "Illegal attempt to derive field '{}' on non-structure value '{}'", fieldName, value); - throw new TypeMismatchError( - String.format( - "Illegal attempt to derive field '%s' on non-structure value '%s'", - fieldName, value)); - } - - value = structure.getValue(fieldName); - - if (value == null) { - // we know that null indicates absence of a proper value because intended nulls would be an - // instance of type Value - log.warn( - "Illegal attempt to derive non-existing field '{}' on structure value '{}'", - fieldName, - structure); - throw new TypeMismatchError( - String.format( - "Illegal attempt to derive non-existing field '%s' on structure value '%s'", - fieldName, structure)); - } - } - - return value; - } - - private static FlagPath getPath(String str) { - final String regex = Pattern.quote("."); - final String[] parts = str.split(regex); - - if (parts.length == 0) { - // this happens for malformed corner cases such as: str = "..." - log.warn("Illegal path string '{}'", str); - throw new GeneralError(String.format("Illegal path string '%s'", str)); - } else if (parts.length == 1) { - // str doesn't contain the delimiter - return new FlagPath(str, List.of()); - } else { - return new FlagPath(parts[0], Arrays.asList(parts).subList(1, parts.length)); - } - } - - private static class FlagPath { - - private final String flag; - private final List path; - - public FlagPath(String flag, List path) { - this.flag = flag; - this.path = path; - } - - public String getFlag() { - return flag; - } - - public List getPath() { - return path; - } - } } diff --git a/src/main/java/com/spotify/confidence/ErrorType.java b/src/main/java/com/spotify/confidence/ErrorType.java new file mode 100644 index 00000000..d26094ae --- /dev/null +++ b/src/main/java/com/spotify/confidence/ErrorType.java @@ -0,0 +1,8 @@ +package com.spotify.confidence; + +public enum ErrorType { + FLAG_NOT_FOUND, + INVALID_CONTEXT, + INTERNAL_ERROR, + NETWORK_ERROR +} diff --git a/src/main/java/com/spotify/confidence/FlagEvaluation.java b/src/main/java/com/spotify/confidence/FlagEvaluation.java new file mode 100644 index 00000000..c1779b11 --- /dev/null +++ b/src/main/java/com/spotify/confidence/FlagEvaluation.java @@ -0,0 +1,50 @@ +package com.spotify.confidence; + +import java.util.Optional; +import lombok.Data; +import lombok.Getter; + +@Data +@Getter +public class FlagEvaluation { + + public T getValue() { + return value; + } + + private T value; + + @Override + public String toString() { + return "FlagEvaluation{" + + "value=" + value + + ", variant='" + variant + '\'' + + ", reason='" + reason + '\'' + + ", errorType=" + errorType + + ", errorMessage='" + errorMessage.orElse("") + '\'' + + '}'; + } + + private String variant; + private String reason; + private Optional errorType; + private Optional errorMessage; + + public FlagEvaluation(T value, + String variant, String reason, ErrorType errorType, String errorMessage) { + this.value = value; + this.variant = variant; + this.reason = reason; + this.errorType = Optional.of(errorType); + this.errorMessage = Optional.of(errorMessage); + } + + public FlagEvaluation(T value, + String variant, String reason) { + this.value = value; + this.variant = variant; + this.reason = reason; + this.errorType = Optional.empty(); + this.errorMessage = Optional.empty(); + } +} diff --git a/src/main/java/com/spotify/confidence/SdkUtils.java b/src/main/java/com/spotify/confidence/SdkUtils.java index 7c4f9e4e..2c9f0fbc 100644 --- a/src/main/java/com/spotify/confidence/SdkUtils.java +++ b/src/main/java/com/spotify/confidence/SdkUtils.java @@ -1,12 +1,22 @@ package com.spotify.confidence; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.io.IOException; +import java.util.Arrays; +import java.util.List; import java.util.Properties; +import java.util.regex.Pattern; +import org.slf4j.Logger; final class SdkUtils { private SdkUtils() {} + private static final Logger log = org.slf4j.LoggerFactory.getLogger(SdkUtils.class); + static String getSdkVersion() { try { final Properties prop = new Properties(); @@ -16,4 +26,72 @@ static String getSdkVersion() { throw new RuntimeException("Can't determine version of the SDK", e); } } + + static FlagPath getPath(String str) { + final String regex = Pattern.quote("."); + final String[] parts = str.split(regex); + + if (parts.length == 0) { + // this happens for malformed corner cases such as: str = "..." + log.warn("Illegal path string '{}'", str); + throw new GeneralError(String.format("Illegal path string '%s'", str)); + } else if (parts.length == 1) { + // str doesn't contain the delimiter + return new FlagPath(str, List.of()); + } else { + return new FlagPath(parts[0], Arrays.asList(parts).subList(1, parts.length)); + } + } + + static class FlagPath { + + private final String flag; + private final List path; + + public FlagPath(String flag, List path) { + this.flag = flag; + this.path = path; + } + + public String getFlag() { + return flag; + } + + public List getPath() { + return path; + } + } + + static Value getValueForPath(List path, Value fullValue) { + Value value = fullValue; + for (String fieldName : path) { + final Structure structure = value.asStructure(); + if (structure == null) { + // value's inner object actually is no structure + log.warn( + "Illegal attempt to derive field '{}' on non-structure value '{}'", fieldName, value); + throw new TypeMismatchError( + String.format( + "Illegal attempt to derive field '%s' on non-structure value '%s'", + fieldName, value)); + } + + value = structure.getValue(fieldName); + + if (value == null) { + // we know that null indicates absence of a proper value because intended nulls would be an + // instance of type Value + log.warn( + "Illegal attempt to derive non-existing field '{}' on structure value '{}'", + fieldName, + structure); + throw new TypeMismatchError( + String.format( + "Illegal attempt to derive non-existing field '%s' on structure value '%s'", + fieldName, structure)); + } + } + + return value; + } }