├── .gitignore ├── README.adoc ├── docs ├── README.adoc └── index.html ├── stock-quotes ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── spring │ │ │ └── workshop │ │ │ └── stockquotes │ │ │ ├── Quote.java │ │ │ ├── QuoteGenerator.java │ │ │ ├── QuoteHandler.java │ │ │ ├── QuoteRouter.java │ │ │ └── StockQuotesApplication.java │ └── resources │ │ └── application.properties │ └── test │ └── java │ └── io │ └── spring │ └── workshop │ └── stockquotes │ └── StockQuotesApplicationTests.java └── trading-service ├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── io │ │ └── spring │ │ └── workshop │ │ └── tradingservice │ │ ├── HomeController.java │ │ ├── Quote.java │ │ ├── QuotesController.java │ │ ├── TradingServiceApplication.java │ │ ├── TradingUser.java │ │ ├── TradingUserRepository.java │ │ ├── UserController.java │ │ ├── UsersCommandLineRunner.java │ │ └── websocket │ │ ├── EchoWebSocketHandler.java │ │ ├── WebSocketController.java │ │ └── WebSocketRouter.java └── resources │ ├── application.properties │ └── templates │ ├── index.html │ ├── quotes.html │ └── websocket.html └── test └── java └── io └── spring └── workshop └── tradingservice ├── TradingServiceApplicationTests.java ├── UserControllerTests.java └── websocket └── EchoWebSocketHandlerTests.java /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[op] 2 | .idea 3 | target 4 | 5 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Spring WebFlux Workshop 2 | 3 | This repository hosts a complete workshop on Spring Boot + Spring WebFlux. 4 | You can follow the script 5 | https://bclozel.github.io/webflux-workshop/[follow the script] 6 | (the source document is in the "docs" folder) to create your first 7 | WebFlux applications! 8 | Each step of this workshop has its companion commit in the git history with a detailed commit message. 9 | 10 | -------------------------------------------------------------------------------- /docs/README.adoc: -------------------------------------------------------------------------------- 1 | = Spring WebFlux Workshop 2 | Brian Clozel, Violeta Georgieva - Pivotal 3 | :sectanchors: true 4 | :source-highlighter: prettify 5 | :icons: font 6 | :toc: 7 | :spring-boot-version: 2.0.2.RELEASE 8 | :spring-framework-version: 5.0.6.RELEASE 9 | :spring-framework-doc-base: http://docs.spring.io/spring-framework/docs/{spring-framework-version} 10 | 11 | This repository hosts a complete workshop on Spring Boot + Spring WebFlux. 12 | Just follow this README and create your first WebFlux applications! 13 | Each step of this workshop has its companion commit in the git history with a detailed commit message. 14 | 15 | We'll create two applications: 16 | 17 | * `stock-quotes` is a functional WebFlux app which streams stock quotes 18 | * `trading-service` is an annotation-based WebFlux app using a datastore, HTML views, and several browser-related technologies 19 | 20 | Reference documentations can be useful while working on those apps: 21 | 22 | * http://projectreactor.io/docs[Reactor Core documentation] 23 | * Spring WebFlux 24 | {spring-framework-doc-base}/spring-framework-reference/web-reactive.html#spring-webflux[reference documentation] 25 | and {spring-framework-doc-base}/javadoc-api/[javadoc] 26 | 27 | 28 | == Stock Quotes application 29 | 30 | === Create this application 31 | 32 | Go to `https://start.spring.io` and create a Maven project with Spring Boot {spring-boot-version}, 33 | with groupId `io.spring.workshop` and artifactId `stock-quotes`. Select the `Reactive Web` 34 | Boot starter. 35 | Unzip the given file into a directory and import that application into your IDE. 36 | 37 | If generated right, you should have a main `Application` class that looks like this: 38 | 39 | [source,java] 40 | .stock-quotes/src/main/java/io/spring/workshop/stockquotes/StockQuotesApplication.java 41 | ---- 42 | include::../stock-quotes/src/main/java/io/spring/workshop/stockquotes/StockQuotesApplication.java[] 43 | ---- 44 | 45 | Edit your `application.properties` file to start the server on a specific port. 46 | 47 | [source,properties] 48 | .stock-quotes/src/main/resources/application.properties 49 | ---- 50 | include::../stock-quotes/src/main/resources/application.properties[] 51 | ---- 52 | 53 | Launching it from your IDE or with `mvn spring-boot:run` should start a Netty server on port 8081. 54 | You should see in the logs something like: 55 | 56 | [source,bash] 57 | ---- 58 | INFO 2208 --- [ restartedMain] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8081 59 | INFO 2208 --- [ restartedMain] i.s.w.s.StockQuotesApplication : Started StockQuotesApplication in 1.905 seconds (JVM running for 3.075) 60 | ---- 61 | 62 | === Create a Quote Generator 63 | 64 | To simulate real stock values, we'll create a generator that emits such values at a specific interval. 65 | Copy the following classes to your project. 66 | 67 | [source,java] 68 | .stock-quotes/src/main/java/io/spring/workshop/stockquotes/Quote.java 69 | ---- 70 | include::../stock-quotes/src/main/java/io/spring/workshop/stockquotes/Quote.java[] 71 | ---- 72 | 73 | [source,java] 74 | .stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteGenerator.java 75 | ---- 76 | include::../stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteGenerator.java[] 77 | ---- 78 | 79 | 80 | === Functional web applications with "WebFlux.fn" 81 | 82 | Spring WebFlux comes in two flavors of web applications: annotation based and functional. 83 | For this first application, we'll use the functional variant. 84 | 85 | Incoming HTTP requests are handled by a `HandlerFunction`, which is essentially a function 86 | that takes a ServerRequest and returns a `Mono`. The annotation counterpart 87 | to a handler function would be a Controller method. 88 | 89 | But how those incoming requests are routed to the right handler? 90 | 91 | We're using a `RouterFunction`, which is a function that takes a `ServerRequest`, and returns 92 | a `Mono`. If a request matches a particular route, a handler function is returned; 93 | otherwise it returns an empty `Mono`. The `RouterFunction` has a similar purpose as the `@RequestMapping` 94 | annotation in `@Controller` classes. 95 | 96 | Take a look at the code samples in 97 | {spring-framework-doc-base}/spring-framework-reference/web.html#web-reactive-server-functional[the Spring WebFlux.fn reference documentation] 98 | 99 | === Create your first HandlerFunction + RouterFunction 100 | 101 | First, create a `QuoteHandler` class and mark is as a `@Component`;this class will have all our handler functions as methods. 102 | Now create a `hello` handler function in that class that always returns "text/plain" HTTP responses with "Hello Spring!" as body. 103 | 104 | To route requests to that handler, you need to expose a `RouterFunction` to Spring Boot. 105 | Create a `QuoteRouter` configuration class (i.e. annotated with `@Configuration`) 106 | that creates a bean of type `RouterFunction`. 107 | 108 | Modify that class so that GET requests to `"/hello"` are routed to the handler you just implemented. 109 | 110 | TIP: Since `QuoteHandler` is a component, you can inject it in `@Bean` methods as a method parameter. 111 | 112 | Your application should now behave like this: 113 | [source,bash] 114 | ---- 115 | $ curl http://localhost:8081/hello -i 116 | HTTP/1.1 200 OK 117 | transfer-encoding: chunked 118 | Content-Type: text/plain;charset=UTF-8 119 | 120 | Hello Spring!% 121 | ---- 122 | 123 | Once done, add another endpoint: 124 | 125 | * with a HandlerFunction `echo` that echoes the request body in the response, as "text/plain" 126 | * and an additional route in our existing `RouterFunction` that accepts POST requests on 127 | `"/echo"` with a "text/plain" body and returns responses with the same content type. 128 | 129 | You can also use this new endpoint with: 130 | 131 | [source,bash] 132 | ---- 133 | $ curl http://localhost:8081/echo -i -d "WebFlux workshop" -H "Content-Type: text/plain" 134 | HTTP/1.1 200 OK 135 | transfer-encoding: chunked 136 | Content-Type: text/plain 137 | 138 | WebFlux workshop% 139 | ---- 140 | 141 | 142 | === Expose the Flux as a web service 143 | 144 | First, let's inject our `QuoteGenerator` instance in our `QuoteHandler`, instantiate 145 | a `Flux` from it that emits a `Quote` every 200 msec and can be **shared** between 146 | multiple subscribers (look at the `Flux` operators for that). This instance should be kept 147 | as an attribute for reusability. 148 | 149 | Now create a `streamQuotes` handler that streams those generated quotes 150 | with the `"application/stream+json"` content type. Add the corresponding part in the `RouterFunction`, 151 | on the `"/quotes"` endpoint. 152 | 153 | [source,bash] 154 | ---- 155 | $ curl http://localhost:8081/quotes -i -H "Accept: application/stream+json" 156 | HTTP/1.1 200 OK 157 | transfer-encoding: chunked 158 | Content-Type: application/stream+json 159 | 160 | {"ticker":"CTXS","price":84.0,"instant":1494841666.633000000} 161 | {"ticker":"DELL","price":67.1,"instant":1494841666.834000000} 162 | {"ticker":"GOOG","price":869,"instant":1494841667.034000000} 163 | {"ticker":"MSFT","price":66.5,"instant":1494841667.231000000} 164 | {"ticker":"ORCL","price":46.13,"instant":1494841667.433000000} 165 | {"ticker":"RHT","price":86.9,"instant":1494841667.634000000} 166 | {"ticker":"VMW","price":93.7,"instant":1494841667.833000000} 167 | ---- 168 | 169 | 170 | Let's now create a variant of that — instead of streaming all values (with an infinite stream), we can 171 | now take the last "n" elements of that `Flux` and return those as a collection of Quotes with 172 | the content type `"application/json"`. Note that you should take the requested number of Quotes 173 | from the request itself, with the query parameter named `"size"` (or pick `10` as the default size 174 | if none was provided). 175 | 176 | [source,bash] 177 | ---- 178 | curl http://localhost:8081/quotes -i -H "Accept: application/json" 179 | HTTP/1.1 200 OK 180 | transfer-encoding: chunked 181 | Content-Type: application/json 182 | 183 | [{"ticker":"CTXS","price":85.8,"instant":1494842241.716000000},{"ticker":"DELL","price":64.69,"instant":1494842241.913000000},{"ticker":"GOOG","price":856.5,"instant":1494842242.112000000},{"ticker":"MSFT","price":68.2,"instant":1494842242.317000000},{"ticker":"ORCL","price":47.4,"instant":1494842242.513000000},{"ticker":"RHT","price":85.6,"instant":1494842242.716000000},{"ticker":"VMW","price":96.1,"instant":1494842242.914000000},{"ticker":"CTXS","price":85.5,"instant":1494842243.116000000},{"ticker":"DELL","price":64.88,"instant":1494842243.316000000},{"ticker":"GOOG","price":889,"instant":1494842243.517000000}]% 184 | ---- 185 | 186 | 187 | === Integration tests with WebTestClient 188 | 189 | Spring WebFlux (actually the `spring-test` module) includes a `WebTestClient` 190 | that can be used to test WebFlux server endpoints with or without a running server. 191 | Tests without a running server are comparable to MockMvc from Spring MVC where mock request 192 | and response are used instead of connecting over the network using a socket. 193 | The WebTestClient however can also perform tests against a running server. 194 | 195 | You can check that your last endpoint is working properly with the following 196 | integration test: 197 | 198 | [source,java] 199 | .stock-quotes/src/test/java/io/spring/workshop/stockquotes/StockQuotesApplicationTests.java 200 | ---- 201 | include::../stock-quotes/src/test/java/io/spring/workshop/stockquotes/StockQuotesApplicationTests.java[] 202 | ---- 203 | 204 | == Trading Service application 205 | 206 | === Create this application 207 | 208 | Go to `https://start.spring.io` and create a Maven project with Spring Boot {spring-boot-version}, 209 | with groupId `io.spring.workshop` and artifactId `trading-service`. Select the `Reactive Web` 210 | , `Devtools`, `Thymeleaf` and `Reactive Mongo` Boot starters. 211 | Unzip the given file into a directory and import that application into your IDE. 212 | 213 | === Use Tomcat as a web engine 214 | 215 | By default, `spring-boot-starter-webflux` transitively brings `spring-boot-starter-reactor-netty` 216 | and Spring Boot auto-configures Reactor Netty as a web server. For this application, we'll use 217 | Tomcat as an alternative. 218 | 219 | [source,xml] 220 | .trading-service/pom.xml 221 | ---- 222 | include::../trading-service/pom.xml[tags=tomcat] 223 | ---- 224 | 225 | Note that Spring Boot supports as well Undertow and Jetty. 226 | 227 | === Use a reactive datastore 228 | 229 | In this application, we'll use a MongoDB datastore with its reactive driver; 230 | for this workshop, we'll use an in-memory instance of MongoDB. So add the following: 231 | 232 | [source,xml] 233 | .trading-service/pom.xml 234 | ---- 235 | include::../trading-service/pom.xml[tags=inMemMongo] 236 | ---- 237 | 238 | We'd like to manage `TradingUser` with our datastore. 239 | 240 | [source,java] 241 | .trading-service/src/main/java/io/spring/workshop/tradingservice/TradingUser.java 242 | ---- 243 | include::../trading-service/src/main/java/io/spring/workshop/tradingservice/TradingUser.java[] 244 | ---- 245 | 246 | Now create a `TradingUserRepository` interface that extends `ReactiveMongoRepository`. 247 | Add a `findByUserName(String userName)` method that returns a single `TradingUser` in a reactive fashion. 248 | 249 | We'd like to insert users in our datastore when the application starts up. For that, create a `UsersCommandLineRunner` 250 | component that implements Spring Boot's `CommandLineRunner`. In the `run` method, use the reactive repository 251 | to insert `TradingUser` instances in the datastore. 252 | 253 | NOTE: Since the `run` method returns void, it expects a blocking implementation. This is why you should use the 254 | `blockLast(Duration)` operator on the `Flux` returned by the repository when inserting data. 255 | You can also `then().block(Duration)` to turn that `Flux` into a `Mono` that waits for completion. 256 | 257 | === Create a JSON web service 258 | 259 | We're now going to expose `TradingUser` through a Controller. 260 | First, create a `UserController` annotated with `@RestController`. 261 | Then add two new Controller methods in order to handle: 262 | 263 | * GET requests to `"/users"`, returning all `TradingUser` instances, serializing them with content-type `"application/json"` 264 | * GET requests to `"/users/{username}"`, returning a single `TradingUser` instance, serializing it with content-type `"application/json"` 265 | 266 | You can now validate your implementation with the following test: 267 | 268 | [source,java] 269 | .trading-service/src/test/java/io/spring/workshop/tradingservice/UserControllerTests.java 270 | ---- 271 | include::../trading-service/src/test/java/io/spring/workshop/tradingservice/UserControllerTests.java[] 272 | ---- 273 | 274 | === Use Thymeleaf to render HTML views 275 | 276 | We already added the Thymeleaf Boot starter when we created our trading application. 277 | 278 | First, let's add a couple of WebJar dependencies to get static resources for our application: 279 | 280 | [source,xml] 281 | .trading-service/pom.xml 282 | ---- 283 | include::../trading-service/pom.xml[tags=webjars] 284 | ---- 285 | 286 | We can now create HTML templates in our `src/main/resources/templates` folder and map them using controllers. 287 | 288 | [source,html] 289 | .trading-service/src/main/resources/templates/index.html 290 | ---- 291 | include::../trading-service/src/main/resources/templates/index.html[] 292 | ---- 293 | 294 | As you can see in that template, we loop over the `"users"` attribute and write a row in our HTML table for each. 295 | 296 | Let's display those users in our application: 297 | 298 | * Create a `HomeController` Controller 299 | * Add a Controller method that handles GET requests to `"/"` 300 | * Inject the Spring `Model` on that method and add a `"users"` attribute to it 301 | 302 | NOTE: Spring WebFlux will resolve automatically `Publisher` instances before rendering the view, 303 | there's no need to involve blocking code at all! 304 | 305 | === Use the WebClient to stream JSON to the browser 306 | 307 | In this section, we'll call our remote `stock-quotes` service to get Quotes from it, so we first need to: 308 | 309 | * copy over the `Quote` class to this application 310 | * add the following template file to your application: 311 | 312 | [source,html] 313 | .trading-service/src/main/resources/templates/quotes.html 314 | ---- 315 | include::../trading-service/src/main/resources/templates/quotes.html[] 316 | ---- 317 | 318 | As you can see in this template file, loading that HTML page will cause the browser to send a request 319 | the server for `Quotes` using the Server Sent Event transport. 320 | 321 | Now create a `QuotesController` annotated with `@Controller` and add two methods. 322 | One that renders the `quotes.html` template for incoming `"GET /quotes"` requests. 323 | The other should response to `"GET /quotes/feed"` requests with the `"text/event-stream"` content-type, 324 | with a `Flux` as the response body. This data is already server by the `stock-quotes` application 325 | , so you can use a `WebClient` to request the remote web service to retrieve that `Flux`. 326 | 327 | TIP: You should avoid making a request to the `stock-quotes` service for every browser connecting to 328 | that page — for that, you can use the `Flux.share()` operator. 329 | 330 | === Create and Configure a WebSocketHandler 331 | 332 | WebFlux includes functional reactive WebSocket client and server support. 333 | 334 | On the server side there are two main components: `WebSocketHandlerAdapter` will handle the incoming 335 | requests by delegating to the configured `WebSocketService` and `WebSocketHandler` will be responsible 336 | to handle WebSocket session. 337 | 338 | Take a look at the code samples in 339 | {spring-framework-doc-base}/spring-framework-reference/web.html#web-reactive-websocket-support[Reactive WebSocket Support documentation] 340 | 341 | First, create an `EchoWebSocketHandler` class; it has to implement `WebSocketHandler`. 342 | Now implement `handle(WebSocketSession session)` method. The handler echoes the incoming messages with a delay of 1s. 343 | 344 | To route requests to that handler, you need to map the above WebSocket handler to a specific URL: here, `"/websocket/echo"`. 345 | Create a `WebSocketRouter` configuration class (i.e. annotated with `@Configuration`) that creates a bean of type `HandlerMapping`. 346 | Create one additional bean of type `WebSocketHandlerAdapter` which will delegate the processing of 347 | the incoming request to the default `WebSocketService` which is `HandshakeWebSocketService`. 348 | 349 | Now create a `WebSocketController` annotated with @Controller and add a method that renders the 350 | `websocket.html` template for incoming `"GET /websocket"` requests. 351 | 352 | Add the following template file to your application: 353 | 354 | [source,html] 355 | .trading-service/src/main/resources/templates/websocket.html 356 | ---- 357 | include::../trading-service/src/main/resources/templates/websocket.html[] 358 | ---- 359 | 360 | === Integration tests with WebSocketClient 361 | 362 | `WebSocketClient` included in Spring WebFlux can be used to test your WebSocket endpoints. 363 | 364 | You can check that your WebSocket endpoint, created in the previous section, is working properly with the 365 | following integration test: 366 | 367 | [source,java] 368 | .trading-service/src/test/java/io/spring/workshop/tradingservice/websocket/EchoWebSocketHandlerTests.java 369 | ---- 370 | include::../trading-service/src/test/java/io/spring/workshop/tradingservice/websocket/EchoWebSocketHandlerTests.java[] 371 | ---- 372 | 373 | == Additional Resources 374 | 375 | Talks on Spring Reactive: 376 | 377 | * https://www.youtube.com/watch?v=rdgJ8fOxJhc[Reactive Web Applications with Spring 5 (Rossen Stoyanchev)] 378 | * https://www.youtube.com/watch?v=Cj4foJzPF80[Developing Reactive applications with Reactive Streams and Java 8 (B.Clozel, S.Deleuze)] 379 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Spring WebFlux Workshop 10 | 11 | 428 | 429 | 430 | 431 | 465 |
466 |
467 |
468 |
469 |

