Skip to content

Commit

Permalink
feat: Native getEvaluation APIs (#124)
Browse files Browse the repository at this point in the history
* feat: Native Flag Provider APIs

* feat: proto to Confidence conversion

* test: Add native API tests

* refactor: Smaller refactoring

* refactor: Remove lombok dependency

* fix: Structs accept NULL values

* test: Full Value

* fix: Invalid value path handle

* fix: Invalid path error handle

* refactor: final variables

* fix: Case of incompatible types

* test: More tests

* test: Test imcompatible schema/value

* refactor: Rename Exceptions class

* fix: Checkstyle

* Add comment

Co-authored-by: Nicklas Lundin <nicklasl@spotify.com>

* refactor: Optimize tests

* refactor: Visible package

---------

Co-authored-by: Nicklas Lundin <nicklasl@spotify.com>
  • Loading branch information
fabriziodemaria and nicklasl authored May 23, 2024
1 parent f29035b commit 4ffa37f
Show file tree
Hide file tree
Showing 19 changed files with 836 additions and 117 deletions.
77 changes: 76 additions & 1 deletion src/main/java/com/spotify/confidence/Confidence.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
package com.spotify.confidence;

import static com.spotify.confidence.ConfidenceTypeMapper.getTyped;
import static com.spotify.confidence.ConfidenceUtils.FlagPath.getPath;
import static com.spotify.confidence.ConfidenceUtils.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.ConfidenceUtils.FlagPath;
import com.spotify.confidence.Exceptions.IllegalValuePath;
import com.spotify.confidence.Exceptions.IllegalValueType;
import com.spotify.confidence.Exceptions.IncompatibleValueType;
import com.spotify.confidence.Exceptions.ValueNotFound;
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 @@ -21,12 +31,13 @@
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 +109,70 @@ public void track(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 = 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 ResolvedFlag resolvedFlag = response.getResolvedFlags(0);
if (!requestFlagName.equals(resolvedFlag.getFlag())) {
final String errorMessage =
String.format(
"Unexpected flag '%s' from remote",
resolvedFlag.getFlag().replaceFirst("^flags/", ""));
log.warn(errorMessage);
return new FlagEvaluation<>(
defaultValue, "", "ERROR", ErrorType.INTERNAL_ERROR, errorMessage);
}
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 {
final ConfidenceValue confidenceValue;
confidenceValue =
getValueForPath(
flagPath.getPath(),
ConfidenceTypeMapper.from(resolvedFlag.getValue(), resolvedFlag.getFlagSchema()));

// regular resolve was successful
return new FlagEvaluation<>(
getTyped(confidenceValue, defaultValue),
resolvedFlag.getVariant(),
resolvedFlag.getReason().toString());
}
} catch (IllegalValuePath | ValueNotFound e) {
log.warn(e.getMessage());
return new FlagEvaluation<>(
defaultValue, "", "ERROR", ErrorType.INVALID_VALUE_PATH, e.getMessage());
} catch (IncompatibleValueType | IllegalValueType e) {
log.warn(e.getMessage());
return new FlagEvaluation<>(
defaultValue, "", "ERROR", ErrorType.INVALID_VALUE_TYPE, e.getMessage());
} catch (Exception e) {
// catch all for any runtime exception
log.warn(e.getMessage());
return new FlagEvaluation<>(
defaultValue, "", "ERROR", ErrorType.INTERNAL_ERROR, e.getMessage());
}
}

CompletableFuture<ResolveFlagsResponse> resolveFlags(String flagName) {
return client().resolveFlags(flagName, getContext());
}
Expand Down
87 changes: 13 additions & 74 deletions src/main/java/com/spotify/confidence/ConfidenceFeatureProvider.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.spotify.confidence;

import static com.spotify.confidence.ConfidenceUtils.FlagPath.getPath;
import static com.spotify.confidence.OpenFeatureUtils.getValueForPath;

import com.google.protobuf.Struct;
import com.spotify.confidence.ConfidenceUtils.FlagPath;
import com.spotify.confidence.Exceptions.IllegalValuePath;
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 +20,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 @@ -144,7 +145,13 @@ private <T> ProviderEvaluation<T> getCastedEvaluation(
public ProviderEvaluation<Value> getObjectEvaluation(
String key, Value defaultValue, EvaluationContext ctx) {

final FlagPath flagPath = getPath(key);
final FlagPath flagPath;
try {
flagPath = getPath(key);
} catch (IllegalValuePath e) {
log.warn(e.getMessage());
throw new RuntimeException(e);
}

final Struct evaluationContext = OpenFeatureUtils.convertToProto(ctx);
// resolve the flag by calling the resolver API
Expand Down Expand Up @@ -191,7 +198,7 @@ public ProviderEvaluation<Value> getObjectEvaluation(
.build();
} else {
final Value fullValue =
TypeMapper.from(resolvedFlag.getValue(), resolvedFlag.getFlagSchema());
OpenFeatureTypeMapper.from(resolvedFlag.getValue(), resolvedFlag.getFlagSchema());

// if a path is given, extract expected portion from the structured value
Value value = getValueForPath(flagPath.getPath(), fullValue);
Expand Down Expand Up @@ -240,72 +247,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;
}
}
}
160 changes: 160 additions & 0 deletions src/main/java/com/spotify/confidence/ConfidenceTypeMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.spotify.confidence;

