Skip to content

Commit 1c04b7e

Browse files
committed
feat/flagsmith-provider: Add tests for identity flags
Signed-off-by: Andrew Helsby <[email protected]>
1 parent 67c26ec commit 1c04b7e

File tree

5 files changed

+272
-10
lines changed

5 files changed

+272
-10
lines changed

providers/flagsmith/src/main/java/dev.openfeature.contrib.providers.flagsmith/FlagsmithProvider.java

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
package dev.openfeature.contrib.providers.flagsmith;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.node.BooleanNode;
5+
import com.fasterxml.jackson.databind.node.DoubleNode;
6+
import com.fasterxml.jackson.databind.node.IntNode;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import com.fasterxml.jackson.databind.node.TextNode;
9+
import com.fasterxml.jackson.databind.node.ValueNode;
310
import com.flagsmith.FlagsmithClient;
411
import com.flagsmith.exceptions.FlagsmithClientError;
512
import com.flagsmith.models.Flags;
13+
import dev.openfeature.contrib.providers.flagsmith.exceptions.FlagsmithJsonException;
614
import dev.openfeature.sdk.ErrorCode;
715
import dev.openfeature.sdk.EvaluationContext;
816
import dev.openfeature.sdk.FeatureProvider;
@@ -16,14 +24,18 @@
1624
import dev.openfeature.sdk.exceptions.OpenFeatureError;
1725
import dev.openfeature.sdk.exceptions.TypeMismatchError;
1826
import java.time.Instant;
27+
import java.util.ArrayList;
1928
import java.util.List;
2029
import java.util.Map;
2130
import java.util.Objects;
2231
import java.util.stream.Collectors;
32+
import lombok.SneakyThrows;
33+
import lombok.extern.slf4j.Slf4j;
2334