This repository hosts a complete workshop on Spring Boot + Spring WebFlux. 470 | Just follow this README and create your first WebFlux applications! 471 | Each step of this workshop has its companion commit in the git history with a detailed commit message.

472 |
473 |
474 |

We’ll create two applications:

475 |
476 |
477 |
    478 |
  • 479 |

    stock-quotes is a functional WebFlux app which streams stock quotes

    480 |
  • 481 |
  • 482 |

    trading-service is an annotation-based WebFlux app using a datastore, HTML views, and several browser-related technologies

    483 |
  • 484 |
485 |
486 |
487 |

Reference documentations can be useful while working on those apps:

488 |
489 |
490 | 500 |
501 |
502 |
503 |
504 |

Stock Quotes application

505 |
506 |
507 |

Create this application

508 |
509 |

Go to https://start.spring.io and create a Maven project with Spring Boot 2.0.2.RELEASE, 510 | with groupId io.spring.workshop and artifactId stock-quotes. Select the Reactive Web 511 | Boot starter. 512 | Unzip the given file into a directory and import that application into your IDE.

513 |
514 |
515 |

If generated right, you should have a main Application class that looks like this:

516 |
517 |
518 |
stock-quotes/src/main/java/io/spring/workshop/stockquotes/StockQuotesApplication.java
519 |
520 |
package io.spring.workshop.stockquotes;
 521 | 
 522 | import org.springframework.boot.SpringApplication;
 523 | import org.springframework.boot.autoconfigure.SpringBootApplication;
 524 | 
 525 | @SpringBootApplication
 526 | public class StockQuotesApplication {
 527 | 
 528 | 	public static void main(String[] args) {
 529 | 		SpringApplication.run(StockQuotesApplication.class, args);
 530 | 	}
 531 | }
532 |
533 |
534 |
535 |

Edit your application.properties file to start the server on a specific port.

536 |
537 |
538 |
stock-quotes/src/main/resources/application.properties
539 |
540 |
server.port=8081
541 |
542 |
543 |
544 |

Launching it from your IDE or with mvn spring-boot:run should start a Netty server on port 8081. 545 | You should see in the logs something like:

546 |
547 |
548 |
549 |
INFO 2208 --- [  restartedMain] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port(s): 8081
 550 | INFO 2208 --- [  restartedMain] i.s.w.s.StockQuotesApplication           : Started StockQuotesApplication in 1.905 seconds (JVM running for 3.075)
551 |
552 |
553 |
554 |
555 |

Create a Quote Generator

556 |
557 |

To simulate real stock values, we’ll create a generator that emits such values at a specific interval. 558 | Copy the following classes to your project.

559 |
560 |
561 |
stock-quotes/src/main/java/io/spring/workshop/stockquotes/Quote.java
562 |
563 |
package io.spring.workshop.stockquotes;
 564 | 
 565 | import java.math.BigDecimal;
 566 | import java.math.MathContext;
 567 | import java.time.Instant;
 568 | 
 569 | public class Quote {
 570 | 
 571 | 	private static final MathContext MATH_CONTEXT = new MathContext(2);
 572 | 
 573 | 	private String ticker;
 574 | 
 575 | 	private BigDecimal price;
 576 | 
 577 | 	private Instant instant;
 578 | 
 579 | 	public Quote() {
 580 | 	}
 581 | 
 582 | 	public Quote(String ticker, BigDecimal price) {
 583 | 		this.ticker = ticker;
 584 | 		this.price = price;
 585 | 	}
 586 | 
 587 | 	public Quote(String ticker, Double price) {
 588 | 		this(ticker, new BigDecimal(price, MATH_CONTEXT));
 589 | 	}
 590 | 
 591 | 	public String getTicker() {
 592 | 		return ticker;
 593 | 	}
 594 | 
 595 | 	public void setTicker(String ticker) {
 596 | 		this.ticker = ticker;
 597 | 	}
 598 | 
 599 | 	public BigDecimal getPrice() {
 600 | 		return price;
 601 | 	}
 602 | 
 603 | 	public void setPrice(BigDecimal price) {
 604 | 		this.price = price;
 605 | 	}
 606 | 
 607 | 	public Instant getInstant() {
 608 | 		return instant;
 609 | 	}
 610 | 
 611 | 	public void setInstant(Instant instant) {
 612 | 		this.instant = instant;
 613 | 	}
 614 | 
 615 | 	@Override
 616 | 	public String toString() {
 617 | 		return "Quote{" +
 618 | 				"ticker='" + ticker + '\'' +
 619 | 				", price=" + price +
 620 | 				", instant=" + instant +
 621 | 				'}';
 622 | 	}
 623 | }