import com.google.protobuf.Struct;
import com.spotify.confidence.Exceptions.IllegalValueType;
import com.spotify.confidence.Exceptions.IncompatibleValueType;
import com.spotify.confidence.Exceptions.ParseError;
import com.spotify.confidence.shaded.flags.types.v1.FlagSchema;
import com.spotify.confidence.shaded.flags.types.v1.FlagSchema.SchemaTypeCase;
import com.spotify.confidence.shaded.flags.types.v1.FlagSchema.StructFlagSchema;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class ConfidenceTypeMapper {

private static ConfidenceValue from(com.google.protobuf.Value value, FlagSchema schema)
throws ParseError {
if (schema.getSchemaTypeCase() == SchemaTypeCase.SCHEMATYPE_NOT_SET) {
throw new ParseError("schemaType not set in FlagSchema");
}

final String mismatchPrefix = "Mismatch between schema and value:";
switch (value.getKindCase()) {
case NULL_VALUE:
return ConfidenceValue.NULL_VALUE;
case NUMBER_VALUE:
switch (schema.getSchemaTypeCase()) {
case INT_SCHEMA:
final int intVal = (int) value.getNumberValue();
if (intVal != value.getNumberValue()) {
throw new ParseError(
String.format(
"%s %s should be an int, but it is a double/long", mismatchPrefix, value));
}
return ConfidenceValue.of(intVal);
case DOUBLE_SCHEMA:
return ConfidenceValue.of(value.getNumberValue());
default:
throw new ParseError(
String.format(
"%s %s is a Number, but it should be %s",
mismatchPrefix, value, schema.getSchemaTypeCase()));
}
case STRING_VALUE:
if (schema.getSchemaTypeCase() != SchemaTypeCase.STRING_SCHEMA) {
throw new ParseError(
String.format(
"%s %s is a String, but it should be %s",
mismatchPrefix, value, schema.getSchemaTypeCase()));
}
return ConfidenceValue.of(value.getStringValue());
case BOOL_VALUE:
if (schema.getSchemaTypeCase() != SchemaTypeCase.BOOL_SCHEMA) {
throw new ParseError(
String.format(
"%s %s is a Bool, but it should be %s",
mismatchPrefix, value, schema.getSchemaTypeCase()));
}
return ConfidenceValue.of(value.getBoolValue());
case STRUCT_VALUE:
if (schema.getSchemaTypeCase() != SchemaTypeCase.STRUCT_SCHEMA) {
throw new ParseError(
String.format(
"%s %s is a Struct, but it should be %s",
mismatchPrefix, value, schema.getSchemaTypeCase()));
}
return from(value.getStructValue(), schema.getStructSchema());
case LIST_VALUE:
if (schema.getSchemaTypeCase() != SchemaTypeCase.LIST_SCHEMA) {
throw new ParseError(
String.format(
"%s %s is a List, but it should be %s",
mismatchPrefix, value, schema.getSchemaTypeCase()));
}
final List<ConfidenceValue> mappedList =
value.getListValue().getValuesList().stream()
.map(val -> from(val, schema.getListSchema().getElementSchema()))
.collect(Collectors.toList());
return ConfidenceValue.of(mappedList);
case KIND_NOT_SET:
throw new ParseError("kind not set in com.google.protobuf.Value");
default:
throw new ParseError("Unknown value type");
}
}

public static ConfidenceValue from(Struct struct, StructFlagSchema schema) {
final Map<String, ConfidenceValue> map =
struct.getFieldsMap().entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey,
entry -> {
if (schema.getSchemaMap().containsKey(entry.getKey())) {
return from(entry.getValue(), schema.getSchemaMap().get(entry.getKey()));
} else {
throw new ParseError(
String.format("Lacking schema for field '%s'", entry.getKey()));
}
}));

return ConfidenceValue.Struct.ofMap(map);
}

public static <T> T getTyped(ConfidenceValue value, T defaultValue)
throws IllegalValueType, IncompatibleValueType {
if (value.equals(ConfidenceValue.NULL_VALUE)) {
return defaultValue;
}

if (defaultValue instanceof String) {
if (value.isString()) {
return (T) value.asString();
}
throw new IncompatibleValueType(
String.format(
"Default type %s, but value of type %s", defaultValue.getClass(), value.getClass()));
} else if (defaultValue instanceof Integer) {
if (value.isInteger()) {
return (T) java.lang.Integer.valueOf(value.asInteger());
}
throw new IncompatibleValueType(
String.format(
"Default type %s, but value of type %s", defaultValue.getClass(), value.getClass()));
} else if (defaultValue instanceof Double) {
if (value.isDouble()) {
return (T) Double.valueOf(value.asDouble());
}
throw new IncompatibleValueType(
String.format(
"Default type %s, but value of type %s", defaultValue.getClass(), value.getClass()));
} else if (defaultValue instanceof Boolean) {
if (value.isBoolean()) {
return (T) Boolean.valueOf(value.asBoolean());
}
throw new IncompatibleValueType(
String.format(
"Default type %s, but value of type %s", defaultValue.getClass(), value.getClass()));
} else if (defaultValue instanceof ConfidenceValue.List) {
if (value.isList()) {
return (T) value.asList();
}
throw new IncompatibleValueType(
String.format(
"Default value type %s, but value of type %s",
defaultValue.getClass(), value.getClass()));
} else if (defaultValue instanceof ConfidenceValue.Struct) {
if (value.isStruct()) {
return (T) value.asStruct();
}
throw new IncompatibleValueType(
String.format(
"Default value type %s, but value of type %s",
defaultValue.getClass(), value.getClass()));
} else {
// Unsupported default value type
throw new IllegalValueType(String.format("Illegal value type: %s", defaultValue.getClass()));
}
}
}
Loading

0 comments on commit 4ffa37f

Please sign in to comment.