├── docs ├── images │ ├── alchemy.jpg │ └── alchemy-logo.jpg ├── manual │ ├── client.md │ └── example.md ├── manual.md └── about.md ├── alchemy-api ├── src │ ├── test │ │ ├── resources │ │ │ ├── MockIdentityDto.json │ │ │ ├── AllocateRequest.json │ │ │ ├── TreatmentDto.json │ │ │ ├── Allocate.json │ │ │ ├── AllocationDto.json │ │ │ ├── Deallocate.json │ │ │ ├── TreatmentOverrideDto.json │ │ │ ├── TreatmentOverrideRequest.json │ │ │ ├── Reallocate.json │ │ │ ├── UpdateExperimentRequest.json │ │ │ ├── ExperimentDto.json │ │ │ └── CreateExperimentRequest.json │ │ └── java │ │ │ └── io │ │ │ └── rtr │ │ │ └── alchemy │ │ │ └── dto │ │ │ └── models │ │ │ ├── AllocationDtoTest.java │ │ │ ├── TreatmentOverrideDtoTest.java │ │ │ ├── ExperimentDtoTest.java │ │ │ └── TreatmentDtoTest.java │ └── main │ │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── dto │ │ ├── identities │ │ ├── IdentityDto.java │ │ └── Identities.java │ │ ├── requests │ │ ├── UpdateTreatmentRequest.java │ │ ├── AllocationRequests.java │ │ ├── AllocateRequest.java │ │ ├── GetExperimentsRequest.java │ │ ├── TreatmentOverrideRequest.java │ │ ├── AllocationRequest.java │ │ ├── CreateExperimentRequest.java │ │ └── UpdateExperimentRequest.java │ │ └── models │ │ ├── TreatmentDto.java │ │ ├── TreatmentOverrideDto.java │ │ ├── AllocationDto.java │ │ └── ExperimentDto.java └── pom.xml ├── alchemy-db-mongo ├── src │ ├── test │ │ ├── resources │ │ │ └── META-INF │ │ │ │ └── morphia-config.properties │ │ └── java │ │ │ └── io │ │ │ └── rtr │ │ │ └── alchemy │ │ │ └── db │ │ │ └── mongo │ │ │ ├── util │ │ │ ├── MongoDbTestHelper.java │ │ │ └── ExceptionSafeIteratorTest.java │ │ │ └── MongoStoreProviderTest.java │ └── main │ │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── db │ │ └── mongo │ │ ├── models │ │ ├── TreatmentEntity.java │ │ ├── MetadataEntity.java │ │ ├── AllocationEntity.java │ │ └── TreatmentOverrideEntity.java │ │ ├── util │ │ ├── DateTimeCodec.java │ │ ├── ExperimentIterable.java │ │ └── ExceptionSafeIterator.java │ │ ├── RevisionManager.java │ │ ├── MongoExperimentsCache.java │ │ └── MongoExperimentsStore.java └── pom.xml ├── alchemy-example ├── src │ └── main │ │ ├── resources │ │ └── META-INF │ │ │ └── morphia-config.properties │ │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── example │ │ ├── config │ │ ├── MemoryStoreProvider.java │ │ ├── PeriodicStaleCheckingCacheStrategy.java │ │ └── MongoStoreProvider.java │ │ ├── dto │ │ └── UserDto.java │ │ ├── ServiceExample.java │ │ ├── mappers │ │ └── UserMapper.java │ │ └── identities │ │ ├── Device.java │ │ ├── User.java │ │ └── Composite.java └── config │ ├── client.yaml │ └── server.yaml ├── .github ├── workflow-variables.yaml ├── workflows │ ├── woke.yml │ └── pr-validation.yml └── dependabot.yml ├── .gitignore ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── externalDependencies.xml └── google-java-format.xml ├── .yamllint ├── alchemy-service └── src │ ├── main │ ├── resources │ │ └── banner.txt │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── service │ │ ├── metadata │ │ ├── IdentitiesMetadata.java │ │ └── IdentityMetadata.java │ │ ├── config │ │ ├── CacheStrategyConfiguration.java │ │ ├── StoreProviderConfiguration.java │ │ ├── AlchemyServiceConfiguration.java │ │ ├── IdentityMapping.java │ │ └── AlchemyServiceConfigurationImpl.java │ │ ├── resources │ │ ├── BaseResource.java │ │ ├── ActiveTreatmentsResource.java │ │ ├── AllocationsResource.java │ │ ├── TreatmentOverridesResource.java │ │ ├── MetadataResource.java │ │ └── TreatmentsResource.java │ │ ├── metrics │ │ └── JmxMetricsManaged.java │ │ ├── jackson │ │ └── ClassKeyDeserializer.java │ │ ├── health │ │ └── ExperimentsDatabaseProviderCheck.java │ │ ├── exceptions │ │ └── RuntimeExceptionMapper.java │ │ └── AlchemyService.java │ └── test │ └── java │ └── io │ └── rtr │ └── alchemy │ └── service │ └── resources │ ├── BaseResourceTest.java │ ├── MetadataResourceTest.java │ ├── ActiveTreatmentsResourceTest.java │ └── TreatmentOverridesResourceTest.java ├── alchemy-mapping ├── src │ ├── main │ │ └── java │ │ │ └── io │ │ │ └── rtr │ │ │ └── alchemy │ │ │ └── mapping │ │ │ └── Mapper.java │ └── test │ │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── mapping │ │ └── MappersTest.java └── pom.xml ├── alchemy-client └── src │ ├── test │ ├── resources │ │ └── test-server.yaml │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── client │ │ ├── providers │ │ └── MemoryStoreConfiguration.java │ │ ├── mappers │ │ └── UserMapper.java │ │ ├── dto │ │ └── UserDto.java │ │ └── identities │ │ └── User.java │ └── main │ └── java │ └── io │ └── rtr │ └── alchemy │ └── client │ ├── builder │ ├── UpdateTreatmentRequestBuilder.java │ ├── GetExperimentsRequestBuilder.java │ ├── UpdateAllocationsRequestBuilder.java │ └── UpdateExperimentRequestBuilder.java │ └── AlchemyClientConfiguration.java ├── alchemy-core └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── rtr │ │ │ └── alchemy │ │ │ ├── db │ │ │ ├── ExperimentsStoreProvider.java │ │ │ ├── ExperimentsCache.java │ │ │ ├── ExperimentsStore.java │ │ │ ├── Filter.java │ │ │ └── Ordering.java │ │ │ ├── identities │ │ │ ├── Attributes.java │ │ │ ├── IdentityBuilder.java │ │ │ └── Identity.java │ │ │ ├── models │ │ │ ├── Named.java │ │ │ ├── Treatment.java │ │ │ ├── Allocation.java │ │ │ └── TreatmentOverride.java │ │ │ ├── caching │ │ │ ├── CacheStrategy.java │ │ │ ├── BasicCacheStrategy.java │ │ │ ├── CacheStrategyIterable.java │ │ │ └── PeriodicStaleCheckingCacheStrategy.java │ │ │ └── filtering │ │ │ ├── FilterErrorListener.java │ │ │ ├── FilterListener.java │ │ │ └── FilterBaseListener.java │ └── antlr4 │ │ └── Filter.g4 │ └── test │ └── java │ └── io │ └── rtr │ └── alchemy │ ├── db │ ├── FilterTest.java │ └── OrderingTest.java │ ├── models │ ├── AllocationTest.java │ ├── NamedTest.java │ ├── TreatmentTest.java │ └── TreatmentOverrideTest.java │ ├── caching │ ├── CacheStrategyIterableTest.java │ ├── PeriodicStaleCheckingCacheStrategyTest.java │ ├── CachingContextTest.java │ └── BasicCacheStrategyTest.java │ └── identities │ ├── IdentityTest.java │ └── AttributesMapTest.java ├── alchemy-db-memory ├── src │ ├── test │ │ └── java │ │ │ └── io │ │ │ └── rtr │ │ │ └── alchemy │ │ │ └── db │ │ │ └── memory │ │ │ └── MemoryStoreProviderTest.java │ └── main │ │ └── java │ │ └── io │ │ └── rtr │ │ └── alchemy │ │ └── db │ │ └── memory │ │ ├── MemoryStoreProvider.java │ │ └── MemoryExperimentsCache.java └── pom.xml ├── README.md ├── LICENSE ├── alchemy-testing └── pom.xml └── alchemy-bom └── pom.xml /docs/images/alchemy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RentTheRunway/alchemy/HEAD/docs/images/alchemy.jpg -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/MockIdentityDto.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "mock", 3 | "value": "foo" 4 | } -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/AllocateRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "treatment": "control", 3 | "size": 10 4 | } -------------------------------------------------------------------------------- /docs/images/alchemy-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RentTheRunway/alchemy/HEAD/docs/images/alchemy-logo.jpg -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/TreatmentDto.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "control", 3 | "description": "the base case" 4 | } -------------------------------------------------------------------------------- /alchemy-db-mongo/src/test/resources/META-INF/morphia-config.properties: -------------------------------------------------------------------------------- 1 | morphia.database=test 2 | morphia.store-empties=true 3 | -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/Allocate.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "allocate", 3 | "treatment": "control", 4 | "size": 10 5 | } -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/AllocationDto.json: -------------------------------------------------------------------------------- 1 | { 2 | "treatment": "control", 3 | "offset": 20, 4 | "size": 10 5 | } -------------------------------------------------------------------------------- /alchemy-example/src/main/resources/META-INF/morphia-config.properties: -------------------------------------------------------------------------------- 1 | morphia.database=experiments 2 | morphia.store-empties=true 3 | -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/Deallocate.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "deallocate", 3 | "treatment": "control", 4 | "size": 10 5 | } -------------------------------------------------------------------------------- /.github/workflow-variables.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | workflows: 4 | actions-audit: 5 | omitJob: true 6 | stale-branches: 7 | omitJob: true 8 | -------------------------------------------------------------------------------- /alchemy-example/config/client.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | service: http://localhost:8080 4 | identityTypes: 5 | - io.rtr.alchemy.example.dto.UserDto 6 | -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/TreatmentOverrideDto.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qa_override", 3 | "treatment": "control", 4 | "filter": "true" 5 | } -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/TreatmentOverrideRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qa_override", 3 | "treatment": "control", 4 | "filter": "foo" 5 | } -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/Reallocate.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "reallocate", 3 | "treatment": "control", 4 | "target": "other", 5 | "size": 10 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | target 3 | build 4 | 5 | # Package Files # 6 | *.jar 7 | *.war 8 | *.ear 9 | 10 | # Idea Files # 11 | *.iml 12 | .idea 13 | *.tokens 14 | gen -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: default 4 | rules: 5 | empty-lines: disable 6 | # the GHA 'on' key is treated like it means on/off; only relevant for values 7 | truthy: 8 | check-keys: false 9 | -------------------------------------------------------------------------------- /.idea/externalDependencies.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/manual/client.md: -------------------------------------------------------------------------------- 1 | [< back to Manual](../manual.md) 2 | 3 | #Alchemy Client 4 | 5 | The `alchemy-client` module provides you with two a performant, instrumented HTTP clients so you can integrate your service with other web services 6 | -------------------------------------------------------------------------------- /.idea/google-java-format.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /alchemy-service/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | __ __ ___ _ _ ____ __ __ _ _ __ 2 | /__\ ( ) / __)( )_( )( ___)( \/ )( \/ ) || 3 | /(__)\ )(__( (__ ) _ ( )__) ) ( \ / / \ 4 | (__)(__)(____)\___)(_) (_)(____)(_/\/\_) (__) (____) -------------------------------------------------------------------------------- /alchemy-mapping/src/main/java/io/rtr/alchemy/mapping/Mapper.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.mapping; 2 | 3 | /** Defines how to map an object to and from DTO and business object */ 4 | public interface Mapper { 5 | TDto toDto(TBo source); 6 | 7 | TBo fromDto(TDto source); 8 | } 9 | -------------------------------------------------------------------------------- /alchemy-client/src/test/resources/test-server.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | identities: 4 | io.rtr.alchemy.client.identities.User: 5 | dto: io.rtr.alchemy.client.dto.UserDto 6 | mapper: io.rtr.alchemy.client.mappers.UserMapper 7 | 8 | provider: 9 | type: io.rtr.alchemy.client.providers.MemoryStoreConfiguration 10 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/test/java/io/rtr/alchemy/db/mongo/util/MongoDbTestHelper.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.util; 2 | 3 | public final class MongoDbTestHelper { 4 | public static final String MONGODB_IMAGE = "mongo:4.4.25"; 5 | public static final String MONGODB_DATABASE = "test"; 6 | 7 | private MongoDbTestHelper() {} 8 | } 9 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/db/ExperimentsStoreProvider.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import java.io.Closeable; 4 | 5 | /** An interface for implementing a provider that is configurable */ 6 | public interface ExperimentsStoreProvider extends Closeable { 7 | ExperimentsCache getCache(); 8 | 9 | ExperimentsStore getStore(); 10 | } 11 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/identities/IdentityDto.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.identities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 4 | 5 | /** Represents an identity which other identity DTOs must extend */ 6 | @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") 7 | public abstract class IdentityDto {} 8 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/metadata/IdentitiesMetadata.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.metadata; 2 | 3 | import java.util.LinkedHashMap; 4 | 5 | /** A collection of meta data for identity types */ 6 | public class IdentitiesMetadata extends LinkedHashMap { 7 | private static final long serialVersionUID = 7083352357635834895L; 8 | } 9 | -------------------------------------------------------------------------------- /docs/manual.md: -------------------------------------------------------------------------------- 1 | [< back to Readme](../README.md) 2 | 3 | #Manual 4 | 5 | The goal of this document is to provide you with all the information required to build and run your experiments. 6 | 7 | - Table of Contents 8 | * [Alchemy Core](manual/core.md) 9 | * [Alchemy Client](manual/client.md) 10 | * [Alchemy Example, Step by Step](manual/example.md) 11 | * [Alchemy REST API](manual/api.md) 12 | 13 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/db/FilterTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class FilterTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(Filter.class).suppress(Warning.STRICT_INHERITANCE).verify(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/woke.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: woke 4 | on: # yamllint disable-line rule:truthy 5 | - pull_request 6 | 7 | jobs: 8 | woke: 9 | name: woke 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | # For more details, see https://github.com/marketplace/actions/run-woke 15 | - uses: get-woke/woke-action@v0 16 | with: 17 | fail-on-error: true 18 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/models/AllocationTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class AllocationTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(Allocation.class).suppress(Warning.STRICT_INHERITANCE).verify(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /alchemy-api/src/test/java/io/rtr/alchemy/dto/models/AllocationDtoTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.models; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class AllocationDtoTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(AllocationDto.class).suppress(Warning.STRICT_INHERITANCE).verify(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /alchemy-api/src/test/java/io/rtr/alchemy/dto/models/TreatmentOverrideDtoTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.models; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class TreatmentOverrideDtoTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(TreatmentOverrideDto.class) 12 | .suppress(Warning.STRICT_INHERITANCE) 13 | .verify(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /alchemy-client/src/test/java/io/rtr/alchemy/client/providers/MemoryStoreConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.providers; 2 | 3 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 4 | import io.rtr.alchemy.db.memory.MemoryStoreProvider; 5 | import io.rtr.alchemy.service.config.StoreProviderConfiguration; 6 | 7 | public class MemoryStoreConfiguration extends StoreProviderConfiguration { 8 | @Override 9 | public ExperimentsStoreProvider createProvider() { 10 | return new MemoryStoreProvider(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/config/CacheStrategyConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 4 | 5 | import io.rtr.alchemy.caching.CacheStrategy; 6 | 7 | /** Base configuration object for creating a cache strategy */ 8 | @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "type") 9 | public abstract class CacheStrategyConfiguration { 10 | public abstract CacheStrategy createStrategy(); 11 | } 12 | -------------------------------------------------------------------------------- /alchemy-api/src/test/java/io/rtr/alchemy/dto/models/ExperimentDtoTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.models; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class ExperimentDtoTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(ExperimentDto.class) 12 | .suppress(Warning.STRICT_INHERITANCE) 13 | .withOnlyTheseFields("name") 14 | .verify(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /alchemy-api/src/test/java/io/rtr/alchemy/dto/models/TreatmentDtoTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.models; 2 | 3 | import nl.jqno.equalsverifier.EqualsVerifier; 4 | import nl.jqno.equalsverifier.Warning; 5 | 6 | import org.junit.Test; 7 | 8 | public class TreatmentDtoTest { 9 | @Test 10 | public void testEqualsHashCode() { 11 | EqualsVerifier.forClass(TreatmentDto.class) 12 | .suppress(Warning.STRICT_INHERITANCE) 13 | .withIgnoredFields("description") 14 | .verify(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/config/MemoryStoreProvider.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.config; 2 | 3 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 4 | import io.rtr.alchemy.service.config.StoreProviderConfiguration; 5 | 6 | /** Configuration object for creating an in-memory provider */ 7 | public class MemoryStoreProvider extends StoreProviderConfiguration { 8 | @Override 9 | public ExperimentsStoreProvider createProvider() { 10 | return new io.rtr.alchemy.db.memory.MemoryStoreProvider(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/config/StoreProviderConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 4 | 5 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 6 | 7 | /** Base configuration object for creating store providers for experiments */ 8 | @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "type") 9 | public abstract class StoreProviderConfiguration { 10 | public abstract ExperimentsStoreProvider createProvider() throws Exception; 11 | } 12 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/identities/Attributes.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.identities; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** Annotates an identity with a list of supported attributes it may generate */ 9 | @Target(ElementType.TYPE) 10 | @Retention(RetentionPolicy.RUNTIME) 11 | public @interface Attributes { 12 | String[] value() default {}; 13 | 14 | Class[] identities() default {}; 15 | } 16 | -------------------------------------------------------------------------------- /alchemy-core/src/main/antlr4/Filter.g4: -------------------------------------------------------------------------------- 1 | grammar Filter; 2 | 3 | AND: ('&' | 'and'); 4 | OR: ('|' | 'or'); 5 | NOT: ('!' | 'not'); 6 | NUMBER: ('-' | '+')? [0-9]+; 7 | STRING: '"' ~('"')* '"'; 8 | BOOLEAN: ('true' | 'false'); 9 | IDENTIFIER: [A-Za-z_-][A-Za-z0-9_-]*; 10 | COMPARISON: ('<' | '>' | '=' | '<>' | '!=' | '<=' | '>='); 11 | WS: [ \r\n\t]+ -> skip; 12 | 13 | exp: term | exp OR term; 14 | term: factor | factor AND term; 15 | factor: '(' exp ')' | value | comparison | NOT factor; 16 | comparison: value COMPARISON value; 17 | constant: BOOLEAN | NUMBER | STRING; 18 | value: constant | IDENTIFIER; 19 | -------------------------------------------------------------------------------- /alchemy-example/config/server.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | server: 4 | applicationConnectors: 5 | - type: http 6 | port: 8080 7 | adminConnectors: 8 | - type: http 9 | port: 8081 10 | 11 | identities: 12 | io.rtr.alchemy.example.identities.User: 13 | dto: io.rtr.alchemy.example.dto.UserDto 14 | mapper: io.rtr.alchemy.example.mappers.UserMapper 15 | 16 | provider: 17 | type: io.rtr.alchemy.example.config.MongoStoreProvider 18 | hosts: ["localhost"] 19 | db: experiments 20 | 21 | cacheStrategy: 22 | type: io.rtr.alchemy.example.config.PeriodicStaleCheckingCacheStrategy 23 | duration: 1m 24 | -------------------------------------------------------------------------------- /alchemy-db-memory/src/test/java/io/rtr/alchemy/db/memory/MemoryStoreProviderTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.memory; 2 | 3 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 4 | import io.rtr.alchemy.testing.db.ExperimentsStoreProviderTest; 5 | 6 | public class MemoryStoreProviderTest extends ExperimentsStoreProviderTest { 7 | @Override 8 | protected ExperimentsStoreProvider createProvider() { 9 | return new MemoryStoreProvider(); 10 | } 11 | 12 | @Override 13 | protected void resetStore() { 14 | // nothing has to be done because each instance of MemoryStoreProvider is a new store 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/dto/UserDto.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.fasterxml.jackson.annotation.JsonTypeName; 5 | 6 | import io.rtr.alchemy.dto.identities.IdentityDto; 7 | 8 | /** An example DTO to be used for service payload */ 9 | @JsonTypeName("user") 10 | public class UserDto extends IdentityDto { 11 | private final String name; 12 | 13 | public UserDto(@JsonProperty("name") String name) { 14 | this.name = name; 15 | } 16 | 17 | public String getName() { 18 | return name; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /alchemy-client/src/test/java/io/rtr/alchemy/client/mappers/UserMapper.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.mappers; 2 | 3 | import io.rtr.alchemy.client.dto.UserDto; 4 | import io.rtr.alchemy.client.identities.User; 5 | import io.rtr.alchemy.mapping.Mapper; 6 | 7 | // referenced in test-server.yaml 8 | @SuppressWarnings("unused") 9 | public class UserMapper implements Mapper { 10 | @Override 11 | public UserDto toDto(User source) { 12 | return new UserDto(source.getName()); 13 | } 14 | 15 | @Override 16 | public User fromDto(UserDto source) { 17 | return new User(source.getName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Please note that this project is deprecated/unsupported and relies on libraries with open CVEs. 2 | 3 | --- 4 | 5 |
6 | 7 | #Alchemy 8 | 9 | **a framework for running A/B experiments -- fast and ops-friendly!** 10 | 11 | 12 | ![alt alchemy-logo](docs/images/alchemy.jpg "Alchemy") 13 | 14 |
15 | 16 | It's written using Dropwizard, a *simple* and *lightweight* REST service framework composed of *stable* and *mature* libraries from the Java ecosystem. 17 | 18 | - Table of Contents 19 | * [Manual](docs/manual.md) 20 | * [About](docs/about.md) 21 | 22 |
23 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/UpdateTreatmentRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import java.util.Optional; 4 | 5 | public class UpdateTreatmentRequest { 6 | private Optional description; 7 | 8 | public UpdateTreatmentRequest() {} 9 | 10 | public UpdateTreatmentRequest(Optional description) { 11 | this.description = description; 12 | } 13 | 14 | public void setDescription(Optional description) { 15 | this.description = description; 16 | } 17 | 18 | public Optional getDescription() { 19 | return description; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/ServiceExample.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example; 2 | 3 | import io.rtr.alchemy.service.AlchemyService; 4 | import io.rtr.alchemy.service.config.AlchemyServiceConfigurationImpl; 5 | 6 | /** This example runs an instance of the Alchemy Dropwizard service with a basic configuration */ 7 | public class ServiceExample extends AlchemyService { 8 | public static void main(String[] args) throws Exception { 9 | new ServiceExample().run(args); 10 | } 11 | 12 | @Override 13 | public String getName() { 14 | return "alchemy-example"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/mappers/UserMapper.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.mappers; 2 | 3 | import io.rtr.alchemy.example.dto.UserDto; 4 | import io.rtr.alchemy.example.identities.User; 5 | import io.rtr.alchemy.mapping.Mapper; 6 | 7 | /** Maps to and from UserDto to User */ 8 | @SuppressWarnings("unused") 9 | public class UserMapper implements Mapper { 10 | @Override 11 | public User fromDto(UserDto source) { 12 | return new User(source.getName()); 13 | } 14 | 15 | @Override 16 | public UserDto toDto(User source) { 17 | return new UserDto(source.getName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alchemy-client/src/test/java/io/rtr/alchemy/client/dto/UserDto.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.fasterxml.jackson.annotation.JsonTypeName; 6 | 7 | import io.rtr.alchemy.dto.identities.IdentityDto; 8 | 9 | @JsonTypeName("user") 10 | public class UserDto extends IdentityDto { 11 | private final String name; 12 | 13 | @JsonCreator 14 | public UserDto(@JsonProperty("name") String name) { 15 | this.name = name; 16 | } 17 | 18 | public String getName() { 19 | return name; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/models/Named.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | import javax.validation.ValidationException; 6 | 7 | public interface Named { 8 | Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z0-9-_]+$"); 9 | 10 | String getName(); 11 | 12 | default void validateName() { 13 | if (getName() == null || !NAME_PATTERN.matcher(getName()).matches()) { 14 | throw new ValidationException( 15 | String.format( 16 | "Invalid name %s, must match %s", getName(), NAME_PATTERN.pattern())); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/resources/BaseResource.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import javax.ws.rs.WebApplicationException; 4 | import javax.ws.rs.core.Response; 5 | 6 | /** Base resource with various helper methods */ 7 | public abstract class BaseResource { 8 | protected static T ensureExists(T value) { 9 | if (value == null) { 10 | throw new WebApplicationException(Response.Status.NOT_FOUND); 11 | } 12 | 13 | return value; 14 | } 15 | 16 | protected static Response created() { 17 | return Response.status(Response.Status.CREATED).build(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/AllocationRequests.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | 6 | /** We need this class to deal with Jackson List serialization issues due to type-erasure */ 7 | public class AllocationRequests extends ArrayList { 8 | private static final long serialVersionUID = 3619787346944661924L; 9 | 10 | public static AllocationRequests of(AllocationRequest... requests) { 11 | final AllocationRequests result = new AllocationRequests(); 12 | Collections.addAll(result, requests); 13 | return result; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/identities/Identities.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.identities; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | 6 | /** We need this class to deal with Jackson List serialization issues due to type-erasure */ 7 | public class Identities extends ArrayList { 8 | private static final long serialVersionUID = 8406201398658647047L; 9 | 10 | public static Identities of(IdentityDto... requests) { 11 | final Identities result = new Identities(); 12 | Collections.addAll(result, requests); 13 | return result; 14 | } 15 | 16 | public static Identities empty() { 17 | return new Identities(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: 2 4 | 5 | registries: 6 | maven-artifactory: 7 | type: maven-repository 8 | url: https://artifactory.rtr.cloud/artifactory/maven-releases/ 9 | username: ${{ secrets.ARTIFACTORY_DEPLOY_USER }} 10 | password: ${{ secrets.ARTIFACTORY_DEPLOY_PASSWORD }} 11 | 12 | updates: 13 | - package-ecosystem: "maven" 14 | registries: 15 | - maven-artifactory 16 | target-branch: "master" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | open-pull-requests-limit: 30 21 | 22 | - package-ecosystem: "github-actions" 23 | target-branch: "master" 24 | directory: ".github/workflows" 25 | schedule: 26 | interval: "weekly" 27 | open-pull-requests-limit: 10 28 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/AllocateRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import javax.validation.constraints.NotNull; 6 | 7 | /** Represents a simple allocation request */ 8 | public class AllocateRequest { 9 | @NotNull private final String treatment; 10 | @NotNull private final Integer size; 11 | 12 | public AllocateRequest( 13 | @JsonProperty("treatment") String treatment, @JsonProperty("size") Integer size) { 14 | this.treatment = treatment; 15 | this.size = size; 16 | } 17 | 18 | public String getTreatment() { 19 | return treatment; 20 | } 21 | 22 | public Integer getSize() { 23 | return size; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/config/AlchemyServiceConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.config; 2 | 3 | import io.rtr.alchemy.identities.Identity; 4 | 5 | import java.util.Map; 6 | 7 | /** The base configuration for the Alchemy Dropwizard service */ 8 | public interface AlchemyServiceConfiguration { 9 | /** Defines a list of known identity types, which are used for assigning users to a treatment */ 10 | Map, IdentityMapping> getIdentities(); 11 | 12 | /** Defines a store configuration for creating a store provider */ 13 | StoreProviderConfiguration getProvider(); 14 | 15 | /** Defines a cache strategy configuration for creating a cache strategy */ 16 | CacheStrategyConfiguration getCacheStrategy(); 17 | } 18 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/metrics/JmxMetricsManaged.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.metrics; 2 | 3 | import com.codahale.metrics.jmx.JmxReporter; 4 | 5 | import io.dropwizard.lifecycle.Managed; 6 | import io.dropwizard.setup.Environment; 7 | 8 | /** Enables logging of metrics to JMX */ 9 | public class JmxMetricsManaged implements Managed { 10 | private final JmxReporter reporter; 11 | 12 | public JmxMetricsManaged(Environment environment) { 13 | reporter = JmxReporter.forRegistry(environment.metrics()).build(); 14 | } 15 | 16 | @Override 17 | public void start() throws Exception { 18 | reporter.start(); 19 | } 20 | 21 | @Override 22 | public void stop() throws Exception { 23 | reporter.stop(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/jackson/ClassKeyDeserializer.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.jackson; 2 | 3 | import com.fasterxml.jackson.databind.DeserializationContext; 4 | import com.fasterxml.jackson.databind.KeyDeserializer; 5 | 6 | import java.io.IOException; 7 | 8 | /** Allows Jackson to deserialize Map keys of type Class<?> */ 9 | public class ClassKeyDeserializer extends KeyDeserializer { 10 | @Override 11 | public Object deserializeKey(String className, DeserializationContext context) 12 | throws IOException { 13 | try { 14 | return Class.forName(className); 15 | } catch (final Exception e) { 16 | throw new IOException(String.format("could not find class %s", className)); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/config/PeriodicStaleCheckingCacheStrategy.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import io.rtr.alchemy.caching.CacheStrategy; 6 | import io.rtr.alchemy.service.config.CacheStrategyConfiguration; 7 | 8 | import org.joda.time.Duration; 9 | 10 | import javax.validation.constraints.NotNull; 11 | 12 | public class PeriodicStaleCheckingCacheStrategy extends CacheStrategyConfiguration { 13 | @NotNull @JsonProperty private Duration duration; 14 | 15 | public Duration getDuration() { 16 | return duration; 17 | } 18 | 19 | @Override 20 | public CacheStrategy createStrategy() { 21 | return new io.rtr.alchemy.caching.PeriodicStaleCheckingCacheStrategy(duration); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/models/TreatmentEntity.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.models; 2 | 3 | import dev.morphia.annotations.Entity; 4 | 5 | import io.rtr.alchemy.models.Treatment; 6 | 7 | /** 8 | * An entity that mirrors Treatment 9 | * 10 | * @see io.rtr.alchemy.models.Treatment 11 | */ 12 | @Entity 13 | public class TreatmentEntity { 14 | public String name; 15 | public String description; 16 | 17 | // Required by Morphia 18 | @SuppressWarnings("unused") 19 | private TreatmentEntity() {} 20 | 21 | private TreatmentEntity(final Treatment treatment) { 22 | name = treatment.getName(); 23 | description = treatment.getDescription(); 24 | } 25 | 26 | public static TreatmentEntity from(final Treatment treatment) { 27 | return new TreatmentEntity(treatment); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/UpdateExperimentRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "seed": null, 3 | "description": "my new experiment", 4 | "filter": "identified", 5 | "hashAttributes": [], 6 | "active": true, 7 | "treatments": [ 8 | { 9 | "name": "control", 10 | "description": "the base case" 11 | }, 12 | { 13 | "name": "x", 14 | "description": "some other condition" 15 | } 16 | ], 17 | "allocations": [ 18 | { 19 | "treatment": "control", 20 | "size": 5 21 | }, 22 | { 23 | "treatment": "x", 24 | "size": 10 25 | } 26 | ], 27 | "overrides": [ 28 | { 29 | "name": "qa_override", 30 | "treatment": "control", 31 | "filter": "foo" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/ExperimentDto.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"my_experiment", 3 | "seed":0, 4 | "description":"my new experiment", 5 | "filter":"identified", 6 | "hashAttributes":[ 7 | 8 | ], 9 | "active":true, 10 | "created":0, 11 | "modified":1, 12 | "activated":2, 13 | "deactivated":3, 14 | "treatments":[ 15 | { 16 | "name":"control", 17 | "description":"the base case" 18 | }, 19 | { 20 | "name":"x", 21 | "description":"some other condition" 22 | } 23 | ], 24 | "allocations":[ 25 | { 26 | "treatment":"control", 27 | "offset":0, 28 | "size":5 29 | }, 30 | { 31 | "treatment":"x", 32 | "offset":5, 33 | "size":10 34 | } 35 | ], 36 | "overrides":[ 37 | { 38 | "name":"qa_override", 39 | "filter":"true", 40 | "treatment":"control" 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/models/MetadataEntity.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.models; 2 | 3 | import dev.morphia.annotations.Entity; 4 | import dev.morphia.annotations.Id; 5 | import dev.morphia.annotations.Property; 6 | 7 | /** An entity for storing additional metadata */ 8 | @Entity(value = "Metadata", useDiscriminator = false) 9 | public class MetadataEntity { 10 | 11 | @Id public String name; 12 | 13 | @Property public Object value; 14 | 15 | // Required by Morphia 16 | @SuppressWarnings("unused") 17 | private MetadataEntity() {} 18 | 19 | private MetadataEntity(final String name, final Object value) { 20 | this.name = name; 21 | this.value = value; 22 | } 23 | 24 | public static MetadataEntity of(final String name, final Object value) { 25 | return new MetadataEntity(name, value); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /alchemy-api/src/test/resources/CreateExperimentRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "seed": 0, 3 | "name": "my_experiment", 4 | "description": "my new experiment", 5 | "filter": "identified", 6 | "hashAttributes": [], 7 | "active": true, 8 | "treatments": [ 9 | { 10 | "name": "control", 11 | "description": "the base case" 12 | }, 13 | { 14 | "name": "x", 15 | "description": "some other condition" 16 | } 17 | ], 18 | "allocations": [ 19 | { 20 | "treatment": "control", 21 | "size": 5 22 | }, 23 | { 24 | "treatment": "x", 25 | "size": 10 26 | } 27 | ], 28 | "overrides": [ 29 | { 30 | "name": "qa_override", 31 | "treatment": "control", 32 | "filter": "foo" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/models/NamedTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import org.junit.Test; 4 | 5 | import javax.validation.ValidationException; 6 | 7 | public class NamedTest { 8 | private static void assertValid(String name) { 9 | ((Named) () -> name).validateName(); 10 | } 11 | 12 | private static void assertNotValid(String name) { 13 | try { 14 | ((Named) () -> name).validateName(); 15 | throw new AssertionError("expected validateName() to throw"); 16 | } catch (ValidationException ignored) { 17 | } 18 | } 19 | 20 | @Test 21 | public void testValidation() { 22 | assertNotValid(null); 23 | assertNotValid(""); 24 | assertNotValid("!"); 25 | assertValid("mIxEd-CaSe"); 26 | assertValid("snake_case"); 27 | assertValid("l33t-sp34k"); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/health/ExperimentsDatabaseProviderCheck.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.health; 2 | 3 | import com.codahale.metrics.health.HealthCheck; 4 | import com.google.inject.Inject; 5 | 6 | import io.rtr.alchemy.db.Filter; 7 | import io.rtr.alchemy.models.Experiments; 8 | 9 | /** A health check that tests whether experiments can be read from the store */ 10 | public class ExperimentsDatabaseProviderCheck extends HealthCheck { 11 | private final Experiments experiments; 12 | 13 | @Inject 14 | public ExperimentsDatabaseProviderCheck(Experiments experiments) { 15 | this.experiments = experiments; 16 | } 17 | 18 | @Override 19 | protected Result check() throws Exception { 20 | experiments.find(Filter.criteria().limit(1).build()); 21 | experiments.getActiveExperiments(); 22 | return Result.healthy(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/GetExperimentsRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | /** Represents a request for getting filtered experiment */ 4 | public class GetExperimentsRequest { 5 | private final String filter; 6 | private final Integer offset; 7 | private final Integer limit; 8 | private final String sort; 9 | 10 | public GetExperimentsRequest(String filter, Integer offset, Integer limit, String sort) { 11 | this.filter = filter; 12 | this.offset = offset; 13 | this.limit = limit; 14 | this.sort = sort; 15 | } 16 | 17 | public String getFilter() { 18 | return filter; 19 | } 20 | 21 | public Integer getOffset() { 22 | return offset; 23 | } 24 | 25 | public Integer getLimit() { 26 | return limit; 27 | } 28 | 29 | public String getSort() { 30 | return sort; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/caching/CacheStrategy.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | 3 | import io.rtr.alchemy.models.Experiment; 4 | 5 | /** Defines the behavior for when to invalidate items in the cache */ 6 | public interface CacheStrategy { 7 | /** Fires when an experiment is loaded from the store */ 8 | void onLoad(Experiment experiment, CachingContext context); 9 | 10 | /** Fires when an experiment is saved to the store */ 11 | void onSave(Experiment experiment, CachingContext context); 12 | 13 | /** Fires when an experiment is deleted from the store */ 14 | void onDelete(String experimentName, CachingContext context); 15 | 16 | /** Fires before an experiment is read from the cache */ 17 | void onCacheRead(String experimentName, CachingContext context); 18 | 19 | /** Fires before all experiments are read from the cache */ 20 | void onCacheRead(CachingContext context); 21 | } 22 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/models/AllocationEntity.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.models; 2 | 3 | import dev.morphia.annotations.Entity; 4 | 5 | import io.rtr.alchemy.models.Allocation; 6 | 7 | /** 8 | * An entity that mirrors Allocation 9 | * 10 | * @see io.rtr.alchemy.models.Allocation 11 | */ 12 | @Entity 13 | public class AllocationEntity { 14 | public String treatment; 15 | public int offset; 16 | public int size; 17 | 18 | // Required by Morphia 19 | @SuppressWarnings("unused") 20 | private AllocationEntity() {} 21 | 22 | private AllocationEntity(final Allocation allocation) { 23 | this.treatment = allocation.getTreatment().getName(); 24 | this.offset = allocation.getOffset(); 25 | this.size = allocation.getSize(); 26 | } 27 | 28 | public static AllocationEntity from(final Allocation allocation) { 29 | return new AllocationEntity(allocation); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /alchemy-service/src/test/java/io/rtr/alchemy/service/resources/BaseResourceTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.junit.Assert.assertTrue; 5 | 6 | import org.junit.Test; 7 | 8 | import javax.ws.rs.WebApplicationException; 9 | import javax.ws.rs.core.Response; 10 | 11 | public class BaseResourceTest { 12 | @Test 13 | public void testCreated() { 14 | final Response created = BaseResource.created(); 15 | assertEquals(Response.Status.CREATED.getStatusCode(), created.getStatus()); 16 | } 17 | 18 | @Test 19 | public void testEnsureExists() { 20 | final Object value = new Object(); 21 | assertTrue( 22 | "expected same object reference value", value == BaseResource.ensureExists(value)); 23 | } 24 | 25 | @Test(expected = WebApplicationException.class) 26 | public void testEnsureExistsThrows() { 27 | BaseResource.ensureExists(null); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alchemy-client/src/test/java/io/rtr/alchemy/client/identities/User.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.identities; 2 | 3 | import io.rtr.alchemy.identities.Attributes; 4 | import io.rtr.alchemy.identities.AttributesMap; 5 | import io.rtr.alchemy.identities.Identity; 6 | 7 | import java.util.Set; 8 | 9 | @Attributes({"anonymous", "identified"}) 10 | public class User extends Identity { 11 | private final String name; 12 | 13 | public User(String name) { 14 | this.name = name; 15 | } 16 | 17 | public String getName() { 18 | return name; 19 | } 20 | 21 | @Override 22 | public long computeHash(int seed, Set hashAttributes, AttributesMap attributes) { 23 | return identity(seed).putString(name).hash(); 24 | } 25 | 26 | @Override 27 | public AttributesMap computeAttributes() { 28 | return name == null 29 | ? attributes().put("anonymous", true).build() 30 | : attributes().put("identified", true).build(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /alchemy-client/src/main/java/io/rtr/alchemy/client/builder/UpdateTreatmentRequestBuilder.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.builder; 2 | 3 | import io.rtr.alchemy.dto.requests.UpdateTreatmentRequest; 4 | 5 | import java.util.Optional; 6 | 7 | import javax.ws.rs.client.Entity; 8 | import javax.ws.rs.client.Invocation; 9 | import javax.ws.rs.core.MediaType; 10 | 11 | public class UpdateTreatmentRequestBuilder { 12 | private final Invocation.Builder builder; 13 | private Optional description; 14 | 15 | public UpdateTreatmentRequestBuilder(Invocation.Builder builder) { 16 | this.builder = builder; 17 | } 18 | 19 | public UpdateTreatmentRequestBuilder setDescription(String description) { 20 | this.description = Optional.ofNullable(description); 21 | return this; 22 | } 23 | 24 | public void apply() { 25 | builder.post( 26 | Entity.entity( 27 | new UpdateTreatmentRequest(description), MediaType.APPLICATION_JSON_TYPE)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/config/IdentityMapping.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import io.rtr.alchemy.dto.identities.IdentityDto; 7 | import io.rtr.alchemy.mapping.Mapper; 8 | 9 | /** Represents a one-directional mapping of one type to another */ 10 | public class IdentityMapping { 11 | private final Class dto; 12 | private final Class mapper; 13 | 14 | @JsonCreator 15 | public IdentityMapping( 16 | @JsonProperty("dto") Class dto, 17 | @JsonProperty("mapper") Class mapper) { 18 | this.dto = dto; 19 | this.mapper = mapper; 20 | } 21 | 22 | public Class getDtoType() { 23 | return dto; 24 | } 25 | 26 | public Class getMapperType() { 27 | return mapper; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/models/TreatmentOverrideEntity.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.models; 2 | 3 | import dev.morphia.annotations.Entity; 4 | 5 | import io.rtr.alchemy.models.TreatmentOverride; 6 | 7 | /** 8 | * An entity that mirrors TreatmentOverride 9 | * 10 | * @see io.rtr.alchemy.models.TreatmentOverride 11 | */ 12 | @Entity 13 | public class TreatmentOverrideEntity { 14 | public String name; 15 | public String treatment; 16 | public String filter; 17 | 18 | // Required by Morphia 19 | @SuppressWarnings("unused") 20 | private TreatmentOverrideEntity() {} 21 | 22 | private TreatmentOverrideEntity(final TreatmentOverride override) { 23 | this.name = override.getName(); 24 | this.treatment = override.getTreatment().getName(); 25 | this.filter = override.getFilter().toString(); 26 | } 27 | 28 | public static TreatmentOverrideEntity from(final TreatmentOverride override) { 29 | return new TreatmentOverrideEntity(override); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/TreatmentOverrideRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import javax.validation.constraints.NotNull; 6 | 7 | /** Represents a request for creating an override */ 8 | public class TreatmentOverrideRequest { 9 | @NotNull private final String treatment; 10 | @NotNull private final String filter; 11 | @NotNull private final String name; 12 | 13 | public TreatmentOverrideRequest( 14 | @JsonProperty("treatment") String treatment, 15 | @JsonProperty("filter") String filter, 16 | @JsonProperty("name") String name) { 17 | this.treatment = treatment; 18 | this.filter = filter; 19 | this.name = name; 20 | } 21 | 22 | public String getTreatment() { 23 | return treatment; 24 | } 25 | 26 | public String getFilter() { 27 | return filter; 28 | } 29 | 30 | public String getName() { 31 | return name; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/identities/Device.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.identities; 2 | 3 | import io.rtr.alchemy.identities.Attributes; 4 | import io.rtr.alchemy.identities.AttributesMap; 5 | import io.rtr.alchemy.identities.Identity; 6 | 7 | import java.util.Set; 8 | 9 | @Attributes({Device.ATTR_DEVICE, Device.ATTR_DEVICE_ID}) 10 | public class Device extends Identity { 11 | public static final String ATTR_DEVICE = "device"; 12 | public static final String ATTR_DEVICE_ID = "device_id"; 13 | private final String id; 14 | 15 | public Device(String id) { 16 | this.id = id; 17 | } 18 | 19 | public String getId() { 20 | return id; 21 | } 22 | 23 | @Override 24 | public long computeHash(int seed, Set hashAttributes, AttributesMap attributes) { 25 | return identity(seed).putString(id).hash(); 26 | } 27 | 28 | @Override 29 | public AttributesMap computeAttributes() { 30 | return attributes().put(ATTR_DEVICE, true).put(ATTR_DEVICE_ID, id).build(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /alchemy-db-memory/src/main/java/io/rtr/alchemy/db/memory/MemoryStoreProvider.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.memory; 2 | 3 | import com.google.common.collect.Maps; 4 | 5 | import io.rtr.alchemy.db.ExperimentsCache; 6 | import io.rtr.alchemy.db.ExperimentsStore; 7 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 8 | import io.rtr.alchemy.models.Experiment; 9 | 10 | import java.io.IOException; 11 | import java.util.Map; 12 | 13 | /** A store provider storing and caching experiments in memory */ 14 | public class MemoryStoreProvider implements ExperimentsStoreProvider { 15 | private final Map db = Maps.newConcurrentMap(); 16 | 17 | @Override 18 | public ExperimentsCache getCache() { 19 | return new MemoryExperimentsCache(db); 20 | } 21 | 22 | @Override 23 | public ExperimentsStore getStore() { 24 | return new MemoryExperimentsStore(db); 25 | } 26 | 27 | public void resetDatabase() { 28 | db.clear(); 29 | } 30 | 31 | @Override 32 | public void close() throws IOException { 33 | db.clear(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/util/DateTimeCodec.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.util; 2 | 3 | import org.bson.BsonReader; 4 | import org.bson.BsonType; 5 | import org.bson.BsonWriter; 6 | import org.bson.codecs.Codec; 7 | import org.bson.codecs.DecoderContext; 8 | import org.bson.codecs.EncoderContext; 9 | import org.joda.time.DateTime; 10 | 11 | /** A morphia codec for Joda Time */ 12 | public class DateTimeCodec implements Codec { 13 | @Override 14 | public DateTime decode(final BsonReader reader, final DecoderContext decoderContext) { 15 | if (reader.getCurrentBsonType().equals(BsonType.INT64)) { 16 | return new DateTime(reader.readInt64()); 17 | } 18 | return new DateTime(reader.readDateTime()); 19 | } 20 | 21 | @Override 22 | public void encode( 23 | final BsonWriter writer, final DateTime value, final EncoderContext encoderContext) { 24 | writer.writeDateTime(value.getMillis()); 25 | } 26 | 27 | @Override 28 | public Class getEncoderClass() { 29 | return DateTime.class; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Rent The Runway 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/models/TreatmentTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import nl.jqno.equalsverifier.EqualsVerifier; 6 | import nl.jqno.equalsverifier.Warning; 7 | 8 | import org.junit.Test; 9 | 10 | import javax.validation.ValidationException; 11 | 12 | public class TreatmentTest { 13 | @Test 14 | public void testEqualsHashCode() { 15 | EqualsVerifier.forClass(Treatment.class) 16 | .suppress(Warning.STRICT_INHERITANCE) 17 | .withIgnoredFields("description") 18 | .verify(); 19 | } 20 | 21 | @Test 22 | public void testValidTreatmentName() throws ValidationException { 23 | String name = "abc"; 24 | final Treatment treatment = new Treatment(name, "a treatment with a valid name"); 25 | assertEquals(treatment.getName(), name); 26 | } 27 | 28 | @Test(expected = ValidationException.class) 29 | public void testInvalidTreatmentName() throws ValidationException { 30 | final Treatment treatment = new Treatment(";;;", "a treatment with an invalid name"); 31 | treatment.validateName(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /alchemy-client/src/main/java/io/rtr/alchemy/client/AlchemyClientConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import io.dropwizard.client.JerseyClientConfiguration; 7 | import io.rtr.alchemy.dto.identities.IdentityDto; 8 | 9 | import java.net.URI; 10 | import java.util.Set; 11 | 12 | import javax.validation.constraints.NotNull; 13 | 14 | /** The client configuration */ 15 | public class AlchemyClientConfiguration extends JerseyClientConfiguration { 16 | @NotNull private final URI service; 17 | 18 | @NotNull private final Set> identityTypes; 19 | 20 | @JsonCreator 21 | public AlchemyClientConfiguration( 22 | @JsonProperty("service") URI service, 23 | @JsonProperty("identityTypes") Set> identityTypes) { 24 | this.service = service; 25 | this.identityTypes = identityTypes; 26 | } 27 | 28 | public URI getService() { 29 | return service; 30 | } 31 | 32 | public Set> getIdentityTypes() { 33 | return identityTypes; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/models/TreatmentDto.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 javax.validation.constraints.NotNull; 8 | 9 | /** Represents a treatment */ 10 | public class TreatmentDto { 11 | @NotNull private final String name; 12 | private final String description; 13 | 14 | @JsonCreator 15 | public TreatmentDto( 16 | @JsonProperty("name") String name, @JsonProperty("description") String description) { 17 | this.name = name; 18 | this.description = description; 19 | } 20 | 21 | public String getName() { 22 | return name; 23 | } 24 | 25 | public String getDescription() { 26 | return description; 27 | } 28 | 29 | @Override 30 | public int hashCode() { 31 | return Objects.hashCode(name); 32 | } 33 | 34 | @Override 35 | public boolean equals(Object obj) { 36 | if (!(obj instanceof TreatmentDto)) { 37 | return false; 38 | } 39 | 40 | final TreatmentDto other = (TreatmentDto) obj; 41 | 42 | return Objects.equal(name, other.name); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/caching/BasicCacheStrategy.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | 3 | import io.rtr.alchemy.models.Experiment; 4 | 5 | /** 6 | * Implements a basic cache strategy that will always update the cache any time an experiment is 7 | * loaded, saved, or deleted 8 | */ 9 | public class BasicCacheStrategy implements CacheStrategy { 10 | private static void updateOrDelete(Experiment experiment, CachingContext context) { 11 | if (!experiment.isActive()) { 12 | context.delete(experiment.getName()); 13 | } else { 14 | context.update(experiment); 15 | } 16 | } 17 | 18 | @Override 19 | public void onLoad(Experiment experiment, CachingContext context) { 20 | updateOrDelete(experiment, context); 21 | } 22 | 23 | @Override 24 | public void onSave(Experiment experiment, CachingContext context) { 25 | updateOrDelete(experiment, context); 26 | } 27 | 28 | @Override 29 | public void onDelete(String experimentName, CachingContext context) { 30 | context.delete(experimentName); 31 | } 32 | 33 | @Override 34 | public void onCacheRead(String experimentName, CachingContext context) {} 35 | 36 | @Override 37 | public void onCacheRead(CachingContext context) {} 38 | } 39 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/db/ExperimentsCache.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import io.rtr.alchemy.models.Experiment; 4 | 5 | import java.util.Map; 6 | 7 | /** 8 | * An interface for retrieving active experiments and treatments information, real-time. Must be 9 | * optimized and fast as this will be called many times, ideally caching internally as needed. 10 | */ 11 | public interface ExperimentsCache { 12 | /** 13 | * Return active experiments 14 | * 15 | * @return all active experiments 16 | */ 17 | Map getActiveExperiments(); 18 | 19 | /** Forces cache to reload all data from storage */ 20 | void invalidateAll(Experiment.BuilderFactory factory); 21 | 22 | /** Forces cache to reload a specific experiment from storage */ 23 | void invalidate(String experimentName, Experiment.Builder builder); 24 | 25 | /** Updates the cache with a newly loaded experiment */ 26 | void update(Experiment experiment); 27 | 28 | /** Updates the cache with a recently deleted experiment */ 29 | void delete(String experimentName); 30 | 31 | /** Checks whether any experiments are stale */ 32 | boolean checkIfAnyStale(); 33 | 34 | /** Checks whether a given experiment is stale */ 35 | boolean checkIfStale(String experimentName); 36 | } 37 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/caching/CacheStrategyIterableTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | import static org.mockito.ArgumentMatchers.eq; 3 | import static org.mockito.Mockito.mock; 4 | import static org.mockito.Mockito.verify; 5 | 6 | import io.rtr.alchemy.models.Experiment; 7 | 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.Iterator; 14 | import java.util.List; 15 | 16 | public class CacheStrategyIterableTest { 17 | private Iterator iterator; 18 | private CachingContext context; 19 | private CacheStrategy strategy; 20 | private Experiment experiment; 21 | 22 | @Before 23 | public void setUp() { 24 | context = mock(CachingContext.class); 25 | strategy = mock(CacheStrategy.class); 26 | 27 | experiment = mock(Experiment.class); 28 | final List experiments = new ArrayList<>(Collections.singletonList(experiment)); 29 | final CacheStrategyIterable iterable = 30 | new CacheStrategyIterable(experiments, context, strategy); 31 | iterator = iterable.iterator(); 32 | } 33 | 34 | @Test 35 | public void testIterable() { 36 | iterator.next(); 37 | verify(strategy).onLoad(eq(experiment), eq(context)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/db/ExperimentsStore.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import io.rtr.alchemy.models.Experiment; 4 | 5 | /** 6 | * An interface for defining basic CRUD operations around experiments, treatments and allocations. 7 | * These operations do not need to be highly optimized or fast 8 | */ 9 | public interface ExperimentsStore { 10 | /** 11 | * Save an experiment, creating or updating it 12 | * 13 | * @param experiment The experiment to create or update 14 | */ 15 | void save(Experiment experiment); 16 | 17 | /** 18 | * Retrieves an experiment 19 | * 20 | * @param experimentName The name of the experiment 21 | * @param builder The builder to use to construct the experiment 22 | * @return The experiment with the given name 23 | */ 24 | Experiment load(String experimentName, Experiment.Builder builder); 25 | 26 | /** 27 | * Deletes an experiment and its associated data 28 | * 29 | * @param experimentName The name of the experiment 30 | */ 31 | void delete(String experimentName); 32 | 33 | /** 34 | * Finds experiments with given criteria 35 | * 36 | * @param filter Criteria for pagination and filtering 37 | * @return Filtered list of experiments 38 | */ 39 | Iterable find(Filter filter, Experiment.BuilderFactory factory); 40 | } 41 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/identities/User.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.identities; 2 | 3 | import io.rtr.alchemy.identities.Attributes; 4 | import io.rtr.alchemy.identities.AttributesMap; 5 | import io.rtr.alchemy.identities.Identity; 6 | 7 | import java.util.Set; 8 | 9 | /** An example identity */ 10 | @Attributes({User.ATTR_USER, User.ATTR_ANONYMOUS, User.ATTR_IDENTIFIED, User.ATTR_USER_NAME}) 11 | public class User extends Identity { 12 | public static final String ATTR_ANONYMOUS = "anonymous"; 13 | public static final String ATTR_IDENTIFIED = "identified"; 14 | public static final String ATTR_USER = "user"; 15 | public static final String ATTR_USER_NAME = "user_name"; 16 | 17 | private final String name; 18 | 19 | public User(String name) { 20 | this.name = name; 21 | } 22 | 23 | public String getName() { 24 | return name; 25 | } 26 | 27 | @Override 28 | public long computeHash(int seed, Set hashAttributes, AttributesMap attributes) { 29 | return identity(seed).putString(name).hash(); 30 | } 31 | 32 | @Override 33 | public AttributesMap computeAttributes() { 34 | return attributes() 35 | .put(ATTR_USER, true) 36 | .put(ATTR_USER_NAME, name) 37 | .put(name == null ? ATTR_ANONYMOUS : ATTR_IDENTIFIED, true) 38 | .build(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/config/AlchemyServiceConfigurationImpl.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 5 | import com.google.common.collect.Maps; 6 | 7 | import io.dropwizard.Configuration; 8 | import io.rtr.alchemy.identities.Identity; 9 | import io.rtr.alchemy.service.jackson.ClassKeyDeserializer; 10 | 11 | import java.util.Map; 12 | 13 | import javax.validation.constraints.NotNull; 14 | 15 | public class AlchemyServiceConfigurationImpl extends Configuration 16 | implements AlchemyServiceConfiguration { 17 | @JsonProperty 18 | @JsonDeserialize(keyUsing = ClassKeyDeserializer.class) 19 | private final Map, IdentityMapping> identities = Maps.newHashMap(); 20 | 21 | @Override 22 | public Map, IdentityMapping> getIdentities() { 23 | return identities; 24 | } 25 | 26 | @JsonProperty @NotNull private final StoreProviderConfiguration provider = null; 27 | 28 | @Override 29 | public StoreProviderConfiguration getProvider() { 30 | return provider; 31 | } 32 | 33 | @JsonProperty private final CacheStrategyConfiguration cacheStrategy = null; 34 | 35 | @Override 36 | public CacheStrategyConfiguration getCacheStrategy() { 37 | return cacheStrategy; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /alchemy-client/src/main/java/io/rtr/alchemy/client/builder/GetExperimentsRequestBuilder.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.builder; 2 | 3 | import com.google.common.base.Function; 4 | 5 | import io.rtr.alchemy.dto.models.ExperimentDto; 6 | import io.rtr.alchemy.dto.requests.GetExperimentsRequest; 7 | 8 | import java.util.List; 9 | 10 | public class GetExperimentsRequestBuilder { 11 | private final Function> builder; 12 | private String filter; 13 | private Integer offset; 14 | private Integer limit; 15 | private String sort; 16 | 17 | public GetExperimentsRequestBuilder( 18 | Function> builder) { 19 | this.builder = builder; 20 | } 21 | 22 | public GetExperimentsRequestBuilder filter(String filter) { 23 | this.filter = filter; 24 | return this; 25 | } 26 | 27 | public GetExperimentsRequestBuilder offset(int offset) { 28 | this.offset = offset; 29 | return this; 30 | } 31 | 32 | public GetExperimentsRequestBuilder limit(int limit) { 33 | this.limit = limit; 34 | return this; 35 | } 36 | 37 | public GetExperimentsRequestBuilder sort(String sort) { 38 | this.sort = sort; 39 | return this; 40 | } 41 | 42 | public List apply() { 43 | return builder.apply(new GetExperimentsRequest(filter, offset, limit, sort)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # yamllint disable rule:line-length 3 | # Automatically runs on PRs to ensure the changes meet requirements and pass tests. 4 | name: PR Validation 5 | 6 | on: # yamllint disable-line rule:truthy 7 | pull_request: 8 | branches: [master] 9 | types: [opened, reopened, synchronize] 10 | 11 | jobs: 12 | pr-validation: 13 | name: PR Validation 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out repo 18 | uses: actions/checkout@v4 19 | 20 | - name: YAML linting 21 | uses: karancode/yamllint-github-action@v3.0.0 22 | with: 23 | yamllint_strict: true 24 | 25 | - name: Set up JDK 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: 21 29 | distribution: temurin 30 | check-latest: false 31 | 32 | - name: Set up Maven 33 | uses: stCarolas/setup-maven@v5 34 | with: 35 | maven-version: 3.9.5 36 | 37 | - name: Resolve dependencies 38 | run: mvn -B dependency:go-offline 39 | 40 | # can't add -o here; go-offline and resolve-plugins don't download plugin dependencies, lol 41 | - name: Build, test, and install 42 | run: mvn -B install 43 | 44 | # dependency:analyze-only is run as part of the previous step, but this sometimes catches more 45 | - name: Run dependency and bug analysis 46 | run: mvn -o -B dependency:analyze 47 | -------------------------------------------------------------------------------- /alchemy-mapping/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 Mapper Library 12 | Library for implementing mapping of DTO to BO to DB 13 | alchemy-mapping 14 | 15 | 16 | 17 | 18 | 19 | io.rtr.alchemy 20 | alchemy-dependencies 21 | ${project.version} 22 | pom 23 | import 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | com.google.guava 32 | guava 33 | 34 | 35 | 36 | 37 | junit 38 | junit 39 | test 40 | 41 | 42 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/models/Treatment.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import com.google.common.base.MoreObjects; 4 | import com.google.common.base.Objects; 5 | 6 | /** Represents a possible user experience in an experiment */ 7 | public class Treatment implements Named { 8 | private final String name; 9 | private String description; 10 | 11 | public Treatment(String name) { 12 | this(name, null); 13 | } 14 | 15 | public Treatment(String name, String description) { 16 | this.name = name; 17 | this.description = description; 18 | } 19 | 20 | public String getName() { 21 | return name; 22 | } 23 | 24 | public String getDescription() { 25 | return description; 26 | } 27 | 28 | public void setDescription(String description) { 29 | this.description = description; 30 | } 31 | 32 | @Override 33 | public int hashCode() { 34 | return Objects.hashCode(name); 35 | } 36 | 37 | @Override 38 | public boolean equals(Object obj) { 39 | if (!(obj instanceof Treatment)) { 40 | return false; 41 | } 42 | 43 | final Treatment other = (Treatment) obj; 44 | return Objects.equal(name, other.name); 45 | } 46 | 47 | @Override 48 | public String toString() { 49 | return MoreObjects.toStringHelper(this) 50 | .add("name", name) 51 | .add("description", description) 52 | .toString(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/models/TreatmentOverrideDto.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.models; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.google.common.base.Objects; 5 | 6 | /** Represents a treatment override */ 7 | public class TreatmentOverrideDto { 8 | private final String name; 9 | private final String filter; 10 | private final String treatment; 11 | 12 | public TreatmentOverrideDto( 13 | @JsonProperty("name") String name, 14 | @JsonProperty("filter") String filter, 15 | @JsonProperty("treatment") String treatment) { 16 | this.name = name; 17 | this.filter = filter; 18 | this.treatment = treatment; 19 | } 20 | 21 | public String getName() { 22 | return name; 23 | } 24 | 25 | public String getFilter() { 26 | return filter; 27 | } 28 | 29 | public String getTreatment() { 30 | return treatment; 31 | } 32 | 33 | @Override 34 | public int hashCode() { 35 | return Objects.hashCode(name, filter, treatment); 36 | } 37 | 38 | @Override 39 | public boolean equals(Object obj) { 40 | if (!(obj instanceof TreatmentOverrideDto)) { 41 | return false; 42 | } 43 | 44 | final TreatmentOverrideDto other = (TreatmentOverrideDto) obj; 45 | return Objects.equal(name, other.name) 46 | && Objects.equal(filter, other.filter) 47 | && Objects.equal(treatment, other.treatment); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /alchemy-client/src/main/java/io/rtr/alchemy/client/builder/UpdateAllocationsRequestBuilder.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.client.builder; 2 | 3 | import io.rtr.alchemy.dto.requests.AllocationRequest; 4 | import io.rtr.alchemy.dto.requests.AllocationRequests; 5 | 6 | import javax.ws.rs.client.Entity; 7 | import javax.ws.rs.client.Invocation; 8 | import javax.ws.rs.core.MediaType; 9 | 10 | public class UpdateAllocationsRequestBuilder { 11 | private final Invocation.Builder builder; 12 | private final AllocationRequests allocations; 13 | 14 | public UpdateAllocationsRequestBuilder(Invocation.Builder builder) { 15 | this.builder = builder; 16 | this.allocations = AllocationRequests.of(); 17 | } 18 | 19 | public UpdateAllocationsRequestBuilder allocate(String treatmentName, int size) { 20 | allocations.add(new AllocationRequest.Allocate(treatmentName, size)); 21 | 22 | return this; 23 | } 24 | 25 | public UpdateAllocationsRequestBuilder deallocate(String treatmentName, int size) { 26 | allocations.add(new AllocationRequest.Deallocate(treatmentName, size)); 27 | return this; 28 | } 29 | 30 | public UpdateAllocationsRequestBuilder reallocate( 31 | final String treatmentName, final String target, final int size) { 32 | allocations.add(new AllocationRequest.Reallocate(treatmentName, size, target)); 33 | return this; 34 | } 35 | 36 | public void apply() { 37 | builder.post(Entity.entity(allocations, MediaType.APPLICATION_JSON_TYPE)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/models/AllocationDto.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 | /** Represents an allocation */ 8 | public class AllocationDto { 9 | private final String treatment; 10 | private final int offset; 11 | private final int size; 12 | 13 | @JsonCreator 14 | public AllocationDto( 15 | @JsonProperty("treatment") String treatment, 16 | @JsonProperty("offset") int offset, 17 | @JsonProperty("size") int size) { 18 | this.treatment = treatment; 19 | this.offset = offset; 20 | this.size = size; 21 | } 22 | 23 | public String getTreatment() { 24 | return treatment; 25 | } 26 | 27 | public int getOffset() { 28 | return offset; 29 | } 30 | 31 | public int getSize() { 32 | return size; 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return Objects.hashCode(treatment, offset, size); 38 | } 39 | 40 | @Override 41 | public boolean equals(Object obj) { 42 | if (!(obj instanceof AllocationDto)) { 43 | return false; 44 | } 45 | 46 | final AllocationDto other = (AllocationDto) obj; 47 | 48 | return Objects.equal(treatment, other.treatment) 49 | && Objects.equal(offset, other.offset) 50 | && Objects.equal(size, other.size); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/util/ExperimentIterable.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.util; 2 | 3 | import io.rtr.alchemy.db.mongo.models.ExperimentEntity; 4 | import io.rtr.alchemy.models.Experiment; 5 | 6 | import java.util.Iterator; 7 | 8 | /** 9 | * An iterator that iterates over ExperimentEntity results and maps them to actual Experiment 10 | * instances 11 | */ 12 | public class ExperimentIterable implements Iterable { 13 | private final Iterator iterator; 14 | private final Experiment.BuilderFactory factory; 15 | 16 | public ExperimentIterable( 17 | final Iterator iterator, final Experiment.BuilderFactory factory) { 18 | this.iterator = iterator; 19 | this.factory = factory; 20 | } 21 | 22 | @Override 23 | public Iterator iterator() { 24 | return ExceptionSafeIterator.wrap( 25 | new Iterator() { 26 | @Override 27 | public boolean hasNext() { 28 | return iterator.hasNext(); 29 | } 30 | 31 | @Override 32 | public Experiment next() { 33 | final ExperimentEntity entity = iterator.next(); 34 | return entity.toExperiment(factory.createBuilder(entity.name)); 35 | } 36 | 37 | @Override 38 | public void remove() { 39 | iterator.remove(); 40 | } 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/metadata/IdentityMetadata.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.metadata; 2 | 3 | import io.rtr.alchemy.dto.identities.IdentityDto; 4 | import io.rtr.alchemy.identities.Identity; 5 | import io.rtr.alchemy.mapping.Mapper; 6 | 7 | import java.util.Set; 8 | 9 | /** Metadata for an identity type */ 10 | public class IdentityMetadata { 11 | private final String typeName; 12 | private final Class identityType; 13 | private final Class dtoType; 14 | private final Class mapperType; 15 | private final Set attributes; 16 | 17 | public IdentityMetadata( 18 | String typeName, 19 | Class identityType, 20 | Class dtoType, 21 | Class mapperType) { 22 | this.typeName = typeName; 23 | this.identityType = identityType; 24 | this.dtoType = dtoType; 25 | this.mapperType = mapperType; 26 | this.attributes = Identity.getSupportedAttributes(identityType); 27 | } 28 | 29 | public String getTypeName() { 30 | return typeName; 31 | } 32 | 33 | public Class getIdentityType() { 34 | return identityType; 35 | } 36 | 37 | public Class getDtoType() { 38 | return dtoType; 39 | } 40 | 41 | public Class getMapperType() { 42 | return mapperType; 43 | } 44 | 45 | public Set getAttributes() { 46 | return attributes; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/models/Allocation.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import com.google.common.base.MoreObjects; 4 | import com.google.common.base.Objects; 5 | 6 | /** Represents a contiguous allocation block of a single treatment in an experiment */ 7 | public class Allocation { 8 | private final Treatment treatment; 9 | private final int offset; 10 | private final int size; 11 | 12 | public Allocation(Treatment treatment, int offset, int size) { 13 | this.treatment = treatment; 14 | this.offset = offset; 15 | this.size = size; 16 | } 17 | 18 | public Treatment getTreatment() { 19 | return treatment; 20 | } 21 | 22 | public int getOffset() { 23 | return offset; 24 | } 25 | 26 | public int getSize() { 27 | return size; 28 | } 29 | 30 | @Override 31 | public boolean equals(Object obj) { 32 | if (!(obj instanceof Allocation)) { 33 | return false; 34 | } 35 | 36 | final Allocation other = (Allocation) obj; 37 | 38 | return Objects.equal(treatment, other.treatment) 39 | && Objects.equal(offset, other.offset) 40 | && Objects.equal(size, other.size); 41 | } 42 | 43 | @Override 44 | public int hashCode() { 45 | return Objects.hashCode(treatment, offset, size); 46 | } 47 | 48 | @Override 49 | public String toString() { 50 | return MoreObjects.toStringHelper(this) 51 | .add("treatment", treatment) 52 | .add("offset", offset) 53 | .add("size", size) 54 | .toString(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/models/TreatmentOverrideTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.mockito.Mockito.mock; 5 | 6 | import io.rtr.alchemy.filtering.FilterExpression; 7 | 8 | import nl.jqno.equalsverifier.EqualsVerifier; 9 | import nl.jqno.equalsverifier.Warning; 10 | 11 | import org.junit.Test; 12 | 13 | import javax.validation.ValidationException; 14 | 15 | public class TreatmentOverrideTest { 16 | final Treatment treatment = new Treatment("treatment"); 17 | 18 | @Test 19 | public void testEqualsHashCode() { 20 | EqualsVerifier.forClass(TreatmentOverride.class) 21 | .suppress(Warning.STRICT_INHERITANCE) 22 | .withPrefabValues( 23 | FilterExpression.class, 24 | FilterExpression.alwaysTrue(), 25 | FilterExpression.of("false")) 26 | .verify(); 27 | } 28 | 29 | @Test 30 | public void testValidName() throws ValidationException { 31 | String name = "a_valid_name"; 32 | final TreatmentOverride override = 33 | new TreatmentOverride(name, mock(FilterExpression.class), treatment); 34 | assertEquals(override.getName(), name); 35 | } 36 | 37 | @Test(expected = ValidationException.class) 38 | public void testInvalidTreatmentName() throws ValidationException { 39 | final TreatmentOverride override = 40 | new TreatmentOverride( 41 | "an invalid name with spaces", mock(FilterExpression.class), treatment); 42 | override.validateName(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/exceptions/RuntimeExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.exceptions; 2 | 3 | import com.google.common.collect.Maps; 4 | 5 | import java.util.Map; 6 | 7 | import javax.ws.rs.WebApplicationException; 8 | import javax.ws.rs.core.MediaType; 9 | import javax.ws.rs.core.Response; 10 | import javax.ws.rs.ext.ExceptionMapper; 11 | 12 | /** Maps common runtime exceptions to their respective HTTP status codes */ 13 | public class RuntimeExceptionMapper implements ExceptionMapper { 14 | private static final Map, Response.Status> EXCEPTION_MAPPINGS; 15 | 16 | static { 17 | EXCEPTION_MAPPINGS = Maps.newLinkedHashMap(); 18 | 19 | // 400 - Bad Request 20 | register( 21 | Response.Status.BAD_REQUEST, 22 | IllegalArgumentException.class, 23 | NullPointerException.class); 24 | } 25 | 26 | private static void register(Response.Status status, Class... types) { 27 | for (Class type : types) { 28 | EXCEPTION_MAPPINGS.put(type, status); 29 | } 30 | } 31 | 32 | @Override 33 | public Response toResponse(RuntimeException e) { 34 | if (e instanceof WebApplicationException) { 35 | return ((WebApplicationException) e).getResponse(); 36 | } 37 | 38 | final Response.Status status = EXCEPTION_MAPPINGS.get(e.getClass()); 39 | 40 | if (status == null) { 41 | throw new WebApplicationException(e); 42 | } 43 | 44 | return Response.status(status) 45 | .type(MediaType.APPLICATION_JSON) 46 | .entity(e.getMessage()) 47 | .build(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/manual/example.md: -------------------------------------------------------------------------------- 1 | [< back to Manual](../manual.md) 2 | 3 | #Alchemy Example, Step by Step 4 | 5 | The `alchemy-example` module provides you with an example application that uses the `alchemy-core` 6 | module to create, configure and query experiments. 7 | 8 | Keep in mind that 9 | the [Morphia configuration](https://morphia.dev/morphia/2.4/configuration.html#_configuration) is 10 | provided through the `resources/META-INF/morphia-config.properties` file in the implementing 11 | service, otherwise it 12 | won't be able to start. The error will look like this: 13 | 14 | ``` 15 | com.google.inject.CreationException: Unable to create injector, see the following errors: 16 | 17 | 1) failed to configure mapper: Command failed with error 13 (Unauthorized): 'not authorized on morphia to execute command { insert: "Metadata", ordered: true, txnNumber: 1, $db: "morphia", lsid: { id: UUID("9983b63c-eb8e-455d-9188-0853d145c3f5") } }' on server my-server.mongodb.net:27017. The full response is {"operationTime": {"$timestamp": {"t": 1700683613, "i": 1}}, "ok": 0.0, "errmsg": "not authorized on morphia to execute command { insert: \"Metadata\", ordered: true, txnNumber: 1, $db: \"morphia\", lsid: { id: UUID(\"9983b63c-eb8e-455d-9188-0853d145c3f5\") } }", "code": 13, "codeName": "Unauthorized", "$clusterTime": {"clusterTime": {"$timestamp": {"t": 1700683613, "i": 1}}, "signature": {"hash": {"$binary": {"base64": "95Hx0k491/WktqeA7sWHyKFJXLM=", "subType": "00"}}, "keyId": 7253867749087117314}}} 18 | at AlchemyModule.configure(AlchemyModule.java:69) 19 | 20 | 1 error 21 | 22 | ====================== 23 | Full classname legend: 24 | ====================== 25 | AlchemyModule: "io.rtr.alchemy.service.guice.AlchemyModule" 26 | ======================== 27 | End of classname legend: 28 | ======================== 29 | ``` -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | [< back to Readme](../README.md) 2 | 3 | #About 4 | 5 | - Table of Contents 6 | * [Contributors](#contributors) 7 | * [Frequently Asked Questions](#faq) 8 | * [Release Notes](#release_notes) 9 | * [Documentation TODOs](#todo) 10 | 11 | 12 | 13 | ###Contributors 14 | 15 | Many thanks to: _Gene Trog and Carlo Barbara_ 16 | 17 | 18 | 19 | ###Frequently Asked Questions 20 | 21 | **_How can I commit to Alchemy?_** 22 | > Go to the [GitHub project](https://github.com/RentTheRunway2/alchemy), fork it, and submit a pull request. 23 | We prefer small, single-purpose pull requests over large, multi-purpose ones. We reserve the right to turn 24 | down any proposed changes, but in general we're delighted when people want to make our projects better! 25 | 26 | 27 | 28 | ### Release Notes 29 | 30 | - v0.1.0 31 | * Initial release 32 | - v0.1.1 33 | * Change filtering of experiments from identity type to segments 34 | - v0.1.2 35 | * Add support for specifying a seed value for randomizing treatment assignment 36 | * More examples using composite identities 37 | - v0.1.3 38 | * Made service more extensible 39 | - v0.1.4 40 | * Allow specifying of credentials for MongoStoreProvider 41 | - v0.1.5 42 | * Refactored filtering entirely 43 | * Replaced concept of segments with attributes name/value pairs 44 | * Allow for filtering on attributes using filtering expressions 45 | * Allow specifying overrides using the same kind of filtering expressions 46 | - v0.1.6 47 | * More robust handling of failed experiment loading in MongoStoreProvider 48 | - v0.1.7 49 | * Expose endpoint for updating treatment description 50 | - v0.1.8 51 | * Changed namespaces to io.rtr 52 | 53 | 54 | ### Documentation TODOs 55 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/models/TreatmentOverride.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.models; 2 | 3 | import com.google.common.base.MoreObjects; 4 | import com.google.common.base.Objects; 5 | 6 | import io.rtr.alchemy.filtering.FilterExpression; 7 | 8 | /** Represents a treatment override assigned to a specific hash value */ 9 | public class TreatmentOverride implements Named { 10 | private final String name; 11 | private final FilterExpression filter; 12 | private final Treatment treatment; 13 | 14 | public TreatmentOverride(String name, FilterExpression filter, Treatment treatment) { 15 | this.name = name; 16 | this.filter = filter; 17 | this.treatment = treatment; 18 | } 19 | 20 | public String getName() { 21 | return name; 22 | } 23 | 24 | public FilterExpression getFilter() { 25 | return filter; 26 | } 27 | 28 | public Treatment getTreatment() { 29 | return treatment; 30 | } 31 | 32 | @Override 33 | public int hashCode() { 34 | return Objects.hashCode(name, filter, treatment); 35 | } 36 | 37 | @Override 38 | public boolean equals(Object obj) { 39 | if (!(obj instanceof TreatmentOverride)) { 40 | return false; 41 | } 42 | 43 | final TreatmentOverride other = (TreatmentOverride) obj; 44 | 45 | return Objects.equal(name, other.name) 46 | && Objects.equal(filter, other.filter) 47 | && Objects.equal(treatment, other.treatment); 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return MoreObjects.toStringHelper(this) 53 | .add("name", name) 54 | .add("filter", filter) 55 | .add("treatment", treatment) 56 | .toString(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/caching/CacheStrategyIterable.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | 3 | import io.rtr.alchemy.models.Experiment; 4 | 5 | import java.util.Iterator; 6 | 7 | /** 8 | * Implements a wrapper around iterable of Experiment in order to trigger the cache strategy as 9 | * results are retrieved 10 | */ 11 | public class CacheStrategyIterable implements Iterable { 12 | private final Iterable iterable; 13 | private final CachingContext context; 14 | private final CacheStrategy strategy; 15 | 16 | public CacheStrategyIterable( 17 | Iterable iterable, CachingContext context, CacheStrategy strategy) { 18 | this.iterable = iterable; 19 | this.context = context; 20 | this.strategy = strategy; 21 | } 22 | 23 | @Override 24 | public Iterator iterator() { 25 | return new CacheStrategyIterator(iterable.iterator()); 26 | } 27 | 28 | private class CacheStrategyIterator implements Iterator { 29 | private final Iterator iterator; 30 | private Experiment current; 31 | 32 | private CacheStrategyIterator(Iterator iterator) { 33 | this.iterator = iterator; 34 | } 35 | 36 | @Override 37 | public boolean hasNext() { 38 | return iterator.hasNext(); 39 | } 40 | 41 | @Override 42 | public Experiment next() { 43 | final Experiment next = iterator.next(); 44 | strategy.onLoad(next, context); 45 | current = next; 46 | return next; 47 | } 48 | 49 | @Override 50 | public void remove() { 51 | iterator.remove(); 52 | strategy.onDelete(current.getName(), context); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /alchemy-mapping/src/test/java/io/rtr/alchemy/mapping/MappersTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.mapping; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import org.junit.Test; 6 | 7 | public class MappersTest { 8 | 9 | private static interface DtoInterface {} 10 | 11 | private static class ParentDto implements DtoInterface {} 12 | 13 | private static class ChildDto extends ParentDto {} 14 | 15 | private static interface BoInterface {} 16 | 17 | private static class ParentBo implements BoInterface {} 18 | 19 | private static class ChildBo extends ParentBo {} 20 | 21 | @Test 22 | public void testMapSubclassesAndInterfaces() { 23 | final Mappers mapper = new Mappers(); 24 | mapper.register( 25 | ChildDto.class, 26 | ChildBo.class, 27 | new Mapper() { 28 | @Override 29 | public Object toDto(Object source) { 30 | return new ChildDto(); 31 | } 32 | 33 | @Override 34 | public Object fromDto(Object source) { 35 | return new ChildBo(); 36 | } 37 | }); 38 | 39 | assertEquals(ChildBo.class, mapper.fromDto(new ChildDto(), ChildBo.class).getClass()); 40 | assertEquals(ChildBo.class, mapper.fromDto(new ChildDto(), ParentBo.class).getClass()); 41 | assertEquals(ChildBo.class, mapper.fromDto(new ChildDto(), BoInterface.class).getClass()); 42 | 43 | assertEquals(ChildDto.class, mapper.toDto(new ChildBo(), ChildDto.class).getClass()); 44 | assertEquals(ChildDto.class, mapper.toDto(new ChildBo(), ParentDto.class).getClass()); 45 | assertEquals(ChildDto.class, mapper.toDto(new ChildBo(), DtoInterface.class).getClass()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/caching/PeriodicStaleCheckingCacheStrategy.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | 3 | import org.joda.time.DateTime; 4 | import org.joda.time.Duration; 5 | 6 | import java.util.concurrent.atomic.AtomicBoolean; 7 | 8 | /** 9 | * This caching strategy will periodically check whether the data in the cache is stale, and if so, 10 | * refresh it. The periodic checking of staleness is driven by cache reads, so, any time the cache 11 | * is read, if it's been a while since the last refresh and the data is stale, the cache will be 12 | * refreshed. 13 | */ 14 | public class PeriodicStaleCheckingCacheStrategy extends BasicCacheStrategy { 15 | private final Duration period; 16 | private final AtomicBoolean lock = new AtomicBoolean(false); 17 | private volatile DateTime lastSync; 18 | 19 | public PeriodicStaleCheckingCacheStrategy(Duration period) { 20 | this.period = period; 21 | this.lastSync = DateTime.now(); 22 | } 23 | 24 | @Override 25 | public void onCacheRead(String experimentName, CachingContext context) { 26 | invalidateAllIfStale(context); 27 | } 28 | 29 | @Override 30 | public void onCacheRead(CachingContext context) { 31 | invalidateAllIfStale(context); 32 | } 33 | 34 | private void invalidateAllIfStale(CachingContext context) { 35 | if (!lock.compareAndSet(false, true)) { 36 | return; 37 | } 38 | 39 | try { 40 | final Duration elapsed = new Duration(lastSync, DateTime.now()); 41 | if (!elapsed.isLongerThan(period)) { 42 | return; 43 | } 44 | 45 | lastSync = DateTime.now(); 46 | } finally { 47 | lock.set(false); 48 | } 49 | 50 | if (context.checkIfAnyStale()) { 51 | context.invalidateAll(true); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/test/java/io/rtr/alchemy/db/mongo/MongoStoreProviderTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo; 2 | 3 | import static io.rtr.alchemy.db.mongo.util.MongoDbTestHelper.MONGODB_DATABASE; 4 | import static io.rtr.alchemy.db.mongo.util.MongoDbTestHelper.MONGODB_IMAGE; 5 | 6 | import com.mongodb.MongoClient; 7 | import com.mongodb.MongoClientURI; 8 | import com.mongodb.MongoException; 9 | import com.mongodb.ServerAddress; 10 | 11 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 12 | import io.rtr.alchemy.testing.db.ExperimentsStoreProviderTest; 13 | 14 | import org.junit.jupiter.api.BeforeAll; 15 | import org.testcontainers.containers.MongoDBContainer; 16 | import org.testcontainers.junit.jupiter.Container; 17 | import org.testcontainers.junit.jupiter.Testcontainers; 18 | 19 | import java.util.stream.Collectors; 20 | 21 | @Testcontainers 22 | public class MongoStoreProviderTest extends ExperimentsStoreProviderTest { 23 | @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer(MONGODB_IMAGE); 24 | 25 | private static MongoClientURI uri; 26 | 27 | @BeforeAll 28 | static void setUpClass() { 29 | uri = new MongoClientURI(mongoDBContainer.getReplicaSetUrl(MONGODB_DATABASE)); 30 | } 31 | 32 | @Override 33 | protected ExperimentsStoreProvider createProvider() { 34 | return MongoStoreProvider.newBuilder() 35 | .setHosts( 36 | uri.getHosts().stream() 37 | .map(ServerAddress::new) 38 | .collect(Collectors.toList())) 39 | .build(); 40 | } 41 | 42 | @Override 43 | protected void resetStore() { 44 | try (final MongoClient client = new MongoClient(uri)) { 45 | client.dropDatabase(MONGODB_DATABASE); 46 | } catch (final MongoException e) { 47 | throw new IllegalStateException(e); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/caching/PeriodicStaleCheckingCacheStrategyTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | import static org.mockito.ArgumentMatchers.eq; 3 | import static org.mockito.Mockito.*; 4 | 5 | import io.rtr.alchemy.db.ExperimentsCache; 6 | import io.rtr.alchemy.models.Experiment; 7 | 8 | import org.joda.time.Duration; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | 12 | public class PeriodicStaleCheckingCacheStrategyTest { 13 | private PeriodicStaleCheckingCacheStrategy strategy; 14 | private ExperimentsCache cache; 15 | private CachingContext context; 16 | 17 | @Before 18 | public void setUp() { 19 | strategy = new PeriodicStaleCheckingCacheStrategy(Duration.millis(-1)); 20 | cache = mock(ExperimentsCache.class); 21 | context = spy(new CachingContext(cache, mock(Experiment.BuilderFactory.class), null)); 22 | } 23 | 24 | @Test 25 | public void testNotStale() { 26 | strategy.onCacheRead(context); 27 | verify(context).checkIfAnyStale(); 28 | verify(context, never()).invalidate(anyString(), anyBoolean()); 29 | verify(context, never()).invalidateAll(anyBoolean()); 30 | verify(cache).checkIfAnyStale(); 31 | verify(cache, never()).invalidate(anyString(), any(Experiment.Builder.class)); 32 | verify(cache, never()).invalidateAll(any(Experiment.BuilderFactory.class)); 33 | } 34 | 35 | @Test 36 | public void testStale() { 37 | doReturn(true).when(cache).checkIfAnyStale(); 38 | strategy.onCacheRead(context); 39 | verify(context).invalidateAll(eq(true)); 40 | } 41 | 42 | @Test 43 | public void testDurationNotElapsed() { 44 | doReturn(true).when(cache).checkIfAnyStale(); 45 | strategy = new PeriodicStaleCheckingCacheStrategy(Duration.standardDays(1)); 46 | strategy.onCacheRead(context); 47 | verifyNoMoreInteractions(context); 48 | verifyNoMoreInteractions(cache); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/filtering/FilterErrorListener.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.filtering; 2 | 3 | import org.antlr.v4.runtime.ANTLRErrorListener; 4 | import org.antlr.v4.runtime.Parser; 5 | import org.antlr.v4.runtime.RecognitionException; 6 | import org.antlr.v4.runtime.Recognizer; 7 | import org.antlr.v4.runtime.atn.ATNConfigSet; 8 | import org.antlr.v4.runtime.dfa.DFA; 9 | import org.antlr.v4.runtime.misc.NotNull; 10 | import org.antlr.v4.runtime.misc.Nullable; 11 | 12 | import java.util.BitSet; 13 | 14 | public class FilterErrorListener implements ANTLRErrorListener { 15 | @Override 16 | public void syntaxError( 17 | @NotNull Recognizer recognizer, 18 | @Nullable Object o, 19 | int i, 20 | int i2, 21 | @NotNull String s, 22 | @Nullable RecognitionException e) { 23 | throw new IllegalArgumentException(e); 24 | } 25 | 26 | @Override 27 | public void reportAmbiguity( 28 | @NotNull Parser parser, 29 | @NotNull DFA dfa, 30 | int i, 31 | int i2, 32 | boolean b, 33 | @Nullable BitSet bitSet, 34 | @NotNull ATNConfigSet atnConfigs) { 35 | throw new IllegalArgumentException("ambiguity"); 36 | } 37 | 38 | @Override 39 | public void reportAttemptingFullContext( 40 | @NotNull Parser parser, 41 | @NotNull DFA dfa, 42 | int i, 43 | int i2, 44 | @Nullable BitSet bitSet, 45 | @NotNull ATNConfigSet atnConfigs) { 46 | throw new IllegalArgumentException("ambiguity"); 47 | } 48 | 49 | @Override 50 | public void reportContextSensitivity( 51 | @NotNull Parser parser, 52 | @NotNull DFA dfa, 53 | int i, 54 | int i2, 55 | int i3, 56 | @NotNull ATNConfigSet atnConfigs) { 57 | throw new IllegalArgumentException("ambiguity"); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /alchemy-testing/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 Unit Test Helpers 12 | alchemy-testing 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 | 35 | 36 | 37 | io.rtr.alchemy 38 | alchemy-core 39 | 40 | 41 | 42 | 43 | com.google.guava 44 | guava 45 | 46 | 47 | javax.validation 48 | validation-api 49 | 50 | 51 | 52 | 53 | org.junit.jupiter 54 | junit-jupiter-api 55 | 56 | 57 | org.mockito 58 | mockito-core 59 | compile 60 | 61 | 62 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/caching/CachingContextTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.caching; 2 | import static org.mockito.ArgumentMatchers.any; 3 | import static org.mockito.ArgumentMatchers.eq; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.never; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.verifyNoMoreInteractions; 8 | 9 | import io.rtr.alchemy.db.ExperimentsCache; 10 | import io.rtr.alchemy.models.Experiment; 11 | 12 | import org.junit.Before; 13 | import org.junit.Test; 14 | 15 | import java.io.IOException; 16 | import java.util.concurrent.ExecutorService; 17 | 18 | public class CachingContextTest { 19 | private CachingContext context; 20 | private ExperimentsCache cache; 21 | private ExecutorService executorService; 22 | 23 | @Before 24 | public void setUp() { 25 | cache = mock(ExperimentsCache.class); 26 | executorService = mock(ExecutorService.class); 27 | context = new CachingContext(cache, mock(Experiment.BuilderFactory.class), executorService); 28 | } 29 | 30 | @Test 31 | public void testInvalidateAll() { 32 | context.invalidateAll(false); 33 | verifyNoMoreInteractions(executorService); 34 | verify(cache).invalidateAll(any(Experiment.BuilderFactory.class)); 35 | } 36 | 37 | @Test 38 | public void testInvalidateAllAsync() { 39 | context.invalidateAll(true); 40 | verify(executorService).execute(any(Runnable.class)); 41 | } 42 | 43 | @Test 44 | public void testInvalidate() { 45 | context.invalidate("foo", false); 46 | verifyNoMoreInteractions(executorService); 47 | verify(cache).invalidate(eq("foo"), any()); 48 | } 49 | 50 | @Test 51 | public void testInvalidateAsync() { 52 | context.invalidate("foo", true); 53 | verify(executorService).execute(any(Runnable.class)); 54 | } 55 | 56 | @Test 57 | public void testClose() throws IOException { 58 | context.close(); 59 | // because context doesn't own executor service, it should not be shut down 60 | verify(executorService, never()).shutdown(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /alchemy-db-memory/src/main/java/io/rtr/alchemy/db/memory/MemoryExperimentsCache.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.memory; 2 | 3 | import com.google.common.base.Predicate; 4 | import com.google.common.collect.Maps; 5 | 6 | import io.rtr.alchemy.db.ExperimentsCache; 7 | import io.rtr.alchemy.models.Experiment; 8 | 9 | import java.util.Map; 10 | import java.util.Map.Entry; 11 | 12 | import javax.annotation.Nullable; 13 | 14 | /** Implements a cache that caches experiments in memory */ 15 | public class MemoryExperimentsCache implements ExperimentsCache { 16 | private static final ActiveFilter ACTIVE_FILTER = new ActiveFilter(); 17 | private static final ExperimentCopyTransformer EXPERIMENT_COPY_TRANSFORMER = 18 | new ExperimentCopyTransformer(); 19 | private final Map db; 20 | 21 | public MemoryExperimentsCache(Map db) { 22 | this.db = db; 23 | } 24 | 25 | @Override 26 | public Map getActiveExperiments() { 27 | final Map filtered = Maps.filterEntries(db, ACTIVE_FILTER); 28 | return Maps.transformEntries(filtered, EXPERIMENT_COPY_TRANSFORMER); 29 | } 30 | 31 | @Override 32 | public void invalidateAll(Experiment.BuilderFactory factory) {} 33 | 34 | @Override 35 | public void invalidate(String experimentName, Experiment.Builder builder) {} 36 | 37 | @Override 38 | public void update(Experiment experiment) {} 39 | 40 | @Override 41 | public void delete(String experimentName) {} 42 | 43 | @Override 44 | public boolean checkIfAnyStale() { 45 | return false; 46 | } 47 | 48 | @Override 49 | public boolean checkIfStale(String experimentName) { 50 | return false; 51 | } 52 | 53 | private static class ActiveFilter implements Predicate> { 54 | @Override 55 | public boolean apply(@Nullable Entry input) { 56 | return input != null && input.getValue().isActive(); 57 | } 58 | } 59 | 60 | private static class ExperimentCopyTransformer 61 | implements Maps.EntryTransformer { 62 | @Override 63 | public Experiment transformEntry(@Nullable String key, @Nullable Experiment value) { 64 | return value == null ? null : Experiment.copyOf(value); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/AllocationRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.fasterxml.jackson.annotation.JsonSubTypes; 5 | import com.fasterxml.jackson.annotation.JsonSubTypes.Type; 6 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 7 | import com.fasterxml.jackson.annotation.JsonTypeName; 8 | 9 | import javax.validation.constraints.NotNull; 10 | 11 | /** Represents requests for multiple allocation actions */ 12 | @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "action") 13 | @JsonSubTypes({ 14 | @Type(AllocationRequest.Reallocate.class), 15 | @Type(AllocationRequest.Allocate.class), 16 | @Type(AllocationRequest.Deallocate.class) 17 | }) 18 | public abstract class AllocationRequest { 19 | @NotNull private final String treatment; 20 | @NotNull private final Integer size; 21 | 22 | public AllocationRequest(String treatment, Integer size) { 23 | this.treatment = treatment; 24 | this.size = size; 25 | } 26 | 27 | public String getTreatment() { 28 | return treatment; 29 | } 30 | 31 | public Integer getSize() { 32 | return size; 33 | } 34 | 35 | @JsonTypeName("allocate") 36 | public static class Allocate extends AllocationRequest { 37 | public Allocate( 38 | @JsonProperty("treatment") String treatment, @JsonProperty("size") Integer size) { 39 | super(treatment, size); 40 | } 41 | } 42 | 43 | @JsonTypeName("deallocate") 44 | public static class Deallocate extends AllocationRequest { 45 | public Deallocate( 46 | @JsonProperty("treatment") String treatment, @JsonProperty("size") Integer size) { 47 | super(treatment, size); 48 | } 49 | } 50 | 51 | @JsonTypeName("reallocate") 52 | public static class Reallocate extends AllocationRequest { 53 | @NotNull private final String target; 54 | 55 | public Reallocate( 56 | @JsonProperty("treatment") String treatment, 57 | @JsonProperty("size") Integer size, 58 | @JsonProperty("target") String target) { 59 | super(treatment, size); 60 | this.target = target; 61 | } 62 | 63 | public String getTarget() { 64 | return target; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/identities/Composite.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.identities; 2 | 3 | import io.rtr.alchemy.identities.Attributes; 4 | import io.rtr.alchemy.identities.AttributesMap; 5 | import io.rtr.alchemy.identities.Identity; 6 | 7 | import java.util.Set; 8 | 9 | /** 10 | * Demonstrates how one may do composite identities, which are complex identities which are made up 11 | * of sub-identities and have complex criteria for computing their hash 12 | */ 13 | @Attributes(identities = {User.class, Device.class}) 14 | public class Composite extends Identity { 15 | private final User user; 16 | private final Device device; 17 | 18 | public Composite(User user, Device device) { 19 | this.user = user; 20 | this.device = device; 21 | } 22 | 23 | public User getUser() { 24 | return user; 25 | } 26 | 27 | public Device getDevice() { 28 | return device; 29 | } 30 | 31 | @Override 32 | public long computeHash(int seed, Set hashAttributes, AttributesMap attributes) { 33 | // we want to compute a hash based on what attribute is preferred (user vs device) 34 | // we'll say that 'user' supersedes 'device', such that if only 'user' is specified, we hash 35 | // user, 36 | // if only device is specified, we hash device, if both are specified, we hash user. If 37 | // neither are 38 | // specified, we can return whichever is not null first user vs device 39 | 40 | if (hashAttributes.contains(User.ATTR_USER)) { // "user" or "both" were requested 41 | return user.computeHash(seed, hashAttributes, attributes); 42 | } else if (hashAttributes.contains(Device.ATTR_DEVICE)) { // "device" was requested 43 | return device.computeHash(seed, hashAttributes, attributes); 44 | } 45 | 46 | // neither was requested, default to whatever the most specifically identifying value we can 47 | // return is 48 | if (user != null) { 49 | return user.computeHash(seed, hashAttributes, attributes); 50 | } 51 | 52 | if (device != null) { 53 | return device.computeHash(seed, hashAttributes, attributes); 54 | } 55 | 56 | return 0; 57 | } 58 | 59 | @Override 60 | public AttributesMap computeAttributes() { 61 | return attributes().put(user).put(device).build(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/identities/IdentityTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.identities; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.junit.Assert.assertFalse; 5 | import static org.junit.Assert.assertNotEquals; 6 | import static org.junit.Assert.assertNull; 7 | import static org.junit.Assert.assertTrue; 8 | 9 | import org.junit.Test; 10 | 11 | import java.util.LinkedHashSet; 12 | import java.util.List; 13 | import java.util.Set; 14 | 15 | @Attributes( 16 | value = {"foo", "bar"}, 17 | identities = {IdentityTest.Other.class}) 18 | public class IdentityTest extends Identity { 19 | @Test 20 | public void testAttributes() { 21 | final AttributesMap attributes = computeAttributes(); 22 | 23 | assertEquals(Set.of("foo", "bar", "baz"), attributes.keySet()); 24 | assertTrue(attributes.getBoolean("foo")); 25 | assertFalse(attributes.getBoolean("bar")); 26 | assertEquals(Long.valueOf(1), attributes.getNumber("baz")); 27 | assertNull(attributes.getNumber("quux")); 28 | } 29 | 30 | @Test 31 | public void testComputeHashcodeOrderMatters() { 32 | final IdentityTest identity = new IdentityTest(); 33 | final AttributesMap map = 34 | AttributesMap.newBuilder().put("foo", "foo").put("bar", "bar").build(); 35 | final Set fooBar = new LinkedHashSet<>(List.of("foo", "bar")); 36 | final Set barFoo = new LinkedHashSet<>(List.of("bar", "foo")); 37 | final int seed = 0; 38 | 39 | assertEquals( 40 | identity.computeHash(seed, fooBar, map), identity.computeHash(seed, fooBar, map)); 41 | assertEquals( 42 | identity.computeHash(seed, barFoo, map), identity.computeHash(seed, barFoo, map)); 43 | 44 | assertNotEquals( 45 | identity.computeHash(seed, fooBar, map), identity.computeHash(seed, barFoo, map)); 46 | assertNotEquals( 47 | identity.computeHash(seed, barFoo, map), identity.computeHash(seed, fooBar, map)); 48 | } 49 | 50 | @Override 51 | public AttributesMap computeAttributes() { 52 | return attributes().put("foo", true).put("bar", false).put(new Other()).build(); 53 | } 54 | 55 | @Attributes({"baz"}) 56 | public static class Other extends Identity { 57 | @Override 58 | public AttributesMap computeAttributes() { 59 | return attributes().put("baz", 1).build(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /alchemy-db-memory/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 using Memory 12 | A sample implementation of experiments store and cache 13 | alchemy-db-memory 14 | 15 | 16 | 17 | 18 | 19 | io.rtr.alchemy 20 | alchemy-dependencies 21 | ${project.version} 22 | pom 23 | import 24 | 25 | 26 | 27 | 28 | io.rtr.alchemy 29 | alchemy-core 30 | ${project.version} 31 | 32 | 33 | 34 | 35 | io.rtr.alchemy 36 | alchemy-testing 37 | ${project.version} 38 | test 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | io.rtr.alchemy 47 | alchemy-core 48 | 49 | 50 | 51 | 52 | com.google.code.findbugs 53 | jsr305 54 | 55 | 56 | com.google.guava 57 | guava 58 | 59 | 60 | joda-time 61 | joda-time 62 | 63 | 64 | 65 | 66 | io.rtr.alchemy 67 | alchemy-testing 68 | test 69 | 70 | 71 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/resources/ActiveTreatmentsResource.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import com.codahale.metrics.annotation.Timed; 4 | import com.google.common.collect.Maps; 5 | import com.google.inject.Inject; 6 | 7 | import io.rtr.alchemy.dto.identities.IdentityDto; 8 | import io.rtr.alchemy.dto.models.TreatmentDto; 9 | import io.rtr.alchemy.identities.Identity; 10 | import io.rtr.alchemy.mapping.Mappers; 11 | import io.rtr.alchemy.models.Experiment; 12 | import io.rtr.alchemy.models.Experiments; 13 | import io.rtr.alchemy.models.Treatment; 14 | 15 | import java.util.Map; 16 | 17 | import javax.validation.Valid; 18 | import javax.ws.rs.Consumes; 19 | import javax.ws.rs.POST; 20 | import javax.ws.rs.Path; 21 | import javax.ws.rs.PathParam; 22 | import javax.ws.rs.Produces; 23 | import javax.ws.rs.core.MediaType; 24 | 25 | /** Resource for querying active experiments */ 26 | @Path("/active") 27 | @Consumes(MediaType.APPLICATION_JSON) 28 | @Produces(MediaType.APPLICATION_JSON) 29 | public class ActiveTreatmentsResource extends BaseResource { 30 | private final Experiments experiments; 31 | private final Mappers mapper; 32 | 33 | @Inject 34 | public ActiveTreatmentsResource(Experiments experiments, Mappers mapper) { 35 | this.experiments = experiments; 36 | this.mapper = mapper; 37 | } 38 | 39 | @POST 40 | @Timed 41 | @Path("/experiments/{experimentName}/treatment") 42 | public TreatmentDto getActiveTreatment( 43 | @PathParam("experimentName") String experimentName, @Valid IdentityDto identity) { 44 | return mapper.toDto( 45 | experiments.getActiveTreatment( 46 | experimentName, mapper.fromDto(identity, Identity.class)), 47 | TreatmentDto.class); 48 | } 49 | 50 | @POST 51 | @Timed 52 | @Path("/treatments") 53 | public Map getActiveTreatments(@Valid IdentityDto identityDto) { 54 | final Map treatments = Maps.newHashMap(); 55 | final Map activeTreatments = 56 | experiments.getActiveTreatments(mapper.fromDto(identityDto, Identity.class)); 57 | 58 | for (final Map.Entry entry : activeTreatments.entrySet()) { 59 | treatments.put( 60 | entry.getKey().getName(), mapper.toDto(entry.getValue(), TreatmentDto.class)); 61 | } 62 | 63 | return treatments; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/filtering/FilterListener.java: -------------------------------------------------------------------------------- 1 | // Generated from Filter.g4 by ANTLR 4.5 2 | package io.rtr.alchemy.filtering; 3 | import org.antlr.v4.runtime.misc.NotNull; 4 | import org.antlr.v4.runtime.tree.ParseTreeListener; 5 | 6 | /** 7 | * This interface defines a complete listener for a parse tree produced by 8 | * {@link FilterParser}. 9 | */ 10 | public interface FilterListener extends ParseTreeListener { 11 | /** 12 | * Enter a parse tree produced by {@link FilterParser#exp}. 13 | * @param ctx the parse tree 14 | */ 15 | void enterExp(FilterParser.ExpContext ctx); 16 | /** 17 | * Exit a parse tree produced by {@link FilterParser#exp}. 18 | * @param ctx the parse tree 19 | */ 20 | void exitExp(FilterParser.ExpContext ctx); 21 | /** 22 | * Enter a parse tree produced by {@link FilterParser#term}. 23 | * @param ctx the parse tree 24 | */ 25 | void enterTerm(FilterParser.TermContext ctx); 26 | /** 27 | * Exit a parse tree produced by {@link FilterParser#term}. 28 | * @param ctx the parse tree 29 | */ 30 | void exitTerm(FilterParser.TermContext ctx); 31 | /** 32 | * Enter a parse tree produced by {@link FilterParser#factor}. 33 | * @param ctx the parse tree 34 | */ 35 | void enterFactor(FilterParser.FactorContext ctx); 36 | /** 37 | * Exit a parse tree produced by {@link FilterParser#factor}. 38 | * @param ctx the parse tree 39 | */ 40 | void exitFactor(FilterParser.FactorContext ctx); 41 | /** 42 | * Enter a parse tree produced by {@link FilterParser#comparison}. 43 | * @param ctx the parse tree 44 | */ 45 | void enterComparison(FilterParser.ComparisonContext ctx); 46 | /** 47 | * Exit a parse tree produced by {@link FilterParser#comparison}. 48 | * @param ctx the parse tree 49 | */ 50 | void exitComparison(FilterParser.ComparisonContext ctx); 51 | /** 52 | * Enter a parse tree produced by {@link FilterParser#constant}. 53 | * @param ctx the parse tree 54 | */ 55 | void enterConstant(FilterParser.ConstantContext ctx); 56 | /** 57 | * Exit a parse tree produced by {@link FilterParser#constant}. 58 | * @param ctx the parse tree 59 | */ 60 | void exitConstant(FilterParser.ConstantContext ctx); 61 | /** 62 | * Enter a parse tree produced by {@link FilterParser#value}. 63 | * @param ctx the parse tree 64 | */ 65 | void enterValue(FilterParser.ValueContext ctx); 66 | /** 67 | * Exit a parse tree produced by {@link FilterParser#value}. 68 | * @param ctx the parse tree 69 | */ 70 | void exitValue(FilterParser.ValueContext ctx); 71 | } -------------------------------------------------------------------------------- /alchemy-example/src/main/java/io/rtr/alchemy/example/config/MongoStoreProvider.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.example.config; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.google.common.net.HostAndPort; 6 | import com.mongodb.MongoCredential; 7 | import com.mongodb.ServerAddress; 8 | 9 | import io.rtr.alchemy.db.ExperimentsStoreProvider; 10 | import io.rtr.alchemy.service.config.StoreProviderConfiguration; 11 | 12 | import java.util.List; 13 | 14 | import javax.validation.constraints.NotNull; 15 | 16 | /** Configuration object for creating a MongoDB provider with given parameters */ 17 | public class MongoStoreProvider extends StoreProviderConfiguration { 18 | @NotNull private final List hosts; 19 | 20 | @NotNull private final String db; 21 | 22 | private final String username; 23 | 24 | private final String password; 25 | 26 | @JsonCreator 27 | public MongoStoreProvider( 28 | @JsonProperty("hosts") final List hosts, 29 | @JsonProperty("db") final String db, 30 | @JsonProperty("username") final String username, 31 | @JsonProperty("password") final String password) { 32 | this.hosts = hosts; 33 | this.db = db; 34 | this.username = username; 35 | this.password = password; 36 | } 37 | 38 | public List getHosts() { 39 | return hosts; 40 | } 41 | 42 | public String getDb() { 43 | return db; 44 | } 45 | 46 | public String getUsername() { 47 | return username; 48 | } 49 | 50 | public String getPassword() { 51 | return password; 52 | } 53 | 54 | @Override 55 | public ExperimentsStoreProvider createProvider() { 56 | final io.rtr.alchemy.db.mongo.MongoStoreProvider.Builder builder = 57 | io.rtr.alchemy.db.mongo.MongoStoreProvider.newBuilder(); 58 | for (final HostAndPort host : hosts) { 59 | if (!host.hasPort()) { 60 | builder.addHost(new ServerAddress(host.getHost())); 61 | } else { 62 | builder.addHost(new ServerAddress(host.getHost(), host.getPort())); 63 | } 64 | } 65 | 66 | if (username != null) { 67 | builder.setCredential( 68 | MongoCredential.createPlainCredential(username, db, password.toCharArray())); 69 | } 70 | 71 | builder.setDatabase(db); 72 | 73 | return builder.build(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /alchemy-service/src/test/java/io/rtr/alchemy/service/resources/MetadataResourceTest.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.assertTrue; 6 | 7 | import com.fasterxml.jackson.module.jsonSchema.JsonSchema; 8 | import com.google.common.collect.Maps; 9 | import com.google.common.collect.Sets; 10 | 11 | import org.junit.Test; 12 | 13 | import java.util.Map; 14 | 15 | import javax.ws.rs.core.Response.Status; 16 | 17 | public class MetadataResourceTest extends ResourceTest { 18 | private static final String METADATA_IDENTITY_TYPES_ENDPOINT = "/metadata/identityTypes"; 19 | private static final String METADATA_IDENTITY_TYPE_SCHEMA_ENDPOINT = 20 | "/metadata/identityTypes/{identityType}/schema"; 21 | private static final String METADATA_IDENTITY_TYPE_ATTRIBUTES_ENDPOINT = 22 | "/metadata/identityTypes/{identityType}/attributes"; 23 | 24 | @Test 25 | public void testGetIdentityTypes() { 26 | final Map expected = Maps.newHashMap(); 27 | expected.put("user", UserDto.class); 28 | expected.put("device", DeviceDto.class); 29 | 30 | final Map actual = 31 | get(METADATA_IDENTITY_TYPES_ENDPOINT) 32 | .assertStatus(Status.OK) 33 | .result(map(String.class, Class.class)); 34 | 35 | assertEquals(expected, actual); 36 | } 37 | 38 | @Test 39 | public void testGetIdentitySchema() { 40 | get(METADATA_IDENTITY_TYPE_SCHEMA_ENDPOINT, "foobar").assertStatus(Status.NOT_FOUND); 41 | 42 | final JsonSchema schema = 43 | get(METADATA_IDENTITY_TYPE_SCHEMA_ENDPOINT, "user") 44 | .assertStatus(Status.OK) 45 | .result(JsonSchema.class); 46 | 47 | assertNotNull(schema); 48 | assertTrue(schema.asObjectSchema().getProperties().get("name").isStringSchema()); 49 | } 50 | 51 | @Test 52 | public void testGetIdentityAttributes() { 53 | get(METADATA_IDENTITY_TYPE_ATTRIBUTES_ENDPOINT, "foobar").assertStatus(Status.NOT_FOUND); 54 | 55 | final Iterable attributes = 56 | get(METADATA_IDENTITY_TYPE_ATTRIBUTES_ENDPOINT, "user") 57 | .assertStatus(Status.OK) 58 | .result(set(String.class)); 59 | 60 | assertNotNull(attributes); 61 | assertEquals(Sets.newHashSet("anonymous", "identified"), attributes); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /alchemy-bom/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-bom 12 | pom 13 | Alchemy BOM 14 | Bill of Materials for Alchemy Libraries 15 | 16 | 17 | 18 | 19 | 20 | io.rtr.alchemy 21 | alchemy-api 22 | ${project.version} 23 | 24 | 25 | io.rtr.alchemy 26 | alchemy-client 27 | ${project.version} 28 | 29 | 30 | io.rtr.alchemy 31 | alchemy-core 32 | ${project.version} 33 | 34 | 35 | io.rtr.alchemy 36 | alchemy-db-memory 37 | ${project.version} 38 | 39 | 40 | io.rtr.alchemy 41 | alchemy-db-mongo 42 | ${project.version} 43 | 44 | 45 | io.rtr.alchemy 46 | alchemy-dependencies 47 | ${project.version} 48 | 49 | 50 | io.rtr.alchemy 51 | alchemy-mapping 52 | ${project.version} 53 | 54 | 55 | io.rtr.alchemy 56 | alchemy-service 57 | ${project.version} 58 | 59 | 60 | io.rtr.alchemy 61 | alchemy-testing 62 | ${project.version} 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /alchemy-api/src/main/java/io/rtr/alchemy/dto/requests/CreateExperimentRequest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.dto.requests; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import io.rtr.alchemy.dto.models.TreatmentDto; 6 | 7 | import java.util.List; 8 | import java.util.Set; 9 | 10 | import javax.validation.Valid; 11 | import javax.validation.constraints.NotNull; 12 | 13 | /** Represents a request for creating an experiment */ 14 | public class CreateExperimentRequest { 15 | @NotNull private final String name; 16 | private final Integer seed; 17 | private final String description; 18 | private final String filter; 19 | private final Set hashAttributes; 20 | private final Boolean active; 21 | @Valid private final List treatments; 22 | @Valid private final List allocations; 23 | @Valid private final List overrides; 24 | 25 | public CreateExperimentRequest( 26 | @JsonProperty("name") String name, 27 | @JsonProperty("seed") Integer seed, 28 | @JsonProperty("description") String description, 29 | @JsonProperty("filter") String filter, 30 | @JsonProperty("hashAttributes") Set hashAttributes, 31 | @JsonProperty("active") Boolean active, 32 | @JsonProperty("treatments") List treatments, 33 | @JsonProperty("allocations") List allocations, 34 | @JsonProperty("overrides") List overrides) { 35 | this.name = name; 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 String getName() { 47 | return name; 48 | } 49 | 50 | public Integer getSeed() { 51 | return seed; 52 | } 53 | 54 | public String getDescription() { 55 | return description; 56 | } 57 | 58 | public String getFilter() { 59 | return filter; 60 | } 61 | 62 | public Set getHashAttributes() { 63 | return hashAttributes; 64 | } 65 | 66 | public Boolean isActive() { 67 | return active; 68 | } 69 | 70 | public List getTreatments() { 71 | return treatments; 72 | } 73 | 74 | public List getAllocations() { 75 | return allocations; 76 | } 77 | 78 | public List getOverrides() { 79 | return overrides; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/resources/AllocationsResource.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import com.google.inject.Inject; 4 | 5 | import io.rtr.alchemy.dto.models.AllocationDto; 6 | import io.rtr.alchemy.dto.requests.AllocationRequest; 7 | import io.rtr.alchemy.mapping.Mappers; 8 | import io.rtr.alchemy.models.Experiment; 9 | import io.rtr.alchemy.models.Experiments; 10 | 11 | import java.util.List; 12 | 13 | import javax.validation.Valid; 14 | import javax.ws.rs.Consumes; 15 | import javax.ws.rs.DELETE; 16 | import javax.ws.rs.GET; 17 | import javax.ws.rs.POST; 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 | 23 | @Path("/experiments/{experimentName}/allocations") 24 | @Consumes(MediaType.APPLICATION_JSON) 25 | @Produces(MediaType.APPLICATION_JSON) 26 | public class AllocationsResource extends BaseResource { 27 | private final Experiments experiments; 28 | private final Mappers mapper; 29 | 30 | @Inject 31 | public AllocationsResource(Experiments experiments, Mappers mapper) { 32 | this.experiments = experiments; 33 | this.mapper = mapper; 34 | } 35 | 36 | @GET 37 | public Iterable getAllocations( 38 | @PathParam("experimentName") String experimentName) { 39 | return mapper.toDto( 40 | ensureExists(experiments.get(experimentName)).getAllocations(), 41 | AllocationDto.class); 42 | } 43 | 44 | @POST 45 | public void updateAllocations( 46 | @PathParam("experimentName") String experimentName, 47 | @Valid List requests) { 48 | final Experiment experiment = ensureExists(experiments.get(experimentName)); 49 | 50 | for (AllocationRequest request : requests) { 51 | if (request instanceof AllocationRequest.Deallocate) { 52 | experiment.deallocate(request.getTreatment(), request.getSize()); 53 | } else if (request instanceof AllocationRequest.Reallocate) { 54 | final AllocationRequest.Reallocate reallocation = 55 | (AllocationRequest.Reallocate) request; 56 | experiment.reallocate( 57 | reallocation.getTreatment(), 58 | reallocation.getTarget(), 59 | reallocation.getSize()); 60 | } else { 61 | experiment.allocate(request.getTreatment(), request.getSize()); 62 | } 63 | } 64 | 65 | experiment.save(); 66 | } 67 | 68 | @DELETE 69 | public void clearAllocations(@PathParam("experimentName") String experimentName) { 70 | ensureExists(experiments.get(experimentName)).deallocateAll().save(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /alchemy-api/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 API Library 12 | Representations for Alchemy Service 13 | alchemy-api 14 | 15 | 16 | 17 | 18 | 19 | io.rtr.alchemy 20 | alchemy-dependencies 21 | ${project.version} 22 | pom 23 | import 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | com.fasterxml.jackson.core 32 | jackson-annotations 33 | 34 | 35 | javax.validation 36 | validation-api 37 | 38 | 39 | joda-time 40 | joda-time 41 | 42 | 43 | com.google.guava 44 | guava 45 | 46 | 47 | 48 | 49 | com.fasterxml.jackson.core 50 | jackson-databind 51 | test 52 | 53 | 54 | io.dropwizard 55 | dropwizard-jackson 56 | test 57 | 58 | 59 | junit 60 | junit 61 | test 62 | 63 | 64 | nl.jqno.equalsverifier 65 | equalsverifier 66 | test 67 | 68 | 69 | 70 | 71 | 72 | 73 | org.apache.maven.plugins 74 | maven-compiler-plugin 75 | ${maven-compiler-plugin.version} 76 | 77 | 78 | 8 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/RevisionManager.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo; 2 | 3 | import com.mongodb.MongoWriteException; 4 | import com.mongodb.client.model.ReturnDocument; 5 | 6 | import dev.morphia.Datastore; 7 | import dev.morphia.ModifyOptions; 8 | import dev.morphia.query.filters.Filters; 9 | import dev.morphia.query.updates.UpdateOperators; 10 | 11 | import io.rtr.alchemy.db.mongo.models.ExperimentEntity; 12 | import io.rtr.alchemy.db.mongo.models.MetadataEntity; 13 | 14 | import java.util.Optional; 15 | 16 | /** Manages what revision experiments are at in order to check when experiments are stale */ 17 | public class RevisionManager { 18 | private static final String NAME = "revision"; 19 | private final Datastore ds; 20 | private volatile Long latestRevision; 21 | 22 | public RevisionManager(final Datastore ds) { 23 | this.ds = ds; 24 | initializeRevision(); 25 | } 26 | 27 | private Long getValue() { 28 | final MetadataEntity entity = 29 | ds.find(MetadataEntity.class).filter(Filters.eq("name", NAME)).first(); 30 | 31 | if (entity == null) { 32 | return null; 33 | } 34 | 35 | return (Long) entity.value; 36 | } 37 | 38 | private Long initialize() { 39 | try { 40 | ds.insert(MetadataEntity.of(NAME, Long.MIN_VALUE)); 41 | return Long.MIN_VALUE; 42 | } catch (final MongoWriteException e) { 43 | return getValue(); 44 | } 45 | } 46 | 47 | private void initializeRevision() { 48 | latestRevision = initialize(); 49 | } 50 | 51 | private Long getExperimentRevision(final String experimentName) { 52 | final ExperimentEntity experiment = 53 | ds.find(ExperimentEntity.class).filter(Filters.eq("name", experimentName)).first(); 54 | return experiment != null ? experiment.revision : null; 55 | } 56 | 57 | public long nextRevision() { 58 | final MetadataEntity incremented = 59 | ds.find(MetadataEntity.class) 60 | .filter(Filters.eq("name", NAME)) 61 | .modify( 62 | new ModifyOptions().returnDocument(ReturnDocument.AFTER), 63 | UpdateOperators.inc("value")); 64 | 65 | return Optional.ofNullable(incremented).map(i -> (Long) i.value).orElse(Long.MIN_VALUE); 66 | } 67 | 68 | public void setLatestRevision(final Long revision) { 69 | latestRevision = revision; 70 | } 71 | 72 | public boolean checkIfAnyStale() { 73 | final Long revision = getValue(); 74 | return revision != null && revision > latestRevision; 75 | } 76 | 77 | public boolean checkIfStale(final String experimentName) { 78 | final Long revision = getExperimentRevision(experimentName); 79 | return revision != null && revision > latestRevision; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/test/java/io/rtr/alchemy/db/mongo/util/ExceptionSafeIteratorTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.util; 2 | 3 | import static org.junit.Assert.assertArrayEquals; 4 | import static org.junit.Assert.assertEquals; 5 | import static org.junit.Assert.assertFalse; 6 | import static org.junit.Assert.assertTrue; 7 | 8 | import com.google.common.collect.Iterators; 9 | import com.google.common.collect.Lists; 10 | 11 | import org.junit.Test; 12 | 13 | import java.util.Collections; 14 | import java.util.Iterator; 15 | import java.util.List; 16 | import java.util.NoSuchElementException; 17 | 18 | public class ExceptionSafeIteratorTest { 19 | @Test 20 | public void testIteratorWithExceptions() { 21 | final Iterator throwIterator = 22 | new Iterator() { 23 | private final Integer[] numbers = {0, 1, null, 2, null, null, 3, null, null}; 24 | private int n = 0; 25 | 26 | @Override 27 | public boolean hasNext() { 28 | return n < numbers.length; 29 | } 30 | 31 | @Override 32 | public Integer next() { 33 | if (numbers[n] == null) { 34 | n++; 35 | throw new UnsupportedOperationException(); 36 | } 37 | 38 | return numbers[n++]; 39 | } 40 | 41 | @Override 42 | public void remove() {} 43 | }; 44 | 45 | final Iterator evens = ExceptionSafeIterator.wrap(throwIterator); 46 | 47 | assertArrayEquals(new Integer[] {0, 1, 2, 3}, Iterators.toArray(evens, Integer.class)); 48 | } 49 | 50 | @Test(expected = NoSuchElementException.class) 51 | public void testIteratorExhausted() { 52 | final Iterator emptyIterator = 53 | ExceptionSafeIterator.wrap(Collections.emptyIterator()); 54 | assertFalse(emptyIterator.hasNext()); 55 | emptyIterator.next(); 56 | } 57 | 58 | @Test(expected = UnsupportedOperationException.class) 59 | public void testIteratorRemoveWithoutNext() { 60 | final Iterator iterator = 61 | ExceptionSafeIterator.wrap(Lists.newArrayList(1, 2, 3).iterator()); 62 | assertTrue(iterator.hasNext()); 63 | iterator.next(); 64 | assertTrue(iterator.hasNext()); 65 | iterator.remove(); 66 | } 67 | 68 | @Test 69 | public void testIteratorRemove() { 70 | final List numbers = Lists.newArrayList(1, 2, 3); 71 | final Iterator iterator = ExceptionSafeIterator.wrap(numbers.iterator()); 72 | assertTrue(iterator.hasNext()); 73 | iterator.next(); 74 | iterator.remove(); 75 | 76 | assertEquals(Lists.newArrayList(2, 3), numbers); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/util/ExceptionSafeIterator.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo.util; 2 | 3 | import com.google.common.collect.AbstractIterator; 4 | 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | import java.util.Iterator; 9 | 10 | /** 11 | * Ensures that if there's a problem loading an entity that we don't fail loading the rest but 12 | * instead just skip over it 13 | * 14 | * @param The type of thing we're iterating over 15 | */ 16 | public class ExceptionSafeIterator implements Iterator { 17 | private static final Logger LOG = LoggerFactory.getLogger(ExceptionSafeIterator.class); 18 | private final Iterator abstractIterator; 19 | private final Iterator iterator; 20 | private boolean nextCalled; 21 | 22 | private ExceptionSafeIterator(final Iterator iterator) { 23 | this.abstractIterator = 24 | new AbstractIterator() { 25 | @Override 26 | protected T computeNext() { 27 | while (iterator.hasNext()) { 28 | try { 29 | return iterator.next(); 30 | } catch (final Exception e) { 31 | LOG.error( 32 | "failed to retrieve next item from iterator, skipping item", 33 | e); 34 | } 35 | } 36 | 37 | return endOfData(); 38 | } 39 | }; 40 | this.iterator = iterator; 41 | } 42 | 43 | public static ExceptionSafeIterator wrap(final Iterator iterator) { 44 | return new ExceptionSafeIterator<>(iterator); 45 | } 46 | 47 | @Override 48 | public boolean hasNext() { 49 | nextCalled = false; 50 | return abstractIterator.hasNext(); 51 | } 52 | 53 | @Override 54 | public T next() { 55 | nextCalled = true; 56 | return abstractIterator.next(); 57 | } 58 | 59 | // AbstractIterator doesn't support remove(), because it peeks ahead and can cause ambiguity 60 | // as to which element 61 | // is being removed. Here we make a compromise where assuming that next() has been called after 62 | // a hasNext(), which 63 | // is the most common use case, we can safely call remove() 64 | @Override 65 | public void remove() { 66 | if (!nextCalled) { 67 | // because elements are peeked in advanced, to avoid confusion as to which element is 68 | // being been removed 69 | // one must first call next() after calling hasNext() before being able to call remove() 70 | throw new UnsupportedOperationException( 71 | "cannot remove element until next() has been called after calling hasNext()"); 72 | } 73 | iterator.remove(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/db/OrderingTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import org.junit.Test; 6 | 7 | import java.util.Map; 8 | 9 | public class OrderingTest { 10 | @Test 11 | public void testParse() { 12 | // null expression 13 | assertEquals(Ordering.empty().getFields(), Ordering.parse(null).getFields()); 14 | 15 | // empty expression 16 | assertEquals(Ordering.empty().getFields(), Ordering.parse("").getFields()); 17 | 18 | // single field expression 19 | assertEquals( 20 | Map.of(Ordering.Field.NAME, Ordering.Direction.ASCENDING), 21 | Ordering.parse("name").getFields()); 22 | 23 | // single descending field expression 24 | assertEquals( 25 | Map.of(Ordering.Field.NAME, Ordering.Direction.DESCENDING), 26 | Ordering.parse("-name").getFields()); 27 | 28 | // multiple fields 29 | assertEquals( 30 | Map.of( 31 | Ordering.Field.ACTIVE, Ordering.Direction.ASCENDING, 32 | Ordering.Field.NAME, Ordering.Direction.DESCENDING, 33 | Ordering.Field.CREATED, Ordering.Direction.ASCENDING), 34 | Ordering.parse("active,-name,created").getFields()); 35 | } 36 | 37 | @Test(expected = IllegalArgumentException.class) 38 | public void testParseUnknown() { 39 | Ordering.parse("unknown"); 40 | } 41 | 42 | @Test 43 | public void testBuilder() { 44 | // empty expression 45 | assertEquals(Ordering.empty().getFields(), Ordering.newBuilder().build().getFields()); 46 | 47 | // single field expression 48 | assertEquals( 49 | Map.of(Ordering.Field.NAME, Ordering.Direction.ASCENDING), 50 | Ordering.newBuilder().orderBy(Ordering.Field.NAME).build().getFields()); 51 | 52 | // single descending field expression 53 | assertEquals( 54 | Map.of(Ordering.Field.NAME, Ordering.Direction.DESCENDING), 55 | Ordering.newBuilder() 56 | .orderBy(Ordering.Field.NAME, Ordering.Direction.DESCENDING) 57 | .build() 58 | .getFields()); 59 | 60 | // multiple fields 61 | assertEquals( 62 | Map.of( 63 | Ordering.Field.ACTIVE, Ordering.Direction.ASCENDING, 64 | Ordering.Field.NAME, Ordering.Direction.DESCENDING, 65 | Ordering.Field.CREATED, Ordering.Direction.ASCENDING), 66 | Ordering.newBuilder() 67 | .orderBy(Ordering.Field.ACTIVE, Ordering.Direction.ASCENDING) 68 | .orderBy(Ordering.Field.NAME, Ordering.Direction.DESCENDING) 69 | .orderBy(Ordering.Field.CREATED) 70 | .build() 71 | .getFields()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/db/Filter.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import com.google.common.base.MoreObjects; 4 | import com.google.common.base.Objects; 5 | 6 | /** Filter criteria for a list query, allowing for pagination and filtering of items */ 7 | public class Filter { 8 | public static final Filter NONE = Filter.criteria().build(); 9 | private final String filter; 10 | private final Integer offset; 11 | private final Integer limit; 12 | private final Ordering ordering; 13 | 14 | private Filter(String filter, Integer offset, Integer limit, Ordering ordering) { 15 | this.filter = filter; 16 | this.offset = offset; 17 | this.limit = limit; 18 | this.ordering = ordering; 19 | } 20 | 21 | public static Builder criteria() { 22 | return new Builder(); 23 | } 24 | 25 | public static class Builder { 26 | private String filter; 27 | private Integer offset; 28 | private Integer limit; 29 | private Ordering ordering; 30 | 31 | public Builder filter(String filter) { 32 | this.filter = filter; 33 | return this; 34 | } 35 | 36 | public Builder offset(Integer offset) { 37 | this.offset = offset; 38 | return this; 39 | } 40 | 41 | public Builder limit(Integer limit) { 42 | this.limit = limit; 43 | return this; 44 | } 45 | 46 | public Builder ordering(Ordering ordering) { 47 | this.ordering = ordering; 48 | return this; 49 | } 50 | 51 | public Filter build() { 52 | return new Filter(filter, offset, limit, ordering); 53 | } 54 | } 55 | 56 | public String getFilter() { 57 | return filter; 58 | } 59 | 60 | public Integer getOffset() { 61 | return offset; 62 | } 63 | 64 | public Integer getLimit() { 65 | return limit; 66 | } 67 | 68 | public Ordering getOrdering() { 69 | return ordering; 70 | } 71 | 72 | @Override 73 | public int hashCode() { 74 | return Objects.hashCode(filter, offset, limit, ordering); 75 | } 76 | 77 | @Override 78 | public boolean equals(Object obj) { 79 | if (!(obj instanceof Filter)) { 80 | return false; 81 | } 82 | 83 | final Filter other = (Filter) obj; 84 | 85 | return Objects.equal(filter, other.filter) 86 | && Objects.equal(offset, other.offset) 87 | && Objects.equal(limit, other.limit) 88 | && Objects.equal(ordering, other.ordering); 89 | } 90 | 91 | @Override 92 | public String toString() { 93 | return MoreObjects.toStringHelper(this) 94 | .add("filter", filter) 95 | .add("offset", offset) 96 | .add("limit", limit) 97 | .add("ordering", ordering) 98 | .toString(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/resources/TreatmentOverridesResource.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import com.google.inject.Inject; 4 | 5 | import io.rtr.alchemy.dto.models.TreatmentOverrideDto; 6 | import io.rtr.alchemy.dto.requests.TreatmentOverrideRequest; 7 | import io.rtr.alchemy.mapping.Mappers; 8 | import io.rtr.alchemy.models.Experiment; 9 | import io.rtr.alchemy.models.Experiments; 10 | 11 | import javax.validation.Valid; 12 | import javax.ws.rs.Consumes; 13 | import javax.ws.rs.DELETE; 14 | import javax.ws.rs.GET; 15 | import javax.ws.rs.PUT; 16 | import javax.ws.rs.Path; 17 | import javax.ws.rs.PathParam; 18 | import javax.ws.rs.Produces; 19 | import javax.ws.rs.core.MediaType; 20 | import javax.ws.rs.core.Response; 21 | 22 | /** Resource for interacting with treatment overrides */ 23 | @Path("/experiments/{experimentName}/overrides") 24 | @Consumes(MediaType.APPLICATION_JSON) 25 | @Produces(MediaType.APPLICATION_JSON) 26 | public class TreatmentOverridesResource extends BaseResource { 27 | private final Experiments experiments; 28 | private final Mappers mapper; 29 | 30 | @Inject 31 | public TreatmentOverridesResource(Experiments experiments, Mappers mapper) { 32 | this.experiments = experiments; 33 | this.mapper = mapper; 34 | } 35 | 36 | @GET 37 | @Path("/{overrideName}") 38 | public TreatmentOverrideDto getOverride( 39 | @PathParam("experimentName") String experimentName, 40 | @PathParam("overrideName") String overrideName) { 41 | return mapper.toDto( 42 | ensureExists( 43 | ensureExists(experiments.get(experimentName)).getOverride(overrideName)), 44 | TreatmentOverrideDto.class); 45 | } 46 | 47 | @GET 48 | public Iterable getOverrides( 49 | @PathParam("experimentName") String experimentName) { 50 | return mapper.toDto( 51 | ensureExists(experiments.get(experimentName)).getOverrides(), 52 | TreatmentOverrideDto.class); 53 | } 54 | 55 | @PUT 56 | public Response addOverride( 57 | @PathParam("experimentName") String experimentName, 58 | @Valid TreatmentOverrideRequest request) { 59 | ensureExists(experiments.get(experimentName)) 60 | .addOverride(request.getName(), request.getTreatment(), request.getFilter()) 61 | .save(); 62 | 63 | return created(); 64 | } 65 | 66 | @DELETE 67 | public void clearOverrides(@PathParam("experimentName") String experimentName) { 68 | ensureExists(experiments.get(experimentName)).clearOverrides().save(); 69 | } 70 | 71 | @DELETE 72 | @Path("/{overrideName}") 73 | public void removeOverride( 74 | @PathParam("experimentName") String experimentName, 75 | @PathParam("overrideName") String overrideName) { 76 | final Experiment experiment = ensureExists(experiments.get(experimentName)); 77 | ensureExists(experiment.getOverride(overrideName)); 78 | experiment.removeOverride(overrideName).save(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /alchemy-db-mongo/src/main/java/io/rtr/alchemy/db/mongo/MongoExperimentsCache.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db.mongo; 2 | 3 | import com.google.common.collect.Maps; 4 | 5 | import dev.morphia.Datastore; 6 | import dev.morphia.query.filters.Filters; 7 | 8 | import io.rtr.alchemy.db.ExperimentsCache; 9 | import io.rtr.alchemy.db.mongo.models.ExperimentEntity; 10 | import io.rtr.alchemy.models.Experiment; 11 | 12 | import java.util.Collections; 13 | import java.util.Iterator; 14 | import java.util.Map; 15 | 16 | /** A cache backed by MongoDB which allows for quick cached access to Experiments */ 17 | public class MongoExperimentsCache implements ExperimentsCache { 18 | private final RevisionManager revisionManager; 19 | private final Datastore ds; 20 | private volatile Map cachedExperiments; 21 | 22 | public MongoExperimentsCache(final Datastore ds, final RevisionManager revisionManager) { 23 | this.ds = ds; 24 | this.revisionManager = revisionManager; 25 | } 26 | 27 | @Override 28 | public void invalidateAll(final Experiment.BuilderFactory factory) { 29 | final Iterator iterator = 30 | ds.find(ExperimentEntity.class).filter(Filters.eq("active", true)).stream() 31 | .iterator(); 32 | 33 | final Map newMap = Maps.newConcurrentMap(); 34 | Long maxRevision = null; 35 | 36 | while (iterator.hasNext()) { 37 | final ExperimentEntity entity = iterator.next(); 38 | 39 | if (maxRevision == null || entity.revision > maxRevision) { 40 | maxRevision = entity.revision; 41 | } 42 | 43 | newMap.put(entity.name, entity.toExperiment(factory.createBuilder(entity.name))); 44 | } 45 | 46 | if (maxRevision != null) { 47 | revisionManager.setLatestRevision(maxRevision); 48 | } 49 | cachedExperiments = newMap; 50 | } 51 | 52 | @Override 53 | public Map getActiveExperiments() { 54 | return Collections.unmodifiableMap(cachedExperiments); 55 | } 56 | 57 | @Override 58 | public void invalidate(final String experimentName, final Experiment.Builder builder) { 59 | final ExperimentEntity entity = 60 | ds.find(ExperimentEntity.class).filter(Filters.eq("name", experimentName)).first(); 61 | if (entity == null) { 62 | return; 63 | } 64 | final Experiment experiment = entity.toExperiment(builder); 65 | if (experiment.isActive()) { 66 | cachedExperiments.put(experimentName, experiment); 67 | } else { 68 | cachedExperiments.remove(experimentName); 69 | } 70 | } 71 | 72 | @Override 73 | public void update(final Experiment experiment) { 74 | cachedExperiments.put(experiment.getName(), experiment); 75 | } 76 | 77 | @Override 78 | public void delete(final String experimentName) { 79 | cachedExperiments.remove(experimentName); 80 | } 81 | 82 | @Override 83 | public boolean checkIfAnyStale() { 84 | return revisionManager.checkIfAnyStale(); 85 | } 86 | 87 | @Override 88 | public boolean checkIfStale(final String experimentName) { 89 | return revisionManager.checkIfStale(experimentName); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /alchemy-core/src/test/java/io/rtr/alchemy/identities/AttributesMapTest.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.identities; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | import static org.junit.Assert.assertNull; 5 | import static org.junit.Assert.assertTrue; 6 | import static org.junit.Assert.fail; 7 | 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | 11 | import java.util.HashSet; 12 | 13 | public class AttributesMapTest { 14 | private AttributesMap map; 15 | 16 | @Before 17 | public void setUp() { 18 | map = 19 | AttributesMap.newBuilder() 20 | .put("true", true) 21 | .put("one", 1) 22 | .put("string", "string") 23 | .build(); 24 | } 25 | 26 | @Test 27 | public void testEmpty() { 28 | assertEquals(0, AttributesMap.empty().size()); 29 | } 30 | 31 | @Test 32 | public void testGet() { 33 | assertEquals(true, map.getBoolean("true")); 34 | assertEquals(Long.valueOf(1), map.getNumber("one")); 35 | assertEquals("string", map.getString("string")); 36 | 37 | // wrong type 38 | assertNull(map.getNumber("true")); 39 | 40 | // does not exist 41 | assertNull(map.getBoolean("bad")); 42 | } 43 | 44 | @Test 45 | public void testGetType() { 46 | assertEquals(Boolean.class, map.getType("true")); 47 | assertEquals(Long.class, map.getType("one")); 48 | assertEquals(String.class, map.getType("string")); 49 | assertNull(map.getType("bad")); 50 | } 51 | 52 | @Test 53 | public void testFilter() { 54 | assertEquals(map.entrySet(), map.filter(map.keySet()).entrySet()); 55 | assertTrue(map.filter(new HashSet<>()).isEmpty()); 56 | } 57 | 58 | private void assertImmutable(String method, Runnable testMethod) { 59 | try { 60 | testMethod.run(); 61 | fail( 62 | String.format( 63 | "method %s should not be allowed on immutable AttributesMap", method)); 64 | } catch (UnsupportedOperationException ignored) { 65 | } 66 | } 67 | 68 | @Test 69 | public void testImmutable() { 70 | assertImmutable( 71 | "put", 72 | new Runnable() { 73 | @Override 74 | public void run() { 75 | map.put("foo", "bar"); 76 | } 77 | }); 78 | 79 | assertImmutable( 80 | "putAll", 81 | new Runnable() { 82 | @Override 83 | public void run() { 84 | map.putAll(map); 85 | } 86 | }); 87 | 88 | assertImmutable( 89 | "remove", 90 | new Runnable() { 91 | @Override 92 | public void run() { 93 | map.remove("true"); 94 | } 95 | }); 96 | 97 | assertImmutable( 98 | "clear", 99 | new Runnable() { 100 | @Override 101 | public void run() { 102 | map.clear(); 103 | } 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/resources/MetadataResource.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service.resources; 2 | 3 | import com.fasterxml.jackson.databind.JsonMappingException; 4 | import com.fasterxml.jackson.module.jsonSchema.JsonSchema; 5 | import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; 6 | import com.google.common.base.Function; 7 | import com.google.common.collect.Maps; 8 | import com.google.inject.Inject; 9 | 10 | import io.dropwizard.setup.Environment; 11 | import io.rtr.alchemy.dto.identities.IdentityDto; 12 | import io.rtr.alchemy.identities.Identity; 13 | import io.rtr.alchemy.mapping.Mappers; 14 | import io.rtr.alchemy.service.metadata.IdentitiesMetadata; 15 | import io.rtr.alchemy.service.metadata.IdentityMetadata; 16 | 17 | import java.util.Map; 18 | import java.util.Set; 19 | 20 | import javax.annotation.Nullable; 21 | import javax.validation.Valid; 22 | import javax.ws.rs.GET; 23 | import javax.ws.rs.POST; 24 | import javax.ws.rs.Path; 25 | import javax.ws.rs.PathParam; 26 | import javax.ws.rs.Produces; 27 | import javax.ws.rs.core.MediaType; 28 | 29 | /** Resource for retrieving registered identity types and their schemas */ 30 | @Path("/metadata") 31 | @Produces(MediaType.APPLICATION_JSON) 32 | public class MetadataResource extends BaseResource { 33 | private static final MetadataDtoTypesMapper DTO_TYPES_MAPPER = new MetadataDtoTypesMapper(); 34 | private final Map> identityTypesByName; 35 | private final IdentitiesMetadata metadata; 36 | private final JsonSchemaGenerator schemaGenerator; 37 | private final Mappers mapper; 38 | 39 | @Inject 40 | public MetadataResource(Environment environment, IdentitiesMetadata metadata, Mappers mapper) { 41 | this.metadata = metadata; 42 | this.identityTypesByName = Maps.transformValues(metadata, DTO_TYPES_MAPPER); 43 | this.schemaGenerator = new JsonSchemaGenerator(environment.getObjectMapper()); 44 | this.mapper = mapper; 45 | } 46 | 47 | @GET 48 | @Path("/identityTypes") 49 | public Map> getIdentityTypes() { 50 | return identityTypesByName; 51 | } 52 | 53 | @GET 54 | @Path("/identityTypes/{identityType}/schema") 55 | public JsonSchema getSchema(@PathParam("identityType") String identityType) 56 | throws JsonMappingException { 57 | final Class dtoType = ensureExists(identityTypesByName.get(identityType)); 58 | return schemaGenerator.generateSchema(dtoType); 59 | } 60 | 61 | @GET 62 | @Path("/identityTypes/{identityType}/attributes") 63 | public Set getAttributes(@PathParam("identityType") String identityType) 64 | throws JsonMappingException { 65 | final IdentityMetadata metadata = ensureExists(this.metadata.get(identityType)); 66 | return metadata.getAttributes(); 67 | } 68 | 69 | @POST 70 | @Path("/identity/attributes") 71 | public Map computeAttributes(@Valid IdentityDto request) { 72 | final Identity identity = ensureExists(mapper.fromDto(request, Identity.class)); 73 | return identity.computeAttributes(); 74 | } 75 | 76 | private static class MetadataDtoTypesMapper implements Function> { 77 | @Nullable 78 | @Override 79 | public Class apply(@Nullable IdentityMetadata input) { 80 | return input == null ? null : input.getDtoType(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /alchemy-core/src/main/java/io/rtr/alchemy/db/Ordering.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.db; 2 | 3 | import com.google.common.base.Preconditions; 4 | 5 | import java.util.Collections; 6 | import java.util.HashMap; 7 | import java.util.LinkedHashMap; 8 | import java.util.Map; 9 | import java.util.StringTokenizer; 10 | 11 | /** Specifies how multiple fields are ordered */ 12 | public class Ordering { 13 | private final Map fields; 14 | 15 | private Ordering(Map fields) { 16 | this.fields = Collections.unmodifiableMap(fields); 17 | } 18 | 19 | public Map getFields() { 20 | return fields; 21 | } 22 | 23 | public boolean isEmpty() { 24 | return fields.isEmpty(); 25 | } 26 | 27 | public static Ordering parse(String ordering) { 28 | if (ordering == null) { 29 | return Ordering.empty(); 30 | } 31 | 32 | final StringTokenizer tokenizer = new StringTokenizer(ordering, ","); 33 | final Map fields = new LinkedHashMap<>(); 34 | 35 | while (tokenizer.hasMoreTokens()) { 36 | final String token = tokenizer.nextToken(); 37 | final int index = token.indexOf('-'); 38 | final Direction direction = index > -1 ? Direction.DESCENDING : Direction.ASCENDING; 39 | final Field field = Field.fromName(token.substring(index + 1)); 40 | 41 | Preconditions.checkArgument(field != null, "Unsupported ordering field: %s", token); 42 | 43 | fields.put(field, direction); 44 | } 45 | 46 | return new Ordering(fields); 47 | } 48 | 49 | public static Ordering empty() { 50 | return new Ordering(new HashMap<>()); 51 | } 52 | 53 | public static Builder newBuilder() { 54 | return new Builder(); 55 | } 56 | 57 | public static enum Direction { 58 | ASCENDING, 59 | DESCENDING 60 | } 61 | 62 | public static enum Field { 63 | NAME("name"), 64 | DESCRIPTION("description"), 65 | CREATED("created"), 66 | MODIFIED("modified"), 67 | ACTIVATED("activated"), 68 | DEACTIVATED("deactivated"), 69 | ACTIVE("active"); 70 | 71 | private final String name; 72 | private static final Map FIELDS_BY_NAME; 73 | 74 | static { 75 | FIELDS_BY_NAME = new HashMap<>(); 76 | for (Field field : Field.values()) { 77 | FIELDS_BY_NAME.put(field.name, field); 78 | } 79 | } 80 | 81 | Field(String name) { 82 | this.name = name; 83 | } 84 | 85 | public static Field fromName(String name) { 86 | return FIELDS_BY_NAME.get(name); 87 | } 88 | 89 | public String getName() { 90 | return name; 91 | } 92 | } 93 | 94 | public static class Builder { 95 | private final Map ordering = new LinkedHashMap<>(); 96 | 97 | public Builder orderBy(Field field) { 98 | ordering.put(field, Direction.ASCENDING); 99 | return this; 100 | } 101 | 102 | public Builder orderBy(Field field, Direction direction) { 103 | ordering.put(field, direction); 104 | return this; 105 | } 106 | 107 | public Ordering build() { 108 | return new Ordering(ordering); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /alchemy-service/src/main/java/io/rtr/alchemy/service/AlchemyService.java: -------------------------------------------------------------------------------- 1 | package io.rtr.alchemy.service; 2 | 3 | import com.fasterxml.jackson.datatype.guava.GuavaModule; 4 | import com.google.common.base.Preconditions; 5 | import com.google.inject.Guice; 6 | import com.google.inject.Injector; 7 | 8 | import io.dropwizard.Application; 9 | import io.dropwizard.Configuration; 10 | import io.dropwizard.setup.Bootstrap; 11 | import io.dropwizard.setup.Environment; 12 | import io.rtr.alchemy.service.config.AlchemyServiceConfiguration; 13 | import io.rtr.alchemy.service.config.IdentityMapping; 14 | import io.rtr.alchemy.service.exceptions.RuntimeExceptionMapper; 15 | import io.rtr.alchemy.service.filters.SparseFieldSetFilter; 16 | import io.rtr.alchemy.service.guice.AlchemyModule; 17 | import io.rtr.alchemy.service.health.ExperimentsDatabaseProviderCheck; 18 | import io.rtr.alchemy.service.metrics.JmxMetricsManaged; 19 | import io.rtr.alchemy.service.resources.ActiveTreatmentsResource; 20 | import io.rtr.alchemy.service.resources.AllocationsResource; 21 | import io.rtr.alchemy.service.resources.ExperimentsResource; 22 | import io.rtr.alchemy.service.resources.MetadataResource; 23 | import io.rtr.alchemy.service.resources.TreatmentOverridesResource; 24 | import io.rtr.alchemy.service.resources.TreatmentsResource; 25 | 26 | /** The entry point for the service */ 27 | public abstract class AlchemyService 28 | extends Application { 29 | private static final Class[] RESOURCES = { 30 | ExperimentsResource.class, 31 | AllocationsResource.class, 32 | TreatmentOverridesResource.class, 33 | TreatmentsResource.class, 34 | ActiveTreatmentsResource.class, 35 | MetadataResource.class 36 | }; 37 | 38 | @Override 39 | public void initialize(final Bootstrap 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 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 | --------------------------------------------------------------------------------