624 |
625 |
626 |
627 |
stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteGenerator.java
628 |
629 |
package io.spring.workshop.stockquotes;
 630 | 
 631 | import java.math.BigDecimal;
 632 | import java.math.MathContext;
 633 | import java.time.Duration;
 634 | import java.time.Instant;
 635 | import java.util.ArrayList;
 636 | import java.util.List;
 637 | import java.util.Random;
 638 | import java.util.stream.Collectors;
 639 | 
 640 | import reactor.core.publisher.Flux;
 641 | 
 642 | import org.springframework.stereotype.Component;
 643 | 
 644 | @Component
 645 | public class QuoteGenerator {
 646 | 
 647 | 	private final MathContext mathContext = new MathContext(2);
 648 | 
 649 | 	private final Random random = new Random();
 650 | 
 651 | 	private final List<Quote> prices = new ArrayList<>();
 652 | 
 653 | 	/**
 654 | 	 * Bootstraps the generator with tickers and initial prices
 655 | 	 */
 656 | 	public QuoteGenerator() {
 657 | 		this.prices.add(new Quote("CTXS", 82.26));
 658 | 		this.prices.add(new Quote("DELL", 63.74));
 659 | 		this.prices.add(new Quote("GOOG", 847.24));
 660 | 		this.prices.add(new Quote("MSFT", 65.11));
 661 | 		this.prices.add(new Quote("ORCL", 45.71));
 662 | 		this.prices.add(new Quote("RHT", 84.29));
 663 | 		this.prices.add(new Quote("VMW", 92.21));
 664 | 	}
 665 | 
 666 | 
 667 | 	public Flux<Quote> fetchQuoteStream(Duration period) {
 668 | 
 669 | 		// We want to emit quotes with a specific period;
 670 | 		// to do so, we create a Flux.interval
 671 | 		return Flux.interval(period)
 672 | 				// In case of back-pressure, drop events
 673 | 				.onBackpressureDrop()
 674 | 				// For each tick, generate a list of quotes
 675 | 				.map(this::generateQuotes)
 676 | 				// "flatten" that List<Quote> into a Flux<Quote>
 677 | 				.flatMapIterable(quotes -> quotes)
 678 | 				.log("io.spring.workshop.stockquotes");
 679 | 	}
 680 | 
 681 | 	/*
 682 | 	 * Create quotes for all tickers at a single instant.
 683 | 	 */
 684 | 	private List<Quote> generateQuotes(long interval) {
 685 | 		final Instant instant = Instant.now();
 686 | 		return prices.stream()
 687 | 				.map(baseQuote -> {
 688 | 					BigDecimal priceChange = baseQuote.getPrice()
 689 | 							.multiply(new BigDecimal(0.05 * this.random.nextDouble()), this.mathContext);
 690 | 					Quote result = new Quote(baseQuote.getTicker(), baseQuote.getPrice().add(priceChange));
 691 | 					result.setInstant(instant);
 692 | 					return result;
 693 | 				})
 694 | 				.collect(Collectors.toList());
 695 | 	}
 696 | 
 697 | }
698 |
699 |
700 |
701 |
702 |

Functional web applications with "WebFlux.fn"

703 |
704 |

Spring WebFlux comes in two flavors of web applications: annotation based and functional. 705 | For this first application, we’ll use the functional variant.

706 |
707 |
708 |

Incoming HTTP requests are handled by a HandlerFunction, which is essentially a function 709 | that takes a ServerRequest and returns a Mono<ServerResponse>. The annotation counterpart 710 | to a handler function would be a Controller method.

711 |
712 |
713 |

But how those incoming requests are routed to the right handler?

714 |
715 |
716 |

We’re using a RouterFunction, which is a function that takes a ServerRequest, and returns 717 | a Mono<HandlerFunction>. If a request matches a particular route, a handler function is returned; 718 | otherwise it returns an empty Mono. The RouterFunction has a similar purpose as the @RequestMapping 719 | annotation in @Controller classes.

720 |
721 |
722 |

Take a look at the code samples in 723 | the Spring WebFlux.fn reference documentation

724 |
725 |
726 |
727 |

Create your first HandlerFunction + RouterFunction

728 |
729 |

First, create a QuoteHandler class and mark is as a @Component;this class will have all our handler functions as methods. 730 | Now create a hello handler function in that class that always returns "text/plain" HTTP responses with "Hello Spring!" as body.

731 |
732 |
733 |

To route requests to that handler, you need to expose a RouterFunction to Spring Boot. 734 | Create a QuoteRouter configuration class (i.e. annotated with @Configuration) 735 | that creates a bean of type RouterFunction<ServerResponse>.

736 |
737 |
738 |

Modify that class so that GET requests to "/hello" are routed to the handler you just implemented.

739 |
740 |
741 | 742 | 743 | 746 | 749 | 750 |
744 | 745 | 747 | Since QuoteHandler is a component, you can inject it in @Bean methods as a method parameter. 748 |
751 |
752 |
753 |

Your application should now behave like this:

754 |
755 |
756 |
757 |
$ curl http://localhost:8081/hello -i
 758 | HTTP/1.1 200 OK
 759 | transfer-encoding: chunked
 760 | Content-Type: text/plain;charset=UTF-8
 761 | 
 762 | Hello Spring!%
763 |
764 |
765 |
766 |

Once done, add another endpoint:

767 |
768 |
769 |
    770 |
  • 771 |

    with a HandlerFunction echo that echoes the request body in the response, as "text/plain"

    772 |
  • 773 |
  • 774 |

    and an additional route in our existing RouterFunction that accepts POST requests on 775 | "/echo" with a "text/plain" body and returns responses with the same content type.

    776 |
  • 777 |
778 |
779 |
780 |

You can also use this new endpoint with:

781 |
782 |
783 |
784 |
$ curl http://localhost:8081/echo -i -d "WebFlux workshop" -H "Content-Type: text/plain"
 785 | HTTP/1.1 200 OK
 786 | transfer-encoding: chunked
 787 | Content-Type: text/plain
 788 | 
 789 | WebFlux workshop%
790 |
791 |
792 |
793 |
794 |

Expose the Flux<Quotes> as a web service

795 |
796 |

First, let’s inject our QuoteGenerator instance in our QuoteHandler, instantiate 797 | a Flux<Quote> from it that emits a Quote every 200 msec and can be shared between 798 | multiple subscribers (look at the Flux operators for that). This instance should be kept 799 | as an attribute for reusability.

800 |
801 |
802 |

Now create a streamQuotes handler that streams those generated quotes 803 | with the "application/stream+json" content type. Add the corresponding part in the RouterFunction, 804 | on the "/quotes" endpoint.

805 |
806 |
807 |
808 |
$ curl http://localhost:8081/quotes -i -H "Accept: application/stream+json"
 809 | HTTP/1.1 200 OK
 810 | transfer-encoding: chunked
 811 | Content-Type: application/stream+json
 812 | 
 813 | {"ticker":"CTXS","price":84.0,"instant":1494841666.633000000}
 814 | {"ticker":"DELL","price":67.1,"instant":1494841666.834000000}
 815 | {"ticker":"GOOG","price":869,"instant":1494841667.034000000}
 816 | {"ticker":"MSFT","price":66.5,"instant":1494841667.231000000}
 817 | {"ticker":"ORCL","price":46.13,"instant":1494841667.433000000}
 818 | {"ticker":"RHT","price":86.9,"instant":1494841667.634000000}
 819 | {"ticker":"VMW","price":93.7,"instant":1494841667.833000000}
820 |
821 |
822 |
823 |

Let’s now create a variant of that — instead of streaming all values (with an infinite stream), we can 824 | now take the last "n" elements of that Flux and return those as a collection of Quotes with 825 | the content type "application/json". Note that you should take the requested number of Quotes 826 | from the request itself, with the query parameter named "size" (or pick 10 as the default size 827 | if none was provided).

828 |
829 |
830 |
831 |
curl http://localhost:8081/quotes -i -H "Accept: application/json"
 832 | HTTP/1.1 200 OK
 833 | transfer-encoding: chunked
 834 | Content-Type: application/json
 835 | 
 836 | [{"ticker":"CTXS","price":85.8,"instant":1494842241.716000000},{"ticker":"DELL","price":64.69,"instant":1494842241.913000000},{"ticker":"GOOG","price":856.5,"instant":1494842242.112000000},{"ticker":"MSFT","price":68.2,"instant":1494842242.317000000},{"ticker":"ORCL","price":47.4,"instant":1494842242.513000000},{"ticker":"RHT","price":85.6,"instant":1494842242.716000000},{"ticker":"VMW","price":96.1,"instant":1494842242.914000000},{"ticker":"CTXS","price":85.5,"instant":1494842243.116000000},{"ticker":"DELL","price":64.88,"instant":1494842243.316000000},{"ticker":"GOOG","price":889,"instant":1494842243.517000000}]%
837 |
838 |
839 |
840 |
841 |

Integration tests with WebTestClient

842 |
843 |

Spring WebFlux (actually the spring-test module) includes a WebTestClient 844 | that can be used to test WebFlux server endpoints with or without a running server. 845 | Tests without a running server are comparable to MockMvc from Spring MVC where mock request 846 | and response are used instead of connecting over the network using a socket. 847 | The WebTestClient however can also perform tests against a running server.

848 |
849 |
850 |

You can check that your last endpoint is working properly with the following 851 | integration test:

852 |
853 |
854 |
stock-quotes/src/test/java/io/spring/workshop/stockquotes/StockQuotesApplicationTests.java
855 |
856 |
package io.spring.workshop.stockquotes;
 857 | 
 858 | import java.util.List;
 859 | 
 860 | import org.junit.Test;
 861 | import org.junit.runner.RunWith;
 862 | 
 863 | import org.springframework.beans.factory.annotation.Autowired;
 864 | import org.springframework.boot.test.context.SpringBootTest;
 865 | import org.springframework.http.MediaType;
 866 | import org.springframework.test.context.junit4.SpringRunner;
 867 | import org.springframework.test.web.reactive.server.WebTestClient;
 868 | 
 869 | import static org.assertj.core.api.Assertions.assertThat;
 870 | 
 871 | @RunWith(SpringRunner.class)
 872 | //  We create a `@SpringBootTest`, starting an actual server on a `RANDOM_PORT`
 873 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
 874 | public class StockQuotesApplicationTests {
 875 | 
 876 | 	// Spring Boot will create a `WebTestClient` for you,
 877 | 	// already configure and ready to issue requests against "localhost:RANDOM_PORT"
 878 | 	@Autowired
 879 | 	private WebTestClient webTestClient;
 880 | 
 881 | 	@Test
 882 | 	public void fetchQuotes() {
 883 | 		webTestClient
 884 | 				// We then create a GET request to test an endpoint
 885 | 				.get().uri("/quotes?size=20")
 886 | 				.accept(MediaType.APPLICATION_JSON)
 887 | 				.exchange()
 888 | 				// and use the dedicated DSL to test assertions against the response
 889 | 				.expectStatus().isOk()
 890 | 				.expectHeader().contentType(MediaType.APPLICATION_JSON)
 891 | 				.expectBodyList(Quote.class)
 892 | 				.hasSize(20)
 893 | 				// Here we check that all Quotes have a positive price value
 894 | 				.consumeWith(allQuotes ->
 895 | 						assertThat(allQuotes.getResponseBody())
 896 | 								.allSatisfy(quote -> assertThat(quote.getPrice()).isPositive()));
 897 | 	}
 898 | 
 899 | 	@Test
 900 | 	public void fetchQuotesAsStream() {
 901 | 		List<Quote> result = webTestClient
 902 | 				// We then create a GET request to test an endpoint
 903 | 				.get().uri("/quotes")
 904 | 				// this time, accepting "application/stream+json"
 905 | 				.accept(MediaType.APPLICATION_STREAM_JSON)
 906 | 				.exchange()
 907 | 				// and use the dedicated DSL to test assertions against the response
 908 | 				.expectStatus().isOk()
 909 | 				.expectHeader().contentType(MediaType.APPLICATION_STREAM_JSON)
 910 | 				.returnResult(Quote.class)
 911 | 				.getResponseBody()
 912 | 				.take(30)
 913 | 				.collectList()
 914 | 				.block();
 915 | 
 916 | 		assertThat(result).allSatisfy(quote -> assertThat(quote.getPrice()).isPositive());
 917 | 	}
 918 | 
 919 | }
920 |
921 |
922 |
923 |
924 |
925 |
926 |

Trading Service application

927 |
928 |
929 |

Create this application

930 |
931 |

Go to https://start.spring.io and create a Maven project with Spring Boot 2.0.2.RELEASE, 932 | with groupId io.spring.workshop and artifactId trading-service. Select the Reactive Web 933 | , Devtools, Thymeleaf and Reactive Mongo Boot starters. 934 | Unzip the given file into a directory and import that application into your IDE.

935 |
936 |
937 |
938 |

Use Tomcat as a web engine

939 |
940 |

