├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── src ├── test │ ├── resources │ │ └── application-test.properties │ └── groovy │ │ └── io │ │ └── pillopl │ │ └── eventsource │ │ └── shop │ │ ├── integration │ │ ├── IntegrationSpec.groovy │ │ ├── eventstore │ │ │ ├── publisher │ │ │ │ ├── PendingEventFetcherSpec.groovy │ │ │ │ └── EventPublisherSpec.groovy │ │ │ ├── EventSourcedShopItemRepositoryIntegrationSpec.groovy │ │ │ └── EventStoreIntegrationSpec.groovy │ │ ├── E2ESpec.groovy │ │ └── boundary │ │ │ └── ShopItemsIntegrationSpec.groovy │ │ ├── CommandFixture.java │ │ ├── ShopItemFixture.java │ │ ├── eventstore │ │ └── EventSerializerSpec.groovy │ │ └── domain │ │ └── ShopItemSpec.groovy └── main │ ├── java │ └── io │ │ └── pillopl │ │ └── eventsource │ │ └── shop │ │ ├── domain │ │ ├── ShopItemState.java │ │ ├── commands │ │ │ ├── Pay.java │ │ │ ├── MarkPaymentTimeout.java │ │ │ ├── Order.java │ │ │ └── Command.java │ │ ├── ShopItemRepository.java │ │ ├── events │ │ │ ├── ItemPaid.java │ │ │ ├── ItemPaymentTimeout.java │ │ │ ├── DomainEvent.java │ │ │ └── ItemOrdered.java │ │ └── ShopItem.java │ │ ├── eventstore │ │ ├── publisher │ │ │ ├── PublishChannel.java │ │ │ ├── PendingEventFetcher.java │ │ │ └── EventPublisher.java │ │ ├── EventStore.java │ │ ├── EventStream.java │ │ ├── EventSerializer.java │ │ ├── EventDescriptor.java │ │ └── EventSourcedShopItemRepository.java │ │ ├── boundary │ │ └── ShopItems.java │ │ └── Application.java │ └── resources │ ├── application.properties │ └── db │ └── changelog │ └── changelog-master.xml ├── .gitignore ├── README.md ├── gradlew.bat ├── gradlew └── LICENSE /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'shop' 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pilloPl/shop/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | 2 | # versions 3 | springBootVersion=1.4.0.RELEASE 4 | 5 | lombokVersion=1.16.8 6 | liquibaseVersion=3.5.0 7 | -------------------------------------------------------------------------------- /src/test/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | liquibase.change-log=classpath:db/changelog/changelog-master.xml 2 | spring.jpa.generate-ddl=false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | build 4 | /src/main/generated 5 | /bin 6 | .gradle 7 | .poject 8 | .settings 9 | .classpath 10 | commitHash.txt 11 | logs 12 | .log 13 | .zip 14 | /out 15 | *~ 16 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/ShopItemState.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain; 2 | 3 | public enum ShopItemState { 4 | INITIALIZED, 5 | ORDERED, 6 | PAID, 7 | PAYMENT_MISSING 8 | } 9 | 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.8.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/commands/Pay.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.commands; 2 | 3 | import lombok.Value; 4 | 5 | import java.time.Instant; 6 | import java.util.UUID; 7 | 8 | @Value 9 | public class Pay implements Command { 10 | 11 | private final UUID uuid; 12 | private final Instant when; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/commands/MarkPaymentTimeout.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.commands; 2 | 3 | import lombok.Value; 4 | 5 | import java.time.Instant; 6 | import java.util.UUID; 7 | 8 | @Value 9 | public class MarkPaymentTimeout implements Command { 10 | 11 | private final UUID uuid; 12 | private final Instant when; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/ShopItemRepository.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain; 2 | 3 | import java.time.Instant; 4 | import java.util.UUID; 5 | 6 | public interface ShopItemRepository { 7 | 8 | ShopItem save(ShopItem aggregate) ; 9 | 10 | ShopItem getByUUID(UUID uuid); 11 | 12 | ShopItem getByUUIDat(UUID uuid, Instant at); 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/commands/Order.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.commands; 2 | 3 | import lombok.Value; 4 | 5 | import java.math.BigDecimal; 6 | import java.time.Instant; 7 | import java.util.UUID; 8 | 9 | @Value 10 | public class Order implements Command { 11 | 12 | private final UUID uuid; 13 | private final BigDecimal price; 14 | private final Instant when; 15 | 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample event sourced application with Command Query Responsibility Segregation 2 | 3 | ** Event sourcing ** 4 | 5 | Shop item can be ordered, paid for, and marked as timed out (payment missing). Aggregate root (ShopItem) emits 3 different types of domain events: ItemBought, ItemPaid, ItemPaymentMissing. All of them are consequences of commands. 6 | 7 | Event store is constructed in database as EventStream table with collection of EventDescriptors. EventStream is fetched by unique aggregate root uuid. 8 | 9 | Application keep sending events to kafka broker. Kafka is bound with the use of spring stream -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/publisher/PublishChannel.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore.publisher; 2 | 3 | import org.springframework.cloud.stream.messaging.Source; 4 | import org.springframework.integration.annotation.Publisher; 5 | import org.springframework.messaging.handler.annotation.Header; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.UUID; 9 | 10 | @Component 11 | public class PublishChannel { 12 | 13 | @Publisher(channel = Source.OUTPUT) 14 | public String send(String payload, @Header UUID uuid) { 15 | return payload; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/publisher/PendingEventFetcher.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore.publisher; 2 | 3 | import io.pillopl.eventsource.shop.eventstore.EventDescriptor; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | import java.util.List; 7 | 8 | interface PendingEventFetcher extends JpaRepository { 9 | 10 | List findTop100ByStatusOrderByOccurredAtAsc(EventDescriptor.Status status); 11 | 12 | default List listPending() { 13 | return findTop100ByStatusOrderByOccurredAtAsc(EventDescriptor.Status.PENDING); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | liquibase.change-log=classpath:db/changelog/changelog-master.xml 2 | spring.jpa.generate-ddl=false 3 | server.port=9874 4 | 5 | 6 | spring.cloud.stream.bindings.output.destination=items 7 | spring.cloud.stream.bindings.output.content-type=application/json 8 | 9 | 10 | spring.cloud.stream.bindings.input.destination=commands 11 | spring.cloud.stream.bindings.input.content-type=application/json 12 | spring.cloud.stream.bindings.input.group=shop 13 | 14 | 15 | spring.cloud.stream.kafka.binder.autoAddPartitions=true 16 | spring.cloud.stream.bindings.output.producer.partitionKeyExpression=headers['uuid'] 17 | spring.cloud.stream.bindings.output.producer.partitionCount=2 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/commands/Command.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.commands; 2 | 3 | 4 | import com.fasterxml.jackson.annotation.JsonSubTypes; 5 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 6 | 7 | @JsonTypeInfo( 8 | use = JsonTypeInfo.Id.NAME, 9 | include = JsonTypeInfo.As.PROPERTY, 10 | property = "type", defaultImpl = VoidCommand.class) 11 | @JsonSubTypes({ 12 | @JsonSubTypes.Type(name = "item.order", value = Order.class), 13 | @JsonSubTypes.Type(name = "item.pay", value = Pay.class), 14 | @JsonSubTypes.Type(name = "item.markPaymentTimeout", value = MarkPaymentTimeout.class) 15 | }) 16 | public interface Command { 17 | 18 | 19 | } 20 | 21 | class VoidCommand implements Command { 22 | 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/events/ItemPaid.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | public class ItemPaid implements DomainEvent { 14 | 15 | public static final String TYPE = "item.paid"; 16 | 17 | private UUID uuid; 18 | private Instant when; 19 | 20 | @Override 21 | public String type() { 22 | return TYPE; 23 | } 24 | 25 | @Override 26 | public Instant when() { 27 | return when; 28 | } 29 | 30 | @Override 31 | public UUID aggregateUuid() { 32 | return uuid; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/events/ItemPaymentTimeout.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | public class ItemPaymentTimeout implements DomainEvent { 14 | 15 | public static final String TYPE = "item.payment.timeout"; 16 | 17 | private UUID uuid; 18 | private Instant when; 19 | 20 | @Override 21 | public String type() { 22 | return TYPE; 23 | } 24 | 25 | @Override 26 | public Instant when() { 27 | return when; 28 | } 29 | 30 | @Override 31 | public UUID aggregateUuid() { 32 | return uuid; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/events/DomainEvent.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.events; 2 | 3 | import com.fasterxml.jackson.annotation.JsonSubTypes; 4 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 5 | 6 | import java.time.Instant; 7 | import java.util.UUID; 8 | 9 | @JsonTypeInfo( 10 | use = JsonTypeInfo.Id.NAME, 11 | include = JsonTypeInfo.As.PROPERTY, 12 | property = "type") 13 | @JsonSubTypes({ 14 | @JsonSubTypes.Type(name = ItemOrdered.TYPE, value = ItemOrdered.class), 15 | @JsonSubTypes.Type(name = ItemPaymentTimeout.TYPE, value = ItemPaymentTimeout.class), 16 | @JsonSubTypes.Type(name = ItemPaid.TYPE, value = ItemPaid.class) 17 | }) 18 | public interface DomainEvent { 19 | 20 | String type(); 21 | Instant when(); 22 | UUID aggregateUuid(); 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/IntegrationSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration 2 | 3 | import groovy.transform.CompileStatic 4 | import io.pillopl.eventsource.shop.Application 5 | import org.springframework.boot.test.SpringApplicationContextLoader 6 | import org.springframework.context.annotation.EnableAspectJAutoProxy 7 | import org.springframework.test.context.ActiveProfiles 8 | import org.springframework.test.context.ContextConfiguration 9 | import org.springframework.test.context.web.WebAppConfiguration 10 | import spock.lang.Specification 11 | 12 | @ContextConfiguration(classes = [Application], loader = SpringApplicationContextLoader) 13 | @CompileStatic 14 | @WebAppConfiguration 15 | @EnableAspectJAutoProxy(proxyTargetClass = true) 16 | @ActiveProfiles("test") 17 | class IntegrationSpec extends Specification { 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/events/ItemOrdered.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.math.BigDecimal; 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | public class ItemOrdered implements DomainEvent { 15 | 16 | public static final String TYPE = "item.ordered"; 17 | 18 | private UUID uuid; 19 | private Instant when; 20 | private Instant paymentTimeoutDate; 21 | private BigDecimal price; 22 | 23 | @Override 24 | public String type() { 25 | return TYPE; 26 | } 27 | 28 | @Override 29 | public Instant when() { 30 | return when; 31 | } 32 | 33 | @Override 34 | public UUID aggregateUuid() { 35 | return uuid; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/EventStore.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.List; 6 | import java.util.Optional; 7 | import java.util.UUID; 8 | 9 | import static java.util.Collections.emptyList; 10 | 11 | interface EventStore extends JpaRepository { 12 | 13 | Optional findByAggregateUUID(UUID uuid); 14 | 15 | default void saveEvents(UUID aggregateId, List events) { 16 | final EventStream eventStream = findByAggregateUUID(aggregateId) 17 | .orElseGet(() -> new EventStream(aggregateId)); 18 | eventStream.addEvents(events); 19 | save(eventStream); 20 | } 21 | 22 | default List getEventsForAggregate(UUID aggregateId) { 23 | return findByAggregateUUID(aggregateId) 24 | .map(EventStream::getEvents) 25 | .orElse(emptyList()); 26 | 27 | } 28 | 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/CommandFixture.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop; 2 | 3 | 4 | import io.pillopl.eventsource.shop.domain.commands.Order; 5 | import io.pillopl.eventsource.shop.domain.commands.MarkPaymentTimeout; 6 | import io.pillopl.eventsource.shop.domain.commands.Pay; 7 | 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | import static java.math.BigDecimal.ZERO; 12 | import static java.time.Instant.now; 13 | 14 | public class CommandFixture { 15 | 16 | public static Order orderItemCommand(UUID uuid) { 17 | return new Order(uuid, ZERO, now()); 18 | } 19 | 20 | public static Order orderItemCommand(UUID uuid, Instant when) { 21 | return new Order(uuid, ZERO, when); 22 | } 23 | 24 | public static Pay payItemCommand(UUID uuid) { 25 | return new Pay(uuid, now()); 26 | } 27 | 28 | public static Pay payItemCommand(UUID uuid, Instant when) { 29 | return new Pay(uuid, when); 30 | } 31 | 32 | public static MarkPaymentTimeout markPaymentTimeoutCommand(UUID uuid) { 33 | return new MarkPaymentTimeout(uuid, now()); 34 | } 35 | 36 | public static MarkPaymentTimeout markPaymentTimeoutCommand(UUID uuid, Instant when) { 37 | return new MarkPaymentTimeout(uuid, when); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/publisher/EventPublisher.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore.publisher; 2 | 3 | import io.pillopl.eventsource.shop.eventstore.EventDescriptor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.scheduling.annotation.Scheduled; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | @Slf4j 11 | public class EventPublisher { 12 | 13 | private final PendingEventFetcher pendingEventFetcher; 14 | private final PublishChannel publishChannel; 15 | 16 | @Autowired 17 | public EventPublisher(PendingEventFetcher pendingEventFetcher, PublishChannel publishChannel) { 18 | this.pendingEventFetcher = pendingEventFetcher; 19 | this.publishChannel = publishChannel; 20 | } 21 | 22 | @Scheduled(fixedRate = 2000) 23 | public void publishPending() { 24 | pendingEventFetcher.listPending().forEach(this::sendSafely); 25 | } 26 | 27 | private EventDescriptor sendSafely(EventDescriptor event) { 28 | final String body = event.getBody(); 29 | try { 30 | log.info("about to send: {}", body); 31 | publishChannel.send(body, event.getAggregateUUID()); 32 | pendingEventFetcher.save(event.sent()); 33 | log.info("send: {}", body); 34 | } catch (Exception e) { 35 | log.error("cannot send {}", body, e); 36 | } 37 | return event; 38 | } 39 | 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/EventStream.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore; 2 | 3 | import lombok.Getter; 4 | 5 | import javax.persistence.*; 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | import java.util.UUID; 9 | 10 | import static java.util.Comparator.comparing; 11 | import static java.util.stream.Collectors.toList; 12 | import static javax.persistence.FetchType.EAGER; 13 | 14 | @Entity(name = "event_streams") 15 | class EventStream { 16 | 17 | @Id 18 | @GeneratedValue(generator = "event_stream_seq", strategy = GenerationType.SEQUENCE) 19 | @SequenceGenerator(name = "event_stream_seq", sequenceName = "event_stream_seq", allocationSize = 1) 20 | private Long id; 21 | 22 | @Getter 23 | @Column(unique = true, nullable = false, name = "aggregate_uuid", length = 36) 24 | private UUID aggregateUUID; 25 | 26 | @Version 27 | @Column(nullable = false) 28 | private long version; 29 | 30 | @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = EAGER) 31 | private List events = new ArrayList<>(); 32 | 33 | private EventStream() { 34 | } 35 | 36 | EventStream(UUID aggregateUUID) { 37 | this.aggregateUUID = aggregateUUID; 38 | } 39 | 40 | void addEvents(List events) { 41 | this.events.addAll(events); 42 | } 43 | 44 | List getEvents() { 45 | return events 46 | .stream() 47 | .sorted(comparing(EventDescriptor::getOccurredAt)) 48 | .collect(toList()); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/EventSerializer.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.fasterxml.jackson.databind.SerializationFeature; 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 7 | import io.pillopl.eventsource.shop.domain.events.DomainEvent; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.io.IOException; 11 | 12 | import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; 13 | 14 | @Component 15 | class EventSerializer { 16 | 17 | private final ObjectMapper objectMapper; 18 | 19 | EventSerializer() { 20 | this.objectMapper = new ObjectMapper(); 21 | objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false); 22 | objectMapper.registerModule(new JavaTimeModule()); 23 | objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 24 | } 25 | 26 | EventDescriptor serialize(DomainEvent event) { 27 | try { 28 | return new EventDescriptor(objectMapper.writeValueAsString(event), event.when(), event.type(), event.aggregateUuid()); 29 | } catch (JsonProcessingException e) { 30 | throw new RuntimeException(e); 31 | } 32 | } 33 | 34 | DomainEvent deserialize(EventDescriptor eventDescriptor) { 35 | try { 36 | return objectMapper.readValue(eventDescriptor.getBody(), DomainEvent.class); 37 | } catch (IOException e) { 38 | throw new RuntimeException(e); 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/ShopItemFixture.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop; 2 | 3 | 4 | import com.google.common.collect.ImmutableList; 5 | import io.pillopl.eventsource.shop.domain.ShopItem; 6 | import io.pillopl.eventsource.shop.domain.ShopItemState; 7 | 8 | import java.math.BigDecimal; 9 | import java.time.Instant; 10 | import java.util.UUID; 11 | 12 | import static java.time.Instant.now; 13 | 14 | public class ShopItemFixture { 15 | 16 | public static final Instant ANY_TIME = now(); 17 | public static final int ANY_NUMBER_OF_HOURS_TO_PAYMENT_TIMEOUT = 48; 18 | public static final BigDecimal ANY_PRICE = BigDecimal.TEN; 19 | 20 | 21 | public static ShopItem initialized() { 22 | return new ShopItem(null, ImmutableList.of(), ShopItemState.INITIALIZED); 23 | } 24 | 25 | public static ShopItem ordered(UUID uuid) { 26 | return initialized() 27 | .order(uuid, ANY_TIME, ANY_NUMBER_OF_HOURS_TO_PAYMENT_TIMEOUT, ANY_PRICE) 28 | .markChangesAsCommitted(); 29 | } 30 | 31 | public static ShopItem paid(UUID uuid) { 32 | return initialized() 33 | .order(uuid, now(), ANY_NUMBER_OF_HOURS_TO_PAYMENT_TIMEOUT, ANY_PRICE) 34 | .pay(now()) 35 | .markChangesAsCommitted(); 36 | } 37 | 38 | public static ShopItem withTimeout(UUID uuid) { 39 | return initialized() 40 | .order(uuid, now(), ANY_NUMBER_OF_HOURS_TO_PAYMENT_TIMEOUT, ANY_PRICE) 41 | .markTimeout(now()) 42 | .markChangesAsCommitted(); 43 | } 44 | 45 | public static ShopItem withTimeoutAndPaid(UUID uuid) { 46 | return initialized() 47 | .order(uuid, now(), ANY_NUMBER_OF_HOURS_TO_PAYMENT_TIMEOUT, ANY_PRICE) 48 | .markTimeout(now()) 49 | .pay(now()) 50 | .markChangesAsCommitted(); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/EventDescriptor.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore; 2 | 3 | import lombok.Getter; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Entity; 7 | import javax.persistence.EnumType; 8 | import javax.persistence.Enumerated; 9 | import javax.persistence.GeneratedValue; 10 | import javax.persistence.GenerationType; 11 | import javax.persistence.Id; 12 | import javax.persistence.SequenceGenerator; 13 | import java.time.Instant; 14 | import java.util.UUID; 15 | 16 | @Entity(name = "event_descriptors") 17 | public class EventDescriptor { 18 | 19 | public enum Status { 20 | PENDING, SENT 21 | } 22 | 23 | @Id 24 | @GeneratedValue(generator = "event_descriptors_seq", strategy = GenerationType.SEQUENCE) 25 | @SequenceGenerator(name = "event_descriptors_seq", sequenceName = "event_descriptors_seq", allocationSize = 1) 26 | @Getter 27 | private Long id; 28 | 29 | @Getter 30 | @Column(nullable = false, length = 600) 31 | private String body; 32 | 33 | @Getter 34 | @Column(nullable = false, name = "occurred_at") 35 | private Instant occurredAt; 36 | 37 | @Getter 38 | @Column(nullable = false, length = 60) 39 | private String type; 40 | 41 | @Column(name = "STATUS", nullable = false) 42 | @Enumerated(EnumType.STRING) 43 | @Getter 44 | private Status status = Status.PENDING; 45 | 46 | @Getter 47 | @Column(nullable = false, name = "aggregate_uuid", length = 36) 48 | private UUID aggregateUUID; 49 | 50 | EventDescriptor(String body, Instant occurredAt, String type, UUID aggregateUUID) { 51 | this.body = body; 52 | this.occurredAt = occurredAt; 53 | this.type = type; 54 | this.aggregateUUID = aggregateUUID; 55 | } 56 | 57 | private EventDescriptor() { 58 | } 59 | 60 | public EventDescriptor sent() { 61 | this.status = Status.SENT; 62 | return this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/eventstore/publisher/PendingEventFetcherSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration.eventstore.publisher 2 | 3 | import io.pillopl.eventsource.shop.eventstore.EventDescriptor 4 | import io.pillopl.eventsource.shop.eventstore.publisher.PendingEventFetcher 5 | import io.pillopl.eventsource.shop.integration.IntegrationSpec 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import spock.lang.Subject 8 | 9 | import java.time.Instant 10 | 11 | import static io.pillopl.eventsource.shop.eventstore.EventDescriptor.Status.PENDING 12 | 13 | class PendingEventFetcherSpec extends IntegrationSpec { 14 | 15 | @Subject @Autowired PendingEventFetcher pendingEventFetcher 16 | 17 | def 'should fetch only pending events'() { 18 | given: 19 | 10.times { pendingEvent() } 20 | 3.times { sentEvent() } 21 | when: 22 | List events = pendingEventFetcher.listPending() 23 | then: 24 | events.every {it.status == PENDING} 25 | } 26 | 27 | def 'should fetch events in correct order'() { 28 | given: 29 | 3.times { pendingEvent() } 30 | when: 31 | List pendingEvents = pendingEventFetcher.listPending() 32 | then: 33 | pendingEvents.head().occurredAt.isBefore(pendingEvents.last().occurredAt) 34 | pendingEvents.head().occurredAt.isBefore(pendingEvents.get(1).occurredAt) 35 | pendingEvents.get(1).occurredAt.isBefore(pendingEvents.last().occurredAt) 36 | 37 | } 38 | 39 | EventDescriptor pendingEvent() { 40 | EventDescriptor event = new EventDescriptor("body", Instant.now(), "type", UUID.randomUUID()) 41 | pendingEventFetcher.save(event) 42 | return event 43 | } 44 | 45 | EventDescriptor sentEvent() { 46 | EventDescriptor event = new EventDescriptor("body", Instant.now(), "type", UUID.randomUUID()) 47 | event.sent() 48 | pendingEventFetcher.save(event) 49 | return event 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/eventstore/publisher/EventPublisherSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration.eventstore.publisher 2 | 3 | import io.pillopl.eventsource.shop.boundary.ShopItems 4 | import io.pillopl.eventsource.shop.eventstore.EventDescriptor 5 | import io.pillopl.eventsource.shop.eventstore.publisher.EventPublisher 6 | import io.pillopl.eventsource.shop.eventstore.publisher.PendingEventFetcher 7 | import io.pillopl.eventsource.shop.integration.IntegrationSpec 8 | import org.springframework.beans.factory.annotation.Autowired 9 | import org.springframework.cloud.stream.messaging.Source 10 | import org.springframework.cloud.stream.test.binder.MessageCollector 11 | import org.springframework.messaging.Message 12 | import spock.lang.Subject 13 | import spock.util.concurrent.PollingConditions 14 | 15 | import java.time.Instant 16 | import java.util.concurrent.BlockingQueue 17 | 18 | class EventPublisherSpec extends IntegrationSpec { 19 | 20 | @Subject @Autowired EventPublisher eventPublisher 21 | @Autowired ShopItems shopItems 22 | @Autowired Source source 23 | @Autowired MessageCollector messageCollector 24 | @Autowired PendingEventFetcher pendingEventFetcher 25 | 26 | PollingConditions conditions 27 | BlockingQueue> channel 28 | 29 | def setup() { 30 | conditions = new PollingConditions(timeout: 12, initialDelay: 0, factor: 1) 31 | channel = messageCollector.forChannel(source.output()) 32 | } 33 | 34 | def 'should publish pending events'() { 35 | given: 36 | EventDescriptor pendingEvent = pendingEvent() 37 | when: 38 | eventPublisher.publishPending() 39 | then: 40 | conditions.eventually { 41 | Message received = channel.poll() 42 | received != null 43 | received.getPayload() == pendingEvent.getBody() 44 | } 45 | } 46 | 47 | EventDescriptor pendingEvent() { 48 | EventDescriptor event = new EventDescriptor("body", Instant.now(), "type", UUID.randomUUID()) 49 | pendingEventFetcher.save(event) 50 | return event 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/eventstore/EventSerializerSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore 2 | 3 | import io.pillopl.eventsource.shop.domain.events.ItemOrdered 4 | import io.pillopl.eventsource.shop.domain.events.ItemPaid 5 | import io.pillopl.eventsource.shop.domain.events.ItemPaymentTimeout 6 | import spock.lang.Specification 7 | import spock.lang.Subject 8 | 9 | import static java.time.Instant.now 10 | 11 | class EventSerializerSpec extends Specification { 12 | 13 | final String ANY_TYPE = "ANY_TYPE" 14 | final UUID ANY_UUID = UUID.fromString("9a94d251-5fdb-4f38-b308-9f72d2355467") 15 | 16 | @Subject EventSerializer eventSerializer = new EventSerializer() 17 | 18 | def "should parse ItemOrdered event"() { 19 | given: 20 | String body = """{ 21 | "type": "$ItemOrdered.TYPE", 22 | "uuid": "${ANY_UUID.toString()}", 23 | "when": "2016-05-24T12:06:41.045Z", 24 | "price": "123.45" 25 | }""" 26 | when: 27 | ItemOrdered event = eventSerializer.deserialize(new EventDescriptor(body, now(), ANY_TYPE, ANY_UUID)) 28 | then: 29 | event.uuid == ANY_UUID 30 | event.price == 123.45 31 | } 32 | 33 | def "should parse ItemPaid event"() { 34 | given: 35 | String body = """{ 36 | "type": "$ItemPaid.TYPE", 37 | "uuid": "${ANY_UUID.toString()}", 38 | "when": "2016-05-24T12:06:41.045Z" 39 | }""" 40 | when: 41 | ItemPaid event = eventSerializer.deserialize(new EventDescriptor(body, now(), ANY_TYPE, ANY_UUID)) 42 | then: 43 | event.uuid == ANY_UUID 44 | } 45 | 46 | def "should parse ItemPaymentTimeout event"() { 47 | given: 48 | String body = """{ 49 | "type": "$ItemPaymentTimeout.TYPE", 50 | "uuid": "${ANY_UUID.toString()}", 51 | "when": "2016-05-24T12:06:41.045Z" 52 | }""" 53 | when: 54 | ItemPaymentTimeout event = eventSerializer.deserialize(new EventDescriptor(body, now(), ANY_TYPE, ANY_UUID)) 55 | then: 56 | event.uuid == ANY_UUID 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/eventstore/EventSourcedShopItemRepository.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.eventstore; 2 | 3 | 4 | import io.pillopl.eventsource.shop.domain.ShopItem; 5 | import io.pillopl.eventsource.shop.domain.ShopItemRepository; 6 | import io.pillopl.eventsource.shop.domain.events.DomainEvent; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.time.Instant; 11 | import java.util.List; 12 | import java.util.UUID; 13 | 14 | import static java.util.stream.Collectors.toList; 15 | 16 | @Component 17 | public class EventSourcedShopItemRepository implements ShopItemRepository { 18 | 19 | private final EventStore eventStore; 20 | private final EventSerializer eventSerializer; 21 | 22 | @Autowired 23 | public EventSourcedShopItemRepository(EventStore eventStore, EventSerializer eventSerializer) { 24 | this.eventStore = eventStore; 25 | this.eventSerializer = eventSerializer; 26 | } 27 | 28 | @Override 29 | public ShopItem save(ShopItem aggregate) { 30 | final List pendingEvents = aggregate.getUncommittedChanges(); 31 | eventStore.saveEvents( 32 | aggregate.getUuid(), 33 | pendingEvents 34 | .stream() 35 | .map(eventSerializer::serialize) 36 | .collect(toList())); 37 | return aggregate.markChangesAsCommitted(); 38 | } 39 | 40 | @Override 41 | public ShopItem getByUUID(UUID uuid) { 42 | return ShopItem.from(uuid, getRelatedEvents(uuid)); 43 | } 44 | 45 | @Override 46 | public ShopItem getByUUIDat(UUID uuid, Instant at) { 47 | return ShopItem. 48 | from(uuid, 49 | getRelatedEvents(uuid) 50 | .stream() 51 | .filter(evt -> !evt.when().isAfter(at)) 52 | .collect(toList())); 53 | } 54 | 55 | 56 | private List getRelatedEvents(UUID uuid) { 57 | return eventStore.getEventsForAggregate(uuid) 58 | .stream() 59 | .map(eventSerializer::deserialize) 60 | .collect(toList()); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/boundary/ShopItems.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.boundary; 2 | 3 | import io.pillopl.eventsource.shop.domain.ShopItem; 4 | import io.pillopl.eventsource.shop.domain.ShopItemRepository; 5 | import io.pillopl.eventsource.shop.domain.commands.Order; 6 | import io.pillopl.eventsource.shop.domain.commands.MarkPaymentTimeout; 7 | import io.pillopl.eventsource.shop.domain.commands.Pay; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.transaction.annotation.Transactional; 13 | 14 | import java.util.UUID; 15 | import java.util.function.UnaryOperator; 16 | 17 | @Service 18 | @Transactional 19 | @Slf4j 20 | public class ShopItems { 21 | 22 | private final ShopItemRepository itemRepository; 23 | private final int hoursToPaymentTimeout; 24 | 25 | @Autowired 26 | public ShopItems(ShopItemRepository itemRepository, @Value("${minutes.to.payment.timeout:1}") int hoursToPaymentTimeout) { 27 | this.itemRepository = itemRepository; 28 | this.hoursToPaymentTimeout = hoursToPaymentTimeout; 29 | } 30 | 31 | public void order(Order command) { 32 | withItem(command.getUuid(), item -> 33 | item.order(command.getUuid(), command.getWhen(), hoursToPaymentTimeout, command.getPrice()) 34 | ); 35 | log.info("{} item ordered at {}", command.getUuid(), command.getWhen()); 36 | } 37 | 38 | public void pay(Pay command) { 39 | withItem(command.getUuid(), item -> 40 | item.pay(command.getWhen()) 41 | ); 42 | log.info("{} item paid at {}", command.getUuid(), command.getWhen()); 43 | } 44 | 45 | public void markPaymentTimeout(MarkPaymentTimeout command) { 46 | withItem(command.getUuid(), item -> 47 | item.markTimeout(command.getWhen()) 48 | ); 49 | log.info("{} item marked as payment timeout at {}", command.getUuid(), command.getWhen()); 50 | } 51 | 52 | public ShopItem getByUUID(UUID uuid) { 53 | return itemRepository.getByUUID(uuid); 54 | } 55 | 56 | private ShopItem withItem(UUID uuid, UnaryOperator action) { 57 | final ShopItem item = getByUUID(uuid); 58 | final ShopItem modified = action.apply(item); 59 | return itemRepository.save(modified); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/Application.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop; 2 | 3 | import io.pillopl.eventsource.shop.boundary.ShopItems; 4 | import io.pillopl.eventsource.shop.domain.commands.Command; 5 | import io.pillopl.eventsource.shop.domain.commands.MarkPaymentTimeout; 6 | import io.pillopl.eventsource.shop.domain.commands.Order; 7 | import io.pillopl.eventsource.shop.domain.commands.Pay; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.SpringApplication; 11 | import org.springframework.boot.autoconfigure.SpringBootApplication; 12 | import org.springframework.cloud.stream.annotation.EnableBinding; 13 | import org.springframework.cloud.stream.annotation.StreamListener; 14 | import org.springframework.cloud.stream.messaging.Processor; 15 | import org.springframework.cloud.stream.messaging.Sink; 16 | import org.springframework.context.annotation.EnableAspectJAutoProxy; 17 | import org.springframework.scheduling.annotation.EnableScheduling; 18 | import org.springframework.scheduling.annotation.Scheduled; 19 | 20 | import java.math.BigDecimal; 21 | import java.time.Instant; 22 | import java.util.Random; 23 | import java.util.UUID; 24 | 25 | @SpringBootApplication 26 | @EnableAspectJAutoProxy(proxyTargetClass = true) 27 | @EnableScheduling 28 | @EnableBinding(Processor.class) 29 | @Slf4j 30 | public class Application { 31 | 32 | Random rand = new Random(); 33 | 34 | @Autowired 35 | ShopItems shopItems; 36 | 37 | public static void main(String[] args) { 38 | SpringApplication application = new SpringApplication(Application.class); 39 | application.run(args); 40 | } 41 | 42 | @Scheduled(fixedRate = 5000) 43 | public void randomOrders() { 44 | UUID uuid = UUID.randomUUID(); 45 | shopItems.order(new Order(uuid, new BigDecimal(rand.nextInt() % 100), Instant.now())); 46 | if(rand.nextBoolean()) { 47 | shopItems.pay(new Pay(uuid, Instant.now())); 48 | 49 | } 50 | } 51 | 52 | @StreamListener(Sink.INPUT) 53 | public void commandStream(Command command) { 54 | log.info("Received command {}", command); 55 | if (command instanceof MarkPaymentTimeout) { 56 | shopItems.markPaymentTimeout((MarkPaymentTimeout) command); 57 | } else if (command instanceof Order) { 58 | shopItems.order((Order) command); 59 | } else if (command instanceof Pay) { 60 | shopItems.pay((Pay) command); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/eventstore/EventSourcedShopItemRepositoryIntegrationSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration.eventstore 2 | 3 | import io.pillopl.eventsource.shop.domain.ShopItem 4 | import io.pillopl.eventsource.shop.eventstore.EventSourcedShopItemRepository 5 | import io.pillopl.eventsource.shop.integration.IntegrationSpec 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import spock.lang.Subject 8 | 9 | import java.time.Instant 10 | 11 | import static io.pillopl.eventsource.shop.ShopItemFixture.initialized 12 | import static io.pillopl.eventsource.shop.domain.ShopItemState.ORDERED 13 | import static io.pillopl.eventsource.shop.domain.ShopItemState.PAID 14 | import static java.time.LocalDate.now 15 | import static java.time.ZoneId.systemDefault 16 | import static java.time.temporal.ChronoUnit.DAYS 17 | 18 | class EventSourcedShopItemRepositoryIntegrationSpec extends IntegrationSpec { 19 | 20 | private static final UUID uuid = UUID.randomUUID() 21 | private static final int PAYMENT_DEADLINE_IN_HOURS = 48 22 | private static final Instant TODAY = now().atStartOfDay(systemDefault()).toInstant() 23 | private static final Instant TOMORROW = TODAY.plus(1, DAYS) 24 | private static final Instant DAY_AFTER_TOMORROW = TOMORROW.plus(1, DAYS) 25 | private static final BigDecimal ANY_PRICE = BigDecimal.TEN 26 | 27 | 28 | @Subject 29 | @Autowired 30 | EventSourcedShopItemRepository shopItemRepository 31 | 32 | def 'should store and load item'() { 33 | given: 34 | ShopItem stored = 35 | initialized() 36 | .order(uuid, TODAY, PAYMENT_DEADLINE_IN_HOURS, ANY_PRICE) 37 | when: 38 | shopItemRepository.save(stored) 39 | and: 40 | ShopItem loaded = shopItemRepository.getByUUID(uuid) 41 | then: 42 | loaded.uuid == stored.uuid 43 | loaded.state == stored.state 44 | } 45 | 46 | def 'should reconstruct item at given moment'() { 47 | given: 48 | ShopItem stored = initialized() 49 | .order(uuid, TOMORROW, PAYMENT_DEADLINE_IN_HOURS, ANY_PRICE) 50 | .pay(DAY_AFTER_TOMORROW) 51 | when: 52 | shopItemRepository.save(stored) 53 | and: 54 | ShopItem bought = shopItemRepository.getByUUIDat(uuid, TOMORROW) 55 | ShopItem paid = shopItemRepository.getByUUIDat(uuid, DAY_AFTER_TOMORROW) 56 | 57 | then: 58 | bought.state == ORDERED 59 | paid.state == PAID 60 | 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/changelog-master.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/E2ESpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration 2 | 3 | import io.pillopl.eventsource.shop.domain.events.ItemOrdered 4 | import io.pillopl.eventsource.shop.domain.events.ItemPaid 5 | import io.pillopl.eventsource.shop.domain.events.ItemPaymentTimeout 6 | import io.pillopl.eventsource.shop.eventstore.publisher.EventPublisher 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.cloud.stream.messaging.Sink 9 | import org.springframework.cloud.stream.messaging.Source 10 | import org.springframework.cloud.stream.test.binder.MessageCollector 11 | import org.springframework.messaging.Message 12 | import org.springframework.messaging.support.GenericMessage 13 | import spock.util.concurrent.PollingConditions 14 | 15 | import java.util.concurrent.BlockingQueue 16 | 17 | import static java.util.UUID.randomUUID 18 | 19 | class E2ESpec extends IntegrationSpec { 20 | 21 | private ANY_UUID = randomUUID() 22 | 23 | @Autowired Source source 24 | @Autowired Sink commands 25 | @Autowired MessageCollector eventsCollector 26 | @Autowired EventPublisher eventPublisher 27 | 28 | BlockingQueue> events 29 | PollingConditions conditions = new PollingConditions(timeout: 12, initialDelay: 0, factor: 1) 30 | 31 | def setup() { 32 | events = eventsCollector.forChannel(source.output()) 33 | } 34 | 35 | def 'received order command should result in emitted item ordered event'() { 36 | when: 37 | commands.input().send(new GenericMessage<>(sampleOrderInJson(ANY_UUID))) 38 | 39 | then: 40 | conditions.eventually { 41 | expectedMessageThatContains(ItemOrdered.TYPE) 42 | } 43 | } 44 | 45 | def 'received pay command should result in emitted item paid event'() { 46 | when: 47 | commands.input().send(new GenericMessage<>(sampleOrderInJson(ANY_UUID))) 48 | and: 49 | commands.input().send(new GenericMessage<>(samplePayInJson(ANY_UUID))) 50 | 51 | then: 52 | conditions.eventually { 53 | expectedMessageThatContains(ItemOrdered.TYPE) 54 | } 55 | and: 56 | conditions.eventually { 57 | expectedMessageThatContains(ItemPaid.TYPE) 58 | } 59 | } 60 | 61 | def 'received mark missing payment command should result in emitted marked as missed event'() { 62 | when: 63 | commands.input().send(new GenericMessage<>(sampleOrderInJson(ANY_UUID))) 64 | and: 65 | commands.input().send(new GenericMessage<>(sampleMarkTimeoutInJson(ANY_UUID))) 66 | 67 | then: 68 | conditions.eventually { 69 | expectedMessageThatContains(ItemOrdered.TYPE) 70 | } 71 | and: 72 | conditions.eventually { 73 | expectedMessageThatContains(ItemPaymentTimeout.TYPE) 74 | } 75 | } 76 | 77 | private static String sampleOrderInJson(UUID uuid) { 78 | return "{\"type\":\"item.order\",\"uuid\":\"$uuid\",\"when\":\"2016-10-06T10:28:23.956Z\"}" 79 | } 80 | 81 | private static String samplePayInJson(UUID uuid) { 82 | return "{\"type\":\"item.pay\",\"uuid\":\"$uuid\",\"when\":\"2016-10-06T10:29:23.956Z\"}" 83 | } 84 | 85 | private static String sampleMarkTimeoutInJson(UUID uuid) { 86 | return "{\"type\":\"item.markPaymentTimeout\",\"uuid\":\"$uuid\",\"when\":\"2016-10-06T10:29:23.956Z\"}" 87 | } 88 | 89 | void expectedMessageThatContains(String text) { 90 | Message msg = events.poll() 91 | println msg 92 | assert msg != null && msg.getPayload().contains(text) 93 | println "GOT IT: contains " + msg.getPayload().contains(text) 94 | assert msg.getPayload().contains(text) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/eventstore/EventStoreIntegrationSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration.eventstore 2 | 3 | import io.pillopl.eventsource.shop.boundary.ShopItems 4 | import io.pillopl.eventsource.shop.domain.events.ItemOrdered 5 | import io.pillopl.eventsource.shop.domain.events.ItemPaid 6 | import io.pillopl.eventsource.shop.domain.events.ItemPaymentTimeout 7 | import io.pillopl.eventsource.shop.integration.IntegrationSpec 8 | import io.pillopl.eventsource.shop.eventstore.EventStore 9 | import io.pillopl.eventsource.shop.eventstore.EventStream 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import spock.lang.Subject 12 | 13 | import static io.pillopl.eventsource.shop.CommandFixture.* 14 | 15 | class EventStoreIntegrationSpec extends IntegrationSpec { 16 | 17 | private final UUID uuid = UUID.randomUUID() 18 | 19 | @Subject @Autowired ShopItems shopItems 20 | 21 | @Autowired EventStore eventStore 22 | 23 | def 'should store item ordered event when create bought item command comes and no item yet'() { 24 | when: 25 | shopItems.order(orderItemCommand(uuid)) 26 | then: 27 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 28 | eventStream.isPresent() 29 | eventStream.get().getEvents()*.type == [ItemOrdered.TYPE] 30 | } 31 | 32 | def 'should store item paid event when paying for existing item'() { 33 | when: 34 | shopItems.order(orderItemCommand(uuid)) 35 | shopItems.pay(payItemCommand(uuid)) 36 | then: 37 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 38 | eventStream.isPresent() 39 | eventStream.get().getEvents()*.type == [ItemOrdered.TYPE, ItemPaid.TYPE] 40 | } 41 | 42 | def 'should store item paid event when receiving missed payment'() { 43 | when: 44 | shopItems.order(orderItemCommand(uuid)) 45 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 46 | shopItems.pay(payItemCommand(uuid)) 47 | then: 48 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 49 | eventStream.isPresent() 50 | eventStream.get().getEvents()*.type == 51 | [ItemOrdered.TYPE, ItemPaymentTimeout.TYPE, ItemPaid.TYPE] 52 | 53 | } 54 | 55 | def 'ordering an item should be idempotent - should store only 1 event'() { 56 | when: 57 | shopItems.order(orderItemCommand(uuid)) 58 | shopItems.order(orderItemCommand(uuid)) 59 | then: 60 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 61 | eventStream.isPresent() 62 | eventStream.get().getEvents()*.type == [ItemOrdered.TYPE] 63 | 64 | } 65 | 66 | def 'marking payment as missing should be idempotent - should store only 1 event'() { 67 | when: 68 | shopItems.order(orderItemCommand(uuid)) 69 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 70 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 71 | then: 72 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 73 | eventStream.isPresent() 74 | eventStream.get().getEvents()*.type == [ItemOrdered.TYPE, ItemPaymentTimeout.TYPE] 75 | } 76 | 77 | def 'paying should be idempotent - should store only 1 event'() { 78 | when: 79 | shopItems.order(orderItemCommand(uuid)) 80 | shopItems.pay(payItemCommand(uuid)) 81 | shopItems.pay(payItemCommand(uuid)) 82 | then: 83 | Optional eventStream = eventStore.findByAggregateUUID(uuid) 84 | eventStream.isPresent() 85 | eventStream.get().getEvents()*.type == [ItemOrdered.TYPE, ItemPaid.TYPE] 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/integration/boundary/ShopItemsIntegrationSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.integration.boundary 2 | 3 | import io.pillopl.eventsource.shop.boundary.ShopItems 4 | import io.pillopl.eventsource.shop.domain.ShopItem 5 | import io.pillopl.eventsource.shop.eventstore.EventStore 6 | import io.pillopl.eventsource.shop.integration.IntegrationSpec 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import spock.lang.Subject 9 | 10 | import static io.pillopl.eventsource.shop.CommandFixture.* 11 | import static io.pillopl.eventsource.shop.domain.ShopItemState.* 12 | 13 | class ShopItemsIntegrationSpec extends IntegrationSpec { 14 | 15 | private final UUID uuid = UUID.randomUUID() 16 | 17 | @Subject 18 | @Autowired 19 | ShopItems shopItems 20 | 21 | @Autowired 22 | EventStore eventStore 23 | 24 | def 'item should wait for payment when create ordered item command comes and no item yet'() { 25 | when: 26 | shopItems.order(orderItemCommand(uuid)) 27 | then: 28 | ShopItem tx = shopItems.getByUUID(uuid) 29 | tx.state == ORDERED 30 | } 31 | 32 | def 'item should be paid when paying for ordered item'() { 33 | when: 34 | shopItems.order(orderItemCommand(uuid)) 35 | shopItems.pay(payItemCommand(uuid)) 36 | then: 37 | ShopItem tx = shopItems.getByUUID(uuid) 38 | tx.state == PAID 39 | } 40 | 41 | def 'cannot pay for not ordered item'() { 42 | when: 43 | shopItems.pay(payItemCommand(uuid)) 44 | then: 45 | Exception e = thrown(IllegalStateException) 46 | e.message.contains("Cannot pay") 47 | } 48 | 49 | def 'item should be marked as payment timeout when payment did not come'() { 50 | when: 51 | shopItems.order(orderItemCommand(uuid)) 52 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 53 | then: 54 | ShopItem tx = shopItems.getByUUID(uuid) 55 | tx.state == PAYMENT_MISSING 56 | } 57 | 58 | def 'cannot mark payment missing when item already paid'() { 59 | when: 60 | shopItems.order(orderItemCommand(uuid)) 61 | shopItems.pay(payItemCommand(uuid)) 62 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 63 | then: 64 | Exception e = thrown(IllegalStateException) 65 | e.message.contains("Item already paid") 66 | } 67 | 68 | def 'cannot mark payment as missing when no item at all'() { 69 | when: 70 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 71 | then: 72 | Exception e = thrown(IllegalStateException) 73 | e.message.contains("Payment is not missing yet") 74 | } 75 | 76 | def 'item should be paid when receiving missed payment'() { 77 | when: 78 | shopItems.order(orderItemCommand(uuid)) 79 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 80 | shopItems.pay(payItemCommand(uuid)) 81 | then: 82 | ShopItem tx = shopItems.getByUUID(uuid) 83 | tx.state == PAID 84 | } 85 | 86 | def 'ordering an item should be idempotent'() { 87 | when: 88 | shopItems.order(orderItemCommand(uuid)) 89 | shopItems.order(orderItemCommand(uuid)) 90 | then: 91 | ShopItem tx = shopItems.getByUUID(uuid) 92 | tx.state == ORDERED 93 | } 94 | 95 | def 'marking payment as missing should be idempotent'() { 96 | when: 97 | shopItems.order(orderItemCommand(uuid)) 98 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 99 | shopItems.markPaymentTimeout(markPaymentTimeoutCommand(uuid)) 100 | then: 101 | ShopItem tx = shopItems.getByUUID(uuid) 102 | tx.state == PAYMENT_MISSING 103 | } 104 | 105 | def 'paying should be idempotent'() { 106 | when: 107 | shopItems.order(orderItemCommand(uuid)) 108 | shopItems.pay(payItemCommand(uuid)) 109 | shopItems.pay(payItemCommand(uuid)) 110 | then: 111 | ShopItem tx = shopItems.getByUUID(uuid) 112 | tx.state == PAID 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/io/pillopl/eventsource/shop/domain/ShopItem.java: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import io.pillopl.eventsource.shop.domain.events.DomainEvent; 5 | import io.pillopl.eventsource.shop.domain.events.ItemOrdered; 6 | import io.pillopl.eventsource.shop.domain.events.ItemPaid; 7 | import io.pillopl.eventsource.shop.domain.events.ItemPaymentTimeout; 8 | import lombok.Getter; 9 | import lombok.RequiredArgsConstructor; 10 | import lombok.experimental.Wither; 11 | 12 | import java.math.BigDecimal; 13 | import java.time.Instant; 14 | import java.time.temporal.ChronoUnit; 15 | import java.util.List; 16 | import java.util.UUID; 17 | 18 | @RequiredArgsConstructor 19 | @Getter 20 | @Wither 21 | public class ShopItem { 22 | 23 | private final UUID uuid; 24 | private final ImmutableList changes; 25 | private final ShopItemState state; 26 | 27 | public ShopItem pay(Instant when) { 28 | throwIfStateIs(ShopItemState.INITIALIZED, "Cannot pay for not ordered item"); 29 | if (state != ShopItemState.PAID) { 30 | return applyChange(new ItemPaid(uuid, when)); 31 | } else { 32 | return this; 33 | } 34 | } 35 | 36 | private ShopItem apply(ItemPaid event) { 37 | return this.withState(ShopItemState.PAID); 38 | } 39 | 40 | public ShopItem order(UUID uuid, Instant when, int minutesToPaymentTimeout, BigDecimal price) { 41 | if (state == ShopItemState.INITIALIZED) { 42 | return applyChange(new ItemOrdered(uuid, when, calculatePaymentTimeoutDate(when, minutesToPaymentTimeout), price)); 43 | } else { 44 | return this; 45 | } 46 | } 47 | 48 | private Instant calculatePaymentTimeoutDate(Instant boughtAt, int hoursToPaymentTimeout) { 49 | final Instant paymentTimeout = boughtAt.plus(hoursToPaymentTimeout, ChronoUnit.MINUTES); 50 | if (paymentTimeout.isBefore(boughtAt)) { 51 | throw new IllegalArgumentException("Payment timeout day is before ordering date!"); 52 | } 53 | return paymentTimeout; 54 | } 55 | 56 | public ShopItem markTimeout(Instant when) { 57 | throwIfStateIs(ShopItemState.INITIALIZED, "Payment is not missing yet"); 58 | throwIfStateIs(ShopItemState.PAID, "Item already paid"); 59 | if (state == ShopItemState.ORDERED) { 60 | return applyChange(new ItemPaymentTimeout(uuid, when)); 61 | } else { 62 | return this; 63 | } 64 | } 65 | 66 | private void throwIfStateIs(ShopItemState unexpectedState, String msg) { 67 | if (state == unexpectedState) { 68 | throw new IllegalStateException(msg + (" UUID: " + uuid)); 69 | } 70 | } 71 | 72 | public static ShopItem from(UUID uuid, List history) { 73 | return history 74 | .stream() 75 | .reduce( 76 | new ShopItem(uuid, ImmutableList.of(), ShopItemState.INITIALIZED), 77 | (tx, event) -> tx.applyChange(event, false), 78 | (t1, t2) -> {throw new UnsupportedOperationException();} 79 | ); 80 | } 81 | 82 | private ShopItem apply(ItemOrdered event) { 83 | return this 84 | .withUuid(event.getUuid()) 85 | .withState(ShopItemState.ORDERED); 86 | } 87 | 88 | private ShopItem apply(ItemPaymentTimeout event) { 89 | return this.withState(ShopItemState.PAYMENT_MISSING); 90 | } 91 | 92 | private ShopItem applyChange(DomainEvent event, boolean isNew) { 93 | final ShopItem item = this.apply(event); 94 | if (isNew) { 95 | return new ShopItem(item.getUuid(), appendChange(item, event), item.getState()); 96 | } else { 97 | return item; 98 | } 99 | } 100 | 101 | private ImmutableList appendChange(ShopItem item, DomainEvent event) { 102 | return ImmutableList 103 | .builder() 104 | .addAll(item.getChanges()) 105 | .add(event) 106 | .build(); 107 | } 108 | 109 | private ShopItem apply(DomainEvent event) { 110 | if (event instanceof ItemPaid) { 111 | return this.apply((ItemPaid) event); 112 | } else if (event instanceof ItemOrdered) { 113 | return this.apply((ItemOrdered) event); 114 | } else if (event instanceof ItemPaymentTimeout) { 115 | return this.apply((ItemPaymentTimeout) event); 116 | } else { 117 | throw new IllegalArgumentException("Cannot handle event " + event.getClass()); 118 | } 119 | } 120 | 121 | private ShopItem applyChange(DomainEvent event) { 122 | return applyChange(event, true); 123 | } 124 | 125 | public ImmutableList getUncommittedChanges() { 126 | return changes; 127 | } 128 | 129 | public ShopItem markChangesAsCommitted() { 130 | return this.withChanges(ImmutableList.of()); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/test/groovy/io/pillopl/eventsource/shop/domain/ShopItemSpec.groovy: -------------------------------------------------------------------------------- 1 | package io.pillopl.eventsource.shop.domain 2 | 3 | import io.pillopl.eventsource.shop.domain.events.ItemPaid 4 | import io.pillopl.eventsource.shop.domain.events.ItemOrdered 5 | import io.pillopl.eventsource.shop.domain.events.ItemPaymentTimeout 6 | import spock.lang.Specification 7 | import spock.lang.Unroll 8 | 9 | import static io.pillopl.eventsource.shop.ShopItemFixture.ordered 10 | import static io.pillopl.eventsource.shop.ShopItemFixture.initialized 11 | import static io.pillopl.eventsource.shop.ShopItemFixture.paid 12 | import static io.pillopl.eventsource.shop.ShopItemFixture.withTimeout 13 | import static io.pillopl.eventsource.shop.ShopItemFixture.withTimeoutAndPaid 14 | import static java.time.Instant.now 15 | import static java.time.Instant.parse 16 | 17 | @Unroll 18 | class ShopItemSpec extends Specification { 19 | 20 | private static final int PAYMENT_DEADLINE_IN_HOURS = 48 21 | private static final BigDecimal ANY_PRICE = BigDecimal.TEN 22 | private final UUID uuid = UUID.randomUUID() 23 | 24 | def 'should emit item ordered event when ordering initialized item'() { 25 | when: 26 | ShopItem item = initialized().order(uuid, now(), PAYMENT_DEADLINE_IN_HOURS, ANY_PRICE) 27 | then: 28 | item.getUncommittedChanges().size() == 1 29 | item.getUncommittedChanges().head().type() == ItemOrdered.TYPE 30 | } 31 | 32 | def 'should calculate #deadline when ordering at #orderingAt and expiration in days #expiresIn'() { 33 | when: 34 | ShopItem item = initialized().order(uuid, parse(orderingAt), expiresInMinutes, ANY_PRICE) 35 | then: 36 | ((ItemOrdered) item.getUncommittedChanges().head()).paymentTimeoutDate == parse(deadline) 37 | where: 38 | orderingAt | expiresInMinutes || deadline 39 | "1995-10-23T10:12:35Z" | 0 || "1995-10-23T10:12:35Z" 40 | "1995-10-23T10:12:35Z" | 1 || "1995-10-23T10:13:35Z" 41 | "1995-10-23T10:12:35Z" | 2 || "1995-10-23T10:14:35Z" 42 | "1995-10-23T10:12:35Z" | 20 || "1995-10-23T10:32:35Z" 43 | "1995-10-23T10:12:35Z" | 24 || "1995-10-23T10:36:35Z" 44 | } 45 | 46 | def 'Payment expiration date cannot be in the past'() { 47 | given: 48 | ShopItem item = initialized() 49 | when: 50 | item.order(uuid, now(), -1, ANY_PRICE) 51 | then: 52 | Exception e = thrown(IllegalArgumentException) 53 | e.message.contains("Payment timeout day is before ordering date") 54 | } 55 | 56 | def 'ordering an item should be idempotent'() { 57 | given: 58 | ShopItem item = ordered(uuid) 59 | when: 60 | item.order(uuid, now(), PAYMENT_DEADLINE_IN_HOURS, ANY_PRICE) 61 | then: 62 | item.getUncommittedChanges().isEmpty() 63 | } 64 | 65 | def 'cannot pay for just initialized item'() { 66 | given: 67 | ShopItem item = initialized() 68 | when: 69 | item.pay(now()) 70 | then: 71 | thrown(IllegalStateException) 72 | } 73 | 74 | def 'cannot mark payment timeout when item just initialized'() { 75 | given: 76 | ShopItem item = initialized() 77 | when: 78 | item.markTimeout(now()) 79 | then: 80 | thrown(IllegalStateException) 81 | } 82 | 83 | def 'should emit item paid event when paying for ordered item'() { 84 | when: 85 | ShopItem item = ordered(uuid).pay(now()) 86 | then: 87 | item.getUncommittedChanges().size() == 1 88 | item.getUncommittedChanges().head().type() == ItemPaid.TYPE 89 | } 90 | 91 | def 'paying for an item should be idempotent'() { 92 | given: 93 | ShopItem item = paid(uuid) 94 | when: 95 | item.pay(now()) 96 | then: 97 | item.getUncommittedChanges().isEmpty() 98 | } 99 | 100 | def 'should emit payment timeout event when marking item as payment missing'() { 101 | when: 102 | ShopItem item = ordered(uuid).markTimeout(now()) 103 | then: 104 | item.getUncommittedChanges().size() == 1 105 | item.getUncommittedChanges().head().type() == ItemPaymentTimeout.TYPE 106 | } 107 | 108 | def 'marking payment timeout should be idempotent'() { 109 | when: 110 | ShopItem item = withTimeout(uuid).markTimeout(now()) 111 | then: 112 | item.getUncommittedChanges().isEmpty() 113 | } 114 | 115 | def 'cannot mark payment missing when item already paid'() { 116 | when: 117 | paid(uuid).markTimeout(now()) 118 | then: 119 | thrown(IllegalStateException) 120 | } 121 | 122 | def 'should emit item paid event when receiving missed payment'() { 123 | when: 124 | ShopItem item = withTimeout(uuid).pay(now()) 125 | then: 126 | item.getUncommittedChanges().size() == 1 127 | item.getUncommittedChanges().head().type() == ItemPaid.TYPE 128 | 129 | } 130 | 131 | def 'receiving payment after timeout should be idempotent'() { 132 | when: 133 | ShopItem item = withTimeoutAndPaid(uuid).pay(now()) 134 | then: 135 | item.getUncommittedChanges().isEmpty() 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------