bootstrap) {
40 | bootstrap.getObjectMapper().registerModule(new GuavaModule());
41 | }
42 |
43 | @Override
44 | public void run(final T configuration, final Environment environment) throws Exception {
45 | Preconditions.checkState(configuration instanceof AlchemyServiceConfiguration);
46 |
47 | final AlchemyModule module = new AlchemyModule(configuration, environment);
48 | environment.lifecycle().manage(module);
49 |
50 | final Injector injector = Guice.createInjector(module);
51 | runInjected(injector, configuration, environment);
52 | environment.jersey().register(new SparseFieldSetFilter(environment.getObjectMapper()));
53 | environment.jersey().register(new RuntimeExceptionMapper());
54 | environment.lifecycle().manage(new JmxMetricsManaged(environment));
55 | registerIdentitySubTypes(configuration, environment);
56 | }
57 |
58 | protected void runInjected(
59 | final Injector injector, final T configuration, final Environment environment)
60 | throws Exception {
61 | for (final Class> resource : RESOURCES) {
62 | environment.jersey().register(injector.getInstance(resource));
63 | }
64 |
65 | environment
66 | .healthChecks()
67 | .register("database", injector.getInstance(ExperimentsDatabaseProviderCheck.class));
68 | }
69 |
70 | private void registerIdentitySubTypes(T configuration, Environment environment) {
71 | for (final IdentityMapping identity : configuration.getIdentities().values()) {
72 | environment.getObjectMapper().registerSubtypes(identity.getDtoType());
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/alchemy-core/src/main/java/io/rtr/alchemy/filtering/FilterBaseListener.java:
--------------------------------------------------------------------------------
1 | // Generated from Filter.g4 by ANTLR 4.5
2 | package io.rtr.alchemy.filtering;
3 |
4 | import org.antlr.v4.runtime.ParserRuleContext;
5 | import org.antlr.v4.runtime.misc.NotNull;
6 | import org.antlr.v4.runtime.tree.ErrorNode;
7 | import org.antlr.v4.runtime.tree.TerminalNode;
8 |
9 | /**
10 | * This class provides an empty implementation of {@link FilterListener},
11 | * which can be extended to create a listener which only needs to handle a subset
12 | * of the available methods.
13 | */
14 | public class FilterBaseListener implements FilterListener {
15 | /**
16 | * {@inheritDoc}
17 | *
18 | * The default implementation does nothing.
19 | */
20 | @Override public void enterExp(FilterParser.ExpContext ctx) { }
21 | /**
22 | * {@inheritDoc}
23 | *
24 | * The default implementation does nothing.
25 | */
26 | @Override public void exitExp(FilterParser.ExpContext ctx) { }
27 | /**
28 | * {@inheritDoc}
29 | *
30 | * The default implementation does nothing.
31 | */
32 | @Override public void enterTerm(FilterParser.TermContext ctx) { }
33 | /**
34 | * {@inheritDoc}
35 | *
36 | * The default implementation does nothing.
37 | */
38 | @Override public void exitTerm(FilterParser.TermContext ctx) { }
39 | /**
40 | * {@inheritDoc}
41 | *
42 | * The default implementation does nothing.
43 | */
44 | @Override public void enterFactor(FilterParser.FactorContext ctx) { }
45 | /**
46 | * {@inheritDoc}
47 | *
48 | * The default implementation does nothing.
49 | */
50 | @Override public void exitFactor(FilterParser.FactorContext ctx) { }
51 | /**
52 | * {@inheritDoc}
53 | *
54 | * The default implementation does nothing.
55 | */
56 | @Override public void enterComparison(FilterParser.ComparisonContext ctx) { }
57 | /**
58 | * {@inheritDoc}
59 | *
60 | * The default implementation does nothing.
61 | */
62 | @Override public void exitComparison(FilterParser.ComparisonContext ctx) { }
63 | /**
64 | * {@inheritDoc}
65 | *
66 | * The default implementation does nothing.
67 | */
68 | @Override public void enterConstant(FilterParser.ConstantContext ctx) { }
69 | /**
70 | * {@inheritDoc}
71 | *
72 | * The default implementation does nothing.
73 | */
74 | @Override public void exitConstant(FilterParser.ConstantContext ctx) { }
75 | /**
76 | * {@inheritDoc}
77 | *
78 | * The default implementation does nothing.
79 | */
80 | @Override public void enterValue(FilterParser.ValueContext ctx) { }
81 | /**
82 | * {@inheritDoc}
83 | *
84 | * The default implementation does nothing.
85 | */
86 | @Override public void exitValue(FilterParser.ValueContext ctx) { }
87 |
88 | /**
89 | * {@inheritDoc}
90 | *
91 | * The default implementation does nothing.
92 | */
93 | @Override public void enterEveryRule(ParserRuleContext ctx) { }
94 | /**
95 | * {@inheritDoc}
96 | *
97 | * The default implementation does nothing.
98 | */
99 | @Override public void exitEveryRule(ParserRuleContext ctx) { }
100 | /**
101 | * {@inheritDoc}
102 | *
103 | * The default implementation does nothing.
104 | */
105 | @Override public void visitTerminal(TerminalNode node) { }
106 | /**
107 | * {@inheritDoc}
108 | *
109 | * The default implementation does nothing.
110 | */
111 | @Override public void visitErrorNode(ErrorNode node) { }
112 | }
--------------------------------------------------------------------------------
/alchemy-core/src/main/java/io/rtr/alchemy/identities/IdentityBuilder.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.identities;
2 |
3 | import com.google.common.hash.Hasher;
4 | import com.google.common.hash.Hashing;
5 |
6 | import java.nio.charset.Charset;
7 |
8 | /** Used for building a unique identity */
9 | public class IdentityBuilder {
10 | private static final Charset CHARSET = Charset.forName("UTF-8");
11 | private final Hasher hasher;
12 |
13 | private IdentityBuilder(int seed) {
14 | this.hasher = Hashing.murmur3_128(seed).newHasher();
15 | }
16 |
17 | public static IdentityBuilder seed(int seed) {
18 | return new IdentityBuilder(seed);
19 | }
20 |
21 | public IdentityBuilder putByte(Byte value) {
22 | if (value == null) {
23 | putNull();
24 | } else {
25 | hasher.putByte(value);
26 | }
27 | return this;
28 | }
29 |
30 | public IdentityBuilder putBytes(byte[] value) {
31 | if (value == null) {
32 | putNull();
33 | } else {
34 | hasher.putBytes(value);
35 | }
36 | return this;
37 | }
38 |
39 | public IdentityBuilder putBytes(byte[] value, int start, int length) {
40 | if (value == null) {
41 | putNull();
42 | } else {
43 | hasher.putBytes(value, start, length);
44 | }
45 | return this;
46 | }
47 |
48 | public IdentityBuilder putShort(Short value) {
49 | if (value == null) {
50 | putNull();
51 | } else {
52 | hasher.putShort(value);
53 | }
54 | return this;
55 | }
56 |
57 | public IdentityBuilder putInt(Integer value) {
58 | if (value == null) {
59 | putNull();
60 | } else {
61 | hasher.putInt(value);
62 | }
63 | return this;
64 | }
65 |
66 | public IdentityBuilder putLong(Long value) {
67 | if (value == null) {
68 | putNull();
69 | } else {
70 | hasher.putLong(value);
71 | }
72 | return this;
73 | }
74 |
75 | public IdentityBuilder putFloat(Float value) {
76 | if (value == null) {
77 | putNull();
78 | } else {
79 | hasher.putFloat(value);
80 | }
81 | return this;
82 | }
83 |
84 | public IdentityBuilder putDouble(Double value) {
85 | if (value == null) {
86 | putNull();
87 | } else {
88 | hasher.putDouble(value);
89 | }
90 | return this;
91 | }
92 |
93 | public IdentityBuilder putBoolean(Boolean value) {
94 | if (value == null) {
95 | putNull();
96 | } else {
97 | hasher.putBoolean(value);
98 | }
99 | return this;
100 | }
101 |
102 | public IdentityBuilder putChar(Character value) {
103 | if (value == null) {
104 | putNull();
105 | } else {
106 | hasher.putChar(value);
107 | }
108 | return this;
109 | }
110 |
111 | public IdentityBuilder putString(CharSequence value) {
112 | if (value == null) {
113 | putNull();
114 | } else {
115 | hasher.putString(value, CHARSET);
116 | }
117 | return this;
118 | }
119 |
120 | public IdentityBuilder putNull() {
121 | hasher.putLong(0);
122 | return this;
123 | }
124 |
125 | public long hash() {
126 | return hasher.hash().asLong();
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/UpdateExperimentRequest.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.dto.requests;
2 |
3 | import io.rtr.alchemy.dto.models.TreatmentDto;
4 |
5 | import java.util.List;
6 | import java.util.Optional;
7 | import java.util.Set;
8 |
9 | import javax.validation.Valid;
10 |
11 | /** Represents a request for updating an experiment */
12 | public class UpdateExperimentRequest {
13 | private Optional seed;
14 | private Optional description;
15 | private Optional filter;
16 | private Optional> hashAttributes;
17 | private Optional active;
18 |
19 | @Valid private Optional> treatments;
20 |
21 | @Valid private Optional> allocations;
22 |
23 | @Valid private Optional> overrides;
24 |
25 | public UpdateExperimentRequest() {}
26 |
27 | public UpdateExperimentRequest(
28 | Optional seed,
29 | Optional description,
30 | Optional filter,
31 | Optional> hashAttributes,
32 | Optional active,
33 | Optional> treatments,
34 | Optional> allocations,
35 | Optional> overrides) {
36 | this.seed = seed;
37 | this.description = description;
38 | this.filter = filter;
39 | this.hashAttributes = hashAttributes;
40 | this.active = active;
41 | this.treatments = treatments;
42 | this.allocations = allocations;
43 | this.overrides = overrides;
44 | }
45 |
46 | public Optional getSeed() {
47 | return seed;
48 | }
49 |
50 | public Optional getDescription() {
51 | return description;
52 | }
53 |
54 | public Optional getFilter() {
55 | return filter;
56 | }
57 |
58 | public Optional> getHashAttributes() {
59 | return hashAttributes;
60 | }
61 |
62 | public Optional getActive() {
63 | return active;
64 | }
65 |
66 | public Optional> getTreatments() {
67 | return treatments;
68 | }
69 |
70 | public Optional> getAllocations() {
71 | return allocations;
72 | }
73 |
74 | public Optional> getOverrides() {
75 | return overrides;
76 | }
77 |
78 | // NOTE: Need setters in order for Optional to work correctly
79 | public void setSeed(Optional seed) {
80 | this.seed = seed;
81 | }
82 |
83 | public void setDescription(Optional description) {
84 | this.description = description;
85 | }
86 |
87 | public void setFilter(Optional filter) {
88 | this.filter = filter;
89 | }
90 |
91 | public void setHashAttributes(Optional> hashAttributes) {
92 | this.hashAttributes = hashAttributes;
93 | }
94 |
95 | public void setActive(Optional active) {
96 | this.active = active;
97 | }
98 |
99 | public void setTreatments(Optional> treatments) {
100 | this.treatments = treatments;
101 | }
102 |
103 | public void setAllocations(Optional> allocations) {
104 | this.allocations = allocations;
105 | }
106 |
107 | public void setOverrides(Optional> overrides) {
108 | this.overrides = overrides;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/MongoExperimentsStore.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.db.mongo;
2 |
3 | import dev.morphia.Datastore;
4 | import dev.morphia.query.FindOptions;
5 | import dev.morphia.query.Query;
6 | import dev.morphia.query.Sort;
7 | import dev.morphia.query.filters.Filters;
8 |
9 | import io.rtr.alchemy.db.ExperimentsStore;
10 | import io.rtr.alchemy.db.Filter;
11 | import io.rtr.alchemy.db.Ordering;
12 | import io.rtr.alchemy.db.Ordering.Direction;
13 | import io.rtr.alchemy.db.Ordering.Field;
14 | import io.rtr.alchemy.db.mongo.models.ExperimentEntity;
15 | import io.rtr.alchemy.db.mongo.util.ExperimentIterable;
16 | import io.rtr.alchemy.models.Experiment;
17 |
18 | import java.util.ArrayList;
19 | import java.util.List;
20 | import java.util.Map.Entry;
21 |
22 | /** A store backed by MongoDB which allows storing Experiments */
23 | public class MongoExperimentsStore implements ExperimentsStore {
24 | private final Datastore ds;
25 | private final RevisionManager revisionManager;
26 |
27 | public MongoExperimentsStore(final Datastore ds, final RevisionManager revisionManager) {
28 | this.ds = ds;
29 | this.revisionManager = revisionManager;
30 | }
31 |
32 | @Override
33 | public void save(final Experiment experiment) {
34 | final ExperimentEntity entity = ExperimentEntity.from(experiment);
35 | entity.revision = revisionManager.nextRevision();
36 | ds.save(entity);
37 | }
38 |
39 | @Override
40 | public Experiment load(final String experimentName, final Experiment.Builder builder) {
41 | final ExperimentEntity entity =
42 | ds.find(ExperimentEntity.class).filter(Filters.eq("name", experimentName)).first();
43 | return entity == null ? null : entity.toExperiment(builder);
44 | }
45 |
46 | @Override
47 | public void delete(final String experimentName) {
48 | ds.find(ExperimentEntity.class).filter(Filters.eq("name", experimentName)).delete();
49 | }
50 |
51 | @Override
52 | public Iterable find(final Filter filter, final Experiment.BuilderFactory factory) {
53 |
54 | final Query query = ds.find(ExperimentEntity.class);
55 |
56 | if (filter.getFilter() != null) {
57 | query.filter(
58 | Filters.or(
59 | Filters.regex("name", filter.getFilter()).caseInsensitive(),
60 | Filters.regex("description", filter.getFilter()).caseInsensitive()));
61 | }
62 |
63 | final FindOptions findOptions = new FindOptions();
64 | final Ordering ordering = filter.getOrdering();
65 | if (ordering != null && !ordering.isEmpty()) {
66 | final List sorts = new ArrayList<>();
67 | for (final Entry entry : ordering.getFields().entrySet()) {
68 | final String field = ExperimentEntity.getFieldName(entry.getKey());
69 |
70 | final Sort sort =
71 | entry.getValue() == Direction.DESCENDING
72 | ? Sort.descending(field)
73 | : Sort.ascending(field);
74 |
75 | sorts.add(sort);
76 | }
77 |
78 | findOptions.sort(sorts.toArray(new Sort[] {}));
79 | }
80 |
81 | if (filter.getOffset() != null) {
82 | findOptions.skip(filter.getOffset());
83 | }
84 |
85 | if (filter.getLimit() != null) {
86 | findOptions.limit(filter.getLimit());
87 | }
88 |
89 | return new ExperimentIterable(query.stream(findOptions).iterator(), factory);
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/alchemy-service/src/main/java/io/rtr/alchemy/service/resources/TreatmentsResource.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.service.resources;
2 |
3 | import com.google.inject.Inject;
4 |
5 | import io.rtr.alchemy.dto.models.TreatmentDto;
6 | import io.rtr.alchemy.dto.requests.UpdateTreatmentRequest;
7 | import io.rtr.alchemy.mapping.Mappers;
8 | import io.rtr.alchemy.models.Experiment;
9 | import io.rtr.alchemy.models.Experiments;
10 | import io.rtr.alchemy.models.Treatment;
11 |
12 | import javax.validation.Valid;
13 | import javax.ws.rs.Consumes;
14 | import javax.ws.rs.DELETE;
15 | import javax.ws.rs.GET;
16 | import javax.ws.rs.POST;
17 | import javax.ws.rs.PUT;
18 | import javax.ws.rs.Path;
19 | import javax.ws.rs.PathParam;
20 | import javax.ws.rs.Produces;
21 | import javax.ws.rs.core.MediaType;
22 | import javax.ws.rs.core.Response;
23 |
24 | /** Resource for interacting with treatments */
25 | @Path("/experiments/{experimentName}/treatments")
26 | @Consumes(MediaType.APPLICATION_JSON)
27 | @Produces(MediaType.APPLICATION_JSON)
28 | public class TreatmentsResource extends BaseResource {
29 | private final Experiments experiments;
30 | private final Mappers mapper;
31 |
32 | @Inject
33 | public TreatmentsResource(Experiments experiments, Mappers mapper) {
34 | this.experiments = experiments;
35 | this.mapper = mapper;
36 | }
37 |
38 | @GET
39 | public Iterable getTreatments(
40 | @PathParam("experimentName") String experimentName) {
41 | return mapper.toDto(
42 | ensureExists(experiments.get(experimentName)).getTreatments(), TreatmentDto.class);
43 | }
44 |
45 | @GET
46 | @Path("/{treatmentName}")
47 | public TreatmentDto getTreatment(
48 | @PathParam("experimentName") String experimentName,
49 | @PathParam("treatmentName") String treatmentName) {
50 | return mapper.toDto(
51 | ensureExists(
52 | ensureExists(experiments.get(experimentName)).getTreatment(treatmentName)),
53 | TreatmentDto.class);
54 | }
55 |
56 | @PUT
57 | public Response addTreatment(
58 | @PathParam("experimentName") String experimentName, @Valid TreatmentDto treatmentDto) {
59 | ensureExists(experiments.get(experimentName))
60 | .addTreatment(treatmentDto.getName(), treatmentDto.getDescription())
61 | .save();
62 |
63 | return created();
64 | }
65 |
66 | @DELETE
67 | @Path("/{treatmentName}")
68 | public void removeTreatment(
69 | @PathParam("experimentName") String experimentName,
70 | @PathParam("treatmentName") String treatmentName) {
71 | final Experiment experiment = ensureExists(experiments.get(experimentName));
72 | ensureExists(experiment.getTreatment(treatmentName));
73 |
74 | experiment.removeTreatment(treatmentName).save();
75 | }
76 |
77 | @POST
78 | @Path("/{treatmentName}")
79 | public void updateTreatment(
80 | @PathParam("experimentName") String experimentName,
81 | @PathParam("treatmentName") String treatmentName,
82 | @Valid UpdateTreatmentRequest request) {
83 | final Experiment experiment = ensureExists(experiments.get(experimentName));
84 | final Treatment treatment =
85 | ensureExists(ensureExists(experiment).getTreatment(treatmentName));
86 |
87 | if (request.getDescription() != null) {
88 | treatment.setDescription(request.getDescription().orElse(null));
89 | }
90 |
91 | experiment.save();
92 | }
93 |
94 | @DELETE
95 | public void clearTreatments(@PathParam("experimentName") String experimentName) {
96 | ensureExists(experiments.get(experimentName)).clearTreatments().save();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/alchemy-client/src/main/java/io/rtr/alchemy/client/builder/UpdateExperimentRequestBuilder.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.client.builder;
2 |
3 | import com.google.common.collect.Lists;
4 | import com.google.common.collect.Sets;
5 |
6 | import io.rtr.alchemy.dto.models.TreatmentDto;
7 | import io.rtr.alchemy.dto.requests.AllocateRequest;
8 | import io.rtr.alchemy.dto.requests.TreatmentOverrideRequest;
9 | import io.rtr.alchemy.dto.requests.UpdateExperimentRequest;
10 |
11 | import java.util.List;
12 | import java.util.Optional;
13 | import java.util.Set;
14 |
15 | import javax.ws.rs.client.Entity;
16 | import javax.ws.rs.client.Invocation;
17 | import javax.ws.rs.core.MediaType;
18 |
19 | public class UpdateExperimentRequestBuilder {
20 | private final Invocation.Builder builder;
21 | private Optional seed;
22 | private Optional description;
23 | private Optional filter;
24 | private Optional> hashAttributes;
25 | private Optional active;
26 | private Optional> treatments;
27 | private Optional> allocations;
28 | private Optional> overrides;
29 |
30 | public UpdateExperimentRequestBuilder(Invocation.Builder builder) {
31 | this.builder = builder;
32 | }
33 |
34 | public UpdateExperimentRequestBuilder setSeed(int seed) {
35 | this.seed = Optional.of(seed);
36 | return this;
37 | }
38 |
39 | public UpdateExperimentRequestBuilder setDescription(String description) {
40 | this.description = Optional.ofNullable(description);
41 | return this;
42 | }
43 |
44 | public UpdateExperimentRequestBuilder setFilter(String filter) {
45 | this.filter = Optional.ofNullable(filter);
46 | return this;
47 | }
48 |
49 | public UpdateExperimentRequestBuilder setHashAttributes(Set hashAttributes) {
50 | this.hashAttributes = Optional.ofNullable(Sets.newLinkedHashSet(hashAttributes));
51 | return this;
52 | }
53 |
54 | public UpdateExperimentRequestBuilder setHashAttributes(String... hashAttributes) {
55 | this.hashAttributes =
56 | Optional.ofNullable(Sets.newLinkedHashSet(Lists.newArrayList(hashAttributes)));
57 | return this;
58 | }
59 |
60 | public UpdateExperimentRequestBuilder activate() {
61 | active = Optional.of(true);
62 | return this;
63 | }
64 |
65 | public UpdateExperimentRequestBuilder deactivate() {
66 | active = Optional.of(false);
67 | return this;
68 | }
69 |
70 | public UpdateExperimentRequestBuilder setTreatments(List treatments) {
71 | this.treatments = Optional.ofNullable(treatments);
72 | return this;
73 | }
74 |
75 | public UpdateExperimentRequestBuilder setAllocations(List allocations) {
76 | this.allocations = Optional.ofNullable(allocations);
77 | return this;
78 | }
79 |
80 | public UpdateExperimentRequestBuilder setOverrides(List overrides) {
81 | this.overrides = Optional.ofNullable(overrides);
82 | return this;
83 | }
84 |
85 | public void apply() {
86 | builder.post(
87 | Entity.entity(
88 | new UpdateExperimentRequest(
89 | seed,
90 | description,
91 | filter,
92 | hashAttributes,
93 | active,
94 | treatments,
95 | allocations,
96 | overrides),
97 | MediaType.APPLICATION_JSON_TYPE));
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/alchemy-service/src/test/java/io/rtr/alchemy/service/resources/ActiveTreatmentsResourceTest.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.service.resources;
2 |
3 | import static org.junit.Assert.assertEquals;
4 |
5 | import com.google.common.collect.ImmutableMap;
6 |
7 | import io.rtr.alchemy.dto.models.TreatmentDto;
8 |
9 | import org.junit.Before;
10 | import org.junit.Test;
11 |
12 | import java.util.Map;
13 |
14 | import javax.ws.rs.core.Response.Status;
15 |
16 | public class ActiveTreatmentsResourceTest extends ResourceTest {
17 | private static final String ENDPOINT_ACTIVE_TREATMENT =
18 | "/active/experiments/{experimentName}/treatment";
19 | private static final String ENDPOINT_ACTIVE_TREATMENTS = "/active/treatments";
20 | private UserDto userDto;
21 | private User user;
22 | private DeviceDto deviceDto;
23 | private Device device;
24 |
25 | @Before
26 | public void setUp() {
27 | super.setUp();
28 |
29 | userDto = new UserDto("user");
30 | user = MAPPER.fromDto(userDto, User.class);
31 |
32 | deviceDto = new DeviceDto("0a1b2c3d4fdeadbeef");
33 | device = MAPPER.fromDto(deviceDto, Device.class);
34 | }
35 |
36 | @Test
37 | public void testGetActiveTreatment() {
38 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_BAD)
39 | .entity(userDto)
40 | .assertStatus(Status.NO_CONTENT);
41 |
42 | final TreatmentDto expected1 =
43 | MAPPER.toDto(
44 | experiment(EXPERIMENT_1).getTreatment(user, user.computeAttributes()),
45 | TreatmentDto.class);
46 | final TreatmentDto expected2 =
47 | MAPPER.toDto(
48 | experiment(EXPERIMENT_2).getTreatment(device, device.computeAttributes()),
49 | TreatmentDto.class);
50 |
51 | final TreatmentDto actual1 =
52 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_1)
53 | .entity(userDto)
54 | .assertStatus(Status.OK)
55 | .result(TreatmentDto.class);
56 |
57 | assertEquals(expected1, actual1);
58 |
59 | // wrong type
60 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_1)
61 | .entity(deviceDto)
62 | .assertStatus(Status.NO_CONTENT);
63 |
64 | final TreatmentDto actual2 =
65 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_2)
66 | .entity(deviceDto)
67 | .assertStatus(Status.OK)
68 | .result(TreatmentDto.class);
69 |
70 | assertEquals(expected2, actual2);
71 |
72 | // not active
73 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_3)
74 | .entity(userDto)
75 | .assertStatus(Status.NO_CONTENT);
76 |
77 | // not allocated
78 | post(ENDPOINT_ACTIVE_TREATMENT, EXPERIMENT_4)
79 | .entity(userDto)
80 | .assertStatus(Status.NO_CONTENT);
81 | }
82 |
83 | @Test
84 | public void testGetActiveTreatments() {
85 | final Map expected =
86 | ImmutableMap.of(
87 | EXPERIMENT_1,
88 | MAPPER.toDto(
89 | experiment(EXPERIMENT_1)
90 | .getTreatment(user, user.computeAttributes()),
91 | TreatmentDto.class));
92 |
93 | final Map actual =
94 | post(ENDPOINT_ACTIVE_TREATMENTS)
95 | .entity(userDto)
96 | .assertStatus(Status.OK)
97 | .result(map(String.class, TreatmentDto.class));
98 |
99 | assertEquals(expected, actual);
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/alchemy-core/src/test/java/io/rtr/alchemy/caching/BasicCacheStrategyTest.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.caching;
2 |
3 | import static org.junit.Assert.assertTrue;
4 | import static org.mockito.ArgumentMatchers.any;
5 | import static org.mockito.ArgumentMatchers.eq;
6 | import static org.mockito.Mockito.doReturn;
7 | import static org.mockito.Mockito.mock;
8 | import static org.mockito.Mockito.reset;
9 | import static org.mockito.Mockito.verify;
10 |
11 | import io.rtr.alchemy.db.ExperimentsCache;
12 | import io.rtr.alchemy.db.ExperimentsStore;
13 | import io.rtr.alchemy.db.ExperimentsStoreProvider;
14 | import io.rtr.alchemy.db.Filter;
15 | import io.rtr.alchemy.models.Experiment;
16 | import io.rtr.alchemy.models.Experiments;
17 |
18 | import org.junit.Before;
19 | import org.junit.Test;
20 |
21 | import java.util.HashSet;
22 | import java.util.Iterator;
23 | import java.util.List;
24 | import java.util.Set;
25 |
26 | public class BasicCacheStrategyTest {
27 | private ExperimentsCache cache;
28 | private Experiments experiments;
29 | private Experiment activeExperiment;
30 | private Experiment inactiveExperiment;
31 |
32 | @Before
33 | public void setUp() {
34 | final CacheStrategy strategy = new BasicCacheStrategy();
35 | final ExperimentsStoreProvider provider = mock(ExperimentsStoreProvider.class);
36 | final ExperimentsStore store = mock(ExperimentsStore.class);
37 | cache = mock(ExperimentsCache.class);
38 | doReturn(store).when(provider).getStore();
39 | doReturn(cache).when(provider).getCache();
40 |
41 | activeExperiment = mock(Experiment.class);
42 | doReturn("foo").when(activeExperiment).getName();
43 | doReturn(true).when(activeExperiment).isActive();
44 | doReturn(activeExperiment).when(store).load(eq("foo"), any(Experiment.Builder.class));
45 |
46 | inactiveExperiment = mock(Experiment.class);
47 | doReturn("bar").when(inactiveExperiment).getName();
48 | doReturn(false).when(inactiveExperiment).isActive();
49 | doReturn(inactiveExperiment).when(store).load(eq("bar"), any(Experiment.Builder.class));
50 |
51 | doReturn(List.of(activeExperiment, inactiveExperiment))
52 | .when(store)
53 | .find(any(Filter.class), any(Experiment.BuilderFactory.class));
54 |
55 | experiments = Experiments.using(provider).using(strategy).build();
56 | }
57 |
58 | @Test
59 | public void testSave() {
60 | experiments.save(activeExperiment);
61 | verify(cache).update(eq(activeExperiment));
62 |
63 | experiments.save(inactiveExperiment);
64 | verify(cache).delete(eq(inactiveExperiment.getName()));
65 | }
66 |
67 | @Test
68 | public void testLoad() {
69 | experiments.get(activeExperiment.getName());
70 | verify(cache).update(eq(activeExperiment));
71 |
72 | experiments.get(inactiveExperiment.getName());
73 | verify(cache).delete(eq(inactiveExperiment.getName()));
74 |
75 | reset(cache);
76 |
77 | final Iterable result = experiments.find();
78 | final Iterator iterator = result.iterator();
79 |
80 | final Set experimentNames =
81 | new HashSet<>(List.of(activeExperiment.getName(), inactiveExperiment.getName()));
82 | assertTrue(
83 | "expected valid experiment name",
84 | experimentNames.remove(iterator.next().getName()));
85 | assertTrue(
86 | "expected valid experiment name",
87 | experimentNames.remove(iterator.next().getName()));
88 | verify(cache).update(eq(activeExperiment));
89 | verify(cache).delete(eq(inactiveExperiment.getName()));
90 | }
91 |
92 | @Test
93 | public void testDelete() {
94 | experiments.delete(inactiveExperiment.getName());
95 |
96 | verify(cache).delete(eq(inactiveExperiment.getName()));
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/alchemy-db-mongo/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 4.0.0
4 |
5 |
6 | alchemy-parent
7 | io.rtr.alchemy
8 | 2.2.18-SNAPSHOT
9 |
10 |
11 | Alchemy Database Support for Mongo
12 | alchemy-db-mongo
13 |
14 |
15 |
16 |
17 |
18 | io.rtr.alchemy
19 | alchemy-dependencies
20 | ${project.version}
21 | pom
22 | import
23 |
24 |
25 |
26 |
27 | io.rtr.alchemy
28 | alchemy-core
29 | ${project.version}
30 |
31 |
32 |
33 |
34 | io.rtr.alchemy
35 | alchemy-testing
36 | ${project.version}
37 | test
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | io.rtr.alchemy
46 | alchemy-core
47 |
48 |
49 |
50 |
51 | com.google.code.findbugs
52 | jsr305
53 |
54 |
55 | com.google.guava
56 | guava
57 |
58 |
59 | joda-time
60 | joda-time
61 |
62 |
63 | dev.morphia.morphia
64 | morphia-core
65 |
66 |
67 | org.mongodb
68 | bson
69 |
70 |
71 | org.mongodb
72 | mongodb-driver-core
73 |
74 |
75 | org.mongodb
76 | mongodb-driver-legacy
77 |
78 |
79 | org.mongodb
80 | mongodb-driver-sync
81 |
82 |
83 | org.slf4j
84 | slf4j-api
85 |
86 |
87 |
88 |
89 | io.rtr.alchemy
90 | alchemy-testing
91 | test
92 |
93 |
94 | junit
95 | junit
96 | test
97 |
98 |
99 | org.junit.jupiter
100 | junit-jupiter-api
101 | test
102 |
103 |
104 | org.testcontainers
105 | junit-jupiter
106 | test
107 |
108 |
109 | org.testcontainers
110 | mongodb
111 | test
112 |
113 |
114 |
--------------------------------------------------------------------------------
/alchemy-service/src/test/java/io/rtr/alchemy/service/resources/TreatmentOverridesResourceTest.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.service.resources;
2 |
3 | import static org.junit.Assert.assertEquals;
4 | import static org.junit.Assert.assertNotNull;
5 | import static org.junit.Assert.assertNull;
6 |
7 | import io.rtr.alchemy.dto.models.TreatmentOverrideDto;
8 | import io.rtr.alchemy.dto.requests.TreatmentOverrideRequest;
9 |
10 | import org.junit.Test;
11 |
12 | import javax.ws.rs.core.Response.Status;
13 |
14 | public class TreatmentOverridesResourceTest extends ResourceTest {
15 | private static final String ENDPOINT_OVERRIDES = "/experiments/{experimentName}/overrides";
16 | private static final String ENDPOINT_OVERRIDE =
17 | "/experiments/{experimentName}/overrides/{overrideName}";
18 |
19 | @Test
20 | public void testGetOverrides() {
21 | get(ENDPOINT_OVERRIDES, EXPERIMENT_BAD).assertStatus(Status.NOT_FOUND);
22 |
23 | final Iterable expected =
24 | MAPPER.toDto(experiment(EXPERIMENT_1).getOverrides(), TreatmentOverrideDto.class);
25 |
26 | final Iterable actual =
27 | get(ENDPOINT_OVERRIDES, EXPERIMENT_1)
28 | .assertStatus(Status.OK)
29 | .result(iterable(TreatmentOverrideDto.class));
30 |
31 | assertEquals(expected, actual);
32 | }
33 |
34 | @Test
35 | public void testGetOverride() {
36 | get(ENDPOINT_OVERRIDE, EXPERIMENT_BAD, EXP_1_OVERRIDE).assertStatus(Status.NOT_FOUND);
37 |
38 | get(ENDPOINT_OVERRIDE, EXPERIMENT_1, OVERRIDE_BAD).assertStatus(Status.NOT_FOUND);
39 |
40 | final TreatmentOverrideDto expected =
41 | MAPPER.toDto(
42 | experiment(EXPERIMENT_1).getOverride(EXP_1_OVERRIDE),
43 | TreatmentOverrideDto.class);
44 |
45 | final TreatmentOverrideDto actual =
46 | get(ENDPOINT_OVERRIDE, EXPERIMENT_1, EXP_1_OVERRIDE)
47 | .assertStatus(Status.OK)
48 | .result(TreatmentOverrideDto.class);
49 |
50 | assertEquals(expected, actual);
51 | }
52 |
53 | @Test
54 | public void testAddOverride() {
55 | final TreatmentOverrideRequest request =
56 | new TreatmentOverrideRequest("control", "qa", "qa_control");
57 |
58 | put(ENDPOINT_OVERRIDES, EXPERIMENT_BAD).entity(request).assertStatus(Status.NOT_FOUND);
59 |
60 | final TreatmentOverrideDto expected =
61 | new TreatmentOverrideDto(
62 | request.getName(), request.getFilter(), request.getTreatment());
63 |
64 | put(ENDPOINT_OVERRIDES, EXPERIMENT_1).entity(request).assertStatus(Status.CREATED);
65 |
66 | final TreatmentOverrideDto actual =
67 | MAPPER.toDto(
68 | experiment(EXPERIMENT_1).getOverride(request.getName()),
69 | TreatmentOverrideDto.class);
70 |
71 | assertEquals(expected, actual);
72 | }
73 |
74 | @Test
75 | public void testRemoveOverride() {
76 | delete(ENDPOINT_OVERRIDE, EXPERIMENT_BAD, EXP_1_OVERRIDE).assertStatus(Status.NOT_FOUND);
77 |
78 | delete(ENDPOINT_OVERRIDE, EXPERIMENT_1, OVERRIDE_BAD).assertStatus(Status.NOT_FOUND);
79 |
80 | assertNotNull(experiment(EXPERIMENT_1).getOverride(EXP_1_OVERRIDE));
81 | delete(ENDPOINT_OVERRIDE, EXPERIMENT_1, EXP_1_OVERRIDE).assertStatus(Status.NO_CONTENT);
82 | assertNull(experiment(EXPERIMENT_1).getOverride(EXP_1_OVERRIDE));
83 | }
84 |
85 | @Test
86 | public void testClearOverrides() {
87 | delete(ENDPOINT_OVERRIDES, EXPERIMENT_BAD).assertStatus(Status.NOT_FOUND);
88 |
89 | assertNotNull(experiment(EXPERIMENT_1).getOverride(EXP_1_OVERRIDE));
90 | delete(ENDPOINT_OVERRIDES, EXPERIMENT_1).assertStatus(Status.NO_CONTENT);
91 | assertNull(experiment(EXPERIMENT_1).getOverride(EXP_1_OVERRIDE));
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/alchemy-core/src/main/java/io/rtr/alchemy/identities/Identity.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.identities;
2 |
3 | import com.google.common.cache.CacheBuilder;
4 | import com.google.common.cache.CacheLoader;
5 | import com.google.common.cache.LoadingCache;
6 |
7 | import java.util.Collections;
8 | import java.util.HashSet;
9 | import java.util.Set;
10 | import java.util.concurrent.ExecutionException;
11 |
12 | import javax.annotation.Nonnull;
13 |
14 | /** Identifies a unique entity whose hash code is used for treatments allocation */
15 | public abstract class Identity {
16 | protected static final Set EMPTY = Collections.emptySet();
17 |
18 | /** Get a list of possible attribute values that can be returned by this identity */
19 | public static Set getSupportedAttributes(Class clazz) {
20 | try {
21 | return ATTRIBUTES_CACHE.get(clazz);
22 | } catch (final ExecutionException e) {
23 | throw new RuntimeException(e);
24 | }
25 | }
26 |
27 | /**
28 | * generates a hash code used to assign identity to treatment
29 | *
30 | * @param seed a seed value to randomize the resulting hash from experiment to experiment for
31 | * the same identity
32 | * @param hashAttributes a set of attributes that should be used to compute the hash code
33 | * @param attributes a map of attribute values
34 | */
35 | public long computeHash(int seed, Set hashAttributes, AttributesMap attributes) {
36 | final IdentityBuilder builder = IdentityBuilder.seed(seed);
37 | final Iterable names =
38 | hashAttributes.isEmpty() ? attributes.keySet() : hashAttributes;
39 |
40 | for (String name : names) {
41 | final Class> type = attributes.getType(name);
42 |
43 | if (type == String.class) {
44 | builder.putString(attributes.getString(name));
45 | } else if (type == Long.class) {
46 | builder.putLong(attributes.getNumber(name));
47 | } else if (type == Boolean.class) {
48 | builder.putBoolean(attributes.getBoolean(name));
49 | }
50 | }
51 |
52 | return builder.hash();
53 | }
54 |
55 | /** generates a list of attributes that describe this identity for filtering */
56 | public abstract AttributesMap computeAttributes();
57 |
58 | /** Convenience method for getting an identity builder given a seed */
59 | protected IdentityBuilder identity(int seed) {
60 | return IdentityBuilder.seed(seed);
61 | }
62 |
63 | /** Convenience method for getting an attributes map builder */
64 | protected AttributesMap.Builder attributes() {
65 | return AttributesMap.newBuilder();
66 | }
67 |
68 | private static final LoadingCache, Set> ATTRIBUTES_CACHE =
69 | CacheBuilder.newBuilder()
70 | .build(
71 | new CacheLoader<>() {
72 | @Override
73 | public Set load(@Nonnull Class> clazz) throws Exception {
74 | final Attributes annotation =
75 | clazz.getAnnotation(Attributes.class);
76 | if (annotation == null) {
77 | return EMPTY;
78 | }
79 |
80 | final Set result = new HashSet<>();
81 | Collections.addAll(result, annotation.value());
82 |
83 | for (final Class extends Identity> identity :
84 | annotation.identities()) {
85 | result.addAll(ATTRIBUTES_CACHE.get(identity));
86 | }
87 |
88 | return result;
89 | }
90 | });
91 | }
92 |
--------------------------------------------------------------------------------
/alchemy-api/src/main/java/io/rtr/alchemy/dto/models/ExperimentDto.java:
--------------------------------------------------------------------------------
1 | package io.rtr.alchemy.dto.models;
2 |
3 | import com.fasterxml.jackson.annotation.JsonCreator;
4 | import com.fasterxml.jackson.annotation.JsonProperty;
5 | import com.google.common.base.Objects;
6 |
7 | import org.joda.time.DateTime;
8 |
9 | import java.util.List;
10 | import java.util.Set;
11 |
12 | /** Represents an experiment */
13 | public class ExperimentDto {
14 | private final String name;
15 | private final int seed;
16 | private final String description;
17 | private final String filter;
18 | private final Set hashAttributes;
19 | private final boolean active;
20 | private final DateTime created;
21 | private final DateTime modified;
22 | private final DateTime activated;
23 | private final DateTime deactivated;
24 | private final List treatments;
25 | private final List allocations;
26 | private final List overrides;
27 |
28 | @JsonCreator
29 | public ExperimentDto(
30 | @JsonProperty("name") String name,
31 | @JsonProperty("seed") int seed,
32 | @JsonProperty("description") String description,
33 | @JsonProperty("filter") String filter,
34 | @JsonProperty("hashAttributes") Set hashAttributes,
35 | @JsonProperty("active") boolean active,
36 | @JsonProperty("created") DateTime created,
37 | @JsonProperty("modified") DateTime modified,
38 | @JsonProperty("activated") DateTime activated,
39 | @JsonProperty("deactivated") DateTime deactivated,
40 | @JsonProperty("treatments") List treatments,
41 | @JsonProperty("allocations") List allocations,
42 | @JsonProperty("overrides") List overrides) {
43 | this.name = name;
44 | this.seed = seed;
45 | this.description = description;
46 | this.filter = filter;
47 | this.hashAttributes = hashAttributes;
48 | this.active = active;
49 | this.created = created;
50 | this.modified = modified;
51 | this.activated = activated;
52 | this.deactivated = deactivated;
53 | this.treatments = treatments;
54 | this.allocations = allocations;
55 | this.overrides = overrides;
56 | }
57 |
58 | public String getName() {
59 | return name;
60 | }
61 |
62 | public int getSeed() {
63 | return seed;
64 | }
65 |
66 | public String getDescription() {
67 | return description;
68 | }
69 |
70 | public String getFilter() {
71 | return filter;
72 | }
73 |
74 | public Set getHashAttributes() {
75 | return hashAttributes;
76 | }
77 |
78 | public boolean isActive() {
79 | return active;
80 | }
81 |
82 | public DateTime getCreated() {
83 | return created;
84 | }
85 |
86 | public DateTime getModified() {
87 | return modified;
88 | }
89 |
90 | public DateTime getActivated() {
91 | return activated;
92 | }
93 |
94 | public DateTime getDeactivated() {
95 | return deactivated;
96 | }
97 |
98 | public List getTreatments() {
99 | return treatments;
100 | }
101 |
102 | public List getAllocations() {
103 | return allocations;
104 | }
105 |
106 | public List getOverrides() {
107 | return overrides;
108 | }
109 |
110 | @Override
111 | public int hashCode() {
112 | return Objects.hashCode(name);
113 | }
114 |
115 | @Override
116 | public boolean equals(Object obj) {
117 | if (!(obj instanceof ExperimentDto)) {
118 | return false;
119 | }
120 |
121 | final ExperimentDto other = (ExperimentDto) obj;
122 |
123 | return Objects.equal(name, other.name);
124 | }
125 | }
126 |
--------------------------------------------------------------------------------