By default, spring-boot-starter-webflux transitively brings spring-boot-starter-reactor-netty 941 | and Spring Boot auto-configures Reactor Netty as a web server. For this application, we’ll use 942 | Tomcat as an alternative.

943 |
944 |
945 |
trading-service/pom.xml
946 |
947 |
		<dependency>
 948 | 			<groupId>org.springframework.boot</groupId>
 949 | 			<artifactId>spring-boot-starter-tomcat</artifactId>
 950 | 		</dependency>
951 |
952 |
953 |
954 |

Note that Spring Boot supports as well Undertow and Jetty.

955 |
956 |
957 |
958 |

Use a reactive datastore

959 |
960 |

In this application, we’ll use a MongoDB datastore with its reactive driver; 961 | for this workshop, we’ll use an in-memory instance of MongoDB. So add the following:

962 |
963 |
964 |
trading-service/pom.xml
965 |
966 |
		<dependency>
 967 | 			<groupId>de.flapdoodle.embed</groupId>
 968 | 			<artifactId>de.flapdoodle.embed.mongo</artifactId>
 969 | 		</dependency>
970 |
971 |
972 |
973 |

We’d like to manage TradingUser with our datastore.

974 |
975 |
976 |
trading-service/src/main/java/io/spring/workshop/tradingservice/TradingUser.java
977 |
978 |
package io.spring.workshop.tradingservice;
 979 | 
 980 | import org.springframework.data.annotation.Id;
 981 | import org.springframework.data.mongodb.core.mapping.Document;
 982 | 
 983 | @Document
 984 | public class TradingUser {
 985 | 
 986 | 	@Id
 987 | 	private String id;
 988 | 
 989 | 	private String userName;
 990 | 
 991 | 	private String fullName;
 992 | 
 993 | 	public TradingUser() {
 994 | 	}
 995 | 
 996 | 	public TradingUser(String id, String userName, String fullName) {
 997 | 		this.id = id;
 998 | 		this.userName = userName;
 999 | 		this.fullName = fullName;
1000 | 	}
1001 | 
1002 | 	public TradingUser(String userName, String fullName) {
1003 | 		this.userName = userName;
1004 | 		this.fullName = fullName;
1005 | 	}
1006 | 
1007 | 	public String getId() {
1008 | 		return id;
1009 | 	}
1010 | 
1011 | 	public void setId(String id) {
1012 | 		this.id = id;
1013 | 	}
1014 | 
1015 | 	public String getUserName() {
1016 | 		return userName;
1017 | 	}
1018 | 
1019 | 	public void setUserName(String userName) {
1020 | 		this.userName = userName;
1021 | 	}
1022 | 
1023 | 	public String getFullName() {
1024 | 		return fullName;
1025 | 	}
1026 | 
1027 | 	public void setFullName(String fullName) {
1028 | 		this.fullName = fullName;
1029 | 	}
1030 | 
1031 | 	@Override
1032 | 	public boolean equals(Object o) {
1033 | 		if (this == o) return true;
1034 | 		if (o == null || getClass() != o.getClass()) return false;
1035 | 
1036 | 		TradingUser that = (TradingUser) o;
1037 | 
1038 | 		if (!id.equals(that.id)) return false;
1039 | 		return userName.equals(that.userName);
1040 | 	}
1041 | 
1042 | 	@Override
1043 | 	public int hashCode() {
1044 | 		int result = id.hashCode();
1045 | 		result = 31 * result + userName.hashCode();
1046 | 		return result;
1047 | 	}
1048 | }
1049 |
1050 |
1051 |
1052 |

Now create a TradingUserRepository interface that extends ReactiveMongoRepository. 1053 | Add a findByUserName(String userName) method that returns a single TradingUser in a reactive fashion.

1054 |
1055 |
1056 |

We’d like to insert users in our datastore when the application starts up. For that, create a UsersCommandLineRunner 1057 | component that implements Spring Boot’s CommandLineRunner. In the run method, use the reactive repository 1058 | to insert TradingUser instances in the datastore.

1059 |
1060 |
1061 | 1062 | 1063 | 1066 | 1071 | 1072 |
1064 | 1065 | 1067 | Since the run method returns void, it expects a blocking implementation. This is why you should use the 1068 | blockLast(Duration) operator on the Flux returned by the repository when inserting data. 1069 | You can also then().block(Duration) to turn that Flux into a Mono<Void> that waits for completion. 1070 |
1073 |
1074 |
1075 |
1076 |

Create a JSON web service

1077 |
1078 |

We’re now going to expose TradingUser through a Controller. 1079 | First, create a UserController annotated with @RestController. 1080 | Then add two new Controller methods in order to handle:

1081 |
1082 |
1083 |
    1084 |
  • 1085 |

    GET requests to "/users", returning all TradingUser instances, serializing them with content-type "application/json"

    1086 |
  • 1087 |
  • 1088 |

    GET requests to "/users/{username}", returning a single TradingUser instance, serializing it with content-type "application/json"

    1089 |
  • 1090 |
1091 |
1092 |
1093 |

You can now validate your implementation with the following test:

1094 |
1095 |
1096 |
trading-service/src/test/java/io/spring/workshop/tradingservice/UserControllerTests.java
1097 |
1098 |
package io.spring.workshop.tradingservice;
1099 | 
1100 | import org.junit.Test;
1101 | import org.junit.runner.RunWith;
1102 | import org.mockito.BDDMockito;
1103 | import reactor.core.publisher.Flux;
1104 | import reactor.core.publisher.Mono;
1105 | 
1106 | import org.springframework.beans.factory.annotation.Autowired;
1107 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
1108 | import org.springframework.boot.test.mock.mockito.MockBean;
1109 | import org.springframework.http.MediaType;
1110 | import org.springframework.test.context.junit4.SpringRunner;
1111 | import org.springframework.test.web.reactive.server.WebTestClient;
1112 | 
1113 | @RunWith(SpringRunner.class)
1114 | @WebFluxTest(UserController.class)
1115 | public class UserControllerTests {
1116 | 
1117 |   @Autowired
1118 |   private WebTestClient webTestClient;
1119 | 
1120 |   @MockBean
1121 |   private TradingUserRepository repository;
1122 | 
1123 |   @Test
1124 |   public void listUsers() {
1125 |     TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller");
1126 |     TradingUser andy = new TradingUser("2", "wilkinsona", "Andy Wilkinson");
1127 | 
1128 |     BDDMockito.given(this.repository.findAll())
1129 |         .willReturn(Flux.just(juergen, andy));
1130 | 
1131 |     this.webTestClient.get().uri("/users").accept(MediaType.APPLICATION_JSON)
1132 |         .exchange()
1133 |         .expectBodyList(TradingUser.class)
1134 |         .hasSize(2)
1135 |         .contains(juergen, andy);
1136 | 
1137 |   }
1138 | 
1139 |   @Test
1140 |   public void showUser() {
1141 |     TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller");
1142 | 
1143 |     BDDMockito.given(this.repository.findByUserName("jhoeller"))
1144 |         .willReturn(Mono.just(juergen));
1145 | 
1146 |     this.webTestClient.get().uri("/users/jhoeller").accept(MediaType.APPLICATION_JSON)
1147 |         .exchange()
1148 |         .expectBody(TradingUser.class)
1149 |         .isEqualTo(juergen);
1150 |   }
1151 | 
1152 | }
1153 |
1154 |
1155 |
1156 |
1157 |

Use Thymeleaf to render HTML views

1158 |
1159 |

We already added the Thymeleaf Boot starter when we created our trading application.

1160 |
1161 |
1162 |

First, let’s add a couple of WebJar dependencies to get static resources for our application:

1163 |
1164 |
1165 |
trading-service/pom.xml
1166 |
1167 |
		<dependency>
1168 | 			<groupId>org.webjars</groupId>
1169 | 			<artifactId>bootstrap</artifactId>
1170 | 			<version>3.3.7</version>
1171 | 		</dependency>
1172 | 		<dependency>
1173 | 			<groupId>org.webjars</groupId>
1174 | 			<artifactId>highcharts</artifactId>
1175 | 			<version>5.0.8</version>
1176 | 		</dependency>
1177 |
1178 |
1179 |
1180 |

We can now create HTML templates in our src/main/resources/templates folder and map them using controllers.

1181 |
1182 |
1183 |
trading-service/src/main/resources/templates/index.html
1184 |
1185 |
<!DOCTYPE html>
1186 | <html lang="en" xmlns:th="http://www.thymeleaf.org">
1187 | <head>
1188 |     <meta charset="utf-8"/>
1189 |     <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
1190 |     <meta name="viewport" content="width=device-width, initial-scale=1"/>
1191 |     <meta name="description" content="Spring WebFlux Workshop"/>
1192 |     <meta name="author" content="Violeta Georgieva and Brian Clozel"/>
1193 |     <title>Spring Trading application</title>
1194 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
1195 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
1196 | </head>
1197 | <body>
1198 | <nav class="navbar navbar-default">
1199 |     <div class="container-fluid">
1200 |         <div class="navbar-header">
1201 |             <a class="navbar-brand" href="/">Spring Trading application</a>
1202 |         </div>
1203 |         <div id="navbar" class="navbar-collapse collapse">
1204 |             <ul class="nav navbar-nav">
1205 |                 <li class="active"><a href="/">Home</a></li>
1206 |                 <li><a href="/quotes">Quotes</a></li>
1207 |                 <li><a href="/websocket">Websocket</a></li>
1208 |             </ul>
1209 |         </div>
1210 |     </div>
1211 | </nav>
1212 | <div class="container wrapper">
1213 |     <h2>Trading users</h2>
1214 |     <table class="table table-striped">
1215 |         <thead>
1216 |         <tr>
1217 |             <th>#</th>
1218 |             <th>User name</th>
1219 |             <th>Full name</th>
1220 |         </tr>
1221 |         </thead>
1222 |         <tbody>
1223 |         <tr th:each="user: ${users}">
1224 |             <th scope="row" th:text="${user.id}">42</th>
1225 |             <td th:text="${user.userName}">janedoe</td>
1226 |             <td th:text="${user.fullName}">Jane Doe</td>
1227 |         </tr>
1228 |         </tbody>
1229 |     </table>
1230 | </div>
1231 | <script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
1232 | <script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
1233 | </body>
1234 | </html>
1235 |
1236 |
1237 |
1238 |

As you can see in that template, we loop over the "users" attribute and write a row in our HTML table for each.

1239 |
1240 |
1241 |

Let’s display those users in our application:

1242 |
1243 |
1244 |
    1245 |
  • 1246 |

    Create a HomeController Controller

    1247 |
  • 1248 |
  • 1249 |

    Add a Controller method that handles GET requests to "/"

    1250 |
  • 1251 |
  • 1252 |

    Inject the Spring Model on that method and add a "users" attribute to it

    1253 |
  • 1254 |
1255 |
1256 |
1257 | 1258 | 1259 | 1262 | 1266 | 1267 |
1260 | 1261 | 1263 | Spring WebFlux will resolve automatically Publisher instances before rendering the view, 1264 | there’s no need to involve blocking code at all! 1265 |
1268 |
1269 |
1270 |
1271 |

Use the WebClient to stream JSON to the browser

1272 |
1273 |

In this section, we’ll call our remote stock-quotes service to get Quotes from it, so we first need to:

1274 |
1275 |
1276 |
    1277 |
  • 1278 |

    copy over the Quote class to this application

    1279 |
  • 1280 |
  • 1281 |

    add the following template file to your application:

    1282 |
  • 1283 |
