From 80e5e1768d8f924931534c224f52bc663016f1b6 Mon Sep 17 00:00:00 2001 From: chandresh pancholi Date: Mon, 4 Oct 2021 11:14:28 +0530 Subject: [PATCH] litmus client integration changes Signed-off-by: chandresh pancholi --- .gitignore | 5 +- litmus-client/pom.xml | 75 +++++ .../com/navi/medici/annotation/Nullable.java | 19 ++ ...LitmusExperimentBootstrapFileProvider.java | 66 +++++ .../LitmusExperimentBootstrapHandler.java | 41 +++ .../LitmusExperimentBootstrapProvider.java | 6 + .../client/ExperimentBackupHandler.java | 10 + .../client/ExperimentBackupHandlerFile.java | 56 ++++ .../navi/medici/client/ExperimentFetcher.java | 9 + .../medici/client/HttpExperimentFetcher.java | 52 ++++ .../com/navi/medici/config/LitmusConfig.java | 270 ++++++++++++++++++ .../medici/exception/LitmusException.java | 10 + .../com/navi/medici/litmus/DefaultLitmus.java | 172 +++++++++++ .../java/com/navi/medici/litmus/Litmus.java | 34 +++ .../provider/LitmusContextProvider.java | 13 + .../repository/ExperimentRepository.java | 13 + .../LitmusExperimentRepository.java | 90 ++++++ .../scheduler/LitmusScheduledExecutor.java | 16 ++ .../LitmusScheduledExecutorImpl.java | 66 +++++ .../navi/medici/strategy/ConstraintUtil.java | 26 ++ .../navi/medici/strategy/DefaultStrategy.java | 19 ++ .../medici/strategy/DeviceWithIdStrategy.java | 36 +++ .../strategy/FlexibleRolloutStrategy.java | 74 +++++ .../com/navi/medici/strategy/Strategy.java | 25 ++ .../navi/medici/strategy/UnknownStrategy.java | 18 ++ .../medici/strategy/UserWithIdStrategy.java | 37 +++ ...itmusExperimentCollectionDeserializer.java | 97 +++++++ .../util/JsonLitmusExperimentParser.java | 31 ++ .../java/com/navi/medici/util/LitmusURLs.java | 48 ++++ .../com/navi/medici/util/StrategyUtils.java | 50 ++++ .../test/java/com/navi/medici/AppTest.java | 19 ++ litmus-core/pom.xml | 21 +- .../src/main/java/com/navi/medici/App.java | 10 +- .../test/java/com/navi/medici/AppTest.java | 9 +- litmus-model/pom.xml | 36 ++- .../src/main/java/com/navi/medici/App.java | 10 +- .../navi/medici/constraint/Constraint.java | 26 ++ .../navi/medici/context/LitmusContext.java | 56 ++++ .../java/com/navi/medici/enums/Operator.java | 6 + .../medici/experiment/LitmusExperiment.java | 28 ++ .../response/LitmusExperimentCollection.java | 39 +++ .../response/LitmusExperimentResponse.java | 57 ++++ .../medici/strategy/ActivationStrategy.java | 37 +++ .../test/java/com/navi/medici/AppTest.java | 9 +- pom.xml | 31 +- settings.xml | 12 + 46 files changed, 1862 insertions(+), 28 deletions(-) create mode 100644 litmus-client/pom.xml create mode 100644 litmus-client/src/main/java/com/navi/medici/annotation/Nullable.java create mode 100644 litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapFileProvider.java create mode 100644 litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapHandler.java create mode 100644 litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapProvider.java create mode 100644 litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandler.java create mode 100644 litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandlerFile.java create mode 100644 litmus-client/src/main/java/com/navi/medici/client/ExperimentFetcher.java create mode 100644 litmus-client/src/main/java/com/navi/medici/client/HttpExperimentFetcher.java create mode 100644 litmus-client/src/main/java/com/navi/medici/config/LitmusConfig.java create mode 100644 litmus-client/src/main/java/com/navi/medici/exception/LitmusException.java create mode 100644 litmus-client/src/main/java/com/navi/medici/litmus/DefaultLitmus.java create mode 100644 litmus-client/src/main/java/com/navi/medici/litmus/Litmus.java create mode 100644 litmus-client/src/main/java/com/navi/medici/provider/LitmusContextProvider.java create mode 100644 litmus-client/src/main/java/com/navi/medici/repository/ExperimentRepository.java create mode 100644 litmus-client/src/main/java/com/navi/medici/repository/LitmusExperimentRepository.java create mode 100644 litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutor.java create mode 100644 litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutorImpl.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/ConstraintUtil.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/DefaultStrategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/DeviceWithIdStrategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/FlexibleRolloutStrategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/Strategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/UnknownStrategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/strategy/UserWithIdStrategy.java create mode 100644 litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentCollectionDeserializer.java create mode 100644 litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentParser.java create mode 100644 litmus-client/src/main/java/com/navi/medici/util/LitmusURLs.java create mode 100644 litmus-client/src/main/java/com/navi/medici/util/StrategyUtils.java create mode 100644 litmus-client/src/test/java/com/navi/medici/AppTest.java create mode 100644 litmus-model/src/main/java/com/navi/medici/constraint/Constraint.java create mode 100644 litmus-model/src/main/java/com/navi/medici/context/LitmusContext.java create mode 100644 litmus-model/src/main/java/com/navi/medici/enums/Operator.java create mode 100644 litmus-model/src/main/java/com/navi/medici/experiment/LitmusExperiment.java create mode 100644 litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentCollection.java create mode 100644 litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentResponse.java create mode 100644 litmus-model/src/main/java/com/navi/medici/strategy/ActivationStrategy.java create mode 100644 settings.xml diff --git a/.gitignore b/.gitignore index 3d4e3d3..e1118e5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ litmus-core/litmus-core.iml litmus-core/target litmus-model/litmus-model.iml -litmus-model/target \ No newline at end of file +litmus-model/target + +litmus-client/litmus-client.iml +litmus-client/target \ No newline at end of file diff --git a/litmus-client/pom.xml b/litmus-client/pom.xml new file mode 100644 index 0000000..54096ea --- /dev/null +++ b/litmus-client/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + litmus + com.navi.medici + 1.0-SNAPSHOT + + + litmus-client + 1.0-SNAPSHOT + + litmus-client + + + 11 + https://nexus.cmd.navi-tech.in + + + + + nexus + Releases + ${nexus.host}/repository/maven-releases/ + + + nexus + Snapshot + ${nexus.host}/repository/maven-snapshots + + + + + + nexus + Snapshot + ${nexus.host}/repository/maven-snapshots + + + + + + com.navi.medici.utils + event-bus-client + 0.1.3-SNAPSHOT + + + + com.navi.medici + litmus-model + 1.0-SNAPSHOT + + + + com.sangupta + murmur + 1.0.0 + + + + com.squareup.okhttp3 + okhttp + 5.0.0-alpha.2 + + + + junit + junit + 4.11 + test + + + + diff --git a/litmus-client/src/main/java/com/navi/medici/annotation/Nullable.java b/litmus-client/src/main/java/com/navi/medici/annotation/Nullable.java new file mode 100644 index 0000000..7dced1a --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/annotation/Nullable.java @@ -0,0 +1,19 @@ +package com.navi.medici.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.annotation.Nonnull; +import javax.annotation.meta.TypeQualifierNickname; +import javax.annotation.meta.When; + +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Nonnull(when = When.MAYBE) +@TypeQualifierNickname +@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) +public @interface Nullable { + +} diff --git a/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapFileProvider.java b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapFileProvider.java new file mode 100644 index 0000000..78a3bc2 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapFileProvider.java @@ -0,0 +1,66 @@ +package com.navi.medici.bootstrap; + +import com.navi.medici.annotation.Nullable; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class LitmusExperimentBootstrapFileProvider implements LitmusExperimentBootstrapProvider { + + final String path; + + public LitmusExperimentBootstrapFileProvider() { + this.path = getBootstrapFile(); + } + + public LitmusExperimentBootstrapFileProvider(String path) { + this.path = path; + } + + @Nullable + private String getBootstrapFile() { + String path = System.getenv("LITMUS_BOOTSTRAP_FILE"); + if (path == null) { + path = System.getProperty("LITMUS_BOOTSTRAP_FILE"); + } + return path; + } + + private String fileAsString(File file) throws IOException { + return Files.readString(file.toPath()); + } + + @Nullable + private File getFile(@Nullable String path) { + if (path != null) { + if (path.startsWith("classpath:")) { + try { + URL resource = + getClass() + .getClassLoader() + .getResource(path.substring("classpath:".length())); + if (resource != null) { + return Paths.get(resource.toURI()).toFile(); + } + return null; + } catch (URISyntaxException e) { + return null; + } + } else { + return Paths.get(path).toFile(); + } + } else { + return null; + } + } + + @Override + public String read() { + return null; + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapHandler.java b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapHandler.java new file mode 100644 index 0000000..1993fc3 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapHandler.java @@ -0,0 +1,41 @@ +package com.navi.medici.bootstrap; + +import com.navi.medici.annotation.Nullable; +import com.navi.medici.config.LitmusConfig; +import com.navi.medici.response.LitmusExperimentCollection; +import com.navi.medici.util.JsonLitmusExperimentParser; +import java.io.StringReader; +import java.util.Collections; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class LitmusExperimentBootstrapHandler { + + private final LitmusExperimentBootstrapProvider litmusExperimentBootstrapProvider; + + public LitmusExperimentBootstrapHandler(LitmusConfig litmusConfig) { + if (litmusConfig.getLitmusExperimentBootstrapProvider() != null) { + this.litmusExperimentBootstrapProvider = litmusConfig.getLitmusExperimentBootstrapProvider(); + } else { + this.litmusExperimentBootstrapProvider = new LitmusExperimentBootstrapFileProvider(); + } + } + + public LitmusExperimentCollection parse(@Nullable String jsonString) { + if (jsonString != null) { + try (StringReader stringReader = new StringReader(jsonString)) { + return JsonLitmusExperimentParser.fromJson(stringReader); + } catch (IllegalStateException ise) { + log.error("Failed to read litmus experiments bootstrap", ise); + } + } + return new LitmusExperimentCollection(Collections.emptyList()); + } + + public LitmusExperimentCollection read() { + if (litmusExperimentBootstrapProvider != null) { + return parse(litmusExperimentBootstrapProvider.read()); + } + return new LitmusExperimentCollection(Collections.emptyList()); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapProvider.java b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapProvider.java new file mode 100644 index 0000000..f3f0f00 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/bootstrap/LitmusExperimentBootstrapProvider.java @@ -0,0 +1,6 @@ +package com.navi.medici.bootstrap; + +public interface LitmusExperimentBootstrapProvider { + + String read(); +} diff --git a/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandler.java b/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandler.java new file mode 100644 index 0000000..08e94db --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandler.java @@ -0,0 +1,10 @@ +package com.navi.medici.client; + +import com.navi.medici.response.LitmusExperimentCollection; + +public interface ExperimentBackupHandler { + + LitmusExperimentCollection read(); + + void write(LitmusExperimentCollection experimentCollection); +} diff --git a/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandlerFile.java b/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandlerFile.java new file mode 100644 index 0000000..4046a73 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/client/ExperimentBackupHandlerFile.java @@ -0,0 +1,56 @@ +package com.navi.medici.client; + +import com.google.gson.JsonParseException; +import com.navi.medici.config.LitmusConfig; +import com.navi.medici.exception.LitmusException; +import com.navi.medici.experiment.LitmusExperiment; +import com.navi.medici.response.LitmusExperimentCollection; +import com.navi.medici.util.JsonLitmusExperimentParser; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class ExperimentBackupHandlerFile implements ExperimentBackupHandler { + + private final String backupFile; + + public ExperimentBackupHandlerFile(LitmusConfig config) { + this.backupFile = config.getBackupFile(); + } + + @Override + public LitmusExperimentCollection read() { + log.info("Litmus will try to load experiments states from temporary backup"); + try (FileReader reader = new FileReader(backupFile)) { + BufferedReader br = new BufferedReader(reader); + LitmusExperimentCollection experimentCollection = JsonLitmusExperimentParser.fromJson(br); + return experimentCollection; + } catch (FileNotFoundException e) { + log.info( + " Litmus could not find the backup-file '" + + backupFile + + "'. \n" + + "This is expected behavior the first time litmus runs in a new environment."); + } catch (IOException | IllegalStateException | JsonParseException e) { + log.error(""); + } + List emptyList = Collections.emptyList(); + return new LitmusExperimentCollection(emptyList); + } + + @Override + public void write(LitmusExperimentCollection experimentCollection) { + try (FileWriter writer = new FileWriter(backupFile)) { + writer.write(JsonLitmusExperimentParser.toJsonString(experimentCollection)); + } catch (IOException e) { + throw new LitmusException( + "Litmus was unable to backup experiments to file: " + backupFile, e); + } + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/client/ExperimentFetcher.java b/litmus-client/src/main/java/com/navi/medici/client/ExperimentFetcher.java new file mode 100644 index 0000000..a6ebc91 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/client/ExperimentFetcher.java @@ -0,0 +1,9 @@ +package com.navi.medici.client; + +import com.navi.medici.exception.LitmusException; +import com.navi.medici.response.LitmusExperimentResponse; + +public interface ExperimentFetcher { + + LitmusExperimentResponse fetchExperiments() throws LitmusException; +} diff --git a/litmus-client/src/main/java/com/navi/medici/client/HttpExperimentFetcher.java b/litmus-client/src/main/java/com/navi/medici/client/HttpExperimentFetcher.java new file mode 100644 index 0000000..d467d30 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/client/HttpExperimentFetcher.java @@ -0,0 +1,52 @@ +package com.navi.medici.client; + +import com.navi.medici.config.LitmusConfig; +import com.navi.medici.exception.LitmusException; +import com.navi.medici.litmus.Litmus; +import com.navi.medici.response.LitmusExperimentCollection; +import com.navi.medici.response.LitmusExperimentResponse; +import com.navi.medici.util.JsonLitmusExperimentParser; +import java.net.URL; +import lombok.extern.log4j.Log4j2; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +@Log4j2 +public class HttpExperimentFetcher implements ExperimentFetcher { + + private final OkHttpClient client; + private final URL experimentUrl; + + public HttpExperimentFetcher(LitmusConfig litmusConfig) { + this.client = new OkHttpClient(); + this.experimentUrl = + litmusConfig.getLitmusURLs() + .getFetchTogglesURL(litmusConfig.getProjectName(), litmusConfig.getNamePrefix()); + } + + @Override + public LitmusExperimentResponse fetchExperiments() throws LitmusException { + Request request = new Request.Builder() + .url(this.experimentUrl) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.code() > 300) { + var responseBody = response.body(); + assert responseBody != null; + + LitmusExperimentCollection toggles = JsonLitmusExperimentParser.fromJson(responseBody.charStream()); + + return new LitmusExperimentResponse(LitmusExperimentResponse.Status.CHANGED, toggles); + } else if(response.code() == 304) { + return new LitmusExperimentResponse(LitmusExperimentResponse.Status.NOT_CHANGED, response.code()); + } else { + return new LitmusExperimentResponse(LitmusExperimentResponse.Status.UNAVAILABLE, response.code()); + } + + } catch (Exception e) { + throw new LitmusException("fetch experiments failed.", e); + } + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/config/LitmusConfig.java b/litmus-client/src/main/java/com/navi/medici/config/LitmusConfig.java new file mode 100644 index 0000000..82adfe5 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/config/LitmusConfig.java @@ -0,0 +1,270 @@ +package com.navi.medici.config; + +import com.navi.medici.annotation.Nullable; +import com.navi.medici.bootstrap.LitmusExperimentBootstrapProvider; +import com.navi.medici.provider.LitmusContextProvider; +import com.navi.medici.scheduler.LitmusScheduledExecutor; +import com.navi.medici.scheduler.LitmusScheduledExecutorImpl; +import com.navi.medici.strategy.Strategy; +import com.navi.medici.strategy.UnknownStrategy; +import com.navi.medici.util.LitmusURLs; +import java.io.File; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.Objects; +import java.util.Optional; + +public class LitmusConfig { + + private final URI litmusAPI; + + private final LitmusURLs litmusURLs; + + private final String appName; + + private final String instanceId; + + private final String backupFile; + @Nullable + private final String projectName; + @Nullable + private final String namePrefix; + private final long fetchLitmusExperimentsInterval; + private final LitmusContextProvider contextProvider; + private final boolean synchronousFetchOnInitialisation; + private final LitmusScheduledExecutor litmusScheduledExecutor; + private final Strategy fallbackStrategy; + private final LitmusExperimentBootstrapProvider litmusExperimentBootstrapProvider; + + + private LitmusConfig( + URI litmusAPI, + String appName, + String instanceId, + String backupFile, + @Nullable String projectName, + @Nullable String namePrefix, + long fetchLitmusExperimentsInterval, + LitmusContextProvider contextProvider, + boolean synchronousFetchOnInitialisation, + LitmusScheduledExecutor litmusScheduledExecutor, + Strategy fallbackStrategy, + LitmusExperimentBootstrapProvider litmusBootstrapProvider) { + + if (appName == null) { + throw new IllegalStateException("You are required to specify the litmus appName"); + } + + if (instanceId == null) { + throw new IllegalStateException("You are required to specify the litmus instanceId"); + } + + if (litmusAPI == null) { + throw new IllegalStateException("You are required to specify the litmusAPI url"); + } + + if (litmusScheduledExecutor == null) { + throw new IllegalStateException("You are required to specify a scheduler"); + } + + this.fallbackStrategy = Objects.requireNonNullElseGet(fallbackStrategy, UnknownStrategy::new); + + this.litmusAPI = litmusAPI; + this.litmusURLs = new LitmusURLs(litmusAPI); + this.appName = appName; + this.instanceId = instanceId; + this.backupFile = backupFile; + this.projectName = projectName; + this.namePrefix = namePrefix; + this.fetchLitmusExperimentsInterval = fetchLitmusExperimentsInterval; + this.contextProvider = contextProvider; + this.synchronousFetchOnInitialisation = synchronousFetchOnInitialisation; + this.litmusScheduledExecutor = litmusScheduledExecutor; + this.litmusExperimentBootstrapProvider = litmusBootstrapProvider; + } + + public static Builder builder() { + return new Builder(); + } + + public URI getLitmusAPI() { + return litmusAPI; + } + + + public String getAppName() { + return appName; + } + + public String getInstanceId() { + return instanceId; + } + + public long getFetchLitmusExperimentsInterval() { + return fetchLitmusExperimentsInterval; + } + + public String getBackupFile() { + return this.backupFile; + } + + public LitmusURLs getLitmusURLs() { + return litmusURLs; + } + + @Nullable + public String getProjectName() { + return projectName; + } + + @Nullable + public String getNamePrefix() { + return namePrefix; + } + + public boolean isSynchronousFetchOnInitialisation() { + return synchronousFetchOnInitialisation; + } + + public LitmusContextProvider getContextProvider() { + return contextProvider; + } + + public LitmusScheduledExecutor getScheduledExecutor() { + return litmusScheduledExecutor; + } + + public Strategy getFallbackStrategy() { + return fallbackStrategy; + } + + public LitmusExperimentBootstrapProvider getLitmusExperimentBootstrapProvider() { + return litmusExperimentBootstrapProvider; + } + + public static class Builder { + + private URI litmusAPI; + private @Nullable String appName; + private String instanceId = getDefaultInstanceId(); + private @Nullable String backupFile; + private @Nullable String projectName; + private @Nullable String namePrefix; + private long fetchLitmusExperimentsInterval = 10; + private LitmusContextProvider contextProvider = LitmusContextProvider.getDefaultProvider(); + private boolean synchronousFetchOnInitialisation = false; + private @Nullable LitmusScheduledExecutor scheduledExecutor; + private @Nullable Strategy fallbackStrategy; + private @Nullable LitmusExperimentBootstrapProvider litmusExperimentBootstrapProvider; + + private static String getHostname() { + String hostName = System.getProperty("hostname"); + if (hostName == null || hostName.length() == 0) { + try { + hostName = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + } + } + return hostName + "-"; + } + + static String getDefaultInstanceId() { + return getHostname() + "generated-" + Math.round(Math.random() * 1000000.0D); + } + + public Builder litmusAPI(URI litmusAPI) { + this.litmusAPI = litmusAPI; + return this; + } + + public Builder litmusAPI(String litmusAPI) { + this.litmusAPI = URI.create(litmusAPI); + return this; + } + + public Builder appName(String appName) { + this.appName = appName; + return this; + } + + public Builder instanceId(String instanceId) { + this.instanceId = instanceId; + return this; + } + + public Builder fetchLitmusExperimentsInterval(long fetchLitmusExperimentsInterval) { + this.fetchLitmusExperimentsInterval = fetchLitmusExperimentsInterval; + return this; + } + + public Builder backupFile(String backupFile) { + this.backupFile = backupFile; + return this; + } + + public Builder projectName(String projectName) { + this.projectName = projectName; + return this; + } + + public Builder namePrefix(String namePrefix) { + this.namePrefix = namePrefix; + return this; + } + + public Builder synchronousFetchOnInitialisation(boolean enable) { + this.synchronousFetchOnInitialisation = enable; + return this; + } + + public Builder scheduledExecutor(LitmusScheduledExecutor scheduledExecutor) { + this.scheduledExecutor = scheduledExecutor; + return this; + } + + public Builder fallbackStrategy(@Nullable Strategy fallbackStrategy) { + this.fallbackStrategy = fallbackStrategy; + return this; + } + + public Builder litmusExperimentBootstrapProvider( + @Nullable LitmusExperimentBootstrapProvider litmusExperimentBootstrapProvider) { + this.litmusExperimentBootstrapProvider = litmusExperimentBootstrapProvider; + return this; + } + + private String getBackupFile() { + if (backupFile != null) { + return backupFile; + } else { + String fileName = "litmus-" + appName + "-repo.json"; + return System.getProperty("java.io.tmpdir") + File.separatorChar + fileName; + } + } + + public LitmusConfig build() { + return new LitmusConfig( + litmusAPI, + appName, + instanceId, + getBackupFile(), + projectName, + namePrefix, + fetchLitmusExperimentsInterval, + contextProvider, + synchronousFetchOnInitialisation, + Optional.ofNullable(scheduledExecutor) + .orElseGet(LitmusScheduledExecutorImpl::getInstance), + fallbackStrategy, + litmusExperimentBootstrapProvider); + } + + public String getDefaultSdkVersion() { + String version = + Optional.ofNullable(getClass().getPackage().getImplementationVersion()) + .orElse("development"); + return "litmus-client:" + version; + } + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/exception/LitmusException.java b/litmus-client/src/main/java/com/navi/medici/exception/LitmusException.java new file mode 100644 index 0000000..f1725ce --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/exception/LitmusException.java @@ -0,0 +1,10 @@ +package com.navi.medici.exception; + +import com.navi.medici.annotation.Nullable; + +public class LitmusException extends RuntimeException { + + public LitmusException(String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/litmus/DefaultLitmus.java b/litmus-client/src/main/java/com/navi/medici/litmus/DefaultLitmus.java new file mode 100644 index 0000000..9066715 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/litmus/DefaultLitmus.java @@ -0,0 +1,172 @@ +package com.navi.medici.litmus; + +import static java.util.Optional.ofNullable; + +import com.navi.medici.annotation.Nullable; +import com.navi.medici.client.ExperimentBackupHandlerFile; +import com.navi.medici.client.HttpExperimentFetcher; +import com.navi.medici.config.LitmusConfig; +import com.navi.medici.context.LitmusContext; +import com.navi.medici.experiment.LitmusExperiment; +import com.navi.medici.provider.LitmusContextProvider; +import com.navi.medici.repository.ExperimentRepository; +import com.navi.medici.repository.LitmusExperimentRepository; +import com.navi.medici.strategy.DefaultStrategy; +import com.navi.medici.strategy.FlexibleRolloutStrategy; +import com.navi.medici.strategy.Strategy; +import com.navi.medici.strategy.UnknownStrategy; +import com.navi.medici.strategy.UserWithIdStrategy; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class DefaultLitmus implements Litmus { + + private static final List BUILTIN_STRATEGIES = + Arrays.asList( + new DefaultStrategy(), + new UserWithIdStrategy(), + new FlexibleRolloutStrategy()); + + public static final UnknownStrategy UNKNOWN_STRATEGY = new UnknownStrategy(); + + private final ExperimentRepository experimentRepository; + private final Map strategyMap; + private final LitmusContextProvider contextProvider; + private final LitmusConfig config; + + private static ExperimentRepository defaultExperimentRepository(LitmusConfig litmusConfig) { + return new LitmusExperimentRepository( + litmusConfig, + new HttpExperimentFetcher(litmusConfig), + new ExperimentBackupHandlerFile(litmusConfig)) { + }; + } + + public DefaultLitmus(LitmusConfig litmusConfig, Strategy... strategies) { + this(litmusConfig, defaultExperimentRepository(litmusConfig), strategies); + } + + public DefaultLitmus( + LitmusConfig litmusConfig, + ExperimentRepository experimentRepository, + Strategy... strategies) { + this( + litmusConfig, + experimentRepository, + buildStrategyMap(strategies), + litmusConfig.getContextProvider()); + } + + // Visible for testing + public DefaultLitmus( + LitmusConfig litmusConfig, + ExperimentRepository experimentRepository, + Map strategyMap, + LitmusContextProvider contextProvider) { + this.config = litmusConfig; + this.experimentRepository = experimentRepository; + this.strategyMap = strategyMap; + this.contextProvider = contextProvider; + } + + @Override + public boolean isEnabled(final String experimentName) { + return isEnabled(experimentName, false); + } + + @Override + public boolean isEnabled(final String experimentName, final boolean defaultSetting) { + return isEnabled(experimentName, contextProvider.getContext(), defaultSetting); + } + + @Override + public boolean isEnabled( + final String experimentName, final LitmusContext context, final boolean defaultSetting) { + return isEnabled(experimentName, context, (n, c) -> defaultSetting); + } + + @Override + public boolean isEnabled( + final String experimentName, + final BiFunction fallbackAction) { + return isEnabled(experimentName, contextProvider.getContext(), fallbackAction); + } + + @Override + public boolean isEnabled( + String experimentName, + LitmusContext context, + BiFunction fallbackAction) { + boolean enabled = checkEnabled(experimentName, context, fallbackAction); + return enabled; + } + + private boolean checkEnabled( + String experimentName, + LitmusContext context, + BiFunction fallbackAction) { + LitmusExperiment litmusExperiment = experimentRepository.getLitmusExperiment(experimentName); + boolean enabled; +// LitmusContext enhancedContext = context.applyStaticFields(config); + + if (litmusExperiment == null) { + enabled = fallbackAction.apply(experimentName, context); + } else if (!litmusExperiment.isEnabled()) { + enabled = false; + } else if (litmusExperiment.getStrategies().size() == 0) { + return true; + } else { + enabled = + litmusExperiment.getStrategies().stream() + .anyMatch( + strategy -> { + Strategy configuredStrategy = + getStrategy(strategy.getName()); + if (configuredStrategy == UNKNOWN_STRATEGY) { + log.warn("Unable to find matching strategy for experiment:{} strategy:{}", + experimentName, strategy.getName()); + } + + return configuredStrategy.isEnabled( + strategy.getParameters(), + context, + strategy.getConstraints()); + }); + } + return enabled; + } + + public Optional getLitmusExperimentDefinition(String experimentName) { + return ofNullable(experimentRepository.getLitmusExperiment(experimentName)); + } + + private static Map buildStrategyMap(@Nullable Strategy[] strategies) { + Map map = new HashMap<>(); + + BUILTIN_STRATEGIES.forEach(strategy -> map.put(strategy.getName(), strategy)); + + if (strategies != null) { + for (Strategy strategy : strategies) { + map.put(strategy.getName(), strategy); + } + } + + return map; + } + + private Strategy getStrategy(String strategy) { + return strategyMap.getOrDefault(strategy, config.getFallbackStrategy()); + } + + @Override + public void shutdown() { + config.getScheduledExecutor().shutdown(); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/litmus/Litmus.java b/litmus-client/src/main/java/com/navi/medici/litmus/Litmus.java new file mode 100644 index 0000000..ad6445c --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/litmus/Litmus.java @@ -0,0 +1,34 @@ +package com.navi.medici.litmus; + +import com.navi.medici.context.LitmusContext; +import java.util.function.BiFunction; + +public interface Litmus { + + boolean isEnabled(String experimentName); + + boolean isEnabled(String experimentName, boolean defaultSetting); + + default boolean isEnabled(String experimentName, LitmusContext context) { + return isEnabled(experimentName, context, false); + } + + default boolean isEnabled(String experimentName, LitmusContext context, boolean defaultSetting) { + return isEnabled(experimentName, defaultSetting); + } + + default boolean isEnabled( + String experimentName, BiFunction fallbackAction) { + return isEnabled(experimentName, false); + } + + default boolean isEnabled( + String experimentName, + LitmusContext context, + BiFunction fallbackAction) { + return isEnabled(experimentName, context, false); + } + + default void shutdown() { + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/provider/LitmusContextProvider.java b/litmus-client/src/main/java/com/navi/medici/provider/LitmusContextProvider.java new file mode 100644 index 0000000..69aa0a0 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/provider/LitmusContextProvider.java @@ -0,0 +1,13 @@ +package com.navi.medici.provider; + +import com.navi.medici.context.LitmusContext; + +public interface LitmusContextProvider { + + LitmusContext getContext(); + + static LitmusContextProvider getDefaultProvider() { + return () -> LitmusContext.builder().build(); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/repository/ExperimentRepository.java b/litmus-client/src/main/java/com/navi/medici/repository/ExperimentRepository.java new file mode 100644 index 0000000..3fd488a --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/repository/ExperimentRepository.java @@ -0,0 +1,13 @@ +package com.navi.medici.repository; + +import com.navi.medici.annotation.Nullable; +import com.navi.medici.experiment.LitmusExperiment; +import java.util.List; + +public interface ExperimentRepository { + + @Nullable + LitmusExperiment getLitmusExperiment(String name); + + List getLitmusExperimentsNames(); +} diff --git a/litmus-client/src/main/java/com/navi/medici/repository/LitmusExperimentRepository.java b/litmus-client/src/main/java/com/navi/medici/repository/LitmusExperimentRepository.java new file mode 100644 index 0000000..b968a58 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/repository/LitmusExperimentRepository.java @@ -0,0 +1,90 @@ +package com.navi.medici.repository; + +import com.navi.medici.annotation.Nullable; +import com.navi.medici.bootstrap.LitmusExperimentBootstrapHandler; +import com.navi.medici.client.ExperimentBackupHandler; +import com.navi.medici.client.ExperimentFetcher; +import com.navi.medici.config.LitmusConfig; +import com.navi.medici.exception.LitmusException; +import com.navi.medici.experiment.LitmusExperiment; +import com.navi.medici.response.LitmusExperimentCollection; +import com.navi.medici.response.LitmusExperimentResponse; +import com.navi.medici.scheduler.LitmusScheduledExecutor; +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class LitmusExperimentRepository implements ExperimentRepository { + + private final ExperimentBackupHandler experimentBackupHandler; + private final LitmusExperimentBootstrapHandler litmusExperimentBootstrapHandler; + private final ExperimentFetcher experimentFetcher; + + private LitmusExperimentCollection experimentCollection; + private boolean ready; + + public LitmusExperimentRepository( + LitmusConfig litmusConfig, + ExperimentFetcher experimentFetcher, + ExperimentBackupHandler experimentBackupHandler) { + this( + litmusConfig, + litmusConfig.getScheduledExecutor(), + experimentFetcher, + experimentBackupHandler); + } + + public LitmusExperimentRepository( + LitmusConfig litmusConfig, + LitmusScheduledExecutor executor, + ExperimentFetcher experimentFetcher, + ExperimentBackupHandler experimentBackupHandler) { + + this.experimentBackupHandler = experimentBackupHandler; + this.experimentFetcher = experimentFetcher; + this.litmusExperimentBootstrapHandler = new LitmusExperimentBootstrapHandler(litmusConfig); + this.experimentCollection = experimentBackupHandler.read(); + if (this.experimentCollection.getLitmusExperiments().isEmpty()) { + this.experimentCollection = litmusExperimentBootstrapHandler.read(); + } + + if (litmusConfig.isSynchronousFetchOnInitialisation()) { + updateExperiments().run(); + } + + executor.setInterval(updateExperiments(), 0, litmusConfig.getFetchLitmusExperimentsInterval()); + } + + private Runnable updateExperiments() { + return () -> { + try { + LitmusExperimentResponse response = experimentFetcher.fetchExperiments(); + if (response.getStatus() == LitmusExperimentResponse.Status.CHANGED) { + experimentCollection = response.getExperimentCollection(); + experimentBackupHandler.write(response.getExperimentCollection()); + } + + if (!ready) { + ready = true; + } + } catch (LitmusException e) { + log.error("litmus exception failed."); + } + }; + } + + @Override + public @Nullable + LitmusExperiment getLitmusExperiment(String name) { + return experimentCollection.getLitmusExperiment(name); + } + + @Override + public List getLitmusExperimentsNames() { + return experimentCollection.getLitmusExperiments().stream() + .map(LitmusExperiment::getName) + .collect(Collectors.toList()); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutor.java b/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutor.java new file mode 100644 index 0000000..e1e493c --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutor.java @@ -0,0 +1,16 @@ +package com.navi.medici.scheduler; + +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; + +public interface LitmusScheduledExecutor { + + ScheduledFuture setInterval(Runnable command, long initialDelaySec, long periodSec) + throws RejectedExecutionException; + + Future scheduleOnce(Runnable runnable); + + public default void shutdown() { + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutorImpl.java b/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutorImpl.java new file mode 100644 index 0000000..b7bef24 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/scheduler/LitmusScheduledExecutorImpl.java @@ -0,0 +1,66 @@ +package com.navi.medici.scheduler; + +import com.navi.medici.annotation.Nullable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class LitmusScheduledExecutorImpl implements LitmusScheduledExecutor { + + @Nullable + private static LitmusScheduledExecutorImpl INSTANCE; + + private final ScheduledThreadPoolExecutor scheduledThreadPoolExecutor; + private final ExecutorService executorService; + + public LitmusScheduledExecutorImpl() { + ThreadFactory threadFactory = + runnable -> { + Thread thread = Executors.defaultThreadFactory().newThread(runnable); + thread.setName("litmus-api-executor"); + thread.setDaemon(true); + return thread; + }; + + this.scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1, threadFactory); + this.scheduledThreadPoolExecutor.setRemoveOnCancelPolicy(true); + + this.executorService = Executors.newSingleThreadExecutor(threadFactory); + } + + public static synchronized LitmusScheduledExecutorImpl getInstance() { + if (INSTANCE == null) { + INSTANCE = new LitmusScheduledExecutorImpl(); + } + return INSTANCE; + } + + @Override + public ScheduledFuture setInterval(Runnable command, long initialDelaySec, long periodSec) throws RejectedExecutionException { + try { + return scheduledThreadPoolExecutor.scheduleAtFixedRate( + command, initialDelaySec, periodSec, TimeUnit.SECONDS); + } catch (RejectedExecutionException ex) { + log.error("litmus background task crashed", ex); + return null; + } + } + + @Override + public Future scheduleOnce(Runnable runnable) { + return (Future) executorService.submit(runnable); + } + + + @Override + public void shutdown() { + this.scheduledThreadPoolExecutor.shutdown(); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/ConstraintUtil.java b/litmus-client/src/main/java/com/navi/medici/strategy/ConstraintUtil.java new file mode 100644 index 0000000..ab5a15a --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/ConstraintUtil.java @@ -0,0 +1,26 @@ +package com.navi.medici.strategy; + +import com.navi.medici.constraint.Constraint; +import com.navi.medici.context.LitmusContext; +import com.navi.medici.enums.Operator; +import java.util.List; +import java.util.Optional; + +public class ConstraintUtil { + + public static boolean validate(List constraints, LitmusContext context) { + if (constraints != null && constraints.size() > 0) { + return constraints.stream().allMatch(c -> validateConstraint(c, context)); + } else { + return true; + } + } + + private static boolean validateConstraint(Constraint constraint, LitmusContext context) { + Optional contextValue = context.getByName(constraint.getContextName()); + boolean isIn = + contextValue.isPresent() + && constraint.getValues().contains(contextValue.get().trim()); + return (constraint.getOperator() == Operator.IN) == isIn; + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/DefaultStrategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/DefaultStrategy.java new file mode 100644 index 0000000..ae673d4 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/DefaultStrategy.java @@ -0,0 +1,19 @@ +package com.navi.medici.strategy; + +import java.util.Map; + +public class DefaultStrategy implements Strategy { + + private static final String STRATEGY_NAME = "default"; + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return true; + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/DeviceWithIdStrategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/DeviceWithIdStrategy.java new file mode 100644 index 0000000..ade1f76 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/DeviceWithIdStrategy.java @@ -0,0 +1,36 @@ +package com.navi.medici.strategy; + +import static java.util.Arrays.asList; + +import com.navi.medici.context.LitmusContext; +import java.util.Map; +import java.util.Optional; + +public class DeviceWithIdStrategy implements Strategy { + + protected static final String PARAM = "deviceIds"; + private static final String STRATEGY_NAME = "deviceWithId"; + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return false; + } + + @Override + public boolean isEnabled(Map parameters, LitmusContext litmusContext) { + return litmusContext + .getDeviceId() + .map( + currentDeviceId -> + Optional.ofNullable(parameters.get(PARAM)) + .map(deviceIdString -> asList(deviceIdString.split(",\\s?"))) + .filter(f -> f.contains(currentDeviceId)) + .isPresent()) + .orElse(false); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/FlexibleRolloutStrategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/FlexibleRolloutStrategy.java new file mode 100644 index 0000000..94d6c42 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/FlexibleRolloutStrategy.java @@ -0,0 +1,74 @@ +package com.navi.medici.strategy; + +import com.navi.medici.context.LitmusContext; +import com.navi.medici.util.StrategyUtils; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +public class FlexibleRolloutStrategy implements Strategy { + + protected static final String PERCENTAGE = "rollout"; + protected static final String GROUP_ID = "groupId"; + + private final Supplier randomGenerator; + + public FlexibleRolloutStrategy() { + this.randomGenerator = () -> Math.random() * 100 + ""; + } + + public FlexibleRolloutStrategy(Supplier randomGenerator) { + this.randomGenerator = randomGenerator; + } + + @Override + public String getName() { + return "flexibleRollout"; + } + + @Override + public boolean isEnabled(Map parameters) { + return false; + } + + private Optional resolveStickiness(String stickiness, LitmusContext context) { + switch (stickiness) { + case "userId": + return context.getUserId(); + case "sessionId": + return context.getSessionId(); + case "deviceId": + return context.getDeviceId(); + case "appVersionCode": + return context.getAppVersionCode(); + case "osType": + return context.getOsType(); + case "random": + return Optional.of(randomGenerator.get()); + case "default": + String value = + context.getUserId() + .orElse(context.getSessionId().orElse(this.randomGenerator.get())); + return Optional.of(value); + default: + return context.getByName(stickiness); + } + } + + @Override + public boolean isEnabled(Map parameters, LitmusContext litmusContext) { + final String stickiness = getStickiness(parameters); + final Optional stickinessId = resolveStickiness(stickiness, litmusContext); + final int percentage = StrategyUtils.getPercentage(parameters.get(PERCENTAGE)); + final String groupId = parameters.getOrDefault(GROUP_ID, ""); + + return stickinessId + .map(stick -> StrategyUtils.getNormalizedNumber(stick, groupId)) + .map(norm -> percentage > 0 && norm <= percentage) + .orElse(false); + } + + private String getStickiness(Map parameters) { + return parameters.getOrDefault("stickiness", "default"); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/Strategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/Strategy.java new file mode 100644 index 0000000..439654e --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/Strategy.java @@ -0,0 +1,25 @@ +package com.navi.medici.strategy; + +import com.navi.medici.constraint.Constraint; +import com.navi.medici.context.LitmusContext; +import java.util.List; +import java.util.Map; + +public interface Strategy { + + String getName(); + + boolean isEnabled(Map parameters); + + default boolean isEnabled(Map parameters, LitmusContext litmusContext) { + return isEnabled(parameters); + } + + default boolean isEnabled( + Map parameters, + LitmusContext litmusContext, + List constraints) { + return ConstraintUtil.validate(constraints, litmusContext) + && isEnabled(parameters, litmusContext); + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/UnknownStrategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/UnknownStrategy.java new file mode 100644 index 0000000..e519b49 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/UnknownStrategy.java @@ -0,0 +1,18 @@ +package com.navi.medici.strategy; + +import java.util.Map; + +public class UnknownStrategy implements Strategy { + + private static final String STRATEGY_NAME = "unknown"; + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return false; + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/strategy/UserWithIdStrategy.java b/litmus-client/src/main/java/com/navi/medici/strategy/UserWithIdStrategy.java new file mode 100644 index 0000000..19a828c --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/strategy/UserWithIdStrategy.java @@ -0,0 +1,37 @@ +package com.navi.medici.strategy; + +import static java.util.Arrays.asList; + +import com.navi.medici.context.LitmusContext; +import java.util.Map; +import java.util.Optional; + +public class UserWithIdStrategy implements Strategy { + + protected static final String PARAM = "userIds"; + private static final String STRATEGY_NAME = "userWithId"; + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public boolean isEnabled(Map parameters) { + return false; + } + + @Override + public boolean isEnabled(Map parameters, LitmusContext litmusContext) { + return litmusContext + .getUserId() + .map( + currentUserId -> + Optional.ofNullable(parameters.get(PARAM)) + .map(userIdString -> asList(userIdString.split(",\\s?"))) + .filter(f -> f.contains(currentUserId)) + .isPresent()) + .orElse(false); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentCollectionDeserializer.java b/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentCollectionDeserializer.java new file mode 100644 index 0000000..a95228e --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentCollectionDeserializer.java @@ -0,0 +1,97 @@ +package com.navi.medici.util; + +import static java.util.Collections.singletonList; + +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import com.navi.medici.annotation.Nullable; +import com.navi.medici.experiment.LitmusExperiment; +import com.navi.medici.response.LitmusExperimentCollection; +import com.navi.medici.strategy.ActivationStrategy; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +public class JsonLitmusExperimentCollectionDeserializer implements JsonDeserializer { + + private static final Type PARAMS_TYPE = new TypeToken>() { + }.getType(); + private static final Type FEATURE_COLLECTION_TYPE = + new TypeToken>() { + }.getType(); + + @Override + public @Nullable + LitmusExperimentCollection deserialize( + JsonElement rootElement, Type type, JsonDeserializationContext context) + throws JsonParseException { + + int version = getVersion(rootElement); + + switch (version) { + case 0: + return deserializeVersion0(rootElement, context); + case 1: + default: + return deserializeVersion1(rootElement, context); + } + } + + static @Nullable + LitmusExperimentCollection deserializeVersion0( + JsonElement rootElement, JsonDeserializationContext context) { + if (!rootElement.getAsJsonObject().has("features")) { + return null; + } + + Collection litmusExperiments = new ArrayList<>(); + + JsonArray features = rootElement.getAsJsonObject().getAsJsonArray("features"); + + features.forEach( + elm -> { + JsonObject featureObj = elm.getAsJsonObject(); + + String name = featureObj.get("name").getAsString(); + boolean enabled = featureObj.get("enabled").getAsBoolean(); + String strategyName = featureObj.get("strategy").getAsString(); + Map strategyParams = + context.deserialize(featureObj.get("parameters"), PARAMS_TYPE); + + ActivationStrategy strategy = + new ActivationStrategy(strategyName, strategyParams); + litmusExperiments.add( + new LitmusExperiment(name, enabled, singletonList(strategy))); + }); + + return new LitmusExperimentCollection(litmusExperiments); + } + + static @Nullable + LitmusExperimentCollection deserializeVersion1( + JsonElement rootElement, JsonDeserializationContext context) { + if (!rootElement.getAsJsonObject().has("features")) { + return null; + } + + JsonArray featureArray = rootElement.getAsJsonObject().getAsJsonArray("features"); + + Collection featureTgggles = + context.deserialize(featureArray, FEATURE_COLLECTION_TYPE); + return new LitmusExperimentCollection(featureTgggles); + } + + private int getVersion(JsonElement rootElement) { + if (!rootElement.getAsJsonObject().has("version")) { + return 0; + } else { + return rootElement.getAsJsonObject().get("version").getAsInt(); + } + } +} diff --git a/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentParser.java b/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentParser.java new file mode 100644 index 0000000..b749d0a --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/util/JsonLitmusExperimentParser.java @@ -0,0 +1,31 @@ +package com.navi.medici.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.navi.medici.response.LitmusExperimentCollection; +import java.io.Reader; + +public class JsonLitmusExperimentParser { + + private JsonLitmusExperimentParser() { + } + + public static String toJsonString(LitmusExperimentCollection litmusExperimentCollection) { + Gson gson = new GsonBuilder().create(); + return gson.toJson(litmusExperimentCollection); + } + + public static LitmusExperimentCollection fromJson(Reader reader) throws IllegalStateException { + Gson gson = + new GsonBuilder() + .registerTypeAdapter( + LitmusExperimentCollection.class, new JsonLitmusExperimentCollectionDeserializer()) + .create(); + LitmusExperimentCollection gsonCollection = gson.fromJson(reader, LitmusExperimentCollection.class); + if (gsonCollection == null) { + throw new IllegalStateException("Could not extract litmus experiments from json"); + } + return new LitmusExperimentCollection(gsonCollection.getLitmusExperiments()); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/util/LitmusURLs.java b/litmus-client/src/main/java/com/navi/medici/util/LitmusURLs.java new file mode 100644 index 0000000..aa9af73 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/util/LitmusURLs.java @@ -0,0 +1,48 @@ +package com.navi.medici.util; + +import com.navi.medici.annotation.Nullable; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; + +public class LitmusURLs { + private final URL fetchTogglesURL; + + public LitmusURLs(URI litmusAPI) { + try { + String litmusAPIstr = litmusAPI.toString(); + fetchTogglesURL = URI.create(litmusAPIstr + "/client/experiments").normalize().toURL(); + + } catch (MalformedURLException ex) { + throw new IllegalArgumentException("Litmus API is not a valid URL: " + litmusAPI); + } + } + + public URL getFetchTogglesURL() { + return fetchTogglesURL; + } + + public URL getFetchTogglesURL(@Nullable String projectName, @Nullable String namePrefix) { + StringBuilder suffix = new StringBuilder(""); + appendParam(suffix, "project", projectName); + appendParam(suffix, "namePrefix", namePrefix); + + try { + return URI.create(fetchTogglesURL + suffix.toString()).normalize().toURL(); + } catch (IllegalArgumentException | MalformedURLException e) { + throw new IllegalArgumentException( + "fetchTogglesURL [" + fetchTogglesURL + suffix + "] was not URL friendly.", e); + } + } + + private void appendParam(StringBuilder suffix, String key, @Nullable String value) { + if (value == null) return; + if (suffix.length() == 0) { + suffix.append("?"); + } else { + suffix.append("&"); + } + suffix.append(key).append("=").append(value); + } + +} diff --git a/litmus-client/src/main/java/com/navi/medici/util/StrategyUtils.java b/litmus-client/src/main/java/com/navi/medici/util/StrategyUtils.java new file mode 100644 index 0000000..57f57f2 --- /dev/null +++ b/litmus-client/src/main/java/com/navi/medici/util/StrategyUtils.java @@ -0,0 +1,50 @@ +package com.navi.medici.util; + +import com.navi.medici.annotation.Nullable; +import com.sangupta.murmur.Murmur3; + +public class StrategyUtils { + + private static final int ONE_HUNDRED = 100; + + public static boolean isNotEmpty(final CharSequence cs) { + return !isEmpty(cs); + } + + public static boolean isEmpty(@Nullable final CharSequence cs) { + return cs == null || cs.length() == 0; + } + + public static boolean isNumeric(final CharSequence cs) { + if (isEmpty(cs)) { + return false; + } + final int sz = cs.length(); + for (int i = 0; i < sz; i++) { + if (!Character.isDigit(cs.charAt(i))) { + return false; + } + } + return true; + } + + public static int getNormalizedNumber(String identifier, String groupId) { + return getNormalizedNumber(identifier, groupId, ONE_HUNDRED); + } + + public static int getNormalizedNumber(String identifier, String groupId, int normalizer) { + byte[] value = (groupId + ':' + identifier).getBytes(); + long hash = Murmur3.hash_x86_32(value, value.length, 0); + return (int) (hash % normalizer) + 1; + } + + public static int getPercentage(String percentage) { + if (isNotEmpty(percentage) && isNumeric(percentage)) { + int p = Integer.parseInt(percentage); + return p; + } else { + return 0; + } + } + +} diff --git a/litmus-client/src/test/java/com/navi/medici/AppTest.java b/litmus-client/src/test/java/com/navi/medici/AppTest.java new file mode 100644 index 0000000..ef75ad6 --- /dev/null +++ b/litmus-client/src/test/java/com/navi/medici/AppTest.java @@ -0,0 +1,19 @@ +package com.navi.medici; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Unit test for simple App. + */ +public class AppTest { + + /** + * Rigorous Test :-) + */ + @Test + public void shouldAnswerWithTrue() { + assertTrue(true); + } +} diff --git a/litmus-core/pom.xml b/litmus-core/pom.xml index d24cde4..cedde40 100644 --- a/litmus-core/pom.xml +++ b/litmus-core/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 litmus @@ -12,7 +13,25 @@ litmus-core + + 11 + https://nexus.cmd.navi-tech.in + + + + + nexus + Snapshot + ${nexus.host}/repository/maven-snapshots + + + + com.navi.medici.utils + event-bus-client + 0.1.3-SNAPSHOT + + junit junit diff --git a/litmus-core/src/main/java/com/navi/medici/App.java b/litmus-core/src/main/java/com/navi/medici/App.java index c0d56af..acb795d 100644 --- a/litmus-core/src/main/java/com/navi/medici/App.java +++ b/litmus-core/src/main/java/com/navi/medici/App.java @@ -2,12 +2,10 @@ package com.navi.medici; /** * Hello world! - * */ -public class App -{ - public static void main( String[] args ) - { - System.out.println( "Hello World!" ); +public class App { + + public static void main(String[] args) { + System.out.println("Hello World!"); } } diff --git a/litmus-core/src/test/java/com/navi/medici/AppTest.java b/litmus-core/src/test/java/com/navi/medici/AppTest.java index 35dc3e5..ef75ad6 100644 --- a/litmus-core/src/test/java/com/navi/medici/AppTest.java +++ b/litmus-core/src/test/java/com/navi/medici/AppTest.java @@ -7,14 +7,13 @@ import org.junit.Test; /** * Unit test for simple App. */ -public class AppTest -{ +public class AppTest { + /** * Rigorous Test :-) */ @Test - public void shouldAnswerWithTrue() - { - assertTrue( true ); + public void shouldAnswerWithTrue() { + assertTrue(true); } } diff --git a/litmus-model/pom.xml b/litmus-model/pom.xml index 907f63f..9e1cdcf 100644 --- a/litmus-model/pom.xml +++ b/litmus-model/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 litmus @@ -12,7 +13,40 @@ litmus-model + + 11 + https://nexus.cmd.navi-tech.in + + + + + nexus + Releases + ${nexus.host}/repository/maven-releases/ + + + nexus + Snapshot + ${nexus.host}/repository/maven-snapshots + + + + + + nexus + Snapshot + ${nexus.host}/repository/maven-snapshots + + + + + com.fasterxml.jackson.core + jackson-annotations + 2.13.0 + + + junit junit diff --git a/litmus-model/src/main/java/com/navi/medici/App.java b/litmus-model/src/main/java/com/navi/medici/App.java index c0d56af..acb795d 100644 --- a/litmus-model/src/main/java/com/navi/medici/App.java +++ b/litmus-model/src/main/java/com/navi/medici/App.java @@ -2,12 +2,10 @@ package com.navi.medici; /** * Hello world! - * */ -public class App -{ - public static void main( String[] args ) - { - System.out.println( "Hello World!" ); +public class App { + + public static void main(String[] args) { + System.out.println("Hello World!"); } } diff --git a/litmus-model/src/main/java/com/navi/medici/constraint/Constraint.java b/litmus-model/src/main/java/com/navi/medici/constraint/Constraint.java new file mode 100644 index 0000000..6de107f --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/constraint/Constraint.java @@ -0,0 +1,26 @@ +package com.navi.medici.constraint; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.navi.medici.enums.Operator; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.FieldDefaults; + +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Constraint { + + String contextName; + Operator operator; + List values; +} diff --git a/litmus-model/src/main/java/com/navi/medici/context/LitmusContext.java b/litmus-model/src/main/java/com/navi/medici/context/LitmusContext.java new file mode 100644 index 0000000..e782dc1 --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/context/LitmusContext.java @@ -0,0 +1,56 @@ +package com.navi.medici.context; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Map; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.FieldDefaults; + +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +public class LitmusContext { + + Optional environment; + Optional appName; + Optional userId; + Optional sessionId; + Optional remoteAddress; + Optional appVersionCode; + Optional osType; + Optional deviceId; + + Map properties; + + public Optional getByName(String contextName) { + switch (contextName) { + case "environment": + return environment; + case "appName": + return appName; + case "userId": + return userId; + case "sessionId": + return sessionId; + case "remoteAddress": + return remoteAddress; + case "appVersionCode": + return appVersionCode; + case "osType": + return osType; + case "deviceId": + return deviceId; + default: + return Optional.ofNullable(properties.get(contextName)); + } + } +} diff --git a/litmus-model/src/main/java/com/navi/medici/enums/Operator.java b/litmus-model/src/main/java/com/navi/medici/enums/Operator.java new file mode 100644 index 0000000..9197db4 --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/enums/Operator.java @@ -0,0 +1,6 @@ +package com.navi.medici.enums; + +public enum Operator { + IN, + NOT_IN +} diff --git a/litmus-model/src/main/java/com/navi/medici/experiment/LitmusExperiment.java b/litmus-model/src/main/java/com/navi/medici/experiment/LitmusExperiment.java new file mode 100644 index 0000000..0131812 --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/experiment/LitmusExperiment.java @@ -0,0 +1,28 @@ +package com.navi.medici.experiment; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.navi.medici.strategy.ActivationStrategy; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.FieldDefaults; + +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@FieldDefaults(level = AccessLevel.PRIVATE) +public class LitmusExperiment { + + String name; + boolean enabled; + List strategies; +// @Nullable +// List variants; +} diff --git a/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentCollection.java b/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentCollection.java new file mode 100644 index 0000000..aa7a27d --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentCollection.java @@ -0,0 +1,39 @@ +package com.navi.medici.response; + + +import com.navi.medici.experiment.LitmusExperiment; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; + +public final class LitmusExperimentCollection { + + private final Collection litmusExperiments; + private final int version = 1; // required for serialization + private final transient Map cache; + + public LitmusExperimentCollection(final Collection litmusExperiments) { + this.litmusExperiments = ensureNotNull(litmusExperiments); + this.cache = new ConcurrentHashMap<>(); + for (LitmusExperiment litmusExperiment : this.litmusExperiments) { + cache.put(litmusExperiment.getName(), litmusExperiment); + } + } + + private Collection ensureNotNull(@Nullable Collection litmusExperiments) { + if (litmusExperiments == null) { + return Collections.emptyList(); + } + return litmusExperiments; + } + + public Collection getLitmusExperiments() { + return Collections.unmodifiableCollection(litmusExperiments); + } + + public LitmusExperiment getLitmusExperiment(final String name) { + return cache.get(name); + } +} \ No newline at end of file diff --git a/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentResponse.java b/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentResponse.java new file mode 100644 index 0000000..1130130 --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/response/LitmusExperimentResponse.java @@ -0,0 +1,57 @@ +package com.navi.medici.response; + +import com.navi.medici.experiment.LitmusExperiment; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +public class LitmusExperimentResponse { + + public enum Status { + NOT_CHANGED, + CHANGED, + UNAVAILABLE + } + + private final Status status; + private final int httpStatusCode; + private final LitmusExperimentCollection experimentCollection; + @Nullable + private String location; + + public LitmusExperimentResponse(Status status, LitmusExperimentCollection experimentCollection) { + this.status = status; + this.httpStatusCode = 200; + this.experimentCollection = experimentCollection; + } + + public LitmusExperimentResponse(Status status, int httpStatusCode) { + this.status = status; + this.httpStatusCode = httpStatusCode; + List emptyList = Collections.emptyList(); + this.experimentCollection = new LitmusExperimentCollection(emptyList); + } + + public LitmusExperimentResponse(Status status, int httpStatusCode, @Nullable String location) { + this(status, httpStatusCode); + this.location = location; + } + + public Status getStatus() { + return status; + } + + public LitmusExperimentCollection getExperimentCollection() { + return experimentCollection; + } + + public int getHttpStatusCode() { + return httpStatusCode; + } + + public @Nullable + String getLocation() { + return location; + } + +} diff --git a/litmus-model/src/main/java/com/navi/medici/strategy/ActivationStrategy.java b/litmus-model/src/main/java/com/navi/medici/strategy/ActivationStrategy.java new file mode 100644 index 0000000..0c916d4 --- /dev/null +++ b/litmus-model/src/main/java/com/navi/medici/strategy/ActivationStrategy.java @@ -0,0 +1,37 @@ +package com.navi.medici.strategy; + +import com.navi.medici.constraint.Constraint; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public final class ActivationStrategy { + + private final String name; + private final Map parameters; + private final List constraints; + + public ActivationStrategy(String name, Map parameters) { + this(name, parameters, Collections.emptyList()); + } + + public ActivationStrategy( + String name, Map parameters, List constraints) { + this.name = name; + this.parameters = parameters; + this.constraints = constraints; + } + + public String getName() { + return name; + } + + public Map getParameters() { + return parameters; + } + + public List getConstraints() { + return constraints; + } +} + diff --git a/litmus-model/src/test/java/com/navi/medici/AppTest.java b/litmus-model/src/test/java/com/navi/medici/AppTest.java index 35dc3e5..ef75ad6 100644 --- a/litmus-model/src/test/java/com/navi/medici/AppTest.java +++ b/litmus-model/src/test/java/com/navi/medici/AppTest.java @@ -7,14 +7,13 @@ import org.junit.Test; /** * Unit test for simple App. */ -public class AppTest -{ +public class AppTest { + /** * Rigorous Test :-) */ @Test - public void shouldAnswerWithTrue() - { - assertTrue( true ); + public void shouldAnswerWithTrue() { + assertTrue(true); } } diff --git a/pom.xml b/pom.xml index d997447..b767fc6 100644 --- a/pom.xml +++ b/pom.xml @@ -1,21 +1,47 @@ - - + + 4.0.0 com.navi.medici + litmus + 1.0-SNAPSHOT + pom + litmus litmus-core litmus-model + litmus-client + + org.projectlombok + lombok + 1.18.20 + provided + + + + co.elastic.logging + log4j2-ecs-layout + 1.0.1 + + + + com.google.code.findbugs + jsr305 + 3.0.2 + true + + + junit junit @@ -35,7 +61,6 @@ 11 - diff --git a/settings.xml b/settings.xml new file mode 100644 index 0000000..7e776df --- /dev/null +++ b/settings.xml @@ -0,0 +1,12 @@ + + + + nexus + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + \ No newline at end of file