Skip to content

Commit 1906f1d

Browse files
author
Pedro González Marcos
committed
feat(features): map feature evaluation to object
1 parent 14baded commit 1906f1d

10 files changed

Lines changed: 329 additions & 26 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.github.pgmarc.space;
2+
3+
import io.github.pgmarc.space.deserializers.ErrorDeserializer;
4+
import io.github.pgmarc.space.deserializers.FeatureEvaluationDeserializer;
5+
import io.github.pgmarc.space.exceptions.SpaceApiException;
6+
import io.github.pgmarc.space.features.Consumption;
7+
import io.github.pgmarc.space.features.FeatureEvaluationResult;
8+
import io.github.pgmarc.space.serializers.ConsumptionSerializer;
9+
import okhttp3.*;
10+
import org.json.JSONObject;
11+
12+
import java.io.IOException;
13+
14+
public final class FeaturesEndpoint {
15+
16+
private static final String ENDPOINT = "features";
17+
private static final MediaType JSON = MediaType.get("application/json");
18+
private static final String STATUS_CODE = "statusCode";
19+
20+
21+
private final OkHttpClient client;
22+
private final HttpUrl baseUrl;
23+
private final Headers requiredHeaders;
24+
private final ConsumptionSerializer consumptionSerializer;
25+
private final ErrorDeserializer errorDeserializer;
26+
27+
FeaturesEndpoint(OkHttpClient client, HttpUrl baseUrl, String apiKey) {
28+
this.client = client;
29+
this.baseUrl = baseUrl.newBuilder().addPathSegment(ENDPOINT).build();
30+
this.requiredHeaders = new Headers.Builder().add("Accept", JSON.toString())
31+
.add("x-api-key", apiKey).build();
32+
this.consumptionSerializer = new ConsumptionSerializer();
33+
this.errorDeserializer = new ErrorDeserializer();
34+
}
35+
36+
private static String formatFeatureId(String service, String feature) {
37+
return service.toLowerCase() + "-" + feature;
38+
}
39+
40+
public FeatureEvaluationResult evaluate(String userId, String service, String feature) throws IOException {
41+
HttpUrl url = this.baseUrl.newBuilder().addEncodedPathSegment(userId)
42+
.addEncodedPathSegment(formatFeatureId(service, feature)).build();
43+
Request request = new Request(url, requiredHeaders ,"POST" , RequestBody.EMPTY);
44+
45+
FeatureEvaluationResult res = null;
46+
try (Response response = client.newCall(request).execute()) {
47+
JSONObject jsonResponse = new JSONObject(response.body().string());
48+
if (!response.isSuccessful()) {
49+
jsonResponse.put(STATUS_CODE, response.code());
50+
throw new SpaceApiException(errorDeserializer.fromJson(jsonResponse));
51+
}
52+
FeatureEvaluationDeserializer deserializer = new FeatureEvaluationDeserializer(service.length());
53+
res = deserializer.fromJson(jsonResponse);
54+
}
55+
56+
return res;
57+
}
58+
59+
public FeatureEvaluationResult evaluateOptimistically(String userId, String service, String featureId, Consumption consumption)
60+
throws IOException {
61+
HttpUrl url = this.baseUrl.newBuilder().addEncodedPathSegment(userId)
62+
.addEncodedPathSegment(formatFeatureId(service, featureId)).build();
63+
RequestBody body = RequestBody.create(consumptionSerializer.toJson(consumption).toString(), JSON);
64+
Request request = new Request(url, requiredHeaders ,"POST" , body);
65+
66+
FeatureEvaluationResult res = null;
67+
try (Response response = client.newCall(request).execute()) {
68+
JSONObject jsonResponse = new JSONObject(response.body().string());
69+
if (!response.isSuccessful()) {
70+
jsonResponse.put(STATUS_CODE, response.code());
71+
throw new SpaceApiException(errorDeserializer.fromJson(jsonResponse));
72+
}
73+
FeatureEvaluationDeserializer deserializer = new FeatureEvaluationDeserializer(service.length());
74+
res = deserializer.fromJson(jsonResponse);
75+
}
76+
77+
return res;
78+
}
79+
80+
public JSONObject getPricingTokenByUserId() {
81+
return null;
82+
}
83+
84+
}