1284 |
1285 |
1286 |
trading-service/src/main/resources/templates/quotes.html
1287 |
1288 |
<!DOCTYPE html>
1289 | <html lang="en" xmlns:th="http://www.thymeleaf.org">
1290 | <head>
1291 |     <meta charset="utf-8"/>
1292 |     <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
1293 |     <meta name="viewport" content="width=device-width, initial-scale=1"/>
1294 |     <meta name="description" content="Spring WebFlux Workshop"/>
1295 |     <meta name="author" content="Violeta Georgieva and Brian Clozel"/>
1296 |     <title>Spring Trading application</title>
1297 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
1298 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
1299 |     <link rel="stylesheet" href="/webjars/highcharts/5.0.8/css/highcharts.css"/>
1300 | </head>
1301 | <body>
1302 | <nav class="navbar navbar-default">
1303 |     <div class="container-fluid">
1304 |         <div class="navbar-header">
1305 |             <a class="navbar-brand" href="/">Spring Trading application</a>
1306 |         </div>
1307 |         <div id="navbar" class="navbar-collapse collapse">
1308 |             <ul class="nav navbar-nav">
1309 |                 <li><a href="/">Home</a></li>
1310 |                 <li class="active"><a href="/quotes">Quotes</a></li>
1311 |                 <li><a href="/websocket">Websocket</a></li>
1312 |             </ul>
1313 |         </div>
1314 |     </div>
1315 | </nav>
1316 | <div class="container wrapper">
1317 |     <div id="chart" style="height: 400px; min-width: 310px"></div>
1318 | </div>
1319 | <script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
1320 | <script type="text/javascript" src="/webjars/highcharts/5.0.8/highcharts.js"></script>
1321 | <script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
1322 | <script type="text/javascript">
1323 | 
1324 |     // Setting up the chart
1325 |     var chart = new Highcharts.Chart('chart', {
1326 |         title: {
1327 |             text: 'My Stock Portfolio'
1328 |         },
1329 |         yAxis: {
1330 |             title: {
1331 |                 text: 'Stock Price'
1332 |             }
1333 |         },
1334 |         legend: {
1335 |             layout: 'vertical',
1336 |             align: 'right',
1337 |             verticalAlign: 'middle'
1338 |         },
1339 |         xAxis: {
1340 |             type: 'datetime'
1341 |         },
1342 |         series: [{
1343 |             name: 'CTXS',
1344 |             data: []
1345 |         }, {
1346 |             name: 'MSFT',
1347 |             data: []
1348 |         }, {
1349 |             name: 'ORCL',
1350 |             data: []
1351 |         }, {
1352 |             name: 'RHT',
1353 |             data: []
1354 |         }, {
1355 |             name: 'VMW',
1356 |             data: []
1357 |         }, {
1358 |             name: 'DELL',
1359 |             data: []
1360 |         }]
1361 |     });
1362 | 
1363 |     // This function adds the given data point to the chart
1364 |     var appendStockData = function (quote) {
1365 |         chart.series
1366 |             .filter(function (serie) {
1367 |                 return serie.name === quote.ticker
1368 |             })
1369 |             .forEach(function (serie) {
1370 |                 var shift = serie.data.length > 40;
1371 |                 serie.addPoint([Date.parse(quote.instant), quote.price], true, shift);
1372 |             });
1373 |     };
1374 | 
1375 |     // The browser connects to the server and receives quotes using ServerSentEvents
1376 |     // those quotes are appended to the chart as they're received
1377 |     var stockEventSource = new EventSource("/quotes/feed");
1378 |     stockEventSource.onmessage = function (e) {
1379 |         appendStockData(JSON.parse(e.data));
1380 |     };
1381 | </script>
1382 | </body>
1383 | </html>
1384 |
1385 |
1386 |
1387 |

As you can see in this template file, loading that HTML page will cause the browser to send a request 1388 | the server for Quotes using the Server Sent Event transport.

1389 |
1390 |
1391 |

Now create a QuotesController annotated with @Controller and add two methods. 1392 | One that renders the quotes.html template for incoming "GET /quotes" requests. 1393 | The other should response to "GET /quotes/feed" requests with the "text/event-stream" content-type, 1394 | with a Flux<Quote> as the response body. This data is already server by the stock-quotes application 1395 | , so you can use a WebClient to request the remote web service to retrieve that Flux.

1396 |
1397 |
1398 | 1399 | 1400 | 1403 | 1407 | 1408 |
1401 | 1402 | 1404 | You should avoid making a request to the stock-quotes service for every browser connecting to 1405 | that page — for that, you can use the Flux.share() operator. 1406 |
1409 |
1410 |
1411 |
1412 |

Create and Configure a WebSocketHandler

1413 |
1414 |

WebFlux includes functional reactive WebSocket client and server support.

1415 |
1416 |
1417 |

On the server side there are two main components: WebSocketHandlerAdapter will handle the incoming 1418 | requests by delegating to the configured WebSocketService and WebSocketHandler will be responsible 1419 | to handle WebSocket session.

1420 |
1421 |
1422 |

Take a look at the code samples in 1423 | Reactive WebSocket Support documentation

1424 |
1425 |
1426 |

First, create an EchoWebSocketHandler class; it has to implement WebSocketHandler. 1427 | Now implement handle(WebSocketSession session) method. The handler echoes the incoming messages with a delay of 1s.

1428 |
1429 |
1430 |

To route requests to that handler, you need to map the above WebSocket handler to a specific URL: here, "/websocket/echo". 1431 | Create a WebSocketRouter configuration class (i.e. annotated with @Configuration) that creates a bean of type HandlerMapping. 1432 | Create one additional bean of type WebSocketHandlerAdapter which will delegate the processing of 1433 | the incoming request to the default WebSocketService which is HandshakeWebSocketService.

1434 |
1435 |
1436 |

Now create a WebSocketController annotated with @Controller and add a method that renders the 1437 | websocket.html template for incoming "GET /websocket" requests.

1438 |
1439 |
1440 |

Add the following template file to your application:

1441 |
1442 |
1443 |
trading-service/src/main/resources/templates/websocket.html
1444 |
1445 |
<!DOCTYPE html>
1446 | <html lang="en" xmlns:th="http://www.thymeleaf.org">
1447 | <head>
1448 |     <meta charset="utf-8"/>
1449 |     <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
1450 |     <meta name="viewport" content="width=device-width, initial-scale=1"/>
1451 |     <meta name="description" content="Spring WebFlux Workshop"/>
1452 |     <meta name="author" content="Violeta Georgieva and Brian Clozel"/>
1453 |     <title>Spring Trading application</title>
1454 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
1455 |     <link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
1456 | </head>
1457 | <body>
1458 | <nav class="navbar navbar-default">
1459 |     <div class="container-fluid">
1460 |         <div class="navbar-header">
1461 |             <a class="navbar-brand" href="/">Spring Trading application</a>
1462 |         </div>
1463 |         <div id="navbar" class="navbar-collapse collapse">
1464 |             <ul class="nav navbar-nav">
1465 |                 <li><a href="/">Home</a></li>
1466 |                 <li><a href="/quotes">Quotes</a></li>
1467 |                 <li class="active"><a href="/websocket">Websocket</a></li>
1468 |             </ul>
1469 |         </div>
1470 |     </div>
1471 | </nav>
1472 | <div class="container wrapper">
1473 |     <h2>Websocket Echo</h2>
1474 |     <form class="form-inline">
1475 |         <div class="form-group">
1476 |             <input class="form-control" type="text" id="input" value="type something">
1477 |             <input class="btn btn-default" type="submit" id="button" value="Send"/>
1478 |         </div>
1479 |     </form>
1480 |     <div id="output"></div>
1481 | </div>
1482 | <script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
1483 | <script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
1484 | <script type="text/javascript">
1485 |     $(document).ready(function () {
1486 |         if (!("WebSocket" in window)) WebSocket = MozWebSocket;
1487 |         var socket = new WebSocket("ws://localhost:8080/websocket/echo");
1488 | 
1489 |         socket.onopen = function (event) {
1490 |             var newMessage = document.createElement('p');
1491 |             newMessage.textContent = "-- CONNECTED";
1492 |             document.getElementById('output').appendChild(newMessage);
1493 | 
1494 |             socket.onmessage = function (e) {
1495 |                 var newMessage = document.createElement('p');
1496 |                 newMessage.textContent = "<< SERVER: " + e.data;
1497 |                 document.getElementById('output').appendChild(newMessage);
1498 |             }
1499 | 
1500 |             $("#button").click(function (e) {
1501 |                 e.preventDefault();
1502 |                 var message = $("#input").val();
1503 |                 socket.send(message);
1504 |                 var newMessage = document.createElement('p');
1505 |                 newMessage.textContent = ">> CLIENT: " + message;
1506 |                 document.getElementById('output').appendChild(newMessage);
1507 |             });
1508 |         }
1509 |     });
1510 | </script>
1511 | </body>
1512 | </html>
1513 |
1514 |
1515 |
1516 |
1517 |

Integration tests with WebSocketClient

1518 |
1519 |

WebSocketClient included in Spring WebFlux can be used to test your WebSocket endpoints.

1520 |
1521 |
1522 |

You can check that your WebSocket endpoint, created in the previous section, is working properly with the 1523 | following integration test:

1524 |
1525 |
1526 |
trading-service/src/test/java/io/spring/workshop/tradingservice/websocket/EchoWebSocketHandlerTests.java
1527 |
1528 |
package io.spring.workshop.tradingservice.websocket;
1529 | 
1530 | import static org.junit.Assert.assertEquals;
1531 | 
1532 | import java.net.URI;
1533 | import java.net.URISyntaxException;
1534 | import java.time.Duration;
1535 | 
1536 | import org.junit.Test;
1537 | import org.junit.runner.RunWith;
1538 | import org.springframework.boot.test.context.SpringBootTest;
1539 | import org.springframework.boot.web.server.LocalServerPort;
1540 | import org.springframework.test.context.junit4.SpringRunner;
1541 | import org.springframework.web.reactive.socket.WebSocketMessage;
1542 | import org.springframework.web.reactive.socket.client.StandardWebSocketClient;
1543 | import org.springframework.web.reactive.socket.client.WebSocketClient;
1544 | 
1545 | import reactor.core.publisher.Flux;
1546 | import reactor.core.publisher.ReplayProcessor;
1547 | 
1548 | @RunWith(SpringRunner.class)
1549 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
1550 | public class EchoWebSocketHandlerTests {
1551 | 
1552 | 	@LocalServerPort
1553 | 	private String port;
1554 | 
1555 | 	@Test
1556 | 	public void echo() throws Exception {
1557 | 		int count = 4;
1558 | 		Flux<String> input = Flux.range(1, count).map(index -> "msg-" + index);
1559 | 		ReplayProcessor<Object> output = ReplayProcessor.create(count);
1560 | 
1561 | 		WebSocketClient client = new StandardWebSocketClient();
1562 | 		client.execute(getUrl("/websocket/echo"),
1563 | 				session -> session
1564 | 						.send(input.map(session::textMessage))
1565 | 						.thenMany(session.receive().take(count).map(WebSocketMessage::getPayloadAsText))
1566 | 						.subscribeWith(output)
1567 | 						.then())
1568 | 				.block(Duration.ofMillis(5000));
1569 | 
1570 | 		assertEquals(input.collectList().block(Duration.ofMillis(5000)), output.collectList().block(Duration.ofMillis(5000)));
1571 | 	}
1572 | 
1573 | 	protected URI getUrl(String path) throws URISyntaxException {
1574 | 		return new URI("ws://localhost:" + this.port + path);
1575 | 	}
1576 | }
1577 |
1578 |
1579 |
1580 |
1581 |
1582 |
1583 |

Additional Resources

1584 |
1585 |
1586 |

Talks on Spring Reactive:

