Skip to content

Commit be9fe20

Browse files
Add springboot smoke tests for the open feature SDK
1 parent ca7c192 commit be9fe20

27 files changed

+6918
-1
lines changed
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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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.FeatureProvider;
8+
import dev.openfeature.sdk.FlagEvaluationDetails;
9+
import dev.openfeature.sdk.MutableContext;
10+
import dev.openfeature.sdk.OpenFeatureAPI;
11+
import dev.openfeature.sdk.Structure;
12+
import dev.openfeature.sdk.Value;
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
import org.springframework.http.ResponseEntity;
19+
import org.springframework.web.bind.annotation.GetMapping;
20+
import org.springframework.web.bind.annotation.PostMapping;
21+
import org.springframework.web.bind.annotation.RequestBody;
22+
import org.springframework.web.bind.annotation.RequestMapping;
23+
import org.springframework.web.bind.annotation.RestController;
24+
25+
@RestController
26+
@RequestMapping("/openfeature")
27+
public class OpenFeatureController {
28+
29+
private static final Logger LOGGER = LoggerFactory.getLogger(OpenFeatureController.class);
30+
31+
private final Client client;
32+
33+
public OpenFeatureController() {
34+
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
35+
api.setProviderAndWait(new datadog.trace.api.openfeature.Provider());
36+
this.client = api.getClient();
37+
}
38+
39+
@GetMapping("/provider-metadata")
40+
public Map<String, Object> getProviderMetadata() {
41+
FeatureProvider provider = OpenFeatureAPI.getInstance().getProvider();
42+
Map<String, Object> response = new HashMap<>();
43+
response.put("providerClass", provider.getClass());
44+
response.put("metadata", provider.getMetadata().getName());
45+
return response;
46+
}
47+
48+
@PostMapping(
49+
value = "/evaluate",
50+
consumes = APPLICATION_JSON_VALUE,
51+
produces = APPLICATION_JSON_VALUE)
52+
public ResponseEntity<?> evaluate(@RequestBody final EvaluateRequest request) {
53+
try {
54+
final EvaluationContext context = context(request);
55+
FlagEvaluationDetails<?> details;
56+
String reason;
57+
switch (request.getVariationType()) {
58+
case "BOOLEAN":
59+
details =
60+
client.getBooleanDetails(
61+
request.getFlag(), (Boolean) request.getDefaultValue(), context);
62+
break;
63+
case "STRING":
64+
details =
65+
client.getStringDetails(
66+
request.getFlag(), (String) request.getDefaultValue(), context);
67+
break;
68+
case "INTEGER":
69+
final Number integerEval = (Number) request.getDefaultValue();
70+
details = client.getIntegerDetails(request.getFlag(), integerEval.intValue(), context);
71+
break;
72+
case "NUMERIC":
73+
final Number doubleEval = (Number) request.getDefaultValue();
74+
details = client.getDoubleDetails(request.getFlag(), doubleEval.doubleValue(), context);
75+
break;
76+
case "JSON":
77+
details =
78+
client.getObjectDetails(
79+
request.getFlag(), Value.objectToValue(request.getDefaultValue()), context);
80+
break;
81+
default:
82+
throw new IllegalArgumentException(
83+
"Unsupported variation type: " + request.getVariationType());
84+
}
85+
86+
final Object value = details.getValue();
87+
final Map<String, Object> result = new HashMap<>();
88+
result.put("flagKey", details.getFlagKey());
89+
result.put("variant", details.getVariant());
90+
result.put("reason", details.getReason());
91+
result.put("value", value instanceof Value ? context.convertValue((Value) value) : value);
92+
result.put("errorCode", details.getErrorCode());
93+
result.put("errorMessage", details.getErrorMessage());
94+
result.put("flagMetadata", details.getFlagMetadata().asUnmodifiableMap());
95+
return ResponseEntity.ok(result);
96+
} catch (Throwable e) {
97+
LOGGER.error("Error on resolution", e);
98+
return ResponseEntity.internalServerError().body(e.getMessage());
99+
}
100+
}
101+
102+
private static EvaluationContext context(final EvaluateRequest request) {
103+
final MutableContext context = new MutableContext();
104+
context.setTargetingKey(request.getTargetingKey());
105+
if (request.attributes != null) {
106+
request.attributes.forEach(
107+
(key, value) -> {
108+
if (value instanceof Boolean) {
109+
context.add(key, (Boolean) value);
110+
} else if (value instanceof Integer) {
111+
context.add(key, (Integer) value);
112+
} else if (value instanceof Double) {
113+
context.add(key, (Double) value);
114+
} else if (value instanceof String) {
115+
context.add(key, (String) value);
116+
} else if (value instanceof Map) {
117+
context.add(key, Value.objectToValue(value).asStructure());
118+
} else if (value instanceof List) {
119+
context.add(key, Value.objectToValue(value).asList());
120+
} else {
121+
context.add(key, (Structure) null);
122+
}
123+
});
124+
}
125+
return context;
126+
}
127+
128+
public static class EvaluateRequest {
129+
private String flag;
130+
private String variationType;
131+
private Object defaultValue;
132+
private String targetingKey;
133+
private Map<String, Object> attributes;
134+
135+
public Map<String, Object> getAttributes() {
136+
return attributes;
137+
}
138+
139+
public void setAttributes(Map<String, Object> attributes) {
140+
this.attributes = attributes;
141+
}
142+
143+
public Object getDefaultValue() {
144+
return defaultValue;
145+
}
146+
147+
public void setDefaultValue(Object defaultValue) {
148+
this.defaultValue = defaultValue;
149+
}
150+
151+
public String getFlag() {
152+
return flag;
153+
}
154+
155+
public void setFlag(String flag) {
156+
this.flag = flag;
157+
}
158+
159+
public String getTargetingKey() {
160+
return targetingKey;
161+
}
162+
163+
public void setTargetingKey(String targetingKey) {
164+
this.targetingKey = targetingKey;
165+
}
166+
167+
public String getVariationType() {
168+
return variationType;
169+
}
170+
171+
public void setVariationType(String variationType) {
172+
this.variationType = variationType;
173+
}
174+
}
175+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.featureflag.exposure.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.addAll(defaultJavaProperties)
39+
command.add('-Ddd.trace.debug=true')
40+
command.add('-Ddd.remote_config.enabled=true')
41+
command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString())
42+
command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()])
43+
final builder = new ProcessBuilder(command).directory(new File(buildDirectory))
44+
builder.environment().put('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', 'true')
45+
return builder
46+
}
47+
48+
@Override
49+
Closure decodedEvpProxyMessageCallback() {
50+
return { String path, byte[] body ->
51+
if (!path.contains('api/v2/exposures')) {
52+
return null
53+
}
54+
return moshi.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(body))))
55+
}
56+
}
57+
58+
void 'test open feature provider metadata'() {
59+
setup:
60+
final url = "http://localhost:${httpPort}/openfeature/provider-metadata"
61+
final request = new Request.Builder().url(url).get().build()
62+
63+
when:
64+
final response = client.newCall(request).execute()
65+
66+
then:
67+
response.code() == 200
68+
final responseBody = new JsonSlurper().parse(response.body().byteStream())
69+
responseBody['metadata'] == 'datadog-openfeature-provider'
70+
responseBody['providerClass'] == 'datadog.trace.api.openfeature.Provider'
71+
responseBody != null
72+
}
73+
74+
void 'test remote config'() {
75+
when:
76+
final rcRequest = waitForRcClientRequest {req ->
77+
decodeProducts(req).find {it == Product.FFE_FLAGS } != null
78+
}
79+
80+
then:
81+
final capabilities = decodeCapabilities(rcRequest)
82+
hasCapability(capabilities, Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES)
83+
}
84+
85+
void 'test open feature evaluation'() {
86+
setup:
87+
final url = "http://localhost:${httpPort}/openfeature/evaluate"
88+
final request = new Request.Builder()
89+
.url(url)
90+
.post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase)))
91+
.build()
92+
93+
when:
94+
final response = client.newCall(request).execute()
95+
96+
then:
97+
response.code() == 200
98+
final responseBody = new JsonSlurper().parse(response.body().byteStream())
99+
assert responseBody.value == testCase.result.value
100+
assert responseBody.variant == testCase.result.variant
101+
assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey
102+
if (testCase.result.flagMetadata?.doLog) {
103+
waitForEvpProxyMessage(exposurePoll) {
104+
final exposure = it.v2 as ExposuresRequest
105+
return exposure.exposures.first().with {
106+
it.flag.key == testCase.flag && it.subject.id == testCase.targetingKey
107+
}
108+
}
109+
}
110+
111+
where:
112+
testCase << parseTestCases()
113+
}
114+
115+
private static URL fetchResource(final String name) {
116+
return Thread.currentThread().getContextClassLoader().getResource(name)
117+
}
118+
119+
private static List<Map<String, Object>> parseTestCases() {
120+
final folder = fetchResource('data')
121+
final uri = folder.toURI()
122+
final testsPath = Paths.get(uri)
123+
final files = Files.list(testsPath)
124+
.filter(path -> path.toString().endsWith('.json'))
125+
final result = []
126+
final slurper = new JsonSlurper()
127+
files.each {
128+
path ->
129+
final testCases = slurper.parse(path.toFile()) as List<Map<String, Object>>
130+
testCases.eachWithIndex {
131+
testCase, index ->
132+
testCase.fileName = path.fileName.toString()
133+
testCase.index = index
134+
}
135+
result.addAll(testCases)
136+
}
137+
return result
138+
}
139+
140+
private static Set<Product> decodeProducts(final Map<String, Object> request) {
141+
return request.client.products.collect { Product.valueOf(it)}
142+
}
143+
144+
private static long decodeCapabilities(final Map<String, Object> request) {
145+
final clientCapabilities = request.client.capabilities as byte[]
146+
long capabilities = 0l
147+
for (int i = 0; i < clientCapabilities.length; i++) {
148+
capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8)
149+
}
150+
return capabilities
151+
}
152+
153+
private static boolean hasCapability(final long capabilities, final long test) {
154+
return (capabilities & test) > 0
155+
}
156+
}

0 commit comments

Comments
 (0)