2435
/**
2536
* FlagsmithProvider is the JAVA provider implementation for the feature flag solution Flagsmith.
2637
*/
38+
@Slf4j
2739
class FlagsmithProvider implements FeatureProvider {
2840

2941
private static final String NAME = "Flagsmith Provider";
@@ -99,6 +111,7 @@ private <T> ProviderEvaluation<T> resolveFlagsmithEvaluation(
99111

100112
Flags flags = Objects.isNull(ctx.getTargetingKey()) || ctx.getTargetingKey().isEmpty()
101113
? flagsmith.getEnvironmentFlags()
114+
// Todo add traits when attributes are added to context
102115
: flagsmith.getIdentityFlags(ctx.getTargetingKey());
103116
// Check if the flag is enabled, return default value if not
104117
Boolean isFlagEnabled = flags.isFeatureEnabled(key);
@@ -121,11 +134,6 @@ private <T> ProviderEvaluation<T> resolveFlagsmithEvaluation(
121134
// Convert the value received from Flagsmith.
122135
flagValue = convertValue(value, expectedType);
123136

124-
if (flagValue.getClass() != expectedType) {
125-
throw new TypeMismatchError("Flag value " + key + " had unexpected type "
126-
+ flagValue.getClass() + ", expected " + expectedType + ".");
127-
}
128-
129137
} catch (FlagsmithClientError flagsmithApiError) {
130138
return buildEvaluation(defaultValue, ErrorCode.GENERAL, Reason.ERROR, null);
131139
}
@@ -166,7 +174,7 @@ private <T> ProviderEvaluation<T> buildEvaluation(
166174
/**
167175
* The method convertValue is converting the object return by the Flagsmith client.
168176
*
169-
* @param value the value we have received
177+
* @param value the value we have received from Flagsmith
170178
* @param expectedType the type we expect for this value
171179
* @param <T> the type we want to convert to
172180
* @return A converted object
@@ -176,11 +184,23 @@ private <T> T convertValue(Object value, Class<?> expectedType) {
176184
|| expectedType == String.class
177185
|| expectedType == Integer.class
178186
|| expectedType == Double.class;
179-
187+
T flagValue;
180188
if (isPrimitive) {
181-
return (T) value;
189+
flagValue = (T) value;
190+
} else {
191+
flagValue = (T) objectToValue(value);
182192
}
183-
return (T) objectToValue(value);
193+
194+
if (flagValue.getClass() != expectedType) {
195+
try {
196+
flagValue = mapJsonNodes(flagValue, expectedType);
197+
} catch (FlagsmithJsonException fje){
198+
log.warn(fje.getMessage());
199+
throw new TypeMismatchError("Flag value had an unexpected type "
200+
+ flagValue.getClass() + ", expected " + expectedType + ".");
201+
}
202+
}
203+
return flagValue;
184204
}
185205

186206
/**
@@ -189,6 +209,7 @@ private <T> T convertValue(Object value, Class<?> expectedType) {
189209
* @param object the object you want to wrap
190210
* @return the wrapped object
191211
*/
212+
@SneakyThrows
192213
private Value objectToValue(Object object) {
193214
if (object instanceof Value) {
194215
return (Value) object;
@@ -213,12 +234,43 @@ private Value objectToValue(Object object) {
213234
return new Value((Instant) object);
214235
} else if (object instanceof Map) {
215236
return new Value(mapToStructure((Map<String, Object>) object));
237+
} else if (object instanceof ObjectNode) {
238+
ObjectNode objectNode = (ObjectNode) object;
239+
return objectToValue(new ObjectMapper().convertValue(objectNode, Object.class));
216240
} else {
217241
throw new TypeMismatchError("Flag value " + object + " had unexpected type "
218242
+ object.getClass() + ".");
219243
}
220244
}
221245

246+
/**
247+
* When using identity flags the objects are returned as json nodes. This
248+
* method converts the nodes to primitive type objects.
249+
*
250+
* @param value the value we have received from Flagsmith
251+
* @param expectedType the type we expect for this value
252+
* @param <T> the type we want to convert to
253+
* @return A converted object
254+
*/
255+
private <T> T mapJsonNodes(T value, Class<?> expectedType) {
256+
if (value.getClass() == BooleanNode.class && expectedType == Boolean.class){
257+
return (T)Boolean.valueOf(((BooleanNode) value).asBoolean());
258+
}
259+
if (value.getClass() == TextNode.class && expectedType == String.class) {
260+
return (T)((TextNode) value).asText();
261+
}
262+
if (value.getClass() == IntNode.class && expectedType == Integer.class) {
263+
return (T)Integer.valueOf(((IntNode) value).asInt());
264+
}
265+
if (value.getClass() == DoubleNode.class && expectedType == Double.class) {
266+
return (T)Double.valueOf(((DoubleNode) value).asDouble());
267+
}
268+
if (value.getClass() == ObjectNode.class && expectedType == Value.class) {
269+
return (T)objectToValue((Object) value);
270+
}
271+
throw new FlagsmithJsonException("Json object could not be cast to primitive type");
272+
}
273+
222274
/**
223275
* The method mapToStructure transform a map coming from a JSON Object to a Structure type.
224276
*
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dev.openfeature.contrib.providers.flagsmith.exceptions;
2+
3+
import dev.openfeature.sdk.ErrorCode;
4+
import dev.openfeature.sdk.exceptions.GeneralError;
5+
import lombok.Getter;
6+
7+
/**
8+
* A Flagsmith provider exception is the main exception for the provider
9+
*/
10+
@Getter
11+
public class FlagsmithJsonException extends GeneralError {
12+
private static final long serialVersionUID = 1L;
13+
private final ErrorCode errorCode = ErrorCode.GENERAL;
14+
15+
public FlagsmithJsonException(String message) {
16+
super(message);
17+
}
18+
19+
}

providers/flagsmith/src/test/java/dev.openfeature.contrib.providers.flagsmith/FlagsmithProviderTest.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
import dev.openfeature.sdk.MutableStructure;
1111
import dev.openfeature.sdk.ProviderEvaluation;
1212
import dev.openfeature.sdk.Reason;
13+
import dev.openfeature.sdk.Structure;
1314
import dev.openfeature.sdk.Value;
1415
import java.io.IOException;
1516
import java.lang.reflect.Method;
1617
import java.nio.file.Files;
1718
import java.nio.file.Paths;
19+
import java.util.ArrayList;
1820
import java.util.HashMap;
1921
import java.util.Map;
2022
import java.util.concurrent.TimeUnit;
@@ -49,6 +51,11 @@ public MockResponse dispatch(RecordedRequest request) {
4951
.setBody(readMockResponse("valid_flags_response.json"))
5052
.addHeader("Content-Type", "application/json");
5153
}
54+
if (request.getPath().startsWith("/identities/")) {
55+
return new MockResponse()
56+
.setBody(readMockResponse("valid_identity_response.json"))
57+
.addHeader("Content-Type", "application/json");
58+
}
5259
return new MockResponse().setResponseCode(404);
5360
}
5461
};
@@ -195,6 +202,30 @@ void shouldResolveFlagCorrectlyWithCorrectFlagType(
195202
assertNull(evaluation.getReason());
196203
}
197204

205+
@SneakyThrows
206+
@ParameterizedTest
207+
@MethodSource("provideKeysForFlagResolution")
208+
void shouldResolveIdentityFlagCorrectlyWithCorrectFlagType(
209+
String key, String methodName, Class<?> expectedType, String flagsmithResult) {
210+
// Given
211+
Object result = null;
212+
EvaluationContext evaluationContext = new MutableContext();
213+
evaluationContext.setTargetingKey("my-identity");
214+
215+
// When
216+
Method method = flagsmithProvider.getClass()
217+
.getMethod(methodName, String.class, expectedType, EvaluationContext.class);
218+
result = method.invoke(flagsmithProvider, key, null, evaluationContext);
219+
220+
// Then
221+
ProviderEvaluation<Object> evaluation = (ProviderEvaluation<Object>) result;
222+
String resultString = getResultString(evaluation.getValue(), expectedType);
223+
224+
assertEquals(flagsmithResult, resultString);
225+
assertNull(evaluation.getErrorCode());
226+
assertNull(evaluation.getReason());
227+
}
228+
198229
@SneakyThrows
199230
@ParameterizedTest
200231
@MethodSource("provideDisabledKeysForFlagResolution")
@@ -260,7 +291,13 @@ private String getResultString(Object responseValue, Class<?> expectedType)
260291
String resultString = "";
261292
if (expectedType == Value.class) {
262293
Value value = (Value) responseValue;
263-
return new ObjectMapper().writeValueAsString(value.asStructure().asMap());
294+
try {
295+
Map<String, Object> structure = value.asStructure().asObjectMap();
296+
return new ObjectMapper().writeValueAsString(structure);
297+
} catch (ClassCastException cce) {
298+
Map<String, Value> structure = value.asStructure().asMap();
299+
return new ObjectMapper().writeValueAsString(structure);
300+
}
264301
} else {
265302
return responseValue.toString();
266303
}

providers/flagsmith/src/test/resources/mock_responses/valid_flags_response.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,23 @@
109109
"feature_state_value": {
110110
"name":"json"
111111
}
112+
},
113+
{
114+
"enabled": false,
115+
"feature": {
116+
"id": 1459,
117+
"type": "STANDARD",
118+
"name": "list_key_disabled"
119+
},
120+
"feature_state_value": ["not_string_1", "not_string_2"]
121+
},
122+
{
123+
"enabled": true,
124+
"feature": {
125+
"id": 1460,
126+
"type": "STANDARD",
127+
"name": "list_key"
128+
},
129+
"feature_state_value": ["string_1", "string_2"]
112130
}
113131
]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
{
2+
"identifier": "my-identity",
3+
"traits": [],
4+
"flags": [
5+
{
6+
"enabled": true,
7+
"feature": {
8+
"id": 1447,
9+
"type": "STANDARD",
10+
"name": "true_key"
11+
},
12+
"feature_state_value": true
13+
},
14+
{
15+
"enabled": true,
16+
"feature": {
17+
"id": 1448,
18+
"type": "STANDARD",
19+
"name": "false_key"
20+
},
21+
"feature_state_value": false
22+
},
23+
{
24+
"enabled": true,
25+
"feature": {
26+
"id": 1449,
27+
"type": "STANDARD",
28+
"name": "string_key"
29+
},
30+
"feature_state_value": "string_value"
31+
},
32+
{
33+
"enabled": true,
34+
"feature": {
35+
"id": 1450,
36+
"type": "STANDARD",
37+
"name": "int_key"
38+
},
39+
"feature_state_value": 1
40+
},
41+
{
42+
"enabled": true,
43+
"feature": {
44+
"id": 1451,
45+
"type": "STANDARD",
46+
"name": "double_key"
47+
},
48+
"feature_state_value": 3.141
49+
},
50+
{
51+
"enabled": true,
52+
"feature": {
53+
"id": 1452,
54+
"type": "STANDARD",
55+
"name": "object_key"
56+
},
57+
"feature_state_value": {
58+
"name": "json"
59+
}
60+
},
61+
{
62+
"enabled": false,
63+
"feature": {
64+
"id": 1453,
65+
"type": "STANDARD",
66+
"name": "true_key_disabled"
67+
},
68+
"feature_state_value": true
69+
},
70+
{
71+
"enabled": false,
72+
"feature": {
73+
"id": 1454,
74+
"type": "STANDARD",
75+
"name": "false_key_disabled"
76+
},
77+
"feature_state_value": false
78+
},
79+
{
80+
"enabled": false,
81+
"feature": {
82+
"id": 1455,
83+
"type": "STANDARD",
84+
"name": "string_key_disabled"
85+
},
86+
"feature_state_value": "string_value"
87+
},
88+
{
89+
"enabled": false,
90+
"feature": {
91+
"id": 1456,
92+
"type": "STANDARD",
93+
"name": "int_key_disabled"
94+
},
95+
"feature_state_value": 1
96+
},
97+
{
98+
"enabled": false,
99+
"feature": {
100+
"id": 1457,
101+
"type": "STANDARD",
102+
"name": "double_key_disabled"
103+
},
104+
"feature_state_value": 3.141
105+
},
106+
{
107+
"enabled": false,
108+
"feature": {
109+
"id": 1458,
110+
"type": "STANDARD",
111+
"name": "object_key_disabled"
112+
},
113+
"feature_state_value": {
114+
"name": "json"
115+
}
116+
},
117+
{
118+
"enabled": false,
119+
"feature": {
120+
"id": 1459,
121+
"type": "STANDARD",
122+
"name": "list_key_disabled"
123+
},
124+
"feature_state_value": ["string_1", "string_2"]
125+
},
126+
{
127+
"enabled": true,
128+
"feature": {
129+
"id": 1460,
130+
"type": "STANDARD",
131+
"name": "list_key"
132+
},
133+
"feature_state_value": ["string_1", "string_2"]
134+
}
135+
]
136+
}

0 commit comments

Comments
 (0)