From b406d8d1d9eabdb6662452259a90e38f3d618d8f Mon Sep 17 00:00:00 2001 From: Benjamin Cavy Date: Mon, 8 Apr 2024 01:45:34 +0200 Subject: [PATCH] feat: SSE support --- .../fr/maif/FeatureCacheConfiguration.java | 36 + src/main/java/fr/maif/IzanamiClient.java | 39 +- src/main/java/fr/maif/http/HttpRequester.java | 19 +- src/main/java/fr/maif/http/ResponseUtils.java | 10 +- .../java/fr/maif/requests/FeatureRequest.java | 22 + .../java/fr/maif/requests/FeatureService.java | 154 +-- .../fr/maif/requests/FetchFeatureService.java | 158 +++ .../fr/maif/requests/SSEFeatureService.java | 159 +++ .../maif/requests/SingleFeatureRequest.java | 13 + .../fr/maif/requests/events/IzanamiEvent.java | 39 + .../fr/maif/requests/events/SSEClient.java | 322 +++++ src/test/java/fr/maif/IzanamiClientTest.java | 128 +- src/test/java/fr/maif/Mocks.java | 74 ++ src/test/java/fr/maif/SSEClientTest.java | 1082 +++++++++++++++++ src/test/java/fr/maif/Sandbox.java | 67 + src/test/resources/logback-test.xml | 2 +- 16 files changed, 2091 insertions(+), 233 deletions(-) create mode 100644 src/main/java/fr/maif/requests/FetchFeatureService.java create mode 100644 src/main/java/fr/maif/requests/SSEFeatureService.java create mode 100644 src/main/java/fr/maif/requests/events/IzanamiEvent.java create mode 100644 src/main/java/fr/maif/requests/events/SSEClient.java create mode 100644 src/test/java/fr/maif/SSEClientTest.java create mode 100644 src/test/java/fr/maif/Sandbox.java diff --git a/src/main/java/fr/maif/FeatureCacheConfiguration.java b/src/main/java/fr/maif/FeatureCacheConfiguration.java index abe8acf..56583cc 100644 --- a/src/main/java/fr/maif/FeatureCacheConfiguration.java +++ b/src/main/java/fr/maif/FeatureCacheConfiguration.java @@ -15,9 +15,21 @@ public class FeatureCacheConfiguration { */ public final Duration refreshInterval; + /** + * Wether izanami client will received feature updates from remote server via SSE. + */ + public final boolean useServerSentEvent; + + /** + * Maximum time between two Izanami heartbeats on SSE connection. Used only when {@link FeatureCacheConfiguration#useServerSentEvent} is true. + */ + public final Duration serverSentEventKeepAliveInterval; + private FeatureCacheConfiguration(Builder builder) { enabled = builder.enabled; + useServerSentEvent = builder.useServerSentEvent; refreshInterval = builder.refreshInterval; + serverSentEventKeepAliveInterval = builder.serverSentEventKeepAliveInterval; } public static Builder newBuilder() { @@ -27,6 +39,8 @@ public static Builder newBuilder() { public static final class Builder { private boolean enabled = false; private Duration refreshInterval = Duration.ofMinutes(10L); + private boolean useServerSentEvent = false; + public Duration serverSentEventKeepAliveInterval = Duration.ofSeconds(25L); private Builder() { } @@ -49,6 +63,28 @@ public Builder withRefreshInterval(Duration val) { return this; } + /** + * @param val wether client should use SSE instead of polling to keep cache up to date. When using SSE, + * Izanami client will keep an http connection opened with Izanami backend, and get notified as soon + * as a feature is created / updated / deleted. + * @return updated builder + */ + public Builder shouldUseServerSentEvent(boolean val) { + this.useServerSentEvent = val; + return this; + } + + /** + * @param val wether client should use SSE instead of polling to keep cache up to date. When using SSE, + * Izanami client will keep an http connection opened with Izanami backend, and get notified as soon + * as a feature is created / updated / deleted. + * @return updated builder + */ + public Builder withServerSentEventKeepAliveInterval(Duration val) { + this.serverSentEventKeepAliveInterval = val; + return this; + } + /** * Build actual cache configuration * @return a new FeatureCacheConfiguration with this builder values diff --git a/src/main/java/fr/maif/IzanamiClient.java b/src/main/java/fr/maif/IzanamiClient.java index 25074d3..9b6e43d 100644 --- a/src/main/java/fr/maif/IzanamiClient.java +++ b/src/main/java/fr/maif/IzanamiClient.java @@ -1,10 +1,9 @@ package fr.maif; import fr.maif.http.IzanamiHttpClient; -import fr.maif.requests.FeatureRequest; -import fr.maif.requests.FeatureService; -import fr.maif.requests.IzanamiConnectionInformation; -import fr.maif.requests.SingleFeatureRequest; +import fr.maif.requests.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.Duration; import java.util.*; @@ -16,6 +15,7 @@ * This should be instantiated only once by application. */ public class IzanamiClient { + private static final Logger LOGGER = LoggerFactory.getLogger(IzanamiClient.class); private final ClientConfiguration configuration; private final FeatureService featureService; private CompletableFuture loader; @@ -36,12 +36,35 @@ public IzanamiClient( duration.orElse(Duration.ofSeconds(10L)) ); - this.featureService = new FeatureService(configuration); + if(this.configuration.cacheConfiguration.useServerSentEvent) { + LOGGER.info("Izanami client will use SSE to keep in sync"); + var service = new SSEFeatureService(configuration); + if(Objects.nonNull(idsToPreload) && !idsToPreload.isEmpty()) { + this.loader = service.featureStates(FeatureRequest.newFeatureRequest().withFeatures(idsToPreload)).thenApply(osef -> null); + } else { + this.loader = CompletableFuture.completedFuture(null); + } + this.featureService = service; + } else { + if(configuration.cacheConfiguration.enabled) { + LOGGER.info("Izanami client will use polling to keep in sync"); + } else { + LOGGER.info("Cache is disabled, Izanami client will query remote instance every time"); + } + this.featureService = new FetchFeatureService(configuration); + if(Objects.nonNull(idsToPreload) && !idsToPreload.isEmpty()) { + this.loader = featureService.featureStates(FeatureRequest.newFeatureRequest().withFeatures(idsToPreload)).thenAccept(v -> {}); + } else { + this.loader = CompletableFuture.completedFuture(null); + } + } + } - if(Objects.nonNull(idsToPreload) && !idsToPreload.isEmpty()) { - this.loader = featureService.featureStates(FeatureRequest.newFeatureRequest().withFeatures(idsToPreload)).thenAccept(v -> {}); + public CompletableFuture close() { + if(this.featureService instanceof SSEFeatureService) { + return ((SSEFeatureService)this.featureService).disconnect(); } else { - this.loader = CompletableFuture.completedFuture(null); + return CompletableFuture.completedFuture(null); } } diff --git a/src/main/java/fr/maif/http/HttpRequester.java b/src/main/java/fr/maif/http/HttpRequester.java index 6ed8f6d..d8cdb9e 100644 --- a/src/main/java/fr/maif/http/HttpRequester.java +++ b/src/main/java/fr/maif/http/HttpRequester.java @@ -17,13 +17,12 @@ public final class HttpRequester { private static final Logger LOGGER = LoggerFactory.getLogger(HttpRequester.class); - static String url(ClientConfiguration configuration, FeatureRequest request) { - var url = configuration.connectionInformation.url + "/v2/features"; + public static TreeMap queryParametersAsMap(FeatureRequest request) { var maybeFeatures = request.getFeatures().stream().sorted(String::compareTo).collect(Collectors.joining(",")); - var params = new TreeMap<>(); - params.put("conditions", true); + var params = new TreeMap(); + params.put("conditions", "true"); if(!maybeFeatures.isBlank()) { params.put("features", maybeFeatures); } @@ -33,9 +32,18 @@ static String url(ClientConfiguration configuration, FeatureRequest request) { .map(user -> params.put("user", user)); - String searchPart = params.entrySet().stream() + return params; + } + public static String queryParameters(FeatureRequest request) { + return queryParametersAsMap(request).entrySet().stream() .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); + } + + static String url(ClientConfiguration configuration, FeatureRequest request) { + var url = configuration.connectionInformation.url + "/v2/features"; + + String searchPart = queryParameters(request); url = !searchPart.isBlank() ? (url + "?" + searchPart) : url; @@ -48,7 +56,6 @@ static CompletableFuture> performCall( Function> responseMapper ) { return configuration.httpClient.apply(request) - // TODO handle error .thenApply(resp -> responseMapper.apply(resp.body)); } public static CompletableFuture>> performRequest( diff --git a/src/main/java/fr/maif/http/ResponseUtils.java b/src/main/java/fr/maif/http/ResponseUtils.java index 2ff680a..382c521 100644 --- a/src/main/java/fr/maif/http/ResponseUtils.java +++ b/src/main/java/fr/maif/http/ResponseUtils.java @@ -41,9 +41,9 @@ public static Result> parseBooleanResponse(String json) { .map(Result::new) .orElseGet(() -> new Result<>("Failed to parse response")); } catch (JsonMappingException e) { - return new Result<>("Unexpected format received from Izanami"); + return new Result<>("Unexpected format received from Izanami: " + json); } catch (JsonProcessingException e) { - return new Result<>("Invalid JSON received from Izanami"); + return new Result<>("Invalid JSON received from Izanami: " + json); } } @@ -60,9 +60,9 @@ public static Result> parseFeatureResponse(String json) { .map(Result::new) .orElseGet(() -> new Result<>("Failed to parse response")); } catch (JsonMappingException e) { - return new Result<>("Unexpected format received from Izanami"); + return new Result<>("Unexpected format received from Izanami: " + json); } catch (JsonProcessingException e) { - return new Result<>("Invalid JSON received from Izanami"); + return new Result<>("Invalid JSON received from Izanami: " + json); } } @@ -83,7 +83,7 @@ static FeatureOverload parseOverload(ObjectNode json) { } } - static Optional parseFeature(String id, ObjectNode json) { + public static Optional parseFeature(String id, ObjectNode json) { if(json.isNull()) { return Optional.empty(); } diff --git a/src/main/java/fr/maif/requests/FeatureRequest.java b/src/main/java/fr/maif/requests/FeatureRequest.java index 387a054..38ee6a8 100644 --- a/src/main/java/fr/maif/requests/FeatureRequest.java +++ b/src/main/java/fr/maif/requests/FeatureRequest.java @@ -32,6 +32,28 @@ public static SingleFeatureRequest newSingleFeatureRequest(String feature) { return new SingleFeatureRequest(feature); } + + public FeatureRequest copy() { + var req = new FeatureRequest(); + req.features = new HashMap<>(this.features); + req.payload = this.payload; + req.user = this.user; + req.callTimeout = this.callTimeout; + req.context = this.context; + req.errorStrategy= this.errorStrategy; + req.ignoreCache = this.ignoreCache; + + return req; + } + + /** + * Remove all features for this request, usefull after cpoying a query, to keep parameters but change features + */ + public FeatureRequest clearFeatures() { + this.features = new HashMap<>(); + return this; + } + /** * Add or update user for this request * @param val user to use for this request diff --git a/src/main/java/fr/maif/requests/FeatureService.java b/src/main/java/fr/maif/requests/FeatureService.java index 00c862f..39b04a5 100644 --- a/src/main/java/fr/maif/requests/FeatureService.java +++ b/src/main/java/fr/maif/requests/FeatureService.java @@ -1,158 +1,14 @@ package fr.maif.requests; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import fr.maif.ClientConfiguration; -import fr.maif.errors.IzanamiError; -import fr.maif.features.Feature; -import fr.maif.http.HttpRequester; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.*; +import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import static fr.maif.requests.FeatureRequest.newFeatureRequest; - -public class FeatureService { - protected ClientConfiguration configuration; - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureService.class); - private final Cache cache; - - public FeatureService(ClientConfiguration configuration) { - this.configuration = configuration; - this.cache = Caffeine.newBuilder() - .build(); - - if(configuration.cacheConfiguration.enabled) { - ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - scheduler.scheduleAtFixedRate( - this::refreshCache, - 0, - configuration.cacheConfiguration.refreshInterval.getSeconds(), TimeUnit.SECONDS - ); - } - } - - private void putValuesInCache(Map features) { - features.forEach(cache::put); - } - - private void refreshCache() { - Set features = cache.asMap().keySet(); - LOGGER.debug("Refreshing cache for {}", String.join(",", features)); - if(features.isEmpty()) { - return; - } - var request = new FeatureRequest().withFeatures(features); - - HttpRequester - .performRequest(configuration, request) - .thenAccept(result -> { - if(!result.isError()) { - LOGGER.debug("Received following features for cache refresh {}", String.join("," + result.value.entrySet())); - cache.invalidateAll(); - putValuesInCache(result.value); - } else { - LOGGER.error("Failed to refresh cache : {}", result.error.get()); - } - }); - } - - public CompletableFuture> featureStates( +public interface FeatureService { + CompletableFuture> featureStates( FeatureRequest request - ) { - LOGGER.debug("Feature activation request for {}", String.join(",", request.features.keySet())); - Set missingFeatures = new HashSet<>(); - Map activation = new HashMap<>(); - - request.features.values() - .forEach(f -> { - boolean shouldIgnoreCache = request.isCacheIgnoredFor(f.feature) - .orElseGet(() -> !configuration.cacheConfiguration.enabled); - if(shouldIgnoreCache) { - missingFeatures.add(f); - } else { - Optional maybeActivation = Optional.ofNullable(cache.getIfPresent(f.feature)) - .flatMap(cachedFeature -> cachedFeature.active(request.context.orElse(null), request.user)); - maybeActivation.ifPresentOrElse(active -> activation.put(f.feature, active), () -> missingFeatures.add(f)); - } - }); - - if(missingFeatures.isEmpty()) { - return CompletableFuture - .completedFuture(activation); - } else { - var missingRequest = newFeatureRequest().withSpecificFeatures(missingFeatures) - .withErrorStrategy(request.errorStrategy.orElse(null)) - .withCallTimeout(request.getTimeout().orElse(null)) - .withUser(request.user) - .withContext(request.context.orElse(null)) - .withPayload(request.payload.orElse(null)); - return HttpRequester.performRequest(configuration, missingRequest) - .thenApply(featureResponse -> { - if(featureResponse.isError()) { - String errorMsg = featureResponse.error.get(); - LOGGER.error("Failed to retrieve features : {}", errorMsg); - missingFeatures.forEach(f -> { - var errorStrategy = missingRequest.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); - if(!errorStrategy.lastKnownFallbackAllowed) { - activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMsg)).join()); - } else { - Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) - .flatMap(feat -> feat.active(missingRequest.context.orElse(null), missingRequest.user)) - .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMsg)).join()); - activation.put(f.feature, active); - } - }); - } else { - Map featuresById = featureResponse.value; - missingFeatures.forEach(f -> { - if(featuresById.containsKey(f.feature)) { - var feature = featuresById.get(f.feature); - cache.put(f.feature, feature); - activation.put(f.feature, feature.active); - } else { - // TODO deduplicate this - var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); - String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; - if(!errorStrategy.lastKnownFallbackAllowed) { - activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); - } else { - Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) - .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) - .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); - activation.put(f.feature, active); - } - } - }); - } - return activation; - }).exceptionally(ex -> { - LOGGER.error("Failed to query remote Izanami", ex); - missingFeatures.forEach(f -> { - // TODO deduplicate this - var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); - String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; - if(!errorStrategy.lastKnownFallbackAllowed) { - activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); - } else { - Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) - .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) - .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); - activation.put(f.feature, active); - } - }); - return activation; - }); - } - } + ); - public CompletableFuture featureStates(SingleFeatureRequest request) { + default CompletableFuture featureStates(SingleFeatureRequest request) { return featureStates(request.toActivationRequest()) .thenApply(resp -> resp.get(request.feature)); } diff --git a/src/main/java/fr/maif/requests/FetchFeatureService.java b/src/main/java/fr/maif/requests/FetchFeatureService.java new file mode 100644 index 0000000..614ab22 --- /dev/null +++ b/src/main/java/fr/maif/requests/FetchFeatureService.java @@ -0,0 +1,158 @@ +package fr.maif.requests; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import fr.maif.ClientConfiguration; +import fr.maif.errors.IzanamiError; +import fr.maif.features.Feature; +import fr.maif.http.HttpRequester; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static fr.maif.requests.FeatureRequest.newFeatureRequest; + +public class FetchFeatureService implements FeatureService { + protected ClientConfiguration configuration; + private static final Logger LOGGER = LoggerFactory.getLogger(FetchFeatureService.class); + private final Cache cache; + + public FetchFeatureService(ClientConfiguration configuration) { + this.configuration = configuration; + this.cache = Caffeine.newBuilder() + .build(); + + + if(configuration.cacheConfiguration.enabled) { + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + scheduler.scheduleAtFixedRate( + this::refreshCache, + 0, + configuration.cacheConfiguration.refreshInterval.getSeconds(), TimeUnit.SECONDS + ); + } + } + + private void putValuesInCache(Map features) { + features.forEach(cache::put); + } + + private void refreshCache() { + Set features = cache.asMap().keySet(); + LOGGER.debug("Refreshing cache for {}", String.join(",", features)); + if(features.isEmpty()) { + return; + } + var request = new FeatureRequest().withFeatures(features); + + HttpRequester + .performRequest(configuration, request) + .thenAccept(result -> { + if(!result.isError()) { + LOGGER.debug("Received following features for cache refresh {}", String.join("," + result.value.entrySet())); + cache.invalidateAll(); + putValuesInCache(result.value); + } else { + LOGGER.error("Failed to refresh cache : {}", result.error.get()); + } + }); + } + + public CompletableFuture> featureStates( + FeatureRequest request + ) { + LOGGER.debug("Feature activation request for {}", String.join(",", request.features.keySet())); + Set missingFeatures = new HashSet<>(); + Map activation = new HashMap<>(); + + request.features.values() + .forEach(f -> { + boolean shouldIgnoreCache = request.isCacheIgnoredFor(f.feature) + .orElseGet(() -> !configuration.cacheConfiguration.enabled); + if(shouldIgnoreCache) { + missingFeatures.add(f); + } else { + Optional maybeActivation = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(cachedFeature -> cachedFeature.active(request.context.orElse(null), request.user)); + maybeActivation.ifPresentOrElse(active -> activation.put(f.feature, active), () -> missingFeatures.add(f)); + } + }); + if(LOGGER.isDebugEnabled()) { + LOGGER.debug("Some features are missing in cache: {}", missingFeatures.stream().map(f -> f.feature).collect(Collectors.joining(","))); + } + + if(missingFeatures.isEmpty()) { + return CompletableFuture + .completedFuture(activation); + } else { + var missingRequest = newFeatureRequest().withSpecificFeatures(missingFeatures) + .withErrorStrategy(request.errorStrategy.orElse(null)) + .withCallTimeout(request.getTimeout().orElse(null)) + .withUser(request.user) + .withContext(request.context.orElse(null)) + .withPayload(request.payload.orElse(null)); + return HttpRequester.performRequest(configuration, missingRequest) + .thenApply(featureResponse -> { + if(featureResponse.isError()) { + String errorMsg = featureResponse.error.get(); + LOGGER.error("Failed to retrieve features : {}", errorMsg); + missingFeatures.forEach(f -> { + var errorStrategy = missingRequest.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMsg)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(missingRequest.context.orElse(null), missingRequest.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMsg)).join()); + activation.put(f.feature, active); + } + }); + } else { + Map featuresById = featureResponse.value; + missingFeatures.forEach(f -> { + if(featuresById.containsKey(f.feature)) { + var feature = featuresById.get(f.feature); + cache.put(f.feature, feature); + activation.put(f.feature, feature.active); + } else { + // TODO deduplicate this + var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + activation.put(f.feature, active); + } + } + }); + } + return activation; + }).exceptionally(ex -> { + LOGGER.error("Failed to query remote Izanami", ex); + missingFeatures.forEach(f -> { + // TODO deduplicate this + var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + activation.put(f.feature, active); + } + }); + return activation; + }); + } + } +} diff --git a/src/main/java/fr/maif/requests/SSEFeatureService.java b/src/main/java/fr/maif/requests/SSEFeatureService.java new file mode 100644 index 0000000..70972a6 --- /dev/null +++ b/src/main/java/fr/maif/requests/SSEFeatureService.java @@ -0,0 +1,159 @@ +package fr.maif.requests; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import fr.maif.ClientConfiguration; +import fr.maif.errors.IzanamiError; +import fr.maif.features.Feature; +import fr.maif.requests.events.IzanamiEvent; +import fr.maif.requests.events.SSEClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +public class SSEFeatureService implements FeatureService { + private static final Logger LOGGER = LoggerFactory.getLogger(SSEFeatureService.class); + private final SSEClient sseClient; + private final Cache cache; + private FeatureRequest scope = FeatureRequest.newFeatureRequest(); + private FetchFeatureService underlying; + private ClientConfiguration configuration; + + public SSEFeatureService(ClientConfiguration clientConfiguration) { + this.configuration = clientConfiguration; + this.sseClient = new SSEClient(clientConfiguration); + this.cache = Caffeine.newBuilder().build(); + this.underlying = new FetchFeatureService(clientConfiguration); + } + + private void processEvent(IzanamiEvent event) { + LOGGER.debug("Processing event {}", event); + if (event instanceof IzanamiEvent.FeatureStates) { + cache.invalidateAll(); + var featureStates = (IzanamiEvent.FeatureStates) event; + featureStates.features.forEach(cache::put); + LOGGER.debug("Done updating cache with feature states"); + } else if (event instanceof IzanamiEvent.FeatureCreated) { + var featureCreated = (IzanamiEvent.FeatureCreated) event; + cache.put(featureCreated.feature.id, featureCreated.feature); + } else if (event instanceof IzanamiEvent.FeatureUpdated) { + var featureUpdated = (IzanamiEvent.FeatureUpdated) event; + cache.put(featureUpdated.feature.id, featureUpdated.feature); + } else if (event instanceof IzanamiEvent.FeatureDeleted) { + cache.invalidate(((IzanamiEvent.FeatureDeleted) event).feature); + } + } + + @Override + public CompletableFuture> featureStates(FeatureRequest request) { + LOGGER.debug("Feature states is requested for {}", String.join(",", request.features.keySet())); + Set missingFeatures = new HashSet<>(); + Set scriptFeatures = new HashSet<>(); + Map activation = new ConcurrentHashMap<>(); + + request.features.values().forEach(f -> { + var maybeFeature = cache.getIfPresent(f.feature); + if (Objects.isNull(maybeFeature)) { + LOGGER.debug("Feature {} is absent from cache", f.feature); + if (!scope.features.containsKey(f.feature)) { + LOGGER.debug("Feature {} is not in scope, adding it", f.feature); + missingFeatures.add(f); + } + } else { + maybeFeature.active(request.context.orElse(null), request.user).ifPresentOrElse(active -> { + LOGGER.debug("Computing activation for {} from cache, result is {}", f.feature, active); + activation.put(f.feature, active); + }, () -> scriptFeatures.add(f)); + } + }); + + LOGGER.debug("Activation is {}", activation); + + if (LOGGER.isDebugEnabled() && !missingFeatures.isEmpty()) { + LOGGER.debug("Missing features in cache and current scope : {}", missingFeatures.stream().map(f -> f.feature).collect(Collectors.joining(","))); + } else if(LOGGER.isDebugEnabled()) { + LOGGER.debug("No missing features in cache"); + } + + List>> results = new ArrayList<>(); + if (!missingFeatures.isEmpty()) { + CompletableFuture> missingFuture = new CompletableFuture<>(); + Duration timeout = request.getTimeout().orElse(configuration.callTimeout); + missingFuture.completeOnTimeout(new HashMap<>(), timeout.getSeconds(), TimeUnit.SECONDS); + + results.add(missingFuture); + AtomicBoolean isFirst = new AtomicBoolean(true); + var newScope = Optional.ofNullable(this.scope) + .map(s -> s.copy().withSpecificFeatures(missingFeatures).withContext(request.context.orElse(null)).withUser(request.user).withPayload(request.payload.orElse(null))) + .orElseGet(() -> request); + this.scope = newScope; + + LOGGER.debug("Requesting {} missing features", missingFeatures.size()); + this.sseClient.reconnectWith(newScope, (connId, evtId, event) -> { + LOGGER.debug("Received {} event in client", event.getClass().getSimpleName()); + this.processEvent(event); + if(isFirst.get() && event instanceof IzanamiEvent.FeatureStates && connId.equals(evtId)) { + LOGGER.debug("Receiving response for missing features"); + isFirst.set(false); + var states = (IzanamiEvent.FeatureStates) event; + Map missingActivations = new HashMap<>(); + states.features.forEach((key, value) -> { + LOGGER.debug("Received {} for feature {}", value.active, key); + cache.put(key, value); + missingActivations.put(key, value.active); + }); + + missingFuture.complete(missingActivations); + } + }).exceptionally(e -> { + LOGGER.error("Received exception while requesting missing features", e); + missingFuture.complete(new HashMap<>()); + return null; + }); + } + + // TODO there might be an optimization to do here, in case we got both missing and script features, + // we would simply need to retrieve missingFeature query result to get our script feature status + if (!scriptFeatures.isEmpty()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Some script feature are requested : {}", missingFeatures.stream().map(f -> f.feature).collect(Collectors.joining(","))); + } + + results.add(underlying.featureStates(request.copy().clearFeatures().withSpecificFeatures(scriptFeatures))); + } + + LOGGER.debug("Will wait for {} queries", results.size()); + return CompletableFuture.allOf(results.toArray(new CompletableFuture[0])).thenApply(useless -> { + results.forEach(cf -> { + var act = cf.join(); + act.entrySet().stream() + .filter(entry -> Objects.nonNull(entry.getValue()) && Objects.nonNull(entry.getKey())) + .forEach(entry -> activation.put(entry.getKey(), entry.getValue())); + }); + // ConcurrentHashMap does not support null values, therefore we switch to a simple HashMap + var activationWithMaybeNulls = new HashMap(); + request.features.values().stream().map(f -> f.feature).forEach(id -> { + if (!activation.containsKey(id)) { + var errorStrategy = request.errorStrategyFor(id).orElseGet(() -> configuration.errorStrategy); + String errorMessage = "Missing feature in Izanami response : " + id + ". Either this feature has been deleted or your key is not authorized for it."; + activationWithMaybeNulls.put(id, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + } else { + activationWithMaybeNulls.put(id, activation.get(id)); + } + }); + return activationWithMaybeNulls; + }); + } + + public CompletableFuture disconnect() { + this.sseClient.close(); + return CompletableFuture.completedFuture(null); + } +} diff --git a/src/main/java/fr/maif/requests/SingleFeatureRequest.java b/src/main/java/fr/maif/requests/SingleFeatureRequest.java index de7a396..22b0c08 100644 --- a/src/main/java/fr/maif/requests/SingleFeatureRequest.java +++ b/src/main/java/fr/maif/requests/SingleFeatureRequest.java @@ -2,6 +2,9 @@ import fr.maif.FeatureClientErrorStrategy; +import java.time.Duration; +import java.util.Optional; + /** * This class is a data container used to represent query for a single feature */ @@ -36,6 +39,16 @@ public SingleFeatureRequest withUser(String val) { return this; } + /** + * Add or update http call timeout for this request + * @param timeout http call timeout to use for this request + * @return this request modified with provided http call timeout + */ + public SingleFeatureRequest withCallTimeout(Duration timeout) { + request.callTimeout = Optional.ofNullable(timeout); + return this; + } + /** * Add or update error strategy for this request * @param errorStrategy error strategy to use for this request diff --git a/src/main/java/fr/maif/requests/events/IzanamiEvent.java b/src/main/java/fr/maif/requests/events/IzanamiEvent.java new file mode 100644 index 0000000..a73e50e --- /dev/null +++ b/src/main/java/fr/maif/requests/events/IzanamiEvent.java @@ -0,0 +1,39 @@ +package fr.maif.requests.events; + +import fr.maif.features.Feature; + +import java.util.Map; + +public interface IzanamiEvent { + public static class FeatureStates implements IzanamiEvent { + public Map features; + + public FeatureStates(Map features) { + this.features = features; + } + } + + public static class FeatureCreated implements IzanamiEvent { + public Feature feature; + + public FeatureCreated(Feature feature) { + this.feature = feature; + } + } + + public static class FeatureUpdated implements IzanamiEvent { + public Feature feature; + + public FeatureUpdated(Feature feature) { + this.feature = feature; + } + } + + public static class FeatureDeleted implements IzanamiEvent { + public String feature; + + public FeatureDeleted(String feature) { + this.feature = feature; + } + } +} diff --git a/src/main/java/fr/maif/requests/events/SSEClient.java b/src/main/java/fr/maif/requests/events/SSEClient.java new file mode 100644 index 0000000..4ff6551 --- /dev/null +++ b/src/main/java/fr/maif/requests/events/SSEClient.java @@ -0,0 +1,322 @@ +package fr.maif.requests.events; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import fr.maif.ClientConfiguration; +import fr.maif.errors.IzanamiException; +import fr.maif.http.HttpRequester; +import fr.maif.http.ResponseUtils; +import fr.maif.requests.FeatureRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.concurrent.TimeUnit.SECONDS; + +public class SSEClient { + private static final Logger LOGGER = LoggerFactory.getLogger(SSEClient.class); + private final ClientConfiguration clientConfiguration; + private final HttpClient httpClient; + private Stream currentConnection; + private final AtomicLong connectionId = new AtomicLong(0L); + + private CompletableFuture queryFuture; + private FeatureRequest request; + private BiConsumer consumer; + + private final ScheduledExecutorService lifeProbeExecutorService; + + private final ExecutorService executorService; + + private final AtomicBoolean connected = new AtomicBoolean(false); + + private final AtomicReference lastEventDate = new AtomicReference<>(LocalDateTime.now()); + + public SSEClient(ClientConfiguration clientConfiguration) { + this.clientConfiguration = clientConfiguration; + lifeProbeExecutorService = Executors.newSingleThreadScheduledExecutor(); + Duration keepAliveInterval = clientConfiguration.cacheConfiguration.serverSentEventKeepAliveInterval; + Duration maxToleratedDurationWithoutEvents = keepAliveInterval.multipliedBy(3L); + lifeProbeExecutorService.scheduleAtFixedRate(() -> { + if (connected.get()) { + Duration periodSinceLastEvent = Duration.between(lastEventDate.get(), LocalDateTime.now()); + LOGGER.debug("Periodic event presence check, it's been {} seconds since last event (max tolerance is {})", periodSinceLastEvent.toSeconds(), maxToleratedDurationWithoutEvents.toSeconds()); + if (maxToleratedDurationWithoutEvents.compareTo(periodSinceLastEvent) < 0) { + LOGGER.error("No event received since {} seconds, will try to disconnect / reconnect", periodSinceLastEvent.toSeconds()); + reconnect(); + } + } + }, + maxToleratedDurationWithoutEvents.getSeconds(), + maxToleratedDurationWithoutEvents.getSeconds(), + SECONDS + ); + this.executorService = Executors.newFixedThreadPool(2); + this.httpClient = HttpClient.newBuilder().executor(this.executorService).build(); + } + + + public CompletableFuture doConnect(FeatureRequest request, BiConsumer consumer, long id) throws IzanamiException { + this.request = request; + this.consumer = consumer; + LOGGER.debug("Connecting to remote Izanami SSE endpoint"); + Map searchPartAsMap = HttpRequester.queryParametersAsMap(request); + searchPartAsMap.put("refreshInterval", Long.toString(clientConfiguration.cacheConfiguration.refreshInterval.toSeconds())); + searchPartAsMap.put("keepAliveInterval", Long.toString(clientConfiguration.cacheConfiguration.serverSentEventKeepAliveInterval.toSeconds())); + + String searchPart = searchPartAsMap.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + + String url = clientConfiguration.connectionInformation.url + "/v2/events"; + if (!searchPart.isBlank()) { + url = url + "?" + searchPart; + } + + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(new URI(url)) + .setHeader("Izanami-Client-Id", clientConfiguration.connectionInformation.clientId) + .setHeader("Izanami-Client-Secret", clientConfiguration.connectionInformation.clientSecret) + .timeout(request.getTimeout().orElse(clientConfiguration.callTimeout)); + + + var r = request.getPayload() + .map(payload -> requestBuilder.POST(HttpRequest.BodyPublishers.ofString(payload))) + .orElseGet(requestBuilder::GET).build(); + + LOGGER.debug("Calling {} with a timeout of {} seconds", r.uri().toString(), r.timeout().get().toSeconds()); + + this.queryFuture = httpClient.sendAsync(r, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(resp -> { + + if(resp.statusCode() >= 400) { + LOGGER.error("Izanami responded with status code {}", resp.statusCode()); + throw new RuntimeException("Failed to connect to Izanami backend"); + } else { + LOGGER.info("Connected to remote Izanami SSE endpoint"); + connected.set(true); + } + + var sseMachine = new SSEStateMachine(); + + this.currentConnection = resp.body(); + + this.currentConnection.map(line -> { + var res = sseMachine.addLine(line); + return res.flatMap(EventService::fromSSE); + }) + .flatMap(Optional::stream) + .forEach(evt -> { + lastEventDate.set(LocalDateTime.now()); + consumer.accept(id, evt); + }); + }).exceptionally(e -> { + connected.set(false); + LOGGER.error("An error occured while connecting to sse endpoint : ", e); + CompletableFuture.delayedExecutor(5, SECONDS, executorService).execute(() -> doConnect(request, consumer, id)); + throw new RuntimeException(e); + }); + return queryFuture; + } catch (URISyntaxException e) { + return CompletableFuture.failedFuture(e); + } + } + + public CompletableFuture disconnect() { + LOGGER.info("Disconnecting from SSE endpoint"); + if (Objects.nonNull(currentConnection)) { + LOGGER.debug("Closing event stream"); + currentConnection.close(); + } + + connected.set(false); + return CompletableFuture.completedFuture(null); + } + + public void close() { + disconnect(); + lifeProbeExecutorService.shutdown(); + executorService.shutdown(); + } + + + @FunctionalInterface + public interface ReconnectionConsumer { + void apply(Long connectionId, Long eventId, IzanamiEvent event); + } + + // TODO factorise + public CompletableFuture reconnect() { + LOGGER.debug("Reconnecting with new feature set..."); + if (Objects.nonNull(currentConnection)) { + LOGGER.debug("Disconnecting"); + disconnect(); + } else { + LOGGER.debug("No connection opened"); + } + + LOGGER.debug("Reconnecting"); + return doConnect(request, consumer, connectionId.get()); + } + + public CompletableFuture reconnectWith(FeatureRequest request, ReconnectionConsumer consumer) { + long nextId = connectionId.incrementAndGet(); + this.request = request; + this.consumer = (evtId, evt) -> consumer.apply(nextId, evtId, evt); + + return reconnect(); + } + + public static class EventService { + public static final Logger LOGGER = LoggerFactory.getLogger(EventService.class); + + public static Optional fromSSE(ServerSentEvent event) { + try { + JsonNode json = ResponseUtils.mapper.readTree(event.data); + switch (event.eventType) { + case "FEATURE_CREATED": + return ResponseUtils.parseFeature(json.get("id").asText(), (ObjectNode) json.get("payload")).map(IzanamiEvent.FeatureCreated::new); + case "FEATURE_DELETED": + String id = json.get("payload").asText(); + return Optional.of(new IzanamiEvent.FeatureDeleted(id)); + case "FEATURE_UPDATED": + return ResponseUtils.parseFeature(json.get("id").asText(), (ObjectNode) json.get("payload")).map(IzanamiEvent.FeatureUpdated::new); + case "FEATURE_STATES": + return Optional.of(new IzanamiEvent.FeatureStates(ResponseUtils.parseFeatureResponse(json.get("payload").toString()).value)); + default: + return Optional.empty(); + } + } catch (JsonProcessingException e) { + LOGGER.error("Failed to parse SSE " + event + "with error", e); + return Optional.empty(); + } + } + } + + public static class SSEStateMachine { + private final Pattern EVENT_LINE_REGEXP = Pattern.compile("^(?
event|id|data)( *):( *)(?.*)$", Pattern.CASE_INSENSITIVE); + private ServerSentEvent.Builder currentBuilder = ServerSentEvent.newBuilder(); + + public Optional addLine(String line) { + LOGGER.info("LINE {}", line); + if (Objects.isNull(line) || line.isBlank()) { + var sse = currentBuilder.build(); + this.currentBuilder = ServerSentEvent.newBuilder(); + if(Objects.nonNull(sse.data)) { + LOGGER.debug("Received {}", sse); + return Optional.of(sse); + } + return Optional.empty(); + } else { + processLine(line); + return Optional.empty(); + } + } + + private void processLine(String line) { + var eventMatcher = EVENT_LINE_REGEXP.matcher(line); + if (eventMatcher.matches()) { + String lineType = eventMatcher.group("header"); + String value = eventMatcher.group("value"); + switch (lineType.toLowerCase()) { + case "data": { + this.currentBuilder.withData(value); + break; + } + case "id": { + this.currentBuilder.withId(value); + break; + } + case "event": { + this.currentBuilder.withEventType(value); + break; + } + default: { + + } + } + } + } + } + + public static class ServerSentEvent { + public final String eventType; + public final String data; + public final String id; + public final Long retry; + + private ServerSentEvent(Builder builder) { + eventType = builder.eventType; + data = builder.data; + id = builder.id; + retry = builder.retry; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private String eventType; + private String data; + private String id; + private Long retry; + + private Builder() { + } + + public Builder withEventType(String val) { + eventType = val; + return this; + } + + public Builder withData(String val) { + data = val; + return this; + } + + public Builder withId(String val) { + id = val; + return this; + } + + public Builder withRetry(Long val) { + retry = val; + return this; + } + + public ServerSentEvent build() { + return new ServerSentEvent(this); + } + } + + @Override + public String toString() { + return "ServerSentEvent{" + + "eventType='" + eventType + '\'' + + ", data='" + data + '\'' + + ", id='" + id + '\'' + + ", retry=" + retry + + '}'; + } + } +} diff --git a/src/test/java/fr/maif/IzanamiClientTest.java b/src/test/java/fr/maif/IzanamiClientTest.java index 619532b..3316b84 100644 --- a/src/test/java/fr/maif/IzanamiClientTest.java +++ b/src/test/java/fr/maif/IzanamiClientTest.java @@ -54,8 +54,8 @@ public void should_recompute_feature_locally_when_requested_with_different_param String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", false).withOverload(overload(true).withCondition(condition(true).withRule(userListRule("foo")))); String stub = newResponse().withFeature(id, featureStub).toJson(); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id + "&user=bar") .withHeader("Izanami-Client-Id", equalTo(clientId)) @@ -106,8 +106,8 @@ public void should_allow_to_bypass_cache() throws InterruptedException { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", false).withOverload(overload(false)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id + "&user=bar") .withHeader("Izanami-Client-Id", equalTo(clientId)) @@ -133,8 +133,8 @@ public void should_allow_to_bypass_cache() throws InterruptedException { featureStub.active = true; mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=ae5dd05d-4e90-4ce7-bee7-3751750fdeaa&user=bar") - .withHeader("Izanami-Client-Id", equalTo("87mpqvd86tskt43h")) - .withHeader("Izanami-Client-Secret", equalTo("kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg")) + .withHeader("Izanami-Client-Id", equalTo("THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS")) + .withHeader("Izanami-Client-Secret", equalTo("THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS")) .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") .withBody(response.toJson()) )); @@ -152,8 +152,8 @@ public void cache_bypass_should_update_cache() throws InterruptedException { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", false).withOverload(overload(false)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) .withHeader("Izanami-Client-Id", equalTo(clientId)) @@ -207,8 +207,8 @@ public void should_not_use_cache_for_script_feature() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true).withScript("foo")); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) .withHeader("Izanami-Client-Id", equalTo(clientId)) @@ -255,8 +255,8 @@ public void should_not_use_cache_if_disabled() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -307,8 +307,8 @@ public void should_use_cache_even_if_disabled_when_query_fails() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -347,8 +347,8 @@ public void should_use_cache_even_if_ignored_when_query_fails() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -387,8 +387,8 @@ public void should_not_use_cache_on_failed_query_if_specified() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -430,8 +430,8 @@ public void should_prioritize_feature_cache_instruction_over_query() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -483,8 +483,8 @@ public void should_prioritize_feature_cache_instruction_over_query() { @Test public void fail_strategy_should_throw_an_exception_when_needed() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -507,8 +507,8 @@ public void fail_strategy_should_throw_an_exception_when_needed() { @Test public void default_value_strategy_should_return_given_value() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -531,8 +531,8 @@ public void default_value_strategy_should_return_given_value() { @Test public void callback_strategy_should_return_callback_value() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; AtomicBoolean callbackCalled = new AtomicBoolean(false); var client = IzanamiClient @@ -561,8 +561,8 @@ public void callback_strategy_should_return_callback_value() { @Test public void feature_error_strategy_should_prevail_over_query() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -591,8 +591,8 @@ public void feature_error_strategy_should_prevail_over_query() { @Test public void feature_error_strategy_should_prevail_over_global() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -620,8 +620,8 @@ public void feature_error_strategy_should_prevail_over_global() { @Test public void query_error_strategy_should_prevail_over_global() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -646,8 +646,8 @@ public void query_error_strategy_should_prevail_over_global() { @Test public void fail_strategy_should_throw_for_single_feature_request() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -670,8 +670,8 @@ public void fail_strategy_should_throw_for_single_feature_request() { @Test public void fail_strategy_should_throw_for_multiple_feature_query() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; var client = IzanamiClient .newBuilder( @@ -698,8 +698,8 @@ public void should_return_all_features_activation_for_multi_feature_query() { var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)); var featureStub2 = Mocks.feature("bar", false).withOverload(overload(true)); var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2; String url2 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1; @@ -743,8 +743,8 @@ public void should_use_error_strategy_for_missing_feature_in_multi_feature_query String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id1, featureStub1); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2; String url2 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1; @@ -788,8 +788,8 @@ public void should_use_individual_strategies_when_query_fails_if_defined() { String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; String id3 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeao"; - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2 + "," + id3; String url2 = "/api/v2/features?conditions=true&features=" + id1 + "," + id3 + "," + id2; String url3 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1 + "," + id3; @@ -839,8 +839,8 @@ public void should_return_activation_status_for_given_context() { var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)).withOverload("foo", overload(false)); var featureStub2 = Mocks.feature("bar", true).withOverload(overload(false)).withOverload("foo", overload(true)); var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -910,8 +910,8 @@ public void should_handle_context_hierarchy_correctly() { var featureStub2 = Mocks.feature("bar2", false).withOverload(overload(true)).withOverload("foo", overload(true)).withOverload("foo/bar", overload(false)); var featureStub3 = Mocks.feature("bar3", true).withOverload(overload(false)).withOverload("foo", overload(true)); var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2).withFeature(id3, featureStub3); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -990,8 +990,8 @@ public void single_queries_with_cache_ignore_should_ignore_cache() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features?conditions=true&features=" + id; mockServer.stubFor(WireMock.get(url) @@ -1041,8 +1041,8 @@ public void multiple_queries_with_cache_ignore_should_ignore_cache() { var featureStub1 = Mocks.feature("bar1", true).withOverload(overload(true)); var featureStub2 = Mocks.feature("bar2", false).withOverload(overload(false)); var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1096,8 +1096,8 @@ public void query_timeout_should_apply() { String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub1 = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id1, featureStub1); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.setGlobalFixedDelay(5000); @@ -1134,8 +1134,8 @@ public void cache_should_be_refreshed_at_specified_periods() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1190,8 +1190,8 @@ public void cache_should_not_be_cleared_if_refresh_fails() throws InterruptedExc String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1243,8 +1243,8 @@ public void cache_should_not_be_cleared_if_refresh_timeout() throws InterruptedE String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1288,8 +1288,8 @@ public void preload_should_aliment_cache() throws InterruptedException { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1328,8 +1328,8 @@ public void preload_failure_should_no_aliment_cache() throws InterruptedExceptio String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) @@ -1378,8 +1378,8 @@ public void request_with_payload_should_trigger_POST_queries() { String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); var response = newResponse().withFeature(id, featureStub); - String clientId = "87mpqvd86tskt43h"; - String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; String url = "/api/v2/features"; mockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo(url)).withRequestBody(equalToJson("{\"foo\": \"bar\"}")) diff --git a/src/test/java/fr/maif/Mocks.java b/src/test/java/fr/maif/Mocks.java index 6385193..6d824ac 100644 --- a/src/test/java/fr/maif/Mocks.java +++ b/src/test/java/fr/maif/Mocks.java @@ -4,14 +4,21 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.databind.node.ValueNode; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import fr.maif.http.ResponseUtils; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.*; import java.util.stream.Collectors; public class Mocks { + private static final Random generator = new Random(); private static final ObjectMapper mapper = new ObjectMapper(); static { @@ -81,6 +88,30 @@ public String toJson() { } } + public String toSSEJson() { + var enveloppe = eventEnveloppe("FEATURE_STATES", Optional.empty()); + try { + enveloppe.put("payload", ResponseUtils.mapper.readTree(toJson())); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + try { + return ResponseUtils.mapper.writeValueAsString(enveloppe); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + } + + public static ObjectNode eventEnveloppe(String type, Optional timestamp) { + try { + var map = Map.of("_id", generator.nextLong(), + "timestamp", ResponseUtils.mapper.writeValueAsString(timestamp.map(ts -> ts.toInstant(ZoneOffset.UTC).toEpochMilli()).orElseGet(() -> Instant.now().toEpochMilli())), + "type", type); + return ResponseUtils.mapper.convertValue(map, ObjectNode.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } public static class MockedFeature { @@ -110,6 +141,49 @@ public String toJson() { throw new RuntimeException(e); } } + + public String toUpdatedEvent(String id) { + var enveloppe = eventEnveloppe("FEATURE_UPDATED", Optional.empty()); + enveloppe.put("payload", mapper.convertValue(this, ObjectNode.class)); + enveloppe.put("id", new TextNode(id)); + try { + return mapper.writeValueAsString(enveloppe); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public String toAddedEvent(String id) { + var enveloppe = eventEnveloppe("FEATURE_CREATED", Optional.empty()); + enveloppe.put("payload", mapper.convertValue(this, ObjectNode.class)); + enveloppe.put("id", new TextNode(id)); + try { + return mapper.writeValueAsString(enveloppe); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + } + + public static String emptyFeatureStatesEvent() { + var enveloppe = eventEnveloppe("FEATURE_STATES", Optional.empty()); + try { + enveloppe.put("payload", mapper.convertValue(new HashMap<>(), ObjectNode.class)); + return mapper.writeValueAsString(enveloppe); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static String featureDeletedEvent(String id) { + var enveloppe = eventEnveloppe("FEATURE_DELETED", Optional.empty()); + enveloppe.put("payload", new TextNode(id)); + + try { + return mapper.writeValueAsString(enveloppe); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } public static class MockOverload { diff --git a/src/test/java/fr/maif/SSEClientTest.java b/src/test/java/fr/maif/SSEClientTest.java new file mode 100644 index 0000000..39f2847 --- /dev/null +++ b/src/test/java/fr/maif/SSEClientTest.java @@ -0,0 +1,1082 @@ +package fr.maif; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import fr.maif.requests.FeatureRequest; +import fr.maif.requests.IzanamiConnectionInformation; +import fr.maif.requests.SingleFeatureRequest; +import org.junit.jupiter.api.*; + +import java.time.Duration; +import java.util.Objects; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static fr.maif.Mocks.*; +import static fr.maif.requests.IzanamiConnectionInformation.connectionInformation; +import static fr.maif.requests.SingleFeatureRequest.newSingleFeatureRequest; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.awaitility.Awaitility.await; + +public class SSEClientTest { + static WireMockServer mockServer; + static IzanamiClient client; + + @BeforeAll + public static void init() { + mockServer = new WireMockServer(options().port(9999)); + mockServer.start(); + } + + @BeforeEach + public void beforeEach() { + mockServer.setGlobalFixedDelay(10); + } + + @AfterAll + public static void tearDown() { + mockServer.stop(); + } + + @AfterEach + public void resetMocks() { + client.close().join(); + mockServer.resetAll(); + } + + @Test + public void should_open_connection_on_first_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(okForContentType("text/event-stream", eventStream)) + ); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + + var result = client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + } + + + @Test + public void should_open_connection_before_first_query_if_preload_is_specified() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(okForContentType("text/event-stream", eventStream)) + ); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .withPreloadedFeatures(id) + .build(); + + client.isLoaded().join(); + mockServer.resetAll(); + + var result = client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + } + + @Test + public void should_update_cache_with_feature_update_events() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + featureStub.conditions.get("").enabled = false; + eventStream += "id:eventid2\n" + + "event:FEATURE_UPDATED\n" + + "data:" + featureStub.toUpdatedEvent(id) + "\n\n"; + + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withChunkedDribbleDelay(10, 3000) + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var result = client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + await().atMost(Duration.ofSeconds(8)).until(() -> + !client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join() + ); + } + + @Test + public void should_remove_feature_from_cache_on_feature_deleted_event() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + featureStub.conditions.get("").enabled = false; + eventStream += "id:eventid2\n" + + "event:FEATURE_DELETED\n" + + "data:" + featureDeletedEvent(id) + "\n\n"; + + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withChunkedDribbleDelay(10, 2000) + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var result = client.checkFeatureActivation( + SingleFeatureRequest.newSingleFeatureRequest(id) + ).join(); + assertThat(result).isTrue(); + + eventStream = "id:eventid3\n" + + "event:FEATURE_STATES\n" + + "data:" + emptyFeatureStatesEvent() + "\n\n"; + mockServer.resetMappings(); + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + + await().atMost(Duration.ofSeconds(8)).until(() -> + !client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id).withErrorStrategy(FeatureClientErrorStrategy.defaultValueStrategy(false))).join() + ); + } + + @Test + public void should_remove_feature_from_cache_on_feature_deleted_event_null_default() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + featureStub.conditions.get("").enabled = false; + eventStream += "id:eventid2\n" + + "event:FEATURE_DELETED\n" + + "data:" + featureDeletedEvent(id) + "\n\n"; + + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withChunkedDribbleDelay(10, 2000) + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var result = client.checkFeatureActivation( + SingleFeatureRequest.newSingleFeatureRequest(id) + ).join(); + assertThat(result).isTrue(); + + eventStream = "id:eventid3\n" + + "event:FEATURE_STATES\n" + + "data:" + emptyFeatureStatesEvent() + "\n\n"; + mockServer.resetMappings(); + mockServer.stubFor(WireMock.get(urlPathEqualTo("/api/v2/events")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + + await().atMost(Duration.ofSeconds(8)).until(() -> + client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join() == null + ); + } + + @Test + public void should_enrich_cache_with_added_feature_event() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + featureStub.conditions.get("").enabled = false; + eventStream += "id:eventid2\n" + + "event:FEATURE_CREATED\n" + + "data:" + featureStub.toAddedEvent(id2) + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id + "," + id2)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withChunkedDribbleDelay(10, 2000) + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2)).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isNull(); + + await().atMost(Duration.ofSeconds(8)).until(() -> { + var res2 = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2)).join(); + return res2.get(id) && Objects.nonNull(res2.get(id2)) && !res2.get(id2); + }); + } + + @Test + public void should_take_timeout_into_account_if_server_does_not_respond_in_time() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = ""; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + await().atMost(Duration.ofSeconds(5L)).until(() -> { + var res = client.checkFeatureActivation( + FeatureRequest.newSingleFeatureRequest(id) + .withCallTimeout(Duration.ofSeconds(2L)) + ).join(); + + return res == null; + }); + } + + @Test + public void should_use_error_strategy_when_server_throws() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .withQueryParam("features", equalTo(id)) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\": \"Somethign went wrong !\"}"))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivation( + FeatureRequest.newSingleFeatureRequest(id) + .withErrorStrategy(FeatureClientErrorStrategy.defaultValueStrategy(false)) + .withCallTimeout(Duration.ofSeconds(2L)) + ).join(); + + assertThat(res).isFalse(); + } + + @Test + public void should_retrieve_script_activation_on_initial_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(false).withScript("myscript")); + String stub = newResponse().withFeature(id, featureStub).withFeature(id2, featureStub2).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id + "," + id2)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2)).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isFalse(); + } + + @Test + public void should_call_remote_izanami_on_second_script_feature_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(false).withScript("myscript")); + String stub = newResponse().withFeature(id, featureStub).withFeature(id2, featureStub2).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id + "," + id2)) + .withQueryParam("user", equalTo("foo")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/features")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id2)) + .withQueryParam("conditions", equalTo("true")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(newResponse().withFeature(id2, featureStub2).toJson()))); + + featureStub2.active = true; + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/features")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id2)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("user", equalTo("foo")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(newResponse().withFeature(id2, featureStub2).toJson()))); + + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2).withUser("foo")).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isFalse(); + + var res2 = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2).withUser("foo")).join(); + assertThat(res2.get(id)).isTrue(); + assertThat(res2.get(id2)).isTrue(); + } + + @Test + public void should_apply_error_strategy_on_server_error_for_script_feature() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(false).withScript("myscript")); + String stub = newResponse().withFeature(id, featureStub).withFeature(id2, featureStub2).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id + "," + id2)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/features")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id2)) + .withQueryParam("conditions", equalTo("true")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Whoops !\"}"))); + + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2)).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isFalse(); + + var res2 = client.checkFeatureActivations(FeatureRequest.newFeatureRequest() + .withFeatures(id, id2) + .withErrorStrategy(FeatureClientErrorStrategy.defaultValueStrategy(true)) + ).join(); + assertThat(res2.get(id)).isTrue(); + assertThat(res2.get(id2)).isTrue(); + } + + + @Test + public void should_apply_query_timeout_for_specific_script_queries() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(false).withScript("myscript")); + String stub = newResponse().withFeature(id, featureStub).withFeature(id2, featureStub2).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id + "," + id2)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/features")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id2)) + .withQueryParam("conditions", equalTo("true")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(newResponse().withFeature(id2, featureStub2).toJson()))); + + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(id, id2)).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isFalse(); + + mockServer.setGlobalFixedDelay(5000); + var res2 = client.checkFeatureActivations(FeatureRequest.newFeatureRequest() + .withFeatures(id, id2) + .withCallTimeout(Duration.ofSeconds(2L)) + .withErrorStrategy(FeatureClientErrorStrategy.defaultValueStrategy(true)) + ).join(); + assertThat(res2.get(id)).isTrue(); + assertThat(res2.get(id2)).isTrue(); + } + + + @Test + public void request_with_payload_should_trigger_POST_queries() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeab"; + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(false).withScript("myscript")); + String stub = newResponse().withFeature(id, featureStub).withFeature(id2, featureStub2).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id + "," + id2)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + client.isLoaded().join(); + + var res = client.checkFeatureActivations(FeatureRequest.newFeatureRequest() + .withFeatures(id, id2) + .withPayload("{\"foo\": \"bar\"}") + ).join(); + assertThat(res.get(id)).isTrue(); + assertThat(res.get(id2)).isFalse(); + } + + + @Test + public void should_respect_timeout_when_calling_sse_endpoint() { + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + mockServer.setGlobalFixedDelay(5000); + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo("foo")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(""))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .withCallTimeout(Duration.ofSeconds(2L)) + .build(); + + assertThat(client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest("foo")).join()).isNull(); + } + + @Test + public void should_reconnect_when_connection_is_closed_by_server() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + String eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .withServerSentEventKeepAliveInterval(Duration.ofSeconds(2)) + .build() + ) + .withCallTimeout(Duration.ofSeconds(2L)) + .build(); + client.isLoaded().join(); + assertThat(client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join()).isTrue(); + + mockServer.resetAll(); + mockServer.shutdownServer(); + System.out.println("MOCK SERVER IS DOWN"); + + Thread.sleep(25000); + + featureStub.active = false; + featureStub.conditions.get("").enabled = false; + eventStream = + "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + newResponse().withFeature(id, featureStub).toSSEJson() + "\n\n"; + System.out.println("RESTARTING MOCK SERVER"); + mockServer.start(); + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(eventStream))); + + + await().atMost(Duration.ofSeconds(5L)).until(() -> !client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join()); + } + + @Test + public void should_reconnect_on_background_timeout() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + StringBuilder eventStream = + new StringBuilder("id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"); + for(int i = 0; i < 10_000; i++) { + eventStream.append("\n "); + } + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withChunkedDribbleDelay(10, 15_000) + .withBody(eventStream.toString()))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .withServerSentEventKeepAliveInterval(Duration.ofSeconds(2)) + .build() + ) + .withCallTimeout(Duration.ofSeconds(2L)) + .build(); + client.isLoaded().join(); + assertThat(client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join()).isTrue(); + + + String newEventStream = "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + featureStub.conditions.get("").enabled = false; + newEventStream += "id:eventid2\n" + + "event:FEATURE_UPDATED\n" + + "data:" + featureStub.toUpdatedEvent(id) + "\n\n"; + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(newEventStream))); + await().atMost(Duration.ofSeconds(20L)).pollInterval(Duration.ofSeconds(1L)).until(() -> !client.checkFeatureActivation(SingleFeatureRequest.newSingleFeatureRequest(id)).join()); + } + + @Test + public void should_use_error_strategy_if_backend_returns_erroneous_status_code() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + + String newEventStream = "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/api/v2/events")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "text/event-stream") + .withBody("{\"error\": \"some error\"}"))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + + await().atMost(1, SECONDS).until(() -> client.checkFeatureActivation(newSingleFeatureRequest(id)).join() == null); + } + + @Test + public void should_reconnect_if_backend_returns_erroneous_status_code() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + String stub = newResponse().withFeature(id, featureStub).toSSEJson(); + String clientId = "THIS_IS_NOT_A_REAL_DATA_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String clientSecret = "THIS_IS_NOT_A_REAL_SECRET_PLEASE_DONT_FILE_AN_ISSUE_ABOUT_THIS"; + String url = "/api/v2/events"; + + String newEventStream = "id:eventid\n" + + "event:FEATURE_STATES\n" + + "data:" + stub + "\n\n"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "text/event-stream") + .withBody("{\"error\": \"some error\"}"))); + + client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .shouldUseServerSentEvent(true) + .build() + ) + .build(); + + assertThat(client.checkFeatureActivation(newSingleFeatureRequest(id)).join()).isNull(); + + mockServer.resetMappings(); + mockServer.resetRequests(); + assertThat(mockServer.countRequestsMatching(getRequestedFor(WireMock.urlPathEqualTo(url)).build()).getCount()).isZero(); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .withQueryParam("features", equalTo(id)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("refreshInterval", equalTo("600")) + .withQueryParam("keepAliveInterval", equalTo("25")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(newEventStream))); + + + await().atMost(10, SECONDS).until(() -> mockServer.countRequestsMatching(getRequestedFor(WireMock.urlPathEqualTo(url)).build()).getCount() == 1); + mockServer.resetMappings(); + assertThat(client.checkFeatureActivation(newSingleFeatureRequest(id)).join()).isTrue(); + + + } + +} diff --git a/src/test/java/fr/maif/Sandbox.java b/src/test/java/fr/maif/Sandbox.java new file mode 100644 index 0000000..91084e4 --- /dev/null +++ b/src/test/java/fr/maif/Sandbox.java @@ -0,0 +1,67 @@ +package fr.maif; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import fr.maif.features.Feature; +import fr.maif.http.ResponseUtils; +import fr.maif.requests.FeatureRequest; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.regex.Pattern; + +import static fr.maif.requests.IzanamiConnectionInformation.connectionInformation; +import static java.util.concurrent.TimeUnit.SECONDS; + +public class Sandbox { + @Test + public void foo() throws URISyntaxException, IOException, InterruptedException { + String clientId = "test_H7mD7i0gV8MtJgXY"; + String clientSecret = "aVUm9dXMp26D3TIlebmwAe5we44kDR0tm83mfwL5zBo09Wy15Bg122IizWZQPOBm"; + String f1 = "d64dccb4-06e9-4b99-b857-684f44cdd584"; + String f2 = "08bd325a-7132-460a-a397-2ca0c7d09a3d"; + String f3 = "legyacy"; + + var client = IzanamiClient.newBuilder( + connectionInformation() + .withUrl("http://localhost:9000/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .shouldUseServerSentEvent(true) + .withServerSentEventKeepAliveInterval(Duration.ofSeconds(3L)) + .build() + ).withPreloadedFeatures(f3) + .withCallTimeout(Duration.ofSeconds(10L)) + .build(); + + client.isLoaded().join(); + System.out.println("Client is loaded"); + + var result = client.checkFeatureActivations(FeatureRequest.newFeatureRequest().withFeatures(f3)).join(); + + var strResult = ResponseUtils.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(result); + System.out.println(strResult); + + while(true) { + Thread.sleep(1000); + } + } + + +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index ba3fa77..08d239a 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -10,7 +10,7 @@ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n - +