1587 |
1588 | 1598 |
1599 |
1600 |
1601 | 1606 | 1607 | 1608 | 1609 | 1610 | -------------------------------------------------------------------------------- /stock-quotes/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | 4 | ### STS ### 5 | .apt_generated 6 | .classpath 7 | .factorypath 8 | .project 9 | .settings 10 | .springBeans 11 | 12 | ### IntelliJ IDEA ### 13 | .idea 14 | *.iws 15 | *.iml 16 | *.ipr 17 | 18 | ### NetBeans ### 19 | nbproject/private/ 20 | build/ 21 | nbbuild/ 22 | dist/ 23 | nbdist/ 24 | .nb-gradle/ -------------------------------------------------------------------------------- /stock-quotes/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bclozel/webflux-workshop/bbe0972673503dd542ccf933758a6aeaca6d3a30/stock-quotes/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /stock-quotes/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.0/apache-maven-3.5.0-bin.zip 2 | -------------------------------------------------------------------------------- /stock-quotes/mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven2 Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Migwn, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | # TODO classpath? 118 | fi 119 | 120 | if [ -z "$JAVA_HOME" ]; then 121 | javaExecutable="`which javac`" 122 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 123 | # readlink(1) is not available as standard on Solaris 10. 124 | readLink=`which readlink` 125 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 126 | if $darwin ; then 127 | javaHome="`dirname \"$javaExecutable\"`" 128 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 129 | else 130 | javaExecutable="`readlink -f \"$javaExecutable\"`" 131 | fi 132 | javaHome="`dirname \"$javaExecutable\"`" 133 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 134 | JAVA_HOME="$javaHome" 135 | export JAVA_HOME 136 | fi 137 | fi 138 | fi 139 | 140 | if [ -z "$JAVACMD" ] ; then 141 | if [ -n "$JAVA_HOME" ] ; then 142 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 143 | # IBM's JDK on AIX uses strange locations for the executables 144 | JAVACMD="$JAVA_HOME/jre/sh/java" 145 | else 146 | JAVACMD="$JAVA_HOME/bin/java" 147 | fi 148 | else 149 | JAVACMD="`which java`" 150 | fi 151 | fi 152 | 153 | if [ ! -x "$JAVACMD" ] ; then 154 | echo "Error: JAVA_HOME is not defined correctly." >&2 155 | echo " We cannot execute $JAVACMD" >&2 156 | exit 1 157 | fi 158 | 159 | if [ -z "$JAVA_HOME" ] ; then 160 | echo "Warning: JAVA_HOME environment variable is not set." 161 | fi 162 | 163 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 164 | 165 | # traverses directory structure from process work directory to filesystem root 166 | # first directory with .mvn subdirectory is considered project base directory 167 | find_maven_basedir() { 168 | 169 | if [ -z "$1" ] 170 | then 171 | echo "Path not specified to find_maven_basedir" 172 | return 1 173 | fi 174 | 175 | basedir="$1" 176 | wdir="$1" 177 | while [ "$wdir" != '/' ] ; do 178 | if [ -d "$wdir"/.mvn ] ; then 179 | basedir=$wdir 180 | break 181 | fi 182 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 183 | if [ -d "${wdir}" ]; then 184 | wdir=`cd "$wdir/.."; pwd` 185 | fi 186 | # end of workaround 187 | done 188 | echo "${basedir}" 189 | } 190 | 191 | # concatenates all lines of a file 192 | concat_lines() { 193 | if [ -f "$1" ]; then 194 | echo "$(tr -s '\n' ' ' < "$1")" 195 | fi 196 | } 197 | 198 | BASE_DIR=`find_maven_basedir "$(pwd)"` 199 | if [ -z "$BASE_DIR" ]; then 200 | exit 1; 201 | fi 202 | 203 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 204 | echo $MAVEN_PROJECTBASEDIR 205 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 206 | 207 | # For Cygwin, switch paths to Windows format before running java 208 | if $cygwin; then 209 | [ -n "$M2_HOME" ] && 210 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 211 | [ -n "$JAVA_HOME" ] && 212 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 213 | [ -n "$CLASSPATH" ] && 214 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 215 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 216 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 217 | fi 218 | 219 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 220 | 221 | exec "$JAVACMD" \ 222 | $MAVEN_OPTS \ 223 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 224 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 225 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 226 | -------------------------------------------------------------------------------- /stock-quotes/mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven2 Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' 39 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 40 | 41 | @REM set %HOME% to equivalent of $HOME 42 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 43 | 44 | @REM Execute a user defined script before this one 45 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 46 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 47 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 48 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 49 | :skipRcPre 50 | 51 | @setlocal 52 | 53 | set ERROR_CODE=0 54 | 55 | @REM To isolate internal variables from possible post scripts, we use another setlocal 56 | @setlocal 57 | 58 | @REM ==== START VALIDATION ==== 59 | if not "%JAVA_HOME%" == "" goto OkJHome 60 | 61 | echo. 62 | echo Error: JAVA_HOME not found in your environment. >&2 63 | echo Please set the JAVA_HOME variable in your environment to match the >&2 64 | echo location of your Java installation. >&2 65 | echo. 66 | goto error 67 | 68 | :OkJHome 69 | if exist "%JAVA_HOME%\bin\java.exe" goto init 70 | 71 | echo. 72 | echo Error: JAVA_HOME is set to an invalid directory. >&2 73 | echo JAVA_HOME = "%JAVA_HOME%" >&2 74 | echo Please set the JAVA_HOME variable in your environment to match the >&2 75 | echo location of your Java installation. >&2 76 | echo. 77 | goto error 78 | 79 | @REM ==== END VALIDATION ==== 80 | 81 | :init 82 | 83 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 84 | @REM Fallback to current working directory if not found. 85 | 86 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 87 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 88 | 89 | set EXEC_DIR=%CD% 90 | set WDIR=%EXEC_DIR% 91 | :findBaseDir 92 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 93 | cd .. 94 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 95 | set WDIR=%CD% 96 | goto findBaseDir 97 | 98 | :baseDirFound 99 | set MAVEN_PROJECTBASEDIR=%WDIR% 100 | cd "%EXEC_DIR%" 101 | goto endDetectBaseDir 102 | 103 | :baseDirNotFound 104 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 105 | cd "%EXEC_DIR%" 106 | 107 | :endDetectBaseDir 108 | 109 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 110 | 111 | @setlocal EnableExtensions EnableDelayedExpansion 112 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 113 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 114 | 115 | :endReadAdditionalConfig 116 | 117 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 118 | 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 123 | if ERRORLEVEL 1 goto error 124 | goto end 125 | 126 | :error 127 | set ERROR_CODE=1 128 | 129 | :end 130 | @endlocal & set ERROR_CODE=%ERROR_CODE% 131 | 132 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 133 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 134 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 135 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 136 | :skipRcPost 137 | 138 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 139 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 140 | 141 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 142 | 143 | exit /B %ERROR_CODE% 144 | -------------------------------------------------------------------------------- /stock-quotes/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.spring.workshop 7 | stock-quotes 8 | 0.0.1-SNAPSHOT 9 | jar 10 | 11 | stock-quotes 12 | Demo project for Spring Boot 13 | 14 | 15 | org.springframework.boot 16 | spring-boot-starter-parent 17 | 2.0.2.RELEASE 18 | 19 | 20 | 21 | 22 | UTF-8 23 | UTF-8 24 | 1.8 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-webflux 31 | 32 | 33 | 34 | org.springframework.boot 35 | spring-boot-starter-test 36 | test 37 | 38 | 39 | 40 | 41 | 42 | 43 | org.springframework.boot 44 | spring-boot-maven-plugin 45 | 46 | 47 | 48 | 49 | 50 | 51 | spring-snapshots 52 | Spring Snapshots 53 | https://repo.spring.io/snapshot 54 | 55 | true 56 | 57 | 58 | 59 | spring-milestones 60 | Spring Milestones 61 | https://repo.spring.io/milestone 62 | 63 | false 64 | 65 | 66 | 67 | 68 | 69 | 70 | spring-snapshots 71 | Spring Snapshots 72 | https://repo.spring.io/snapshot 73 | 74 | true 75 | 76 | 77 | 78 | spring-milestones 79 | Spring Milestones 80 | https://repo.spring.io/milestone 81 | 82 | false 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /stock-quotes/src/main/java/io/spring/workshop/stockquotes/Quote.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import java.math.BigDecimal; 4 | import java.math.MathContext; 5 | import java.time.Instant; 6 | 7 | public class Quote { 8 | 9 | private static final MathContext MATH_CONTEXT = new MathContext(2); 10 | 11 | private String ticker; 12 | 13 | private BigDecimal price; 14 | 15 | private Instant instant; 16 | 17 | public Quote() { 18 | } 19 | 20 | public Quote(String ticker, BigDecimal price) { 21 | this.ticker = ticker; 22 | this.price = price; 23 | } 24 | 25 | public Quote(String ticker, Double price) { 26 | this(ticker, new BigDecimal(price, MATH_CONTEXT)); 27 | } 28 | 29 | public String getTicker() { 30 | return ticker; 31 | } 32 | 33 | public void setTicker(String ticker) { 34 | this.ticker = ticker; 35 | } 36 | 37 | public BigDecimal getPrice() { 38 | return price; 39 | } 40 | 41 | public void setPrice(BigDecimal price) { 42 | this.price = price; 43 | } 44 | 45 | public Instant getInstant() { 46 | return instant; 47 | } 48 | 49 | public void setInstant(Instant instant) { 50 | this.instant = instant; 51 | } 52 | 53 | @Override 54 | public String toString() { 55 | return "Quote{" + 56 | "ticker='" + ticker + '\'' + 57 | ", price=" + price + 58 | ", instant=" + instant + 59 | '}'; 60 | } 61 | } -------------------------------------------------------------------------------- /stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteGenerator.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import java.math.BigDecimal; 4 | import java.math.MathContext; 5 | import java.time.Duration; 6 | import java.time.Instant; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.Random; 10 | import java.util.stream.Collectors; 11 | 12 | import reactor.core.publisher.Flux; 13 | 14 | import org.springframework.stereotype.Component; 15 | 16 | @Component 17 | public class QuoteGenerator { 18 | 19 | private final MathContext mathContext = new MathContext(2); 20 | 21 | private final Random random = new Random(); 22 | 23 | private final List prices = new ArrayList<>(); 24 | 25 | /** 26 | * Bootstraps the generator with tickers and initial prices 27 | */ 28 | public QuoteGenerator() { 29 | this.prices.add(new Quote("CTXS", 82.26)); 30 | this.prices.add(new Quote("DELL", 63.74)); 31 | this.prices.add(new Quote("GOOG", 847.24)); 32 | this.prices.add(new Quote("MSFT", 65.11)); 33 | this.prices.add(new Quote("ORCL", 45.71)); 34 | this.prices.add(new Quote("RHT", 84.29)); 35 | this.prices.add(new Quote("VMW", 92.21)); 36 | } 37 | 38 | 39 | public Flux fetchQuoteStream(Duration period) { 40 | 41 | // We want to emit quotes with a specific period; 42 | // to do so, we create a Flux.interval 43 | return Flux.interval(period) 44 | // In case of back-pressure, drop events 45 | .onBackpressureDrop() 46 | // For each tick, generate a list of quotes 47 | .map(this::generateQuotes) 48 | // "flatten" that List into a Flux 49 | .flatMapIterable(quotes -> quotes) 50 | .log("io.spring.workshop.stockquotes"); 51 | } 52 | 53 | /* 54 | * Create quotes for all tickers at a single instant. 55 | */ 56 | private List generateQuotes(long interval) { 57 | final Instant instant = Instant.now(); 58 | return prices.stream() 59 | .map(baseQuote -> { 60 | BigDecimal priceChange = baseQuote.getPrice() 61 | .multiply(new BigDecimal(0.05 * this.random.nextDouble()), this.mathContext); 62 | Quote result = new Quote(baseQuote.getTicker(), baseQuote.getPrice().add(priceChange)); 63 | result.setInstant(instant); 64 | return result; 65 | }) 66 | .collect(Collectors.toList()); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteHandler.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import reactor.core.publisher.Flux; 4 | import reactor.core.publisher.Mono; 5 | 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.web.reactive.function.BodyInserters; 8 | import org.springframework.web.reactive.function.server.ServerRequest; 9 | import org.springframework.web.reactive.function.server.ServerResponse; 10 | 11 | import static java.time.Duration.ofMillis; 12 | import static org.springframework.http.MediaType.APPLICATION_JSON; 13 | import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON; 14 | import static org.springframework.http.MediaType.TEXT_PLAIN; 15 | import static org.springframework.web.reactive.function.server.ServerResponse.ok; 16 | 17 | @Component 18 | public class QuoteHandler { 19 | 20 | private final Flux quoteStream; 21 | 22 | public QuoteHandler(QuoteGenerator quoteGenerator) { 23 | this.quoteStream = quoteGenerator.fetchQuoteStream(ofMillis(1000)).share(); 24 | } 25 | 26 | public Mono hello(ServerRequest request) { 27 | return ok().contentType(TEXT_PLAIN) 28 | .body(BodyInserters.fromObject("Hello Spring!")); 29 | } 30 | 31 | public Mono echo(ServerRequest request) { 32 | return ok().contentType(TEXT_PLAIN) 33 | .body(request.bodyToMono(String.class), String.class); 34 | } 35 | 36 | public Mono streamQuotes(ServerRequest request) { 37 | return ok() 38 | .contentType(APPLICATION_STREAM_JSON) 39 | .body(this.quoteStream, Quote.class); 40 | } 41 | 42 | public Mono fetchQuotes(ServerRequest request) { 43 | int size = Integer.parseInt(request.queryParam("size").orElse("10")); 44 | return ok() 45 | .contentType(APPLICATION_JSON) 46 | .body(this.quoteStream.take(size), Quote.class); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /stock-quotes/src/main/java/io/spring/workshop/stockquotes/QuoteRouter.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.reactive.function.server.RouterFunction; 6 | import org.springframework.web.reactive.function.server.RouterFunctions; 7 | import org.springframework.web.reactive.function.server.ServerResponse; 8 | 9 | import static org.springframework.http.MediaType.APPLICATION_JSON; 10 | import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON; 11 | import static org.springframework.http.MediaType.TEXT_PLAIN; 12 | import static org.springframework.web.reactive.function.server.RequestPredicates.GET; 13 | import static org.springframework.web.reactive.function.server.RequestPredicates.POST; 14 | import static org.springframework.web.reactive.function.server.RequestPredicates.accept; 15 | import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; 16 | 17 | @Configuration 18 | public class QuoteRouter { 19 | 20 | @Bean 21 | public RouterFunction route(QuoteHandler quoteHandler) { 22 | return RouterFunctions 23 | .route(GET("/hello").and(accept(TEXT_PLAIN)), quoteHandler::hello) 24 | .andRoute(POST("/echo").and(accept(TEXT_PLAIN).and(contentType(TEXT_PLAIN))), quoteHandler::echo) 25 | .andRoute(GET("/quotes").and(accept(APPLICATION_JSON)), quoteHandler::fetchQuotes) 26 | .andRoute(GET("/quotes").and(accept(APPLICATION_STREAM_JSON)), quoteHandler::streamQuotes); 27 | } 28 | } -------------------------------------------------------------------------------- /stock-quotes/src/main/java/io/spring/workshop/stockquotes/StockQuotesApplication.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class StockQuotesApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(StockQuotesApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /stock-quotes/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | server.port=8081 -------------------------------------------------------------------------------- /stock-quotes/src/test/java/io/spring/workshop/stockquotes/StockQuotesApplicationTests.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.stockquotes; 2 | 3 | import java.util.List; 4 | 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.test.context.junit4.SpringRunner; 12 | import org.springframework.test.web.reactive.server.WebTestClient; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | @RunWith(SpringRunner.class) 17 | // We create a `@SpringBootTest`, starting an actual server on a `RANDOM_PORT` 18 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 19 | public class StockQuotesApplicationTests { 20 | 21 | // Spring Boot will create a `WebTestClient` for you, 22 | // already configure and ready to issue requests against "localhost:RANDOM_PORT" 23 | @Autowired 24 | private WebTestClient webTestClient; 25 | 26 | @Test 27 | public void fetchQuotes() { 28 | webTestClient 29 | // We then create a GET request to test an endpoint 30 | .get().uri("/quotes?size=20") 31 | .accept(MediaType.APPLICATION_JSON) 32 | .exchange() 33 | // and use the dedicated DSL to test assertions against the response 34 | .expectStatus().isOk() 35 | .expectHeader().contentType(MediaType.APPLICATION_JSON) 36 | .expectBodyList(Quote.class) 37 | .hasSize(20) 38 | // Here we check that all Quotes have a positive price value 39 | .consumeWith(allQuotes -> 40 | assertThat(allQuotes.getResponseBody()) 41 | .allSatisfy(quote -> assertThat(quote.getPrice()).isPositive())); 42 | } 43 | 44 | @Test 45 | public void fetchQuotesAsStream() { 46 | List result = webTestClient 47 | // We then create a GET request to test an endpoint 48 | .get().uri("/quotes") 49 | // this time, accepting "application/stream+json" 50 | .accept(MediaType.APPLICATION_STREAM_JSON) 51 | .exchange() 52 | // and use the dedicated DSL to test assertions against the response 53 | .expectStatus().isOk() 54 | .expectHeader().contentType(MediaType.APPLICATION_STREAM_JSON) 55 | .returnResult(Quote.class) 56 | .getResponseBody() 57 | .take(30) 58 | .collectList() 59 | .block(); 60 | 61 | assertThat(result).allSatisfy(quote -> assertThat(quote.getPrice()).isPositive()); 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /trading-service/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | 4 | ### STS ### 5 | .apt_generated 6 | .classpath 7 | .factorypath 8 | .project 9 | .settings 10 | .springBeans 11 | 12 | ### IntelliJ IDEA ### 13 | .idea 14 | *.iws 15 | *.iml 16 | *.ipr 17 | 18 | ### NetBeans ### 19 | nbproject/private/ 20 | build/ 21 | nbbuild/ 22 | dist/ 23 | nbdist/ 24 | .nb-gradle/ -------------------------------------------------------------------------------- /trading-service/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bclozel/webflux-workshop/bbe0972673503dd542ccf933758a6aeaca6d3a30/trading-service/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /trading-service/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.0/apache-maven-3.5.0-bin.zip 2 | -------------------------------------------------------------------------------- /trading-service/mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven2 Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Migwn, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | # TODO classpath? 118 | fi 119 | 120 | if [ -z "$JAVA_HOME" ]; then 121 | javaExecutable="`which javac`" 122 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 123 | # readlink(1) is not available as standard on Solaris 10. 124 | readLink=`which readlink` 125 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 126 | if $darwin ; then 127 | javaHome="`dirname \"$javaExecutable\"`" 128 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 129 | else 130 | javaExecutable="`readlink -f \"$javaExecutable\"`" 131 | fi 132 | javaHome="`dirname \"$javaExecutable\"`" 133 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 134 | JAVA_HOME="$javaHome" 135 | export JAVA_HOME 136 | fi 137 | fi 138 | fi 139 | 140 | if [ -z "$JAVACMD" ] ; then 141 | if [ -n "$JAVA_HOME" ] ; then 142 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 143 | # IBM's JDK on AIX uses strange locations for the executables 144 | JAVACMD="$JAVA_HOME/jre/sh/java" 145 | else 146 | JAVACMD="$JAVA_HOME/bin/java" 147 | fi 148 | else 149 | JAVACMD="`which java`" 150 | fi 151 | fi 152 | 153 | if [ ! -x "$JAVACMD" ] ; then 154 | echo "Error: JAVA_HOME is not defined correctly." >&2 155 | echo " We cannot execute $JAVACMD" >&2 156 | exit 1 157 | fi 158 | 159 | if [ -z "$JAVA_HOME" ] ; then 160 | echo "Warning: JAVA_HOME environment variable is not set." 161 | fi 162 | 163 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 164 | 165 | # traverses directory structure from process work directory to filesystem root 166 | # first directory with .mvn subdirectory is considered project base directory 167 | find_maven_basedir() { 168 | 169 | if [ -z "$1" ] 170 | then 171 | echo "Path not specified to find_maven_basedir" 172 | return 1 173 | fi 174 | 175 | basedir="$1" 176 | wdir="$1" 177 | while [ "$wdir" != '/' ] ; do 178 | if [ -d "$wdir"/.mvn ] ; then 179 | basedir=$wdir 180 | break 181 | fi 182 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 183 | if [ -d "${wdir}" ]; then 184 | wdir=`cd "$wdir/.."; pwd` 185 | fi 186 | # end of workaround 187 | done 188 | echo "${basedir}" 189 | } 190 | 191 | # concatenates all lines of a file 192 | concat_lines() { 193 | if [ -f "$1" ]; then 194 | echo "$(tr -s '\n' ' ' < "$1")" 195 | fi 196 | } 197 | 198 | BASE_DIR=`find_maven_basedir "$(pwd)"` 199 | if [ -z "$BASE_DIR" ]; then 200 | exit 1; 201 | fi 202 | 203 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 204 | echo $MAVEN_PROJECTBASEDIR 205 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 206 | 207 | # For Cygwin, switch paths to Windows format before running java 208 | if $cygwin; then 209 | [ -n "$M2_HOME" ] && 210 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 211 | [ -n "$JAVA_HOME" ] && 212 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 213 | [ -n "$CLASSPATH" ] && 214 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 215 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 216 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 217 | fi 218 | 219 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 220 | 221 | exec "$JAVACMD" \ 222 | $MAVEN_OPTS \ 223 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 224 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 225 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 226 | -------------------------------------------------------------------------------- /trading-service/mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven2 Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' 39 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 40 | 41 | @REM set %HOME% to equivalent of $HOME 42 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 43 | 44 | @REM Execute a user defined script before this one 45 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 46 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 47 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 48 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 49 | :skipRcPre 50 | 51 | @setlocal 52 | 53 | set ERROR_CODE=0 54 | 55 | @REM To isolate internal variables from possible post scripts, we use another setlocal 56 | @setlocal 57 | 58 | @REM ==== START VALIDATION ==== 59 | if not "%JAVA_HOME%" == "" goto OkJHome 60 | 61 | echo. 62 | echo Error: JAVA_HOME not found in your environment. >&2 63 | echo Please set the JAVA_HOME variable in your environment to match the >&2 64 | echo location of your Java installation. >&2 65 | echo. 66 | goto error 67 | 68 | :OkJHome 69 | if exist "%JAVA_HOME%\bin\java.exe" goto init 70 | 71 | echo. 72 | echo Error: JAVA_HOME is set to an invalid directory. >&2 73 | echo JAVA_HOME = "%JAVA_HOME%" >&2 74 | echo Please set the JAVA_HOME variable in your environment to match the >&2 75 | echo location of your Java installation. >&2 76 | echo. 77 | goto error 78 | 79 | @REM ==== END VALIDATION ==== 80 | 81 | :init 82 | 83 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 84 | @REM Fallback to current working directory if not found. 85 | 86 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 87 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 88 | 89 | set EXEC_DIR=%CD% 90 | set WDIR=%EXEC_DIR% 91 | :findBaseDir 92 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 93 | cd .. 94 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 95 | set WDIR=%CD% 96 | goto findBaseDir 97 | 98 | :baseDirFound 99 | set MAVEN_PROJECTBASEDIR=%WDIR% 100 | cd "%EXEC_DIR%" 101 | goto endDetectBaseDir 102 | 103 | :baseDirNotFound 104 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 105 | cd "%EXEC_DIR%" 106 | 107 | :endDetectBaseDir 108 | 109 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 110 | 111 | @setlocal EnableExtensions EnableDelayedExpansion 112 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 113 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 114 | 115 | :endReadAdditionalConfig 116 | 117 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 118 | 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 123 | if ERRORLEVEL 1 goto error 124 | goto end 125 | 126 | :error 127 | set ERROR_CODE=1 128 | 129 | :end 130 | @endlocal & set ERROR_CODE=%ERROR_CODE% 131 | 132 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 133 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 134 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 135 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 136 | :skipRcPost 137 | 138 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 139 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 140 | 141 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 142 | 143 | exit /B %ERROR_CODE% 144 | -------------------------------------------------------------------------------- /trading-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.spring.workshop 7 | trading-service 8 | 0.0.1-SNAPSHOT 9 | jar 10 | 11 | trading-service 12 | Demo project for Spring Boot 13 | 14 | 15 | org.springframework.boot 16 | spring-boot-starter-parent 17 | 2.0.2.RELEASE 18 | 19 | 20 | 21 | 22 | UTF-8 23 | UTF-8 24 | 1.8 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-data-mongodb-reactive 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-thymeleaf 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-webflux 39 | 40 | 41 | 42 | org.springframework.boot 43 | spring-boot-starter-tomcat 44 | 45 | 46 | 47 | 48 | de.flapdoodle.embed 49 | de.flapdoodle.embed.mongo 50 | 51 | 52 | 53 | 54 | org.webjars 55 | bootstrap 56 | 3.3.7 57 | 58 | 59 | org.webjars 60 | highcharts 61 | 5.0.8 62 | 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-devtools 67 | runtime 68 | 69 | 70 | org.springframework.boot 71 | spring-boot-starter-test 72 | test 73 | 74 | 75 | 76 | 77 | 78 | 79 | org.springframework.boot 80 | spring-boot-maven-plugin 81 | 82 | 83 | 84 | 85 | 86 | 87 | spring-snapshots 88 | Spring Snapshots 89 | https://repo.spring.io/snapshot 90 | 91 | true 92 | 93 | 94 | 95 | spring-milestones 96 | Spring Milestones 97 | https://repo.spring.io/milestone 98 | 99 | false 100 | 101 | 102 | 103 | 104 | 105 | 106 | spring-snapshots 107 | Spring Snapshots 108 | https://repo.spring.io/snapshot 109 | 110 | true 111 | 112 | 113 | 114 | spring-milestones 115 | Spring Milestones 116 | https://repo.spring.io/milestone 117 | 118 | false 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/HomeController.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | 7 | @Controller 8 | public class HomeController { 9 | 10 | private final TradingUserRepository tradingUserRepository; 11 | 12 | public HomeController(TradingUserRepository tradingUserRepository) { 13 | this.tradingUserRepository = tradingUserRepository; 14 | } 15 | 16 | @GetMapping("/") 17 | public String home(Model model) { 18 | model.addAttribute("users", this.tradingUserRepository.findAll()); 19 | return "index"; 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/Quote.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import java.math.BigDecimal; 4 | import java.math.MathContext; 5 | import java.time.Instant; 6 | 7 | public class Quote { 8 | 9 | private static final MathContext MATH_CONTEXT = new MathContext(2); 10 | 11 | private String ticker; 12 | 13 | private BigDecimal price; 14 | 15 | private Instant instant; 16 | 17 | public Quote() { 18 | } 19 | 20 | public Quote(String ticker, BigDecimal price) { 21 | this.ticker = ticker; 22 | this.price = price; 23 | } 24 | 25 | public Quote(String ticker, Double price) { 26 | this(ticker, new BigDecimal(price, MATH_CONTEXT)); 27 | } 28 | 29 | public String getTicker() { 30 | return ticker; 31 | } 32 | 33 | public void setTicker(String ticker) { 34 | this.ticker = ticker; 35 | } 36 | 37 | public BigDecimal getPrice() { 38 | return price; 39 | } 40 | 41 | public void setPrice(BigDecimal price) { 42 | this.price = price; 43 | } 44 | 45 | public Instant getInstant() { 46 | return instant; 47 | } 48 | 49 | public void setInstant(Instant instant) { 50 | this.instant = instant; 51 | } 52 | 53 | @Override 54 | public String toString() { 55 | return "Quote{" + 56 | "ticker='" + ticker + '\'' + 57 | ", price=" + price + 58 | ", instant=" + instant + 59 | '}'; 60 | } 61 | } -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/QuotesController.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import reactor.core.publisher.Flux; 4 | 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.ResponseBody; 8 | import org.springframework.web.reactive.function.client.WebClient; 9 | 10 | import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON; 11 | import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; 12 | 13 | @Controller 14 | public class QuotesController { 15 | 16 | @GetMapping("/quotes") 17 | public String quotes() { 18 | return "quotes"; 19 | } 20 | 21 | @GetMapping(path = "/quotes/feed", produces = TEXT_EVENT_STREAM_VALUE) 22 | @ResponseBody 23 | public Flux quotesStream() { 24 | return WebClient.create("http://localhost:8081") 25 | .get() 26 | .uri("/quotes") 27 | .accept(APPLICATION_STREAM_JSON) 28 | .retrieve() 29 | .bodyToFlux(Quote.class) 30 | .share() 31 | .log("io.spring.workshop.tradingservice"); 32 | } 33 | } -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/TradingServiceApplication.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class TradingServiceApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(TradingServiceApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/TradingUser.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import org.springframework.data.annotation.Id; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | 6 | @Document 7 | public class TradingUser { 8 | 9 | @Id 10 | private String id; 11 | 12 | private String userName; 13 | 14 | private String fullName; 15 | 16 | public TradingUser() { 17 | } 18 | 19 | public TradingUser(String id, String userName, String fullName) { 20 | this.id = id; 21 | this.userName = userName; 22 | this.fullName = fullName; 23 | } 24 | 25 | public TradingUser(String userName, String fullName) { 26 | this.userName = userName; 27 | this.fullName = fullName; 28 | } 29 | 30 | public String getId() { 31 | return id; 32 | } 33 | 34 | public void setId(String id) { 35 | this.id = id; 36 | } 37 | 38 | public String getUserName() { 39 | return userName; 40 | } 41 | 42 | public void setUserName(String userName) { 43 | this.userName = userName; 44 | } 45 | 46 | public String getFullName() { 47 | return fullName; 48 | } 49 | 50 | public void setFullName(String fullName) { 51 | this.fullName = fullName; 52 | } 53 | 54 | @Override 55 | public boolean equals(Object o) { 56 | if (this == o) return true; 57 | if (o == null || getClass() != o.getClass()) return false; 58 | 59 | TradingUser that = (TradingUser) o; 60 | 61 | if (!id.equals(that.id)) return false; 62 | return userName.equals(that.userName); 63 | } 64 | 65 | @Override 66 | public int hashCode() { 67 | int result = id.hashCode(); 68 | result = 31 * result + userName.hashCode(); 69 | return result; 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/TradingUserRepository.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import reactor.core.publisher.Mono; 4 | 5 | import org.springframework.data.mongodb.repository.ReactiveMongoRepository; 6 | 7 | public interface TradingUserRepository extends ReactiveMongoRepository { 8 | 9 | Mono findByUserName(String userName); 10 | 11 | } 12 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/UserController.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import reactor.core.publisher.Flux; 4 | import reactor.core.publisher.Mono; 5 | 6 | import org.springframework.http.MediaType; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | @RestController 12 | public class UserController { 13 | 14 | private final TradingUserRepository tradingUserRepository; 15 | 16 | public UserController(TradingUserRepository tradingUserRepository) { 17 | this.tradingUserRepository = tradingUserRepository; 18 | } 19 | 20 | @GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE) 21 | public Flux listUsers() { 22 | return this.tradingUserRepository.findAll(); 23 | } 24 | 25 | @GetMapping(path = "/users/{username}", produces = MediaType.APPLICATION_JSON_VALUE) 26 | public Mono showUsers(@PathVariable String username) { 27 | return this.tradingUserRepository.findByUserName(username); 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/UsersCommandLineRunner.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import java.time.Duration; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | 7 | import org.springframework.boot.CommandLineRunner; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class UsersCommandLineRunner implements CommandLineRunner { 12 | 13 | private final TradingUserRepository repository; 14 | 15 | public UsersCommandLineRunner(TradingUserRepository repository) { 16 | this.repository = repository; 17 | } 18 | 19 | @Override 20 | public void run(String... strings) throws Exception { 21 | List users = Arrays.asList( 22 | new TradingUser("sdeleuze", "Sebastien Deleuze"), 23 | new TradingUser("snicoll", "Stephane Nicoll"), 24 | new TradingUser("rstoyanchev", "Rossen Stoyanchev"), 25 | new TradingUser("poutsma", "Arjen Poutsma"), 26 | new TradingUser("smaldini", "Stephane Maldini"), 27 | new TradingUser("simonbasle", "Simon Basle"), 28 | new TradingUser("violetagg", "Violeta Georgieva"), 29 | new TradingUser("bclozel", "Brian Clozel") 30 | ); 31 | this.repository.insert(users).blockLast(Duration.ofSeconds(3)); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/websocket/EchoWebSocketHandler.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice.websocket; 2 | 3 | import java.time.Duration; 4 | 5 | import org.springframework.web.reactive.socket.WebSocketHandler; 6 | import org.springframework.web.reactive.socket.WebSocketMessage; 7 | import org.springframework.web.reactive.socket.WebSocketSession; 8 | 9 | import reactor.core.publisher.Mono; 10 | 11 | public class EchoWebSocketHandler implements WebSocketHandler { 12 | 13 | @Override 14 | public Mono handle(WebSocketSession session) { 15 | return session.send(session.receive() 16 | .doOnNext(WebSocketMessage::retain) 17 | .delayElements(Duration.ofSeconds(1)).log()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/websocket/WebSocketController.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice.websocket; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | 6 | @Controller 7 | public class WebSocketController { 8 | 9 | @GetMapping("/websocket") 10 | public String websocket() { 11 | return "websocket"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /trading-service/src/main/java/io/spring/workshop/tradingservice/websocket/WebSocketRouter.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice.websocket; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.web.reactive.HandlerMapping; 9 | import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; 10 | import org.springframework.web.reactive.socket.WebSocketHandler; 11 | import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter; 12 | 13 | @Configuration 14 | public class WebSocketRouter { 15 | 16 | @Bean 17 | public HandlerMapping handlerMapping() { 18 | 19 | Map map = new HashMap<>(); 20 | map.put("/websocket/echo", new EchoWebSocketHandler()); 21 | 22 | SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); 23 | mapping.setOrder(10); 24 | mapping.setUrlMap(map); 25 | return mapping; 26 | } 27 | 28 | @Bean 29 | public WebSocketHandlerAdapter handlerAdapter() { 30 | return new WebSocketHandlerAdapter(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /trading-service/src/main/resources/application.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bclozel/webflux-workshop/bbe0972673503dd542ccf933758a6aeaca6d3a30/trading-service/src/main/resources/application.properties -------------------------------------------------------------------------------- /trading-service/src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Spring Trading application 10 | 11 | 12 | 13 | 14 | 28 |
29 |