space-client/src/main/java/io/github/pgmarc/space/SpaceClient.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public final class SpaceClient {
1313
private final String apiKey;
1414

1515
private ContractsEndpoint contracts;
16+
private FeaturesEndpoint features;
1617

1718
private SpaceClient(OkHttpClient httpClient, HttpUrl baseUrl, String apiKey) {
1819
this.httpClient = httpClient;
@@ -22,11 +23,18 @@ private SpaceClient(OkHttpClient httpClient, HttpUrl baseUrl, String apiKey) {
2223

2324
public ContractsEndpoint contracts() {
2425
if (contracts == null) {
25-
this.contracts = new ContractsEndpoint(httpClient, baseUrl, apiKey);
26+
contracts = new ContractsEndpoint(httpClient, baseUrl, apiKey);
2627
}
2728
return contracts;
2829
}
2930

31+
public FeaturesEndpoint features() {
32+
if (features == null) {
33+
features = new FeaturesEndpoint(httpClient, baseUrl, apiKey);
34+
}
35+
return features;
36+
}
37+
3038
public static Builder builder(String host, String apiKey) {
3139
return new Builder(host, apiKey);
3240
}

space-client/src/main/java/io/github/pgmarc/space/deserializers/FeatureEvaluationDeserializer.java

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
public class FeatureEvaluationDeserializer implements JsonDeserializable<FeatureEvaluationResult> {
1111

12+
private final int serviceLength;
13+
14+
public FeatureEvaluationDeserializer(int serviceLength) {
15+
this.serviceLength = serviceLength;
16+
}
17+
1218
private enum Keys {
1319
EVAL("eval"),
1420
ERROR("error"),
@@ -40,19 +46,27 @@ public FeatureEvaluationResult fromJson(JSONObject json) {
4046
}
4147

4248
boolean available = json.getBoolean(Keys.EVAL.toString());
43-
JSONObject jsonUsed = json.optJSONObject(Keys.USED.toString());
44-
Map<String,Number> used = jsonUsed != null ? numberMapFromJson(jsonUsed) : Map.of();
45-
JSONObject jsonLimit = json.optJSONObject(Keys.LIMIT.toString());
46-
Map<String,Number> limit = jsonLimit != null ? numberMapFromJson(jsonLimit) : Map.of();
49+
Map<String, FeatureEvaluationResult.Usage> quotas = featureQuotasFromJson(json, serviceLength);
4750

48-
return FeatureEvaluationResult.of(available, used, limit);
51+
return FeatureEvaluationResult.of(available, quotas);
4952
}
5053

51-
private static Map<String,Number> numberMapFromJson(JSONObject jsonObject) {
52-
Map<String,Number> res = new HashMap<>();
53-
for (String key : jsonObject.keySet()) {
54-
res.put(key, jsonObject.getNumber(key));
54+
private static Map<String, FeatureEvaluationResult.Usage> featureQuotasFromJson(JSONObject json, int serviceNameLength) {
55+
Map<String, FeatureEvaluationResult.Usage> res = new HashMap<>();
56+
57+
if (json.isNull(Keys.USED.toString())) {
58+
return res;
5559
}
60+
61+
for (String usageLimitId : json.getJSONObject(Keys.USED.toString()).keySet()) {
62+
String usedJsonPointer = "/" + Keys.USED + "/" + usageLimitId;
63+
String limitJsonPointer = "/" + Keys.LIMIT + "/" + usageLimitId;
64+
String usageLimit = usageLimitId.substring(serviceNameLength + 1);
65+
Number used = (Number) json.query(usedJsonPointer);
66+
Number limit = (Number) json.query(limitJsonPointer);
67+
res.put(usageLimit, FeatureEvaluationResult.Usage.of(used, limit));
68+
}
69+
5670
return res;
5771
}
5872
}

space-client/src/main/java/io/github/pgmarc/space/features/FeatureEvaluationResult.java

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,64 @@
22

33
import java.util.Collections;
44
import java.util.Map;
5+
import java.util.Objects;
6+
import java.util.Optional;
57

68
public final class FeatureEvaluationResult {
79

810
private final boolean available;
9-
private final Map<String, Number> used;
10-
private final Map<String, Number> limit;
11+
private final Map<String,Usage> quotas;
1112

12-
private FeatureEvaluationResult(boolean available, Map<String,Number> used, Map<String,Number> limit) {
13+
private FeatureEvaluationResult(boolean available, Map<String,Usage> quotas) {
1314
this.available = available;
14-
this.used = Collections.unmodifiableMap(used);
15-
this.limit = Collections.unmodifiableMap(limit);
15+
this.quotas = quotas;
1616
}
1717

1818
public boolean isAvailable() {
1919
return available;
2020
}
2121

22-
public Number getConsumed(String usageLimit) {
23-
return used.get(usageLimit);
22+
public Map<String,Usage> getQuotas() {
23+
return Collections.unmodifiableMap(quotas);
2424
}
2525

26-
public Number getLimit(String usageLimit) {
27-
return limit.get(usageLimit);
26+
public Optional<Number> getConsumed(String usageLimit) {
27+
Objects.requireNonNull(usageLimit, "usage limit must not be null");
28+
return quotas.containsKey(usageLimit) ?
29+
Optional.of(quotas.get(usageLimit).getUsed()) : Optional.empty();
2830
}
2931

30-
public static FeatureEvaluationResult of(boolean available, Map<String,Number> used, Map<String,Number> limit) {
31-
return new FeatureEvaluationResult(available, used, limit);
32+
public Optional<Number> getLimit(String usageLimit) {
33+
return quotas.containsKey(usageLimit) ?
34+
Optional.of(quotas.get(usageLimit).getLimit()) : Optional.empty();
35+
}
36+
37+
38+
public static FeatureEvaluationResult of(boolean available, Map<String,Usage> quotas) {
39+
return new FeatureEvaluationResult(available, quotas);
40+
}
41+
42+
public final static class Usage {
43+
44+
private final Number used;
45+
private final Number limit;
46+
47+
private Usage(Number used, Number limit) {
48+
this.used = used;
49+
this.limit = limit;
50+
}
51+
52+
public Number getUsed() {
53+
return used;
54+
}
55+
56+
public Number getLimit() {
57+
return limit;
58+
}
59+
60+
public static Usage of(Number used, Number limit) {
61+
return new Usage(used, limit);
62+
}
3263
}
3364

3465
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.github.pgmarc.space;
2+
3+
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
4+
import okhttp3.HttpUrl;
5+
import okhttp3.OkHttpClient;
6+
import org.junit.jupiter.api.BeforeAll;
7+
import org.junit.jupiter.api.extension.RegisterExtension;
8+
9+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
10+
11+
public class BaseEndpointTest {
12+
13+
protected static final String TEST_API_KEY = "prueba";
14+
protected static final OkHttpClient httpClient = new OkHttpClient.Builder().build();
15+
protected static HttpUrl url;
16+
17+
@RegisterExtension
18+
protected static WireMockExtension wm = WireMockExtension.newInstance()
19+
.options(wireMockConfig().dynamicPort().globalTemplating(true))
20+
.build();
21+
22+
@BeforeAll
23+
static void setUp() {
24+
url = new HttpUrl.Builder().scheme("http").host("localhost").port(wm.getPort()).build();
25+
}
26+
27+
28+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package io.github.pgmarc.space;
2+
3+
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
4+
import io.github.pgmarc.space.features.Consumption;
5+
import io.github.pgmarc.space.features.FeatureEvaluationResult;
6+
import org.junit.jupiter.api.BeforeAll;
7+
import org.junit.jupiter.api.Test;
8+
9+
import java.io.IOException;
10+
11+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
12+
13+
import static org.assertj.core.api.Assertions.*;
14+
15+
@WireMockTest
16+
class FeaturesEndpointTest extends BaseEndpointTest {
17+
18+
private static FeaturesEndpoint endpoint;
19+
20+
@BeforeAll
21+
static void setup() {
22+
endpoint = new FeaturesEndpoint(httpClient, url, TEST_API_KEY);
23+
}
24+
25+
@Test
26+
void givenSimpleFeatureIdShouldEvaluate() {
27+
28+
String userId = "e8e053c5-fd2b-4e4c-85a0-f1a52f0da72e";
29+
String featureId = "petclinic-featureA";
30+
31+
wm.stubFor(post(urlPathTemplate("/features/{userId}/{featureId}"))
32+
.withHeader("x-api-key", equalTo("prueba"))
33+
.withPathParam("userId", equalTo(userId))
34+
.withPathParam("featureId", equalTo(featureId))
35+
.willReturn(
36+
ok()
37+
.withHeader("Content-Type", "application/json")
38+
.withBodyFile("boolean-feature-evaluation.json")));
39+
40+
try {
41+
FeatureEvaluationResult res = endpoint.evaluate(userId, "Petclinic", "featureA");
42+
assertThat(res.isAvailable()).isTrue();
43+
assertThat(res.getQuotas()).isEmpty();
44+
} catch (IOException e) {
45+
fail();
46+
}
47+
48+
}
49+
50+
@Test
51+
void givenConsumptionShouldEvaluateOptimistically() {
52+
53+
String userId = "e8e053c5-fd2b-4e4c-85a0-f1a52f0da72e";
54+
String featureId = "petclinic-featureA";
55+
56+
wm.stubFor(post(urlPathTemplate("/features/{userId}/{featureId}"))
57+
.withHeader("x-api-key", equalTo("prueba"))
58+
.withPathParam("userId", equalTo(userId))
59+
.withPathParam("featureId", equalTo(featureId))
60+
.willReturn(
61+
ok()
62+
.withHeader("Content-Type", "application/json")
63+
.withBodyFile("optimistic-evaluation-response.json")));
64+
65+
String service = "Petclinic";
66+
String feature = "featureA";
67+
String usageLimit = "featureALimit";
68+
69+
try {
70+
Consumption consumption = Consumption.builder().addInt(service, usageLimit, 100).build();
71+
FeatureEvaluationResult res = endpoint.evaluateOptimistically(userId, service, feature, consumption);
72+
assertThat(res.isAvailable()).isTrue();
73+
assertThat(res.getConsumed(usageLimit)).hasValue(100);
74+
assertThat(res.getLimit(usageLimit)).hasValue(500);
75+
} catch (IOException e) {
76+
fail();
77+
}
78+
}
79+
80+
@Test
81+
void getPricingTokenByUserId() {
82+
}
83+
}

0 commit comments

Comments
 (0)