Skip to content

Commit

Permalink
feat: Native Flag Provider APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
fabriziodemaria committed May 20, 2024
1 parent 9bc883c commit 4ddb301
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 73 deletions.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@

<dependencies>
<!-- compile scope -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
Expand Down
99 changes: 98 additions & 1 deletion src/main/java/com/spotify/confidence/Confidence.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String, ConfidenceValue> context = Maps.newHashMap();
private static final Logger log = org.slf4j.LoggerFactory.getLogger(Confidence.class);

private Confidence() {}

Expand Down Expand Up @@ -98,6 +104,97 @@ public void send(String eventName, ConfidenceValue.Struct message) {
}
}

public <T> T getValue(String key, T defaultValue) {
return getEvaluation(key, defaultValue).getValue();
}

public <T> FlagEvaluation<T> 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> 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<ResolveFlagsResponse> resolveFlags(String flagName) {
return client().resolveFlags(flagName, getContext());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 */
Expand Down Expand Up @@ -240,72 +240,4 @@ private static void handleStatusRuntimeException(StatusRuntimeException e) {
e.getMessage()));
}
}

private static Value getValueForPath(List<String> 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<String> path;

public FlagPath(String flag, List<String> path) {
this.flag = flag;
this.path = path;
}

public String getFlag() {
return flag;
}

public List<String> getPath() {
return path;
}
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/spotify/confidence/ErrorType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.spotify.confidence;

public enum ErrorType {
FLAG_NOT_FOUND,
INVALID_CONTEXT,
INTERNAL_ERROR,
NETWORK_ERROR
}
50 changes: 50 additions & 0 deletions src/main/java/com/spotify/confidence/FlagEvaluation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.spotify.confidence;

import java.util.Optional;
import lombok.Data;
import lombok.Getter;

@Data
@Getter
public class FlagEvaluation<T> {

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> errorType;
private Optional<String> 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();
}
}
Loading

0 comments on commit 4ddb301

Please sign in to comment.