Trading users

30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
#User nameFull name
42janedoeJane Doe
46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /trading-service/src/main/resources/templates/quotes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Spring Trading application 10 | 11 | 12 | 13 | 14 | 15 | 29 |
30 |
31 |
32 | 33 | 34 | 35 | 95 | 96 | -------------------------------------------------------------------------------- /trading-service/src/main/resources/templates/websocket.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Spring Trading application 10 | 11 | 12 | 13 | 14 | 28 |
29 |

Websocket Echo

30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | 39 | 40 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /trading-service/src/test/java/io/spring/workshop/tradingservice/TradingServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | @RunWith(SpringRunner.class) 9 | @SpringBootTest 10 | public class TradingServiceApplicationTests { 11 | 12 | @Test 13 | public void contextLoads() { 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /trading-service/src/test/java/io/spring/workshop/tradingservice/UserControllerTests.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.mockito.BDDMockito; 6 | import reactor.core.publisher.Flux; 7 | import reactor.core.publisher.Mono; 8 | 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 11 | import org.springframework.boot.test.mock.mockito.MockBean; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.test.context.junit4.SpringRunner; 14 | import org.springframework.test.web.reactive.server.WebTestClient; 15 | 16 | @RunWith(SpringRunner.class) 17 | @WebFluxTest(UserController.class) 18 | public class UserControllerTests { 19 | 20 | @Autowired 21 | private WebTestClient webTestClient; 22 | 23 | @MockBean 24 | private TradingUserRepository repository; 25 | 26 | @Test 27 | public void listUsers() { 28 | TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller"); 29 | TradingUser andy = new TradingUser("2", "wilkinsona", "Andy Wilkinson"); 30 | 31 | BDDMockito.given(this.repository.findAll()) 32 | .willReturn(Flux.just(juergen, andy)); 33 | 34 | this.webTestClient.get().uri("/users").accept(MediaType.APPLICATION_JSON) 35 | .exchange() 36 | .expectBodyList(TradingUser.class) 37 | .hasSize(2) 38 | .contains(juergen, andy); 39 | 40 | } 41 | 42 | @Test 43 | public void showUser() { 44 | TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller"); 45 | 46 | BDDMockito.given(this.repository.findByUserName("jhoeller")) 47 | .willReturn(Mono.just(juergen)); 48 | 49 | this.webTestClient.get().uri("/users/jhoeller").accept(MediaType.APPLICATION_JSON) 50 | .exchange() 51 | .expectBody(TradingUser.class) 52 | .isEqualTo(juergen); 53 | } 54 | 55 | } 56 | 57 | -------------------------------------------------------------------------------- /trading-service/src/test/java/io/spring/workshop/tradingservice/websocket/EchoWebSocketHandlerTests.java: -------------------------------------------------------------------------------- 1 | package io.spring.workshop.tradingservice.websocket; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import java.net.URI; 6 | import java.net.URISyntaxException; 7 | import java.time.Duration; 8 | 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.boot.web.server.LocalServerPort; 13 | import org.springframework.test.context.junit4.SpringRunner; 14 | import org.springframework.web.reactive.socket.WebSocketMessage; 15 | import org.springframework.web.reactive.socket.client.StandardWebSocketClient; 16 | import org.springframework.web.reactive.socket.client.WebSocketClient; 17 | 18 | import reactor.core.publisher.Flux; 19 | import reactor.core.publisher.ReplayProcessor; 20 | 21 | @RunWith(SpringRunner.class) 22 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 23 | public class EchoWebSocketHandlerTests { 24 | 25 | @LocalServerPort 26 | private String port; 27 | 28 | @Test 29 | public void echo() throws Exception { 30 | int count = 4; 31 | Flux input = Flux.range(1, count).map(index -> "msg-" + index); 32 | ReplayProcessor output = ReplayProcessor.create(count); 33 | 34 | WebSocketClient client = new StandardWebSocketClient(); 35 | client.execute(getUrl("/websocket/echo"), 36 | session -> session 37 | .send(input.map(session::textMessage)) 38 | .thenMany(session.receive().take(count).map(WebSocketMessage::getPayloadAsText)) 39 | .subscribeWith(output) 40 | .then()) 41 | .block(Duration.ofMillis(5000)); 42 | 43 | assertEquals(input.collectList().block(Duration.ofMillis(5000)), output.collectList().block(Duration.ofMillis(5000))); 44 | } 45 | 46 | protected URI getUrl(String path) throws URISyntaxException { 47 | return new URI("ws://localhost:" + this.port + path); 48 | } 49 | } 50 | --------------------------------------------------------------------------------