Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@
**/DataStreams* @DataDog/data-streams-monitoring

# @DataDog/feature-flagging-and-experimentation-sdk
/products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk
/dd-smoke-tests/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk
/products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk

# @DataDog/profiling-java
/dd-java-agent/agent-profiling/ @DataDog/profiling-java
Expand Down
38 changes: 38 additions & 0 deletions dd-smoke-tests/openfeature/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar

plugins {
id 'java'
id 'org.springframework.boot' version '2.7.15'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

ext {
minJavaVersionForTests = JavaVersion.VERSION_11
}

apply from: "$rootDir/gradle/java.gradle"
apply from: "$rootDir/gradle/spring-boot-plugin.gradle"
description = 'Open Feature provider Smoke Tests.'

tasks.named("compileJava", JavaCompile) {
configureCompiler(it, 11, JavaVersion.VERSION_11)
}

dependencies {
implementation project(':products:feature-flagging:api')
implementation 'org.springframework.boot:spring-boot-starter-web'

testImplementation project(':dd-smoke-tests')
testImplementation project(':products:feature-flagging:lib')
}

tasks.withType(Test).configureEach {
dependsOn "bootJar"
def bootJarTask = tasks.named('bootJar', BootJar)
jvmArgumentProviders.add(new CommandLineArgumentProvider() {
@Override
Iterable<String> asArguments() {
return bootJarTask.map { ["-Ddatadog.smoketest.springboot.shadowJar.path=${it.archiveFile.get()}"] }.get()
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package datadog.smoketest.springboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringbootApplication {

public static void main(final String[] args) {
SpringApplication.run(SpringbootApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package datadog.smoketest.springboot.openfeature;

import datadog.trace.api.openfeature.Provider;
import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.OpenFeatureAPI;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenFeatureConfiguration {

@Bean
public Client openFeatureClient() {
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(new Provider());
return api.getClient();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package datadog.smoketest.springboot.openfeature;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.Structure;
import dev.openfeature.sdk.Value;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/openfeature")
public class OpenFeatureController {

private static final Logger LOGGER = LoggerFactory.getLogger(OpenFeatureController.class);

private final Client client;

public OpenFeatureController(final Client client) {
this.client = client;
}

@PostMapping(
value = "/evaluate",
consumes = APPLICATION_JSON_VALUE,
produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> evaluate(@RequestBody final EvaluateRequest request) {
try {
final EvaluationContext context = context(request);
FlagEvaluationDetails<?> details;
switch (request.getVariationType()) {
case "BOOLEAN":
details =
client.getBooleanDetails(
request.getFlag(), (Boolean) request.getDefaultValue(), context);
break;
case "STRING":
details =
client.getStringDetails(
request.getFlag(), (String) request.getDefaultValue(), context);
break;
case "INTEGER":
final Number integerEval = (Number) request.getDefaultValue();
details = client.getIntegerDetails(request.getFlag(), integerEval.intValue(), context);
break;
case "NUMERIC":
final Number doubleEval = (Number) request.getDefaultValue();
details = client.getDoubleDetails(request.getFlag(), doubleEval.doubleValue(), context);
break;
case "JSON":
details =
client.getObjectDetails(
request.getFlag(), Value.objectToValue(request.getDefaultValue()), context);
break;
default:
throw new IllegalArgumentException(
"Unsupported variation type: " + request.getVariationType());
}

final Object value = details.getValue();
final Map<String, Object> result = new HashMap<>();
result.put("flagKey", details.getFlagKey());
result.put("variant", details.getVariant());
result.put("reason", details.getReason());
result.put("value", value instanceof Value ? context.convertValue((Value) value) : value);
result.put("errorCode", details.getErrorCode());
result.put("errorMessage", details.getErrorMessage());
result.put("flagMetadata", details.getFlagMetadata().asUnmodifiableMap());
return ResponseEntity.ok(result);
} catch (Throwable e) {
LOGGER.error("Error on resolution", e);
return ResponseEntity.internalServerError().body(e.getMessage());
}
}

private static EvaluationContext context(final EvaluateRequest request) {
final MutableContext context = new MutableContext();
context.setTargetingKey(request.getTargetingKey());
if (request.attributes != null) {
request.attributes.forEach(
(key, value) -> {
if (value instanceof Boolean) {
context.add(key, (Boolean) value);
} else if (value instanceof Integer) {
context.add(key, (Integer) value);
} else if (value instanceof Double) {
context.add(key, (Double) value);
} else if (value instanceof String) {
context.add(key, (String) value);
} else if (value instanceof Map) {
context.add(key, Value.objectToValue(value).asStructure());
} else if (value instanceof List) {
context.add(key, Value.objectToValue(value).asList());
} else {
context.add(key, (Structure) null);
}
});
}
return context;
}

public static class EvaluateRequest {
private String flag;
private String variationType;
private Object defaultValue;
private String targetingKey;
private Map<String, Object> attributes;

public Map<String, Object> getAttributes() {
return attributes;
}

public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}

public Object getDefaultValue() {
return defaultValue;
}

public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}

public String getFlag() {
return flag;
}

public void setFlag(String flag) {
this.flag = flag;
}

public String getTargetingKey() {
return targetingKey;
}

public void setTargetingKey(String targetingKey) {
this.targetingKey = targetingKey;
}

public String getVariationType() {
return variationType;
}

public void setVariationType(String variationType) {
this.variationType = variationType;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package datadog.smoketest.springboot

import com.squareup.moshi.Moshi
import datadog.remoteconfig.Capabilities
import datadog.remoteconfig.Product
import datadog.smoketest.AbstractServerSmokeTest
import datadog.trace.api.featureflag.exposure.ExposuresRequest
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import java.nio.file.Files
import java.nio.file.Paths
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import okio.Okio
import spock.lang.Shared
import spock.util.concurrent.PollingConditions

class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest {

@Shared
private final rcPayload = new JsonSlurper().parse(fetchResource("config/flags-v1.json")).with { json ->
return JsonOutput.toJson(json.data.attributes)
}

@Shared
private final moshi = new Moshi.Builder().build().adapter(ExposuresRequest)

@Shared
private final exposurePoll = new PollingConditions(timeout: 5, initialDelay: 0, delay: 0.1D, factor: 2)

@Override
ProcessBuilder createProcessBuilder() {
setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload)

final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path")
final command = [javaPath()]
command.addAll(defaultJavaProperties)
command.add('-Ddd.trace.debug=true')
command.add('-Ddd.remote_config.enabled=true')
command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString())
command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()])
final builder = new ProcessBuilder(command).directory(new File(buildDirectory))
builder.environment().put('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', 'true')
return builder
}

@Override
Closure decodedEvpProxyMessageCallback() {
return { String path, byte[] body ->
if (!path.contains('api/v2/exposures')) {
return null
}
return moshi.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(body))))
}
}

void 'test remote config'() {
when:
final rcRequest = waitForRcClientRequest {req ->
decodeProducts(req).find {it == Product.FFE_FLAGS } != null
}

then:
final capabilities = decodeCapabilities(rcRequest)
hasCapability(capabilities, Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES)
}

void 'test open feature evaluation'() {
setup:
setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload)
final url = "http://localhost:${httpPort}/openfeature/evaluate"
final request = new Request.Builder()
.url(url)
.post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase)))
.build()

when:
final response = client.newCall(request).execute()

then:
response.code() == 200
final responseBody = new JsonSlurper().parse(response.body().byteStream())
responseBody.value == testCase.result.value
responseBody.variant == testCase.result.variant
responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey
if (testCase.result.flagMetadata?.doLog) {
waitForEvpProxyMessage(exposurePoll) {
final exposure = it.v2 as ExposuresRequest
return exposure.exposures.first().with {
it.flag.key == testCase.flag && it.subject.id == testCase.targetingKey
}
}
}

where:
testCase << parseTestCases()
}

private static URL fetchResource(final String name) {
return Thread.currentThread().getContextClassLoader().getResource(name)
}

private static List<Map<String, Object>> parseTestCases() {
final folder = fetchResource('data')
final uri = folder.toURI()
final testsPath = Paths.get(uri)
final files = Files.list(testsPath)
.filter(path -> path.toString().endsWith('.json'))
final result = []
final slurper = new JsonSlurper()
files.each {
path ->
final testCases = slurper.parse(path.toFile()) as List<Map<String, Object>>
testCases.eachWithIndex {
testCase, index ->
testCase.fileName = path.fileName.toString()
testCase.index = index
}
result.addAll(testCases)
}
return result
}

private static Set<Product> decodeProducts(final Map<String, Object> request) {
return request.client.products.collect { Product.valueOf(it)}
}

private static long decodeCapabilities(final Map<String, Object> request) {
final clientCapabilities = request.client.capabilities as byte[]
long capabilities = 0l
for (int i = 0; i < clientCapabilities.length; i++) {
capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8)
}
return capabilities
}

private static boolean hasCapability(final long capabilities, final long test) {
return (capabilities & test) > 0
}
}
Loading
Loading