Skip to content

Commit de1816f

Browse files
Add springboot smoke tests for the open feature SDK
1 parent d926f7a commit de1816f

29 files changed

+6908
-1
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ dd-trace-core/src/test/groovy/datadog/trace/llmobs/ @DataDog/ml-observability
142142

143143

144144
# @DataDog/feature-flagging-and-experimentation-sdk
145+
/dd-smoke-tests/openfeature-sdk/ @DataDog/feature-flagging-and-experimentation-sdk
145146
/internal-api/src/main/java/datadog/trace/api/featureflag/ @DataDog/feature-flagging-and-experimentation-sdk
146147
/internal-api/src/test/groovy/datadog/trace/api/featureflag/ @DataDog/feature-flagging-and-experimentation-sdk
147148
/dd-java-agent/agent-feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import org.springframework.boot.gradle.tasks.bundling.BootJar
2+
3+
plugins {
4+
id 'java'
5+
id 'org.springframework.boot' version '2.7.15'
6+
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
7+
}
8+
9+
ext {
10+
minJavaVersionForTests = JavaVersion.VERSION_11
11+
}
12+
13+
apply from: "$rootDir/gradle/java.gradle"
14+
apply from: "$rootDir/gradle/spring-boot-plugin.gradle"
15+
description = 'Open Feature provider Smoke Tests.'
16+
17+
tasks.named("compileJava", JavaCompile) {
18+
configureCompiler(it, 11, JavaVersion.VERSION_11)
19+
}
20+
21+
dependencies {
22+
implementation project(':products:openfeature')
23+
implementation 'org.springframework.boot:spring-boot-starter-web'
24+
25+
testImplementation project(':dd-smoke-tests')
26+
}
27+
28+
tasks.withType(Test).configureEach {
29+
dependsOn "bootJar"
30+
def bootJarTask = tasks.named('bootJar', BootJar)
31+
jvmArgumentProviders.add(new CommandLineArgumentProvider() {
32+
@Override
33+
Iterable<String> asArguments() {
34+
return bootJarTask.map { ["-Ddatadog.smoketest.springboot.shadowJar.path=${it.archiveFile.get()}"] }.get()
35+
}
36+
})
37+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package datadog.smoketest.springboot;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class SpringbootApplication {
8+
9+
public static void main(final String[] args) {
10+
SpringApplication.run(SpringbootApplication.class, args);
11+
}
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package datadog.smoketest.springboot.openfeature;
2+
3+
import datadog.trace.api.openfeature.Provider;
4+
import dev.openfeature.sdk.Client;
5+
import dev.openfeature.sdk.OpenFeatureAPI;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
@Configuration
10+
public class OpenFeatureConfiguration {
11+
12+
@Bean
13+
public Client openFeatureClient() {
14+
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
15+
api.setProviderAndWait(new Provider());
16+
return api.getClient();
17+
}
18+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package datadog.smoketest.springboot.openfeature;
2+
3+
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
4+
5+
import dev.openfeature.sdk.Client;
6+
import dev.openfeature.sdk.EvaluationContext;
7+
import dev.openfeature.sdk.FlagEvaluationDetails;
8+
import dev.openfeature.sdk.MutableContext;
9+
import dev.openfeature.sdk.Structure;
10+
import dev.openfeature.sdk.Value;
11+
import java.util.HashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
import org.springframework.http.ResponseEntity;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
import org.springframework.web.bind.annotation.RequestMapping;
20+
import org.springframework.web.bind.annotation.RestController;
21+
22+
@RestController
23+
@RequestMapping("/openfeature")
24+
public class OpenFeatureController {
25+
26+
private static final Logger LOGGER = LoggerFactory.getLogger(OpenFeatureController.class);
27+
28+
private final Client client;
29+
30+
public OpenFeatureController(final Client client) {
31+
this.client = client;
32+
}
33+
34+
@PostMapping(
35+
value = "/evaluate",
36+
consumes = APPLICATION_JSON_VALUE,
37+
produces = APPLICATION_JSON_VALUE)
38+
public ResponseEntity<?> evaluate(@RequestBody final EvaluateRequest request) {
39+
try {
40+
final EvaluationContext context = context(request);
41+
FlagEvaluationDetails<?> details;
42+
switch (request.getVariationType()) {
43+
case "BOOLEAN":
44+
details =
45+
client.getBooleanDetails(
46+
request.getFlag(), (Boolean) request.getDefaultValue(), context);
47+
break;
48+
case "STRING":
49+
details =
50+
client.getStringDetails(
51+
request.getFlag(), (String) request.getDefaultValue(), context);
52+
break;
53+
case "INTEGER":
54+
final Number integerEval = (Number) request.getDefaultValue();
55+
details = client.getIntegerDetails(request.getFlag(), integerEval.intValue(), context);
56+
break;
57+
case "NUMERIC":
58+
final Number doubleEval = (Number) request.getDefaultValue();
59+
details = client.getDoubleDetails(request.getFlag(), doubleEval.doubleValue(), context);
60+
break;
61+
case "JSON":
62+
details =
63+
client.getObjectDetails(
64+
request.getFlag(), Value.objectToValue(request.getDefaultValue()), context);
65+
break;
66+
default:
67+
throw new IllegalArgumentException(
68+
"Unsupported variation type: " + request.getVariationType());
69+
}
70+
71+
final Object value = details.getValue();
72+
final Map<String, Object> result = new HashMap<>();
73+
result.put("flagKey", details.getFlagKey());
74+
result.put("variant", details.getVariant());
75+
result.put("reason", details.getReason());
76+
result.put("value", value instanceof Value ? context.convertValue((Value) value) : value);
77+
result.put("errorCode", details.getErrorCode());
78+
result.put("errorMessage", details.getErrorMessage());
79+
result.put("flagMetadata", details.getFlagMetadata().asUnmodifiableMap());
80+
return ResponseEntity.ok(result);
81+
} catch (Throwable e) {
82+
LOGGER.error("Error on resolution", e);
83+
return ResponseEntity.internalServerError().body(e.getMessage());
84+
}
85+
}
86+
87+
private static EvaluationContext context(final EvaluateRequest request) {
88+
final MutableContext context = new MutableContext();
89+
context.setTargetingKey(request.getTargetingKey());
90+
if (request.attributes != null) {
91+
request.attributes.forEach(
92+
(key, value) -> {
93+
if (value instanceof Boolean) {
94+
context.add(key, (Boolean) value);
95+
} else if (value instanceof Integer) {
96+
context.add(key, (Integer) value);
97+
} else if (value instanceof Double) {
98+
context.add(key, (Double) value);
99+
} else if (value instanceof String) {
100+
context.add(key, (String) value);
101+
} else if (value instanceof Map) {
102+
context.add(key, Value.objectToValue(value).asStructure());
103+
} else if (value instanceof List) {
104+
context.add(key, Value.objectToValue(value).asList());
105+
} else {
106+
context.add(key, (Structure) null);
107+
}
108+
});
109+
}
110+
return context;
111+
}
112+
113+
public static class EvaluateRequest {
114+
private String flag;
115+
private String variationType;
116+
private Object defaultValue;
117+
private String targetingKey;
118+
private Map<String, Object> attributes;
119+
120+
public Map<String, Object> getAttributes() {
121+
return attributes;
122+
}
123+
124+
public void setAttributes(Map<String, Object> attributes) {
125+
this.attributes = attributes;
126+
}
127+
128+
public Object getDefaultValue() {
129+
return defaultValue;
130+
}
131+
132+
public void setDefaultValue(Object defaultValue) {
133+
this.defaultValue = defaultValue;
134+
}
135+
136+
public String getFlag() {
137+
return flag;
138+
}
139+
140+
public void setFlag(String flag) {
141+
this.flag = flag;
142+
}
143+
144+
public String getTargetingKey() {
145+
return targetingKey;
146+
}
147+
148+
public void setTargetingKey(String targetingKey) {
149+
this.targetingKey = targetingKey;
150+
}
151+
152+
public String getVariationType() {
153+
return variationType;
154+
}
155+
156+
public void setVariationType(String variationType) {
157+
this.variationType = variationType;
158+
}
159+
}
160+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package datadog.smoketest.springboot
2+
3+
import com.squareup.moshi.Moshi
4+
import datadog.remoteconfig.Capabilities
5+
import datadog.remoteconfig.Product
6+
import datadog.smoketest.AbstractServerSmokeTest
7+
import datadog.trace.api.openfeature.exposure.dto.ExposuresRequest
8+
import groovy.json.JsonOutput
9+
import groovy.json.JsonSlurper
10+
import java.nio.file.Files
11+
import java.nio.file.Paths
12+
import okhttp3.MediaType
13+
import okhttp3.Request
14+
import okhttp3.RequestBody
15+
import okio.Okio
16+
import spock.lang.Shared
17+
import spock.util.concurrent.PollingConditions
18+
19+
class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest {
20+
21+
@Shared
22+
private final rcPayload = new JsonSlurper().parse(fetchResource("config/flags-v1.json")).with { json ->
23+
return JsonOutput.toJson(json.data.attributes)
24+
}
25+
26+
@Shared
27+
private final moshi = new Moshi.Builder().build().adapter(ExposuresRequest)
28+
29+
@Shared
30+
private final exposurePoll = new PollingConditions(timeout: 5, initialDelay: 0, delay: 0.1D, factor: 2)
31+
32+
@Override
33+
ProcessBuilder createProcessBuilder() {
34+
setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload)
35+
36+
final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path")
37+
final command = [javaPath()]
38+
command.add('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005')
39+
command.addAll(defaultJavaProperties)
40+
command.add('-Ddd.trace.debug=true')
41+
command.add('-Ddd.remote_config.enabled=true')
42+
command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString())
43+
command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()])
44+
final builder = new ProcessBuilder(command).directory(new File(buildDirectory))
45+
builder.environment().put('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', 'true')
46+
return builder
47+
}
48+
49+
@Override
50+
Closure decodedEvpProxyMessageCallback() {
51+
return { String path, byte[] body ->
52+
if (!path.contains('api/v2/exposures')) {
53+
return null
54+
}
55+
return moshi.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(body))))
56+
}
57+
}
58+
59+
void 'test remote config'() {
60+
when:
61+
final rcRequest = waitForRcClientRequest {req ->
62+
decodeProducts(req).find {it == Product.FFE_FLAGS } != null
63+
}
64+
65+
then:
66+
final capabilities = decodeCapabilities(rcRequest)
67+
hasCapability(capabilities, Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES)
68+
}
69+
70+
void 'test open feature evaluation'() {
71+
setup:
72+
setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload)
73+
final url = "http://localhost:${httpPort}/openfeature/evaluate"
74+
final request = new Request.Builder()
75+
.url(url)
76+
.post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase)))
77+
.build()
78+
79+
when:
80+
final response = client.newCall(request).execute()
81+
82+
then:
83+
response.code() == 200
84+
final responseBody = new JsonSlurper().parse(response.body().byteStream())
85+
responseBody.value == testCase.result.value
86+
responseBody.variant == testCase.result.variant
87+
responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey
88+
if (testCase.result.flagMetadata?.doLog) {
89+
waitForEvpProxyMessage(exposurePoll) {
90+
final exposure = it.v2 as ExposuresRequest
91+
return exposure.exposures.first().with {
92+
it.flag.key == testCase.flag && it.subject.id == testCase.targetingKey
93+
}
94+
}
95+
}
96+
97+
where:
98+
testCase << parseTestCases()
99+
}
100+
101+
private static URL fetchResource(final String name) {
102+
return Thread.currentThread().getContextClassLoader().getResource(name)
103+
}
104+
105+
private static List<Map<String, Object>> parseTestCases() {
106+
final folder = fetchResource('data')
107+
final uri = folder.toURI()
108+
final testsPath = Paths.get(uri)
109+
final files = Files.list(testsPath)
110+
.filter(path -> path.toString().endsWith('.json'))
111+
final result = []
112+
final slurper = new JsonSlurper()
113+
files.each {
114+
path ->
115+
final testCases = slurper.parse(path.toFile()) as List<Map<String, Object>>
116+
testCases.eachWithIndex {
117+
testCase, index ->
118+
testCase.fileName = path.fileName.toString()
119+
testCase.index = index
120+
}
121+
result.addAll(testCases)
122+
}
123+
return result
124+
}
125+
126+
private static Set<Product> decodeProducts(final Map<String, Object> request) {
127+
return request.client.products.collect { Product.valueOf(it)}
128+
}
129+
130+
private static long decodeCapabilities(final Map<String, Object> request) {
131+
final clientCapabilities = request.client.capabilities as byte[]
132+
long capabilities = 0l
133+
for (int i = 0; i < clientCapabilities.length; i++) {
134+
capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8)
135+
}
136+
return capabilities
137+
}
138+
139+
private static boolean hasCapability(final long capabilities, final long test) {
140+
return (capabilities & test) > 0
141+
}
142+
}

0 commit comments

Comments
 (0)