├── README.md ├── checkout-service ├── .gitignore ├── Dockerfile ├── build.gradle ├── classes │ └── production │ │ └── checkout-service │ │ └── application.yml ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── epages │ │ │ └── checkout │ │ │ ├── Cart.java │ │ │ ├── CartController.java │ │ │ ├── CartRepository.java │ │ │ ├── CheckoutServiceApplication.java │ │ │ ├── ProductLineItem.java │ │ │ ├── ProductRef.java │ │ │ ├── ProductRefRepository.java │ │ │ └── ProductSubscriber.java │ └── resources │ │ └── application.yml │ └── test │ └── java │ └── com │ └── epages │ └── checkout │ ├── CartApiTest.java │ ├── ProductSubscriberContractTest.java │ └── ProductSubscriberHardcodedPayloadTest.java ├── docker-compose.yml └── product-service ├── .gitignore ├── Dockerfile ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src ├── main ├── java │ └── com │ │ └── epages │ │ └── product │ │ ├── Product.java │ │ ├── ProductEventHandler.java │ │ ├── ProductRepository.java │ │ └── ProductServiceApplication.java └── resources │ └── application.yml └── test ├── java └── com │ └── epages │ └── product │ ├── ProductApiTest.java │ └── ProductPublisherTestBase.java └── resources └── contracts └── shouldProduceValidProductEvent.groovy /README.md: -------------------------------------------------------------------------------- 1 | # Testing asynchronous interactions in distributed systems using Spring Cloud Contract 2 | 3 | This is a sample application code of the blog post _Testing asynchronous interactions in distributed systems using Spring Cloud Contract_ [published in the epages developer blog](https://developer.epages.com/blog/2017/01/17/how-to-test-eventbased-services-using-contracts.html). 4 | 5 | It illustrates how [Spring Cloud Contract](https://cloud.spring.io/spring-cloud-contract/) can be used to test asynchronous service interactions. 6 | 7 | Please see the blog post for details. 8 | 9 | ## Run the application 10 | 11 | A `docker-compose` setup exists to start the `product-service`, the `checkout-service`, and RabbitMQ. 12 | 13 | First build the services to generate the jar files. And then use `docker-compose` to start the services. 14 | 15 | ```bash 16 | cd /product-service 17 | ./gradlew check bootRepackage 18 | ``` 19 | 20 | For the product service we also need to make sure that the contracts have been published to the local maven repository. 21 | 22 | ```bash 23 | ./gradlew publishToMavenLocal 24 | ``` 25 | 26 | ```bash 27 | cd /checkout-service 28 | ./gradlew check bootRepackage 29 | ``` 30 | 31 | ```bash 32 | docker-compose up -d 33 | ``` 34 | 35 | ## Create a Product and add it to a cart 36 | 37 | We use [httpie](https://github.com/jkbrzt/httpie) as command line HTTP client. 38 | 39 | *Create a product* 40 | 41 | ```bash 42 | http POST :8081/products name=some salesPrice=49.99 purchasePrice=39.99 43 | ``` 44 | 45 | *Create a cart* 46 | 47 | ```bash 48 | http POST :8082/carts 49 | ``` 50 | Use the uri in the location header for the next request 51 | 52 | *Add a product to the cart* 53 | 54 | The product data should be available in the cart now and we should be able to add it. 55 | ```bash 56 | http PUT :8082/carts/1 quantity=2 productId=1 57 | ``` 58 | -------------------------------------------------------------------------------- /checkout-service/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | 5 | ### STS ### 6 | .apt_generated 7 | .classpath 8 | .factorypath 9 | .project 10 | .settings 11 | .springBeans 12 | 13 | ### IntelliJ IDEA ### 14 | .idea 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### NetBeans ### 20 | nbproject/private/ 21 | build/ 22 | nbbuild/ 23 | dist/ 24 | nbdist/ 25 | .nb-gradle/ -------------------------------------------------------------------------------- /checkout-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM epages/java:8u111-jre 2 | 3 | COPY build/libs/checkout-service-0.0.1-SNAPSHOT.jar /app/app.jar 4 | -------------------------------------------------------------------------------- /checkout-service/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '1.4.3.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 10 | } 11 | } 12 | 13 | apply plugin: 'java' 14 | apply plugin: 'eclipse' 15 | apply plugin: 'org.springframework.boot' 16 | 17 | jar { 18 | baseName = 'checkout-service' 19 | version = '0.0.1-SNAPSHOT' 20 | } 21 | 22 | sourceCompatibility = 1.8 23 | 24 | repositories { 25 | mavenCentral() 26 | } 27 | 28 | 29 | dependencies { 30 | compile('org.springframework.boot:spring-boot-starter-amqp') 31 | compile('org.springframework.boot:spring-boot-starter-data-jpa') 32 | compile('org.springframework.boot:spring-boot-starter-hateoas') 33 | compile('org.springframework.boot:spring-boot-starter-web') 34 | compileOnly('org.projectlombok:lombok') 35 | runtime('com.h2database:h2') 36 | 37 | testCompile('org.springframework.boot:spring-boot-starter-test') 38 | testCompile ("org.springframework.cloud:spring-cloud-starter-contract-stub-runner:1.0.2.RELEASE") 39 | } 40 | -------------------------------------------------------------------------------- /checkout-service/classes/production/checkout-service/application.yml: -------------------------------------------------------------------------------- 1 | stubrunner: 2 | work-offline: true 3 | ids: 'com.epages:product-service' 4 | amqp: 5 | enabled: true 6 | 7 | spring: 8 | output: 9 | ansi: 10 | enabled: 'ALWAYS' -------------------------------------------------------------------------------- /checkout-service/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mduesterhoeft/testing-asynchonous-interactions/2056c1fc9d9f3eb3fdc8b7bbdb1fdacb30f9e7b0/checkout-service/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /checkout-service/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.13-bin.zip 6 | -------------------------------------------------------------------------------- /checkout-service/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 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 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /checkout-service/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 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/Cart.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import lombok.Data; 4 | import org.springframework.hateoas.Identifiable; 5 | 6 | import javax.persistence.*; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static javax.persistence.CascadeType.ALL; 11 | import static javax.persistence.GenerationType.IDENTITY; 12 | 13 | @Entity 14 | @Data 15 | public class Cart implements Identifiable { 16 | 17 | @Id 18 | @GeneratedValue(strategy = IDENTITY) 19 | private Long id; 20 | 21 | @OneToMany(cascade = ALL, orphanRemoval = true) 22 | private List lineItems = new ArrayList<>(); 23 | } 24 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/CartController.java: -------------------------------------------------------------------------------- 1 | /* / \____ _ _ ____ ______ / \ ____ __ _______ 2 | * / / \/ \ / \/ \ / /\__\/ // \/ \ // /\__\ JΛVΛSLΛNG 3 | * _/ / /\ \ \/ / /\ \\__\\ \ // /\ \ /\\/ \ /__\ \ Copyright 2014-2017 Javaslang, http://javaslang.io 4 | * /___/\_/ \_/\____/\_/ \_/\__\/__/\__\_/ \_// \__/\_____/ Licensed under the Apache License, Version 2.0 5 | */ 6 | package com.epages.checkout; 7 | 8 | import lombok.AllArgsConstructor; 9 | import lombok.Getter; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.hateoas.EntityLinks; 12 | import org.springframework.hateoas.ExposesResourceFor; 13 | import org.springframework.hateoas.Resource; 14 | import org.springframework.http.ResponseEntity; 15 | import org.springframework.web.bind.annotation.*; 16 | 17 | import javax.validation.Valid; 18 | import javax.validation.constraints.NotNull; 19 | import java.net.URI; 20 | import java.util.Optional; 21 | 22 | @RestController 23 | @RequestMapping("/carts") 24 | @RequiredArgsConstructor 25 | @ExposesResourceFor(Cart.class) 26 | public class CartController { 27 | 28 | private final CartRepository cartRepository; 29 | private final ProductRefRepository productRefRepository; 30 | private final EntityLinks entityLinks; 31 | 32 | @PostMapping 33 | public ResponseEntity createCart() { 34 | Cart cart = cartRepository.saveAndFlush(new Cart()); 35 | 36 | URI location = entityLinks.linkForSingleResource(cart).toUri(); 37 | return ResponseEntity.created(location).build(); 38 | } 39 | 40 | @GetMapping("/{cartId}") 41 | public ResponseEntity> getCart(@PathVariable Long cartId) { 42 | return Optional.ofNullable(cartRepository.findOne(cartId)) 43 | .map(cart -> new Resource<>(cart, entityLinks.linkForSingleResource(cart).withSelfRel())) 44 | .map(ResponseEntity::ok) 45 | .orElse(ResponseEntity.notFound().build()); 46 | } 47 | 48 | @PutMapping("/{cartId}") 49 | public ResponseEntity> addLineItem(@PathVariable Long cartId, 50 | @RequestBody @Valid AddLineItemRequest addLineItemRequest) { 51 | return Optional.ofNullable(cartRepository.findOne(cartId)) 52 | .map(cart -> addLineItemOrIncrementQuantity(cart, addLineItemRequest)) 53 | .map(cart -> cartRepository.saveAndFlush(cart)) 54 | .map(cart -> new Resource<>(cart, entityLinks.linkForSingleResource(cart).withSelfRel())) 55 | .map(ResponseEntity::ok) 56 | .orElse(ResponseEntity.notFound().build()); 57 | } 58 | 59 | private Cart addLineItemOrIncrementQuantity(Cart cart, AddLineItemRequest addLineItemRequest) { 60 | Optional existingLineItemForProduct = cart.getLineItems().stream() 61 | .filter(item -> item.getProduct().getId().equals(addLineItemRequest.getProductId())) 62 | .findFirst(); 63 | 64 | return existingLineItemForProduct.map(item -> { 65 | item.setQuantity(item.getQuantity() + addLineItemRequest.getQuantity()); 66 | return cart; 67 | }).orElseGet(() -> { 68 | ProductRef product = productRefRepository.findOne(addLineItemRequest.getProductId()); 69 | cart.getLineItems().add(ProductLineItem.builder() 70 | .product(product) 71 | .quantity(addLineItemRequest.getQuantity()).build()); 72 | return cart; 73 | }); 74 | } 75 | 76 | @AllArgsConstructor 77 | @Getter 78 | static class AddLineItemRequest { 79 | @NotNull 80 | private final int quantity; 81 | @NotNull 82 | private final Long productId; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/CartRepository.java: -------------------------------------------------------------------------------- 1 | /* / \____ _ _ ____ ______ / \ ____ __ _______ 2 | * / / \/ \ / \/ \ / /\__\/ // \/ \ // /\__\ JΛVΛSLΛNG 3 | * _/ / /\ \ \/ / /\ \\__\\ \ // /\ \ /\\/ \ /__\ \ Copyright 2014-2017 Javaslang, http://javaslang.io 4 | * /___/\_/ \_/\____/\_/ \_/\__\/__/\__\_/ \_// \__/\_____/ Licensed under the Apache License, Version 2.0 5 | */ 6 | package com.epages.checkout; 7 | 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | 10 | public interface CartRepository extends JpaRepository { 11 | } 12 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/CheckoutServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; 5 | import org.springframework.amqp.rabbit.connection.ConnectionFactory; 6 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 7 | import org.springframework.amqp.support.converter.ContentTypeDelegatingMessageConverter; 8 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; 9 | import org.springframework.amqp.support.converter.MessageConverter; 10 | import org.springframework.boot.SpringApplication; 11 | import org.springframework.boot.autoconfigure.SpringBootApplication; 12 | import org.springframework.context.annotation.Bean; 13 | 14 | import static org.springframework.amqp.core.MessageProperties.CONTENT_TYPE_JSON; 15 | 16 | @SpringBootApplication 17 | public class CheckoutServiceApplication { 18 | 19 | @Bean 20 | public MessageConverter messageConverter(ObjectMapper objectMapper) { 21 | final Jackson2JsonMessageConverter jsonMessageConverter = new Jackson2JsonMessageConverter(); 22 | jsonMessageConverter.setJsonObjectMapper(objectMapper); 23 | jsonMessageConverter.setCreateMessageIds(true); 24 | final ContentTypeDelegatingMessageConverter messageConverter = new ContentTypeDelegatingMessageConverter(jsonMessageConverter); 25 | messageConverter.addDelegate(CONTENT_TYPE_JSON, jsonMessageConverter); 26 | return messageConverter; 27 | } 28 | 29 | @Bean 30 | public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { 31 | RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); 32 | rabbitTemplate.setMessageConverter(messageConverter); 33 | return rabbitTemplate; 34 | } 35 | 36 | @Bean 37 | public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory, 38 | MessageConverter messageConverter) { 39 | SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); 40 | factory.setConnectionFactory(connectionFactory); 41 | factory.setConcurrentConsumers(3); 42 | factory.setMaxConcurrentConsumers(10); 43 | factory.setMessageConverter(messageConverter); 44 | return factory; 45 | } 46 | 47 | public static void main(String[] args) { 48 | SpringApplication.run(CheckoutServiceApplication.class, args); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/ProductLineItem.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import lombok.*; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.GeneratedValue; 7 | import javax.persistence.Id; 8 | import javax.persistence.ManyToOne; 9 | 10 | import static javax.persistence.GenerationType.IDENTITY; 11 | import static lombok.AccessLevel.PRIVATE; 12 | 13 | @Entity 14 | @Data 15 | @Builder 16 | @NoArgsConstructor(access = PRIVATE) 17 | @AllArgsConstructor 18 | public class ProductLineItem { 19 | 20 | @Id 21 | @GeneratedValue(strategy = IDENTITY) 22 | private Long id; 23 | 24 | private int quantity; 25 | 26 | @ManyToOne 27 | private ProductRef product; 28 | } 29 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/ProductRef.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import lombok.*; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | import java.math.BigDecimal; 8 | 9 | import static lombok.AccessLevel.NONE; 10 | import static lombok.AccessLevel.PRIVATE; 11 | 12 | @Entity 13 | @AllArgsConstructor 14 | @NoArgsConstructor(access = PRIVATE) //why JPA why? 15 | @Getter 16 | @Setter 17 | @ToString 18 | public class ProductRef { 19 | 20 | @Id 21 | @Setter(NONE) 22 | private Long id; 23 | 24 | private String name; 25 | 26 | private BigDecimal salesPrice; 27 | } -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/ProductRefRepository.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.checkout; 3 | 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ProductRefRepository extends JpaRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /checkout-service/src/main/java/com/epages/checkout/ProductSubscriber.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.checkout; 3 | 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.amqp.rabbit.annotation.Exchange; 7 | import org.springframework.amqp.rabbit.annotation.Queue; 8 | import org.springframework.amqp.rabbit.annotation.QueueBinding; 9 | import org.springframework.amqp.rabbit.annotation.RabbitListener; 10 | import org.springframework.stereotype.Component; 11 | 12 | import static org.springframework.amqp.core.ExchangeTypes.TOPIC; 13 | 14 | @Component 15 | @RequiredArgsConstructor 16 | @Slf4j 17 | public class ProductSubscriber { 18 | 19 | private final ProductRefRepository productRefRepository; 20 | 21 | @RabbitListener(bindings = @QueueBinding( 22 | value = @Queue(value = "test.queue"), 23 | exchange = @Exchange(value = "test-exchange", type = TOPIC), 24 | key="#")) 25 | public void handleProductEvent(ProductRef product) { 26 | ProductRef savedProduct = productRefRepository.save(product); 27 | log.info("saved product ref {}", savedProduct); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /checkout-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | stubrunner: 2 | work-offline: true 3 | ids: 'com.epages:product-service' 4 | amqp: 5 | enabled: true 6 | 7 | spring: 8 | output: 9 | ansi: 10 | enabled: 'ALWAYS' -------------------------------------------------------------------------------- /checkout-service/src/test/java/com/epages/checkout/CartApiTest.java: -------------------------------------------------------------------------------- 1 | /* / \____ _ _ ____ ______ / \ ____ __ _______ 2 | * / / \/ \ / \/ \ / /\__\/ // \/ \ // /\__\ JΛVΛSLΛNG 3 | * _/ / /\ \ \/ / /\ \\__\\ \ // /\ \ /\\/ \ /__\ \ Copyright 2014-2017 Javaslang, http://javaslang.io 4 | * /___/\_/ \_/\____/\_/ \_/\__\/__/\__\_/ \_// \__/\_____/ Licensed under the Apache License, Version 2.0 5 | */ 6 | package com.epages.checkout; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; 14 | import org.springframework.test.context.junit4.SpringRunner; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | import org.springframework.test.web.servlet.ResultActions; 17 | 18 | import java.math.BigDecimal; 19 | 20 | import static org.hamcrest.Matchers.*; 21 | import static org.springframework.http.HttpHeaders.LOCATION; 22 | import static org.springframework.http.MediaType.APPLICATION_JSON; 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 24 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 25 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 27 | 28 | @RunWith(SpringRunner.class) 29 | @SpringBootTest 30 | @AutoConfigureMockMvc 31 | @AutoConfigureMessageVerifier //just here to get the RabbitMockConnectionFactoryAutoConfiguration 32 | public class CartApiTest { 33 | 34 | @Autowired 35 | private MockMvc mockMvc; 36 | 37 | @Autowired 38 | private ProductRefRepository productRefRepository; 39 | 40 | private ProductRef product; 41 | private ResultActions resultActions; 42 | private String cartLocation; 43 | 44 | @Test 45 | public void should_add_product() throws Exception { 46 | givenProduct(); 47 | givenCart(); 48 | 49 | whenProductLineItemAdded(); 50 | 51 | resultActions 52 | .andExpect(status().isOk()) 53 | .andExpect(jsonPath("lineItems", hasSize(1))) 54 | .andExpect(jsonPath("lineItems[0].quantity", is(2))) 55 | .andExpect(jsonPath("lineItems[0].product.id", is(product.getId().intValue()))) 56 | ; 57 | } 58 | 59 | private void whenProductLineItemAdded() throws Exception { 60 | resultActions = mockMvc.perform(put(cartLocation) 61 | .contentType(APPLICATION_JSON) 62 | .content("{\"quantity\": 2, \"productId\": " + product.getId() + "}")) 63 | .andDo(print()); 64 | } 65 | 66 | private void givenCart() throws Exception { 67 | resultActions = mockMvc.perform(post("/carts")) 68 | .andExpect(status().isCreated()) 69 | .andExpect(header().string(LOCATION, not(isEmptyOrNullString()))) 70 | .andDo(print()); 71 | 72 | cartLocation = resultActions.andReturn().getResponse().getHeader(LOCATION); 73 | } 74 | 75 | private void givenProduct() { 76 | product = productRefRepository.saveAndFlush(new ProductRef(1L, "some", BigDecimal.valueOf(50))); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /checkout-service/src/test/java/com/epages/checkout/ProductSubscriberContractTest.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.cloud.contract.stubrunner.StubTrigger; 8 | import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; 9 | import org.springframework.test.context.junit4.SpringRunner; 10 | 11 | import static org.assertj.core.api.BDDAssertions.then; 12 | 13 | @RunWith(SpringRunner.class) 14 | @SpringBootTest(classes = CheckoutServiceApplication.class) 15 | @AutoConfigureStubRunner 16 | public class ProductSubscriberContractTest { 17 | 18 | @Autowired 19 | private StubTrigger stubTrigger; 20 | 21 | @Autowired 22 | private ProductRefRepository productRefRepository; 23 | 24 | 25 | @Test 26 | public void should_handle_product_created_event() { 27 | //emit an event from contract with label "product.created.event" 28 | stubTrigger.trigger("product.created.event"); 29 | 30 | then(productRefRepository.exists(9L)).isTrue(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /checkout-service/src/test/java/com/epages/checkout/ProductSubscriberHardcodedPayloadTest.java: -------------------------------------------------------------------------------- 1 | package com.epages.checkout; 2 | 3 | import static org.assertj.core.api.BDDAssertions.then; 4 | import static org.junit.Assert.fail; 5 | import static org.springframework.amqp.core.MessageProperties.CONTENT_TYPE_JSON; 6 | 7 | import java.util.stream.Stream; 8 | 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.springframework.amqp.core.Message; 12 | import org.springframework.amqp.core.MessageListener; 13 | import org.springframework.amqp.core.MessagePropertiesBuilder; 14 | import org.springframework.amqp.rabbit.listener.MessageListenerContainer; 15 | import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry; 16 | import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.boot.test.context.SpringBootTest; 19 | import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; 20 | import org.springframework.test.context.junit4.SpringRunner; 21 | 22 | @RunWith(SpringRunner.class) 23 | @SpringBootTest(classes = CheckoutServiceApplication.class) 24 | @AutoConfigureMessageVerifier //just here to get the RabbitMockConnectionFactoryAutoConfiguration 25 | public class ProductSubscriberHardcodedPayloadTest { 26 | 27 | @Autowired 28 | private RabbitListenerEndpointRegistry rabbitListenerEndpointRegistry; 29 | 30 | @Autowired 31 | private ProductRefRepository productRefRepository; 32 | 33 | @Test 34 | public void should_handle_product_created_event() { 35 | //GIVEN 36 | String payload = "{\n" + 37 | " \"id\": 8,\n" + 38 | " \"name\": \"Awesome Jeans\",\n" + 39 | " \"salesPrice\": 49.99\n" + 40 | "}"; 41 | 42 | //WHEN 43 | createAndEmitEvent(payload); 44 | 45 | //THEN 46 | then(productRefRepository.exists(8L)).isTrue(); 47 | } 48 | 49 | private void createAndEmitEvent(String payload) { 50 | Message message = org.springframework.amqp.core.MessageBuilder 51 | .withBody(payload.getBytes()) 52 | .andProperties( 53 | MessagePropertiesBuilder.newInstance() 54 | .setContentType(CONTENT_TYPE_JSON).build()) 55 | .build(); 56 | 57 | SimpleMessageListenerContainer listenerContainer = getListenerContainer(); 58 | 59 | ((MessageListener) listenerContainer.getMessageListener()).onMessage(message); 60 | } 61 | 62 | 63 | /** 64 | * rabbitListenerEndpointRegistry provides access to annotated listener methods. 65 | * We use it to find the listener bound to our test.queue 66 | * @return 67 | */ 68 | private SimpleMessageListenerContainer getListenerContainer() { 69 | 70 | for (MessageListenerContainer listenerContainer : this.rabbitListenerEndpointRegistry.getListenerContainers()) { 71 | if (listenerContainer instanceof SimpleMessageListenerContainer) { 72 | SimpleMessageListenerContainer simpleMessageListenerContainer = (SimpleMessageListenerContainer) listenerContainer; 73 | if (Stream.of(simpleMessageListenerContainer.getQueueNames()).anyMatch(queueName -> queueName.equals("test.queue"))) { 74 | return simpleMessageListenerContainer; 75 | } 76 | } 77 | } 78 | fail("No SimpleMessageListenerContainer found for test.queue"); 79 | return null; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | product: 3 | build: product-service/ 4 | links: 5 | - rabbitmq 6 | environment: 7 | - SPRING_RABBITMQ_HOST=rabbitmq 8 | ports: 9 | - "8081:8080" 10 | 11 | checkout: 12 | build: checkout-service/ 13 | links: 14 | - rabbitmq 15 | environment: 16 | - SPRING_RABBITMQ_HOST=rabbitmq 17 | ports: 18 | - "8082:8080" 19 | 20 | rabbitmq: 21 | image: rabbitmq:management 22 | ports: 23 | - "5672:5672" 24 | - "15672:15672" 25 | -------------------------------------------------------------------------------- /product-service/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | 5 | ### STS ### 6 | .apt_generated 7 | .classpath 8 | .factorypath 9 | .project 10 | .settings 11 | .springBeans 12 | 13 | ### IntelliJ IDEA ### 14 | .idea 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### NetBeans ### 20 | nbproject/private/ 21 | build/ 22 | nbbuild/ 23 | dist/ 24 | nbdist/ 25 | .nb-gradle/ -------------------------------------------------------------------------------- /product-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM epages/java:8u111-jre 2 | 3 | COPY build/libs/product-service-0.0.1-SNAPSHOT.jar /app/app.jar 4 | -------------------------------------------------------------------------------- /product-service/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '1.4.3.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 10 | classpath "org.springframework.cloud:spring-cloud-contract-gradle-plugin:1.0.2.RELEASE" 11 | } 12 | } 13 | 14 | apply plugin: 'java' 15 | apply plugin: 'groovy' 16 | apply plugin: 'eclipse' 17 | apply plugin: 'org.springframework.boot' 18 | apply plugin: 'spring-cloud-contract' 19 | apply plugin: 'maven-publish' 20 | 21 | jar { 22 | baseName = 'product-service' 23 | version = '0.0.1-SNAPSHOT' 24 | } 25 | 26 | sourceCompatibility = 1.8 27 | 28 | repositories { 29 | mavenCentral() 30 | } 31 | 32 | dependencies { 33 | compile('org.springframework.boot:spring-boot-starter-amqp') 34 | compile('org.springframework.boot:spring-boot-starter-data-jpa') 35 | compile('org.springframework.boot:spring-boot-starter-data-rest') 36 | compile('org.springframework.boot:spring-boot-starter-web') 37 | compileOnly('org.projectlombok:lombok') 38 | runtime('com.h2database:h2') 39 | 40 | testCompile('org.springframework.boot:spring-boot-starter-test') 41 | testCompile('org.springframework.cloud:spring-cloud-starter-contract-verifier:1.0.2.RELEASE') 42 | } 43 | 44 | ext { 45 | stubsOutputDirRoot = file("${project.buildDir}/stubs/") 46 | } 47 | 48 | task stubsJar(type: Jar, dependsOn: "generateWireMockClientStubs") { 49 | baseName = "${project.name}" 50 | classifier = "stubs" 51 | from stubsOutputDirRoot 52 | } 53 | 54 | artifacts { 55 | archives stubsJar 56 | } 57 | 58 | publishing { 59 | publications { 60 | stubs(MavenPublication) { 61 | artifactId "${project.name}" 62 | groupId "com.epages" 63 | artifact stubsJar 64 | } 65 | } 66 | } 67 | 68 | contracts { 69 | baseClassMappings { 70 | baseClassMapping('.*', 'com.epages.product.ProductPublisherTestBase') 71 | } 72 | stubsOutputDir = stubsOutputDirRoot 73 | } 74 | 75 | -------------------------------------------------------------------------------- /product-service/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mduesterhoeft/testing-asynchonous-interactions/2056c1fc9d9f3eb3fdc8b7bbdb1fdacb30f9e7b0/product-service/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /product-service/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.13-bin.zip 6 | -------------------------------------------------------------------------------- /product-service/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 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 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /product-service/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 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /product-service/src/main/java/com/epages/product/Product.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.product; 3 | 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import javax.persistence.Entity; 10 | import javax.persistence.GeneratedValue; 11 | import javax.persistence.Id; 12 | import java.math.BigDecimal; 13 | 14 | import static javax.persistence.GenerationType.IDENTITY; 15 | import static lombok.AccessLevel.NONE; 16 | import static lombok.AccessLevel.PRIVATE; 17 | 18 | @Entity 19 | @AllArgsConstructor 20 | @NoArgsConstructor(access = PRIVATE) //why JPA why? 21 | @Getter @Setter 22 | public class Product { 23 | 24 | @Id 25 | @GeneratedValue(strategy = IDENTITY) 26 | @Setter(NONE) 27 | private Long id; 28 | private String name; 29 | private BigDecimal salesPrice; 30 | private BigDecimal purchasePrice; 31 | } 32 | -------------------------------------------------------------------------------- /product-service/src/main/java/com/epages/product/ProductEventHandler.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.product; 3 | 4 | import lombok.RequiredArgsConstructor; 5 | 6 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 7 | import org.springframework.data.rest.core.annotation.HandleAfterCreate; 8 | import org.springframework.data.rest.core.annotation.HandleAfterSave; 9 | import org.springframework.data.rest.core.annotation.RepositoryEventHandler; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | @RepositoryEventHandler 14 | @RequiredArgsConstructor 15 | public class ProductEventHandler { 16 | 17 | private final RabbitTemplate rabbitTemplate; 18 | 19 | @HandleAfterCreate 20 | public void handleCreated(Product product) { 21 | this.rabbitTemplate.convertAndSend("test-exchange", "person.created.event", product); 22 | } 23 | 24 | @HandleAfterSave 25 | public void handleUpdate(Product product) { 26 | this.rabbitTemplate.convertAndSend("test-exchange", "person.updated.event", product); 27 | } 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /product-service/src/main/java/com/epages/product/ProductRepository.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.product; 3 | 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ProductRepository extends JpaRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /product-service/src/main/java/com/epages/product/ProductServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.epages.product; 2 | 3 | import org.springframework.amqp.rabbit.connection.ConnectionFactory; 4 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 5 | import org.springframework.amqp.support.converter.ContentTypeDelegatingMessageConverter; 6 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; 7 | import org.springframework.amqp.support.converter.MessageConverter; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.SpringApplication; 10 | import org.springframework.boot.autoconfigure.SpringBootApplication; 11 | import org.springframework.context.annotation.Bean; 12 | 13 | import com.fasterxml.jackson.databind.ObjectMapper; 14 | 15 | @SpringBootApplication 16 | public class ProductServiceApplication { 17 | 18 | @Autowired 19 | RabbitTemplate rabbitTemplate; 20 | 21 | @Bean 22 | public MessageConverter messageConverter(ObjectMapper objectMapper) { 23 | Jackson2JsonMessageConverter jsonMessageConverter = new Jackson2JsonMessageConverter(); 24 | jsonMessageConverter.setJsonObjectMapper(objectMapper); 25 | jsonMessageConverter.setCreateMessageIds(true); 26 | ContentTypeDelegatingMessageConverter messageConverter = new ContentTypeDelegatingMessageConverter(jsonMessageConverter); 27 | messageConverter.addDelegate("application/json", jsonMessageConverter); 28 | return messageConverter; 29 | } 30 | 31 | @Bean 32 | public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { 33 | RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); 34 | rabbitTemplate.setMessageConverter(messageConverter); 35 | return rabbitTemplate; 36 | } 37 | 38 | public static void main(String[] args) { 39 | SpringApplication.run(ProductServiceApplication.class, args); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /product-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | stubrunner: 2 | amqp: 3 | enabled: true 4 | 5 | spring: 6 | output: 7 | ansi: 8 | enabled: 'ALWAYS' 9 | -------------------------------------------------------------------------------- /product-service/src/test/java/com/epages/product/ProductApiTest.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.product; 3 | 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 8 | import org.springframework.amqp.rabbit.support.CorrelationData; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; 13 | import org.springframework.http.MediaType; 14 | import org.springframework.test.context.junit4.SpringRunner; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | 17 | import java.util.HashMap; 18 | 19 | import static org.mockito.BDDMockito.then; 20 | import static org.mockito.Matchers.any; 21 | import static org.mockito.Matchers.eq; 22 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | @RunWith(SpringRunner.class) 27 | @SpringBootTest(classes = ProductServiceApplication.class) 28 | @AutoConfigureMockMvc 29 | @AutoConfigureMessageVerifier 30 | public class ProductApiTest { 31 | 32 | @Autowired 33 | private RabbitTemplate rabbitTemplate; 34 | 35 | @Autowired 36 | private ObjectMapper objectMapper; 37 | 38 | @Autowired 39 | private MockMvc mockMvc; 40 | 41 | @Test 42 | public void should_create_product() throws Exception { 43 | HashMap jsonInput = new HashMap<>(); 44 | jsonInput.put("name", "Awesome Jeans"); 45 | jsonInput.put("salesPrice", 49.99); 46 | jsonInput.put("purchasePrice", 29.99); 47 | 48 | mockMvc.perform(post("/products") 49 | .content(objectMapper.writeValueAsString(jsonInput)) 50 | .contentType(MediaType.APPLICATION_JSON)) 51 | .andDo(print()) 52 | .andExpect(status().isCreated()); 53 | 54 | then(rabbitTemplate).should().convertAndSend(eq("test-exchange"), eq("person.created.event"), any(Product.class), any(CorrelationData.class)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /product-service/src/test/java/com/epages/product/ProductPublisherTestBase.java: -------------------------------------------------------------------------------- 1 | 2 | package com.epages.product; 3 | 4 | import org.junit.runner.RunWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; 8 | import org.springframework.test.context.junit4.SpringRunner; 9 | 10 | import java.math.BigDecimal; 11 | 12 | @RunWith(SpringRunner.class) 13 | @SpringBootTest(classes = ProductServiceApplication.class) 14 | @AutoConfigureMessageVerifier 15 | public abstract class ProductPublisherTestBase { 16 | 17 | @Autowired 18 | private ProductEventHandler productEventHandler; 19 | 20 | public void emitProductCreatedEvent() { 21 | Product product = new Product(1L, "Awesome Jeans", 22 | BigDecimal.valueOf(49.99), BigDecimal.valueOf(25.00)); 23 | productEventHandler.handleCreated(product); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /product-service/src/test/resources/contracts/shouldProduceValidProductEvent.groovy: -------------------------------------------------------------------------------- 1 | package contracts 2 | 3 | org.springframework.cloud.contract.spec.Contract.make { 4 | description 'should produce valid product event' 5 | label 'product.created.event' 6 | input { 7 | // the contract will be triggered by a method 8 | triggeredBy('emitProductCreatedEvent()') 9 | } 10 | outputMessage { 11 | sentTo 'test-exchange' 12 | headers { 13 | header('contentType': 'application/json') 14 | } 15 | body ([ 16 | id: $(consumer(9), producer(regex("[0-9]+"))), 17 | name: "Awesome Jeans", 18 | salesPrice: 49.99, 19 | purchasePrice: 25.00 20 | ]) 21 | } 22 | } 23 | --------------------------------------------------------------------------------