├── .github └── dco.yml ├── .gitignore ├── LICENSE.code.txt ├── LICENSE.writing.txt ├── README.adoc ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts ├── src ├── main │ ├── kotlin │ │ └── com │ │ │ └── example │ │ │ └── kotlin │ │ │ └── chat │ │ │ ├── ChatKotlinApplication.kt │ │ │ ├── controller │ │ │ ├── HtmlController.kt │ │ │ └── MessageResource.kt │ │ │ └── service │ │ │ ├── FakeMessageService.kt │ │ │ ├── MessageService.kt │ │ │ └── ViewModel.kt │ └── resources │ │ ├── application.properties │ │ ├── static │ │ ├── rsocket-core.js │ │ ├── rsocket-flowable.js │ │ ├── rsocket-types.js │ │ └── rsocket-websocket-client.js │ │ └── templates │ │ ├── chat.html │ │ └── chatrs.html └── test │ └── kotlin │ └── com │ └── example │ └── kotlin │ └── chat │ └── ChatKotlinApplicationTests.kt └── static ├── application-architecture.png ├── chat.gif ├── download-from-vcs-github.png ├── download-from-vcs.png ├── intellij-git-branches.png ├── intellij-git-compare-with-branch-diff.png ├── intellij-git-compare-with-branch-file-diff.png ├── intellij-git-compare-with-branch.png ├── intellij-gradle-reload.png ├── intellij-run-app-from-main.png ├── intellij-running-tests.png ├── project-tree.png └── schema-sql-location.png /.github/dco.yml: -------------------------------------------------------------------------------- 1 | require: 2 | members: false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | out/ 23 | !**/src/main/**/out/ 24 | !**/src/test/**/out/ 25 | 26 | ### NetBeans ### 27 | /nbproject/private/ 28 | /nbbuild/ 29 | /dist/ 30 | /nbdist/ 31 | /.nb-gradle/ 32 | 33 | ### VS Code ### 34 | .vscode/ 35 | -------------------------------------------------------------------------------- /LICENSE.code.txt: -------------------------------------------------------------------------------- 1 | Copyright 2015-2019 the original author or authors. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /LICENSE.writing.txt: -------------------------------------------------------------------------------- 1 | Except where otherwise noted, this work is licensed under https://creativecommons.org/licenses/by-nd/3.0/ -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :toc: 2 | :icons: font 3 | :source-highlighter: prettify 4 | :project_id: tut-spring-webflux-kotlin-rsocket 5 | :tabsize: 2 6 | :image-width: 500 7 | :images: https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/master/static 8 | :book-root: . 9 | 10 | The tutorial shows you how to build a simple chat application using Spring Boot and Kotlin. You will learn about the benefits of using Kotlin for server-side development from a syntax perspective. 11 | 12 | We’ll start with a minimal implementation of the application, and we will evolve it step by step.At the start, the application will generate and display fake messages and use the classical blocking request-response model to get data to the UI.Through the tutorial, we are going to evolve the application by adding persistence and extensions, and migrating to a non-blocking streaming style for serving the data from the backend to the UI. 13 | 14 | The tutorial consists of 5 parts: 15 | 16 | * Part 1: Initial setup and introduction to the project 17 | * Part 2: Adding persistence and integration tests 18 | * Part 3: Implementing extensions 19 | * Part 4: Refactoring to Spring WebFlux with Kotlin Coroutines 20 | * Part 5: Streaming with RSocket 21 | 22 | This tutorial is designed for Java developers who have already had their hands on Spring MVC / WebFlux and want to see how to use Kotlin with Spring. 23 | 24 | 25 | == Part 1: Initial setup and introduction to the project 26 | 27 | To start working on this tutorial, we'll need one of the latest versions of IntelliJ IDEA – any version from 2018.1 onwards.You can download the latest free community version https://www.jetbrains.com/idea/download[here]. 28 | 29 | This project is based on Spring Boot 2.4.0, which requires Kotlin 1.4.10. Make sure version 1.4+ of the Kotlin plugin is installed.To update the Kotlin plugin, use `Tools | Kotlin | Configure Kotlin Plugin Updates`. 30 | 31 | === Downloading the project 32 | 33 | Clone the repository from IntelliJ IDEA by choosing `File | New | Project from Version Control`. 34 | 35 | image::{images}/download-from-vcs.png[] 36 | 37 | Specify the project path: https://github.com/kotlin-hands-on/kotlin-spring-chat. 38 | 39 | image::{images}/download-from-vcs-github.png[] 40 | 41 | Once you clone the project, IntelliJ IDEA will import and open it automatically. 42 | Alternatively, you can clone the project with the command line: 43 | 44 | [source,bash] 45 | $ git clone https://github.com/kotlin-hands-on/kotlin-spring-chat 46 | 47 | === Solution branches 48 | 49 | Note that the project includes solution branches for each part of the tutorial. You can browse all the branches in the IDE by invoking the Branches action: 50 | 51 | image::{images}/intellij-git-branches.png[] 52 | 53 | Or you can use the command line: 54 | 55 | [source,bash] 56 | git branch -a 57 | 58 | It is possible to use the `Compare with branch` command in IntelliJ IDEA to compare your solution with the proposed one. 59 | 60 | image::{images}/intellij-git-compare-with-branch.png[] 61 | 62 | For instance, here is the list differences between the `initial` branch and `part-2` branch: 63 | 64 | image::{images}/intellij-git-compare-with-branch-diff.png[] 65 | 66 | By clicking on the individual files, you can see the changes at a line level. 67 | 68 | image::{images}/intellij-git-compare-with-branch-file-diff.png[] 69 | 70 | This should help you in the event that you have any trouble with the instructions at any stage of the tutorial. 71 | 72 | === Launching the application 73 | The `main` method for the application is located in the `ChatKotlinApplication.kt` file. Simply click on the gutter icon next to the main method or hit the `Alt+Enter` shortcut to invoke the launch menu in IntelliJ IDEA: 74 | 75 | image::{images}/intellij-run-app-from-main.png[] 76 | 77 | Alternatively, you can run the `./gradlew bootRun` command in the terminal. 78 | 79 | Once the application starts, open the following URL: http://localhost:8080. You will see a chat page with a collection of messages. 80 | 81 | image::{images}/chat.gif[] 82 | 83 | In the following step, we will demonstrate how to integrate our application with a real database to store the messages. 84 | 85 | === Project overview 86 | 87 | Let's take a look at the general application overview. In this tutorial, we are going to build a simple chat application that has the following architecture: 88 | 89 | image::{images}/application-architecture.png[] 90 | 91 | Our application is an ordinary 3-tier web application. The client facing tier is implemented by the `HtmlController` and `MessagesResource` classes. The application makes use of server-side rendering via the _Thymeleaf_ template engine and is served by `HtmlController`. The message data API is provided by `MessagesResource`, which connects to the service layer. 92 | 93 | The service layer is represented by `MessagesService`, which has two different implementations: 94 | 95 | * `FakeMessageService` – the first implementation, which produces random messages 96 | * `PersistentMessageService` - the second implementation, which works with real data storage. We will add this implementation in part 2 of this tutorial. 97 | 98 | The `PersistentMessageService` connects to a database to store the messages. We will use the H2 database and access it via the Spring Data Repository API. 99 | 100 | After you have downloaded the project sources and opened them in the IDE, you will see the following structure, which includes the classes mentioned above. 101 | 102 | image::{images}/project-tree.png[] 103 | 104 | 105 | Under the `main/kotlin` folder there are packages and classes that belong to the application. In that folder, we are going to add more classes and make changes to the existing code to evolve the application. 106 | 107 | In the `main/resources` folder you will find various static resources and configuration files. 108 | 109 | The `test/kotlin` folder contains tests. We are going to make changes to the test sources accordingly with the changes to the main application. 110 | 111 | The entry point to the application is the `ChatKotlinApplication.kt` file. This is where the `main` method is. 112 | 113 | ==== HtmlController 114 | 115 | `HtmlController` is a `@Controller` annotated endpoint which will be exposing an HTML page generated using the https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html[Thymeleaf template engine] 116 | 117 | [source,kotlin] 118 | ----- 119 | import com.example.kotlin.chat.service.MessageService 120 | import com.example.kotlin.chat.service.MessageVM 121 | import org.springframework.stereotype.Controller 122 | import org.springframework.ui.Model 123 | import org.springframework.ui.set 124 | import org.springframework.web.bind.annotation.GetMapping 125 | 126 | @Controller 127 | class HtmlController(val messageService: MessageService) { 128 | @GetMapping("/") 129 | fun index(model: Model): String { 130 | val messages = messageService.latest() 131 | 132 | model["messages"] = messages 133 | model["lastMessageId"] = messages.lastOrNull()?.id ?: "" 134 | return "chat" 135 | } 136 | } 137 | ----- 138 | 139 | 💡One of the features you can immediately spot in Kotlin is the https://kotlinlang.org/spec/type-inference.html[type inference]. It means that some type of information in the code may be omitted, to be inferred by the compiler. 140 | 141 | In our example above, the compiler knows that the type of the `messages` variable is `List<MessageVM>` from looking at the return type of the `messageService.latest()` function. 142 | 143 | 💡Spring Web users may notice that `Model` is used in this example as a `Map` even though it does not extend this API. This becomes possible with https://docs.spring.io/spring-framework/docs/5.0.0.RELEASE/kdoc-api/spring-framework/org.springframework.ui/index.html[another Kotlin extension], which provides overloading for the `set` operator. For more information, please see the https://kotlinlang.org/docs/reference/operator-overloading.html[operator overloading] documentation. 144 | 145 | 💡 https://kotlinlang.org/docs/reference/null-safety.html[Null safety] is one of the most important features of the language. In the example above, you can see an application of this feature: `messages.lastOrNull()?.id ?: "".` First, `?.` is the https://kotlinlang.org/docs/reference/null-safety.html#safe-calls[safe call] operator, which checks whether the result of `lastOrNull()` is `null` and then gets an `id`. If the result of the expression is `null`, then we use an https://kotlinlang.org/docs/reference/null-safety.html#elvis-operator[Elvis operator] to provide a default value, which in our example is an empty string (`""`). 146 | 147 | ==== MessageResource 148 | 149 | We need an API endpoint to serve polling requests. This functionality is implemented by the `MessageResource` class, which exposes the latest messages in JSON format. 150 | 151 | If the `lastMessageId` query parameter is specified, the endpoint serves the latest messages after the specific message-id, otherwise, it serves all available messages. 152 | 153 | 154 | [source,kotlin] 155 | ----- 156 | @RestController 157 | @RequestMapping("/api/v1/messages") 158 | class MessageResource(val messageService: MessageService) { 159 | 160 | @GetMapping 161 | fun latest(@RequestParam(value = "lastMessageId", defaultValue = "") lastMessageId: String): ResponseEntity> { 162 | val messages = if (lastMessageId.isNotEmpty()) { 163 | messageService.after(lastMessageId) 164 | } else { 165 | messageService.latest() 166 | } 167 | 168 | return if (messages.isEmpty()) { 169 | with(ResponseEntity.noContent()) { 170 | header("lastMessageId", lastMessageId) 171 | build>() 172 | } 173 | } else { 174 | with(ResponseEntity.ok()) { 175 | header("lastMessageId", messages.last().id) 176 | body(messages) 177 | } 178 | } 179 | } 180 | 181 | @PostMapping 182 | fun post(@RequestBody message: MessageVM) { 183 | messageService.post(message) 184 | } 185 | } 186 | ----- 187 | 188 | 💡In Kotlin, `if` https://kotlinlang.org/docs/reference/control-flow.html#if-expression[is an expression], and it returns a value. This is why we can assign the result of an `if` expression to a variable: `val messages = if (lastMessageId.isNotEmpty()) { … }` 189 | 190 | 💡 The Kotlin standard library contains https://kotlinlang.org/docs/reference/scope-functions.html[scope functions] whose sole purpose is to execute a block of code within the context of an object. In the example above, we use the https://kotlinlang.org/docs/reference/scope-functions.html#with[`with()`] function to build a response object. 191 | 192 | 193 | ==== FakeMessageService 194 | 195 | `FakeMessageService` is the initial implementation of the `MessageService` interface. It supplies fake data to our chat. We use the http://dius.github.io/java-faker/[Java Faker] library to generate the fake data. The service generates random messages using famous quotes from Shakespeare, Yoda, and Rick & Morty: 196 | 197 | 198 | [source,kotlin] 199 | ----- 200 | @Service 201 | class FakeMessageService : MessageService { 202 | 203 | val users: Map = mapOf( 204 | "Shakespeare" to UserVM("Shakespeare", URL("https://blog.12min.com/wp-content/uploads/2018/05/27d-William-Shakespeare.jpg")), 205 | "RickAndMorty" to UserVM("RickAndMorty", URL("http://thecircular.org/wp-content/uploads/2015/04/rick-and-morty-fb-pic1.jpg")), 206 | "Yoda" to UserVM("Yoda", URL("https://news.toyark.com/wp-content/uploads/sites/4/2019/03/SH-Figuarts-Yoda-001.jpg")) 207 | ) 208 | 209 | val usersQuotes: Map String> = mapOf( 210 | "Shakespeare" to { Faker.instance().shakespeare().asYouLikeItQuote() }, 211 | "RickAndMorty" to { Faker.instance().rickAndMorty().quote() }, 212 | "Yoda" to { Faker.instance().yoda().quote() } 213 | ) 214 | 215 | override fun latest(): List { 216 | val count = Random.nextInt(1, 15) 217 | return (0..count).map { 218 | val user = users.values.random() 219 | val userQuote = usersQuotes.getValue(user.name).invoke() 220 | 221 | MessageVM(userQuote, user, Instant.now(), 222 | Random.nextBytes(10).toString()) 223 | }.toList() 224 | } 225 | 226 | override fun after(lastMessageId: String): List { 227 | return latest() 228 | } 229 | 230 | override fun post(message: MessageVM) { 231 | TODO("Not yet implemented") 232 | } 233 | } 234 | ----- 235 | 236 | 237 | 💡 Kotlin features https://kotlinlang.org/docs/reference/lambdas.html#function-types[functional types], which we often use in a form of https://kotlinlang.org/docs/reference/lambdas.html#lambda-expressions-and-anonymous-functions[lambda expressions]. In the example above, `userQuotes` is a map object where the keys are strings and the values are lambda expressions. A type signature of `() -> String` says that the lambda expression takes no arguments and produces `String` as a result. Hence, the type of `userQuotes` is specified as `Map<String, () -> String>` 238 | 239 | 💡 The `mapOf` function lets you create a map of `Pair`s, where the pair’s definition is provided with an https://kotlinlang.org/docs/reference/extensions.html[extension] method `<A, B> A.to(that: B): Pair<A, B>`. 240 | 241 | 💡 The `TODO()` function plays two roles: the reminder role and the stab role, as it always throws the `NotImplementedError` exception. 242 | 243 | The main task of the `FakeMessageService` class is to generate a random number of fake messages to be sent to the chat’s UI. The `latest()` method is the place where this logic is implemented. 244 | 245 | [source,kotlin] 246 | ----- 247 | val count = Random.nextInt(1, 15) 248 | return (0..count).map { 249 | val user = users.values.random() 250 | val userQuote = usersQuotes.getValue(user.name).invoke() 251 | 252 | MessageVM(userQuote, user, Instant.now(), Random.nextBytes(10).toString()) 253 | }.toList() 254 | ----- 255 | 256 | In Kotlin, to generate a https://kotlinlang.org/docs/reference/ranges.html[range] of integers all we need to do is say `(0..count)`. We then apply a `map()` function to transform each number into a message. 257 | 258 | Notably, the selection of a random element from any collection is also quite simple. Kotlin provides an extension method for collections, which is called `random()`. We use this extension method to select and return a user from the list: `users.values.random()` 259 | 260 | Once the user is selected, we need to acquire the user’s quote from the `userQuotes` map. The selected value from `userQuotes` is actually a lambda expression that we have to invoke in order to acquire a real quote: `usersQuotes.getValue(user.name).invoke()` 261 | 262 | Next, we create an instance of the `MessageVM` class. This is a view model used to deliver data to a client: 263 | 264 | [source,kotlin] 265 | ----- 266 | data class MessageVM(val content: String, val user: UserVM, val sent: Instant, val id: String? = null) 267 | ----- 268 | 269 | 💡For https://kotlinlang.org/docs/reference/data-classes.html[data classes], the compiler automatically generates the `toString`, `equals`, and `hashCode` functions, minimizing the amount of utility code that you have to write. 270 | 271 | == Part 2: Adding persistence and integration tests 272 | 273 | In this part, we will implement a persisting version of the `MessageService` interface using Spring Data JDBC and H2 as the database. We will introduce the following classes: 274 | 275 | * `PersistentMessageService` – an implementation of the `MessageService` interface, which will interact with the real data storage via the Spring Data Repository API. 276 | * `MessageRepository` – a repository implementation used by `MessageService.` 277 | 278 | === Adding new dependencies 279 | First of all, we have to add the required dependencies to the project. For that, we need to add to the following lines to the `dependencies` block in the` build.gradle.kts `file: 280 | 281 | [source,kotlin] 282 | ----- 283 | implementation("org.springframework.boot:spring-boot-starter-data-jdbc") 284 | runtimeOnly("com.h2database:h2") 285 | ----- 286 | 287 | ⚠️ Note, in this example, we use `spring-data-jdbc` as a lightweight and straightforward way to use JDBC in Spring Framework. If you wish to see an example of JPA usage, please see the following https://spring.io/guides/tutorials/spring-boot-kotlin/?#_persistence_with_jpa[blog post]. 288 | 289 | ⚠️ To refresh the list of the project dependencies, click on the little elephant icon that appears in the top right-hand corner of the editor. 290 | 291 | image::{images}/intellij-gradle-reload.png[] 292 | 293 | === Create database schema and configuration 294 | 295 | Once the dependencies are added and resolved, we can start modeling our database schema. Since this is a demo project, we will not be designing anything complex and we’ll stick to the following structure: 296 | 297 | [source,sql] 298 | ----- 299 | CREATE TABLE IF NOT EXISTS messages ( 300 | id VARCHAR(60) DEFAULT RANDOM_UUID() PRIMARY KEY, 301 | content VARCHAR NOT NULL, 302 | content_type VARCHAR(128) NOT NULL, 303 | sent TIMESTAMP NOT NULL, 304 | username VARCHAR(60) NOT NULL, 305 | user_avatar_image_link VARCHAR(256) NOT NULL 306 | ); 307 | ----- 308 | 309 | ⌨️ Create a new folder called `sql` in the `src/main/resources` directory. Then put the SQL code from above into the `src/main/resources/sql/schema.sql` file. 310 | 311 | image::{images}/schema-sql-location.png[] 312 | 313 | Also, you should modify `application.properties` so it contains the following attributes: 314 | 315 | [source,properties] 316 | ----- 317 | spring.datasource.schema=classpath:sql/schema.sql 318 | spring.datasource.url=jdbc:h2:file:./build/data/testdb 319 | spring.datasource.driverClassName=org.h2.Driver 320 | spring.datasource.username=sa 321 | spring.datasource.password=password 322 | spring.datasource.initialization-mode=always 323 | ----- 324 | 325 | === Working with data 326 | 327 | Using Spring Data, the table mentioned above can be expressed using the following domain classes, which should be put in the `src/main/kotlin/com/example/kotlin/chat/repository/DomainModel.kt `file: 328 | 329 | [source,kotlin] 330 | ----- 331 | import org.springframework.data.annotation.Id 332 | import org.springframework.data.relational.core.mapping.Table 333 | import java.time.Instant 334 | 335 | @Table("MESSAGES") 336 | data class Message( 337 | val content: String, 338 | val contentType: ContentType, 339 | val sent: Instant, 340 | val username: String, 341 | val userAvatarImageLink: String, 342 | @Id var id: String? = null) 343 | 344 | enum class ContentType { 345 | PLAIN 346 | } 347 | ----- 348 | 349 | There are a few things here that require explanation. Fields like `content`, `sent`, and `id` mirror the `MessageVM` class. However, to decrease the number of tables and simplify the final relationship structure, we’ve flattened the `User` object and make its fields a part of the `Message` class. Apart from that, there is a new extra field called `contentType`, which indicates the content type of the stored message. Since most modern chats support different markup languages, it is common to support different message content encodings. At first we will just support `PLAIN` text, but later we will extend `ContentType` to support the `MARKDOWN` type, too. 350 | 351 | Once we have the table representation as a class, we may introduce convenient access to the data via `Repository`. 352 | 353 | ⌨️ Put `MessageRepository.kt` in the `src/main/kotlin/com/example/kotlin/chat/repository` folder. 354 | 355 | [source,kotlin] 356 | ----- 357 | import org.springframework.data.jdbc.repository.query.Query 358 | import org.springframework.data.repository.CrudRepository 359 | import org.springframework.data.repository.query.Param 360 | 361 | interface MessageRepository : CrudRepository { 362 | 363 | // language=SQL 364 | @Query(""" 365 | SELECT * FROM ( 366 | SELECT * FROM MESSAGES 367 | ORDER BY "SENT" DESC 368 | LIMIT 10 369 | ) ORDER BY "SENT" 370 | """) 371 | fun findLatest(): List 372 | 373 | // language=SQL 374 | @Query(""" 375 | SELECT * FROM ( 376 | SELECT * FROM MESSAGES 377 | WHERE SENT > (SELECT SENT FROM MESSAGES WHERE ID = :id) 378 | ORDER BY "SENT" DESC 379 | ) ORDER BY "SENT" 380 | """) 381 | fun findLatest(@Param("id") id: String): List 382 | } 383 | ----- 384 | 385 | Our `MessageRepository` extends an ordinary `CrudRepository` and provides two different methods with custom queries for retrieving the latest messages and for retrieving messages associated with specific message IDs. 386 | 387 | 💡 Did you notice the https://kotlinlang.org/docs/reference/basic-types.html#string-literals[multiline Strings] used to express the SQL query in the readable format? Kotlin provides a set of useful additions for Strings. You can learn more about these additions in the Kotlin language https://kotlinlang.org/docs/reference/basic-types.html#strings[documentation] 388 | 389 | Our next step is implementing the `MessageService` class that integrates with the `MessageRepository` class. 390 | 391 | ⌨️ Put the `PersistentMessageService` class into the `src/main/kotlin/com/example/kotlin/chat/service` folder, replacing the previous `FakeMessageService` implementation. 392 | 393 | [source,kotlin] 394 | ----- 395 | package com.example.kotlin.chat.service 396 | 397 | import com.example.kotlin.chat.repository.ContentType 398 | import com.example.kotlin.chat.repository.Message 399 | import com.example.kotlin.chat.repository.MessageRepository 400 | import org.springframework.context.annotation.Primary 401 | import org.springframework.stereotype.Service 402 | import java.net.URL 403 | 404 | @Service 405 | @Primary 406 | class PersistentMessageService(val messageRepository: MessageRepository) : MessageService { 407 | 408 | override fun latest(): List = 409 | messageRepository.findLatest() 410 | .map { with(it) { MessageVM(content, UserVM(username, 411 | URL(userAvatarImageLink)), sent, id) } } 412 | 413 | override fun after(lastMessageId: String): List = 414 | messageRepository.findLatest(lastMessageId) 415 | .map { with(it) { MessageVM(content, UserVM(username, 416 | URL(userAvatarImageLink)), sent, id) } } 417 | 418 | override fun post(message: MessageVM) { 419 | messageRepository.save( 420 | with(message) { Message(content, ContentType.PLAIN, sent, 421 | user.name, user.avatarImageLink.toString()) } 422 | ) 423 | } 424 | } 425 | ----- 426 | 427 | `PersistentMessageService` is a thin layer for the `MessageRepository`, since here we are just doing some simple object mapping. All business queries take place on the `Repository` level. On the other hand, the simplicity of this implementation is the merit of the Kotlin language, which provides extension functions like `map` and `with`. 428 | 429 | If we now launch the application, we will once again see an empty chat page. However, if we type a message into the text input and send it, we will see it appear on the screen a few moments later. If we open a new browser page, we will see this message again as a part of the message history. 430 | 431 | Finally, we can write a few integration tests to ensure that our code will continue to work properly over time. 432 | 433 | === Adding integration tests 434 | 435 | To begin, we have to modify the `ChatKotlinApplicationTests` file in `/src/test` and add the fields we will need to use in the tests: 436 | 437 | [source,kotlin] 438 | ----- 439 | import com.example.kotlin.chat.repository.ContentType 440 | import com.example.kotlin.chat.repository.Message 441 | import com.example.kotlin.chat.repository.MessageRepository 442 | import com.example.kotlin.chat.service.MessageVM 443 | import com.example.kotlin.chat.service.UserVM 444 | import org.assertj.core.api.Assertions.assertThat 445 | import org.junit.jupiter.api.AfterEach 446 | import org.junit.jupiter.api.BeforeEach 447 | import org.junit.jupiter.api.Test 448 | import org.junit.jupiter.params.ParameterizedTest 449 | import org.junit.jupiter.params.provider.ValueSource 450 | import org.springframework.beans.factory.annotation.Autowired 451 | import org.springframework.boot.test.context.SpringBootTest 452 | import org.springframework.boot.test.web.client.TestRestTemplate 453 | import org.springframework.boot.test.web.client.postForEntity 454 | import org.springframework.core.ParameterizedTypeReference 455 | import org.springframework.http.HttpMethod 456 | import org.springframework.http.RequestEntity 457 | import java.net.URI 458 | import java.net.URL 459 | import java.time.Instant 460 | import java.time.temporal.ChronoUnit.MILLIS 461 | 462 | @SpringBootTest( 463 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 464 | properties = [ 465 | "spring.datasource.url=jdbc:h2:mem:testdb" 466 | ] 467 | ) 468 | class ChatKotlinApplicationTests { 469 | 470 | @Autowired 471 | lateinit var client: TestRestTemplate 472 | 473 | @Autowired 474 | lateinit var messageRepository: MessageRepository 475 | 476 | lateinit var lastMessageId: String 477 | 478 | val now: Instant = Instant.now() 479 | } 480 | ----- 481 | 482 | We use the https://kotlinlang.org/docs/reference/properties.html#late-initialized-properties-and-variables[lateinit] keyword, which works perfectly for cases where the initialization of non-null fields has to be deferred. In our case, we use it to `@Autowire` the `MessageRepository` field and resolve `TestRestTemplate`. 483 | 484 | For simplicity, we will be testing three general cases: 485 | 486 | * Resolving message when `lastMessageId` is not available. 487 | * Resolving message when `lastMessageId` is present. 488 | * And sending messages. 489 | 490 | To test message resolution, we have to prepare some test messages, as well as clean up the storage after the completion of each case. Add the following to `ChatKotlinApplicationTests`: 491 | 492 | [source,kotlin] 493 | ----- 494 | @BeforeEach 495 | fun setUp() { 496 | val secondBeforeNow = now.minusSeconds(1) 497 | val twoSecondBeforeNow = now.minusSeconds(2) 498 | val savedMessages = messageRepository.saveAll(listOf( 499 | Message( 500 | "*testMessage*", 501 | ContentType.PLAIN, 502 | twoSecondBeforeNow, 503 | "test", 504 | "http://test.com" 505 | ), 506 | Message( 507 | "**testMessage2**", 508 | ContentType.PLAIN, 509 | secondBeforeNow, 510 | "test1", 511 | "http://test.com" 512 | ), 513 | Message( 514 | "`testMessage3`", 515 | ContentType.PLAIN, 516 | now, 517 | "test2", 518 | "http://test.com" 519 | ) 520 | )) 521 | lastMessageId = savedMessages.first().id ?: "" 522 | } 523 | 524 | @AfterEach 525 | fun tearDown() { 526 | messageRepository.deleteAll() 527 | } 528 | ----- 529 | 530 | 531 | Once the preparation is done, we can create our first test case for message retrieval: 532 | 533 | [source,kotlin] 534 | ----- 535 | @ParameterizedTest 536 | @ValueSource(booleans = [true, false]) 537 | fun `test that messages API returns latest messages`(withLastMessageId: Boolean) { 538 | val messages: List? = client.exchange( 539 | RequestEntity( 540 | HttpMethod.GET, 541 | URI("/api/v1/messages?lastMessageId=${if (withLastMessageId) lastMessageId else ""}") 542 | ), 543 | object : ParameterizedTypeReference>() {}).body 544 | 545 | if (!withLastMessageId) { 546 | assertThat(messages?.map { with(it) { copy(id = null, sent = sent.truncatedTo(MILLIS))}}) 547 | .first() 548 | .isEqualTo(MessageVM( 549 | "*testMessage*", 550 | UserVM("test", URL("http://test.com")), 551 | now.minusSeconds(2).truncatedTo(MILLIS) 552 | )) 553 | } 554 | 555 | assertThat(messages?.map { with(it) { copy(id = null, sent = sent.truncatedTo(MILLIS))}}) 556 | .containsSubsequence( 557 | MessageVM( 558 | "**testMessage2**", 559 | UserVM("test1", URL("http://test.com")), 560 | now.minusSeconds(1).truncatedTo(MILLIS) 561 | ), 562 | MessageVM( 563 | "`testMessage3`", 564 | UserVM("test2", URL("http://test.com")), 565 | now.truncatedTo(MILLIS) 566 | ) 567 | ) 568 | } 569 | ----- 570 | 571 | 💡 All data classes have a https://kotlinlang.org/docs/reference/data-classes.html#copying[`copy`] method, which lets you make a full copy of the instance while customizing certain fields if necessary. This is very useful in our case, since we want to truncate the message sent time to the same time units so we can compare the timestamps. 572 | 573 | 💡 Kotlin’s support for https://kotlinlang.org/docs/reference/basic-types.html#string-templates[String templates] is an excellent addition for testing. 574 | 575 | Once we have implemented this test, the last piece that we have to implement is a message posting test. Add the following code to `ChatKotlinApplicationTests`: 576 | 577 | [source,kotlin] 578 | ----- 579 | @Test 580 | fun `test that messages posted to the API is stored`() { 581 | client.postForEntity( 582 | URI("/api/v1/messages"), 583 | MessageVM( 584 | "`HelloWorld`", 585 | UserVM("test", URL("http://test.com")), 586 | now.plusSeconds(1) 587 | ) 588 | ) 589 | 590 | messageRepository.findAll() 591 | .first { it.content.contains("HelloWorld") } 592 | .apply { 593 | assertThat(this.copy(id = null, sent = sent.truncatedTo(MILLIS))) 594 | .isEqualTo(Message( 595 | "`HelloWorld`", 596 | ContentType.PLAIN, 597 | now.plusSeconds(1).truncatedTo(MILLIS), 598 | "test", 599 | "http://test.com" 600 | )) 601 | } 602 | } 603 | ----- 604 | 605 | 💡 It's acceptable to use function names with spaces enclosed in backticks _in tests_. See the related https://kotlinlang.org/docs/reference/coding-conventions.html#function-names[documentation]. 606 | 607 | The test above looks similar to the previous one, except we check that the posted messages are stored in the database. In this example, we can see the https://kotlinlang.org/docs/reference/scope-functions.html#run[`run`] scope function, which makes it possible to use the target object within the invocation scope as `this`. 608 | 609 | Once we have implemented all these tests, we can run them and see whether they pass. 610 | 611 | image::{images}/intellij-running-tests.png[] 612 | 613 | At this stage, we added message persistence to our chat application. The messages can now be delivered to all active clients that connect to the application. Additionally, we can now access the historical data, so everyone can read previous messages if they need to. 614 | 615 | This implementation may look complete, but the code we wrote has some room for improvement. Therefore, we will see how our code can be improved with Kotlin extensions during the next step. 616 | 617 | == Part 3: Implementing extensions 618 | 619 | In this part, we will be implementing https://kotlinlang.org/docs/reference/extensions.html[extension functions] to decrease the amount of code repetition in a few places. 620 | 621 | For example, you may notice that the `Message` <--> `MessageVM` conversion currently happens explicitly in the `PersistableMessageService`. We may also want to extend the support for a different content type by adding support for Markdown. 622 | 623 | First, we create the extension methods for `Message` and `MessageVM`. The new methods implement the conversion logic from `Message` to `MessageVM` and vice versa: 624 | 625 | 626 | [source,kotlin] 627 | ----- 628 | import com.example.kotlin.chat.repository.ContentType 629 | import com.example.kotlin.chat.repository.Message 630 | import com.example.kotlin.chat.service.MessageVM 631 | import com.example.kotlin.chat.service.UserVM 632 | import java.net.URL 633 | 634 | fun MessageVM.asDomainObject(contentType: ContentType = ContentType.PLAIN): Message = Message( 635 | content, 636 | contentType, 637 | sent, 638 | user.name, 639 | user.avatarImageLink.toString(), 640 | id 641 | ) 642 | 643 | fun Message.asViewModel(): MessageVM = MessageVM( 644 | content, 645 | UserVM(username, URL(userAvatarImageLink)), 646 | sent, 647 | id 648 | ) 649 | ----- 650 | 651 | 652 | ⌨️ We’ll store the above functions in the `src/main/kotlin/com/example/kotlin/chat/Extensions.kt` file. 653 | 654 | Now that we have extension methods for `MessageVM` and `Message` conversion, we can use them in the `PersistentMessageService`: 655 | 656 | 657 | [source,kotlin] 658 | ----- 659 | @Service 660 | class PersistentMessageService(val messageRepository: MessageRepository) : MessageService { 661 | 662 | override fun latest(): List = 663 | messageRepository.findLatest() 664 | .map { it.asViewModel() } 665 | 666 | override fun after(lastMessageId: String): List = 667 | messageRepository.findLatest(lastMessageId) 668 | .map { it.asViewModel() } 669 | 670 | override fun post(message: MessageVM) { 671 | messageRepository.save(message.asDomainObject()) 672 | } 673 | } 674 | ----- 675 | 676 | The code above is better than it was before. It is more concise and it reads better. However, we can improve even further. As we can see, we use the same `map()`operators with the same function mapper twice. In fact, we can improve that by adding a custom `map` function for a `List` with a specific generic type. Add the following line to the `Extensions.kt` file: 677 | 678 | 679 | [source,kotlin] 680 | ----- 681 | fun List.mapToViewModel(): List = map { it.asViewModel() } 682 | ----- 683 | 684 | With this line included, Kotlin will provide the mentioned extension method to any `List` whose generic type corresponds to the specified one: 685 | 686 | [source,kotlin] 687 | ----- 688 | @Service 689 | class PersistentMessageService(val messageRepository: MessageRepository) : MessageService { 690 | 691 | override fun latest(): List = 692 | messageRepository.findLatest() 693 | .mapToViewModel() // now we can use the mentioned extension on List 694 | 695 | override fun after(lastMessageId: String): List = 696 | messageRepository.findLatest(lastMessageId) 697 | .mapToViewModel() 698 | //... 699 | } 700 | ----- 701 | 702 | ⚠️ Note that you cannot use the same extension name for the same class with a different generic type. The reason for this is https://kotlinlang.org/docs/reference/generics.html#type-erasure[type erasure], which means that at runtime, the same method would be used for both classes, and it would not be possible to guess which one should be invoked. 703 | 704 | Once all the extensions are applied, we can do a similar trick and declare supportive extensions for usage in test classes. Put the following in the `src/test/kotlin/com/example/kotlin/chat/TestExtensions.kt` file 705 | 706 | [source,kotlin] 707 | ----- 708 | import com.example.kotlin.chat.repository.Message 709 | import com.example.kotlin.chat.service.MessageVM 710 | import java.time.temporal.ChronoUnit.MILLIS 711 | 712 | fun MessageVM.prepareForTesting() = copy(id = null, sent = sent.truncatedTo(MILLIS)) 713 | 714 | fun Message.prepareForTesting() = copy(id = null, sent = sent.truncatedTo(MILLIS)) 715 | ----- 716 | 717 | We can now move forward and implement support for the `MARKDOWN` content type. First of all, we need to add the utility for Markdown content rendering. For this purpose, we can add an https://github.com/valich/intellij-markdown[official Markdown library] from JetBrains to the `build.gradle.kts` file: 718 | 719 | 720 | [source] 721 | ----- 722 | dependencies { 723 | ... 724 | implementation("org.jetbrains:markdown:0.2.2") 725 | ... 726 | } 727 | ----- 728 | 729 | Since we have already learned how to use extensions, let’s create another one in the `Extensions.kt` file for the `ContentType` enum, so each enum value will know how to render a specific content. 730 | 731 | 732 | [source,kotlin] 733 | ----- 734 | fun ContentType.render(content: String): String = when (this) { 735 | ContentType.PLAIN -> content 736 | } 737 | ----- 738 | 739 | In the example above, we use a https://kotlinlang.org/docs/reference/control-flow.html#when-expression[`when`] expression, which provides pattern-matching in Kotlin. If `when` is used as an expression, the `else` branch is mandatory. However, if the `when` expression is used with exhaustive values (e.g. `enum` with a constant number of outcomes or `sealed classes` with the defined number of subclasses), then the `else` branch is not required. The example above is precisely one of those cases where we know at compile-time all the possible outcomes (and all of them are handled), thus we don’t have to specify the `else` branch. 740 | 741 | Now that we know how the `when` expression works, let’s finally add a second option to the `ContentType` enum: 742 | 743 | [source,kotlin] 744 | ----- 745 | enum class ContentType { 746 | PLAIN, MARKDOWN 747 | } 748 | ----- 749 | 750 | The power of the `when` expression comes with the strong requirement to be exhaustive. Any timea new value is added to `enum`, we have to fix compilation issues before pushing our software to production: 751 | 752 | [source,kotlin] 753 | ----- 754 | fun ContentType.render(content: String): String = when (this) { 755 | ContentType.PLAIN -> content 756 | ContentType.MARKDOWN -> { 757 | val flavour = CommonMarkFlavourDescriptor() 758 | HtmlGenerator(content, MarkdownParser(flavour).buildMarkdownTreeFromString(content), 759 | flavour).generateHtml() 760 | } 761 | } 762 | ----- 763 | 764 | Once we have fixed the `render` method to support the new `ContentType`, we can modify `Message` and `MessageVM` extensions methods to enable use of the `MARKDOWN` type and render its content accordingly: 765 | 766 | [source,kotlin] 767 | ----- 768 | fun MessageVM.asDomainObject(contentType: ContentType = ContentType.MARKDOWN): Message = Message( 769 | content, 770 | contentType, 771 | sent, 772 | user.name, 773 | user.avatarImageLink.toString(), 774 | id 775 | ) 776 | 777 | fun Message.asViewModel(): MessageVM = MessageVM( 778 | contentType.render(content), 779 | UserVM(username, URL(userAvatarImageLink)), 780 | sent, 781 | id 782 | ) 783 | ----- 784 | 785 | We also need to modify the tests to ensure that the `MARKDOWN` content type is rendered correctly. For this purpose, we have to alter the `ChatKotlinApplicationTests.kt` and change the following: 786 | 787 | [source,kotlin] 788 | ----- 789 | @BeforeEach 790 | fun setUp() { 791 | //... 792 | Message( 793 | "*testMessage*", 794 | ContentType.PLAIN, 795 | twoSecondBeforeNow, 796 | "test", 797 | "http://test.com" 798 | ), 799 | Message( 800 | "**testMessage2**", 801 | ContentType.MARKDOWN, 802 | secondBeforeNow, 803 | "test1", 804 | "http://test.com" 805 | ), 806 | Message( 807 | "`testMessage3`", 808 | ContentType.MARKDOWN, 809 | now, 810 | "test2", 811 | "http://test.com" 812 | ) 813 | //... 814 | } 815 | 816 | @ParameterizedTest 817 | @ValueSource(booleans = [true, false]) 818 | fun `test that messages API returns latest messages`(withLastMessageId: Boolean) { 819 | //... 820 | 821 | assertThat(messages?.map { it.prepareForTesting() }) 822 | .containsSubsequence( 823 | MessageVM( 824 | "

testMessage2

", 825 | UserVM("test1", URL("http://test.com")), 826 | now.minusSeconds(1).truncatedTo(MILLIS) 827 | ), 828 | MessageVM( 829 | "

testMessage3

", 830 | UserVM("test2", URL("http://test.com")), 831 | now.truncatedTo(MILLIS) 832 | ) 833 | ) 834 | } 835 | 836 | @Test 837 | fun `test that messages posted to the API are stored`() { 838 | //... 839 | messageRepository.findAll() 840 | .first { it.content.contains("HelloWorld") } 841 | .apply { 842 | assertThat(this.prepareForTesting()) 843 | .isEqualTo(Message( 844 | "`HelloWorld`", 845 | ContentType.MARKDOWN, 846 | now.plusSeconds(1).truncatedTo(MILLIS), 847 | "test", 848 | "http://test.com" 849 | )) 850 | } 851 | } 852 | ----- 853 | 854 | Once this is done, we will see that all tests are still passing, and the messages with the `MARKDOWN` content type are rendered as expected. 855 | 856 | In this step, we learned how to use extensions to improve code quality. We also learned the `when` expression and how it can reduce human error when it comes to adding new business features. 857 | 858 | == Part 4: Refactoring to Spring WebFlux with Kotlin Coroutines 859 | 860 | In this part of the tutorial, we will be modifying our codebase to add support for https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html[coroutines]. 861 | 862 | Essentially, coroutines are light-weight threads that make it possible to express asynchronous code in an imperative manner. This solves various https://stackoverflow.com/a/11632412/4891253[problems] associated with the callback (observer) pattern which was used above to achieve the same effect. 863 | 864 | ⚠️ In this tutorial, we will not look too closely at the coroutines and the standard *kotlinx.coroutines* library. To learn more about coroutines and their features, please take a look at the following https://play.kotlinlang.org/hands-on/Introduction%20to%20Coroutines%20and%20Channels/01_Introduction[tutorial]. 865 | 866 | === Adding Coroutines 867 | 868 | To start using Kotlin coroutines, we have to add three additional libraries to the `build.gradle.kts`: 869 | 870 | [source] 871 | ----- 872 | dependencies { 873 | ... 874 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") 875 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive") 876 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") 877 | ... 878 | } 879 | ----- 880 | 881 | Once we’ve added the dependencies, we can start using the main coroutines-related keyword: `suspend`. The `suspend` keyword indicates that the function being called is an asynchronous one. Unlike in other languages where a similar concept is exposed via the `async` or `await` keywords, the `suspend` function must be handled in the coroutine context, which can be either another `suspend` function or an explicit coroutine https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/index.html[`Job`] created using the https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html[`CoroutineScope.launch`] or https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html[`runBlocking`] functions. 882 | 883 | Thus, as our very first step in our move to bring coroutines into the project, we will add the `suspend` keyword to all of the project’s controllers and service methods. For example, after the modification, the `MessageService` interface should look like this: 884 | 885 | [source,kotlin] 886 | ----- 887 | interface MessageService { 888 | 889 | suspend fun latest(): List 890 | 891 | suspend fun after(lastMessageId: String): List 892 | 893 | suspend fun post(message: MessageVM) 894 | } 895 | ----- 896 | 897 | 898 | The change above will also affect the places in our code where `MessageService` is used. All the functions in `PersistentMessageService` have to be updated accordingly by adding the `suspend` keyword. 899 | 900 | 901 | [source,kotlin] 902 | ----- 903 | @Service 904 | class PersistentMessageService(val messageRepository: MessageRepository) : MessageService { 905 | 906 | override suspend fun latest(): List = 907 | messageRepository.findLatest() 908 | .mapToViewModel() 909 | 910 | override suspend fun after(messageId: String): List = 911 | messageRepository.findLatest(messageId) 912 | .mapToViewModel() 913 | 914 | override suspend fun post(message: MessageVM) { 915 | messageRepository.save(message.asDomainObject()) 916 | } 917 | } 918 | ----- 919 | 920 | Both request handlers, `HtmlController` and `MessageResource`, have to be adjusted as well: 921 | 922 | [source,kotlin] 923 | ----- 924 | // src/main/kotlin/com/example/kotlin/chat/controller/HtmlController.kt 925 | 926 | @Controller 927 | class HtmlController(val messageService: MessageService) { 928 | 929 | @GetMapping("/") 930 | suspend fun index(model: Model): String { 931 | //... 932 | } 933 | } 934 | ----- 935 | 936 | [source,kotlin] 937 | ----- 938 | // src/main/kotlin/com/example/kotlin/chat/controller/MessageResource.kt 939 | 940 | @RestController 941 | @RequestMapping("/api/v1/messages") 942 | class MessageResource(val messageService: MessageService) { 943 | 944 | @GetMapping 945 | suspend fun latest(@RequestParam(value = "lastMessageId", defaultValue = "") lastMessageId: String): ResponseEntity> { 946 | //... 947 | } 948 | 949 | @PostMapping 950 | suspend fun post(@RequestBody message: MessageVM) { 951 | //... 952 | } 953 | } 954 | ----- 955 | 956 | 957 | We have prepared our code for migration to the reactive Spring stack, https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html[Spring WebFlux]. Read on! 958 | 959 | === Adding WebFlux and R2DBC 960 | 961 | Although in most cases it is enough to add the `org.jetbrains.kotlinx:kotlinx-coroutines-core` dependency, to have proper integration with Spring Framework we need to replace the web and database modules: 962 | 963 | 964 | [source] 965 | ----- 966 | dependencies { 967 | ... 968 | implementation("org.springframework.boot:spring-boot-starter-web") 969 | implementation("org.springframework.boot:spring-boot-starter-data-jdbc") 970 | ... 971 | } 972 | ----- 973 | 974 | with the following: 975 | 976 | [source] 977 | ----- 978 | dependencies { 979 | ... 980 | implementation("org.springframework.boot:spring-boot-starter-webflux") 981 | implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") 982 | implementation("io.r2dbc:r2dbc-h2") 983 | ... 984 | } 985 | ----- 986 | 987 | By adding the above dependencies, we replace the standard blocking https://docs.spring.io/spring-framework/docs/current/reference/html/web.html[Web MVC] with the fully reactive and non-blocking https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html[WebFlux]. Additionally, JDBC is replaced with a fully reactive and non-blocking https://r2dbc.io/[R2DBC]. 988 | 989 | Thanks to the hard work of all the Spring Framework engineers, migration from Spring Web MVC to Spring WebFlux is seamless, and we don't have to rewrite anything at all! For R2DBC, however, we have a few extra steps. First, we need to add a configuration class. 990 | 991 | ⌨️ We place this class into the `com/example/kotlin/chat/ChatKotlinApplication.kt` file, where the `main()` method of our application is. 992 | 993 | [source,kotlin] 994 | ----- 995 | @Configuration 996 | class Config { 997 | 998 | @Bean 999 | fun initializer(connectionFactory: ConnectionFactory): ConnectionFactoryInitializer { 1000 | val initializer = ConnectionFactoryInitializer() 1001 | initializer.setConnectionFactory(connectionFactory) 1002 | val populator = CompositeDatabasePopulator() 1003 | populator.addPopulators(ResourceDatabasePopulator(ClassPathResource("./sql/schema.sql"))) 1004 | initializer.setDatabasePopulator(populator) 1005 | return initializer 1006 | } 1007 | } 1008 | ----- 1009 | 1010 | The above configuration ensures that the table's schema is initialized when the application starts up. 1011 | 1012 | Next, we need to modify the properties in `application.properties` to include just one attribute: 1013 | 1014 | 1015 | [source,properties] 1016 | ----- 1017 | spring.r2dbc.url=r2dbc:h2:file:///./build/data/testdb;USER=sa;PASSWORD=password 1018 | ----- 1019 | 1020 | Once we have made a few basic configuration-related changes, we’ll perform the migration from Spring Data JDBC to Spring Data R2DBC. For this, we need to update the MessageRepository interface to derive from `CoroutineCrudRepository` and mark its methods with the `suspend` keyword. We do this as follows: 1021 | 1022 | 1023 | [source,kotlin] 1024 | ----- 1025 | interface MessageRepository : CoroutineCrudRepository { 1026 | 1027 | // language=SQL 1028 | @Query(""" 1029 | SELECT * FROM ( 1030 | SELECT * FROM MESSAGES 1031 | ORDER BY "SENT" DESC 1032 | LIMIT 10 1033 | ) ORDER BY "SENT" 1034 | """) 1035 | suspend fun findLatest(): List 1036 | 1037 | // language=SQL 1038 | @Query(""" 1039 | SELECT * FROM ( 1040 | SELECT * FROM MESSAGES 1041 | WHERE SENT > (SELECT SENT FROM MESSAGES WHERE ID = :id) 1042 | ORDER BY "SENT" DESC 1043 | ) ORDER BY "SENT" 1044 | """) 1045 | suspend fun findLatest(@Param("id") id: String): List 1046 | } 1047 | ----- 1048 | 1049 | All the methods of the `CoroutineCrudRepository` are designed with Kotlin coroutines in mind. 1050 | 1051 | ⚠️ Note that the `@Query` annotation is now in a different package, so it should be imported as the following: 1052 | 1053 | [source,kotlin] 1054 | ----- 1055 | import org.springframework.data.r2dbc.repository.Query 1056 | ----- 1057 | 1058 | At this stage, these changes should be sufficient to make your application asynchronous and non-blocking. Once the application is re-run, nothing should change from a functionality perspective, but the executions will now be asynchronous and non-blocking. 1059 | 1060 | Finally, we need to apply a few more fixes to our tests, as well. Since our `MessageRepository` is now asynchronous, we need to change the datasource URL and run all the related operations in the coroutine context, enclosed within `runBlocking` as shown below (in the `ChatKotlinApplicationTests.kt` file): 1061 | 1062 | [source,kotlin] 1063 | ----- 1064 | // ... 1065 | // new imports 1066 | import kotlinx.coroutines.flow.first 1067 | import kotlinx.coroutines.runBlocking 1068 | 1069 | @SpringBootTest( 1070 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 1071 | properties = [ 1072 | "spring.r2dbc.url=r2dbc:h2:mem:///testdb;USER=sa;PASSWORD=password" 1073 | ] 1074 | ) 1075 | class ChatKotlinApplicationTests { 1076 | //... 1077 | 1078 | @BeforeEach 1079 | fun setUp() { 1080 | runBlocking { 1081 | //... 1082 | } 1083 | } 1084 | 1085 | @AfterEach 1086 | fun tearDown() { 1087 | runBlocking { 1088 | //... 1089 | } 1090 | } 1091 | 1092 | //... 1093 | 1094 | @Test 1095 | fun `test that messages posted to the API is stored`() { 1096 | runBlocking { 1097 | //... 1098 | } 1099 | } 1100 | } 1101 | ----- 1102 | 1103 | Our application is now asynchronous and non-blocking. But it still uses polling to deliver the messages from the backend to the UI. In the next part, we will modify the application to use RSocket to stream the messages to all connected clients. 1104 | 1105 | 1106 | == Part 5: Streaming with RSocket 1107 | 1108 | We are going to use https://rsocket.io/[RSocket] to convert message delivery to a streaming-like approach. 1109 | 1110 | RSocket is a binary protocol for use on byte stream transports such as TCP and WebSockets. The API is provided for various programming languages, including https://github.com/rsocket/rsocket-kotlin[Kotlin]. However, in our example we do not need to use the API directly. Instead, we are going to use https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-messaging[Spring Messaging], which integrates with RSocket and provides a convenient annotation based approach to configuration. 1111 | 1112 | To start using RSocket with Spring, we need to add and import a new dependency to `build.gradle.kts`: 1113 | 1114 | [source] 1115 | ----- 1116 | dependencies { 1117 | .... 1118 | implementation("org.springframework.boot:spring-boot-starter-rsocket") 1119 | .... 1120 | } 1121 | ----- 1122 | 1123 | 1124 | Next, we’ll update `MessageRepository` to return an asynchronous stream of messages exposed through `Flow<Messages>` instead of `List`s. 1125 | 1126 | 1127 | [source,kotlin] 1128 | ----- 1129 | interface MessageRepository : CoroutineCrudRepository { 1130 | 1131 | //... 1132 | fun findLatest(): Flow 1133 | 1134 | //... 1135 | fun findLatest(@Param("id") id: String): Flow 1136 | } 1137 | ----- 1138 | 1139 | We need to make similar changes to the `MessageService` interface to prepare it for streaming. We no longer need the `suspend` keyword. Instead, we are going to use the `Flow` interface that represents the asynchronous data stream. Any function that produced a `List` as a result will now produce a `Flow` instead. The post method will receive the `Flow` type as an argument, as well. 1140 | 1141 | [source] 1142 | ----- 1143 | import kotlinx.coroutines.flow.Flow 1144 | 1145 | interface MessageService { 1146 | 1147 | fun latest(): Flow 1148 | 1149 | fun after(messageId: String): Flow 1150 | 1151 | fun stream(): Flow 1152 | 1153 | suspend fun post(messages: Flow) 1154 | } 1155 | ----- 1156 | 1157 | Now we can connect the dots and update the `PersistentMessageService` class to integrate the above changes. 1158 | 1159 | [source,kotlin] 1160 | ----- 1161 | import com.example.kotlin.chat.asDomainObject 1162 | import com.example.kotlin.chat.asRendered 1163 | import com.example.kotlin.chat.mapToViewModel 1164 | import com.example.kotlin.chat.repository.MessageRepository 1165 | import kotlinx.coroutines.flow.Flow 1166 | import kotlinx.coroutines.flow.MutableSharedFlow 1167 | import kotlinx.coroutines.flow.map 1168 | import kotlinx.coroutines.flow.onEach 1169 | import kotlinx.coroutines.flow.collect 1170 | import org.springframework.stereotype.Service 1171 | 1172 | @Service 1173 | class PersistentMessageService(val messageRepository: MessageRepository) : MessageService { 1174 | 1175 | val sender: MutableSharedFlow = MutableSharedFlow() 1176 | 1177 | override fun latest(): Flow = 1178 | messageRepository.findLatest() 1179 | .mapToViewModel() 1180 | 1181 | override fun after(messageId: String): Flow = 1182 | messageRepository.findLatest(messageId) 1183 | .mapToViewModel() 1184 | 1185 | override fun stream(): Flow = sender 1186 | 1187 | override suspend fun post(messages: Flow) = 1188 | messages 1189 | .onEach { sender.emit(it.asRendered()) } 1190 | .map { it.asDomainObject() } 1191 | .let { messageRepository.saveAll(it) } 1192 | .collect() 1193 | } 1194 | ----- 1195 | 1196 | First, since the `MessageService` interface has been changed, we need to update the method signatures in the corresponding implementation. Consequently, the `mapToViewModel `extension method that we defined previously in the `Extension.kt` file for the `List` type is now needed for the `Flow` type, instead. 1197 | 1198 | [source,kotlin] 1199 | ----- 1200 | import kotlinx.coroutines.flow.Flow 1201 | import kotlinx.coroutines.flow.map 1202 | 1203 | fun Flow.mapToViewModel(): Flow = map { it.asViewModel() } 1204 | ----- 1205 | 1206 | For better readability we also added the `asRendered` extension function for the MessageVM class. In `Extensions.kt` file: 1207 | 1208 | [source,kotlin] 1209 | ----- 1210 | fun MessageVM.asRendered(contentType: ContentType = ContentType.MARKDOWN): MessageVM = 1211 | this.copy(content = contentType.render(this.content)) 1212 | ----- 1213 | 1214 | Next, we will use the https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-mutable-shared-flow/[`MutableSharedFlow`] from the Coroutines API to broadcast messages to the connected clients. 1215 | 1216 | We are getting closer to the desired UI with the changes. Next, we are going to update `MessageResource` and `HtmlController`. 1217 | 1218 | `MessageResource` gets a totally new implementation. First of all, we are going to use this class to support messaging by applying the `@MessageMapping` annotation instead of `@RequestMapping`. The new methods, `send()` and `receive(),` are mapped to the same endpoint by `@MessageMapping("stream")` for duplex communication. 1219 | 1220 | 1221 | [source,kotlin] 1222 | ----- 1223 | @Controller 1224 | @MessageMapping("api.v1.messages") 1225 | class MessageResource(val messageService: MessageService) { 1226 | 1227 | @MessageMapping("stream") 1228 | suspend fun receive(@Payload inboundMessages: Flow) = 1229 | messageService.post(inboundMessages) 1230 | 1231 | @MessageMapping("stream") 1232 | fun send(): Flow = messageService 1233 | .stream() 1234 | .onStart { 1235 | emitAll(messageService.latest()) 1236 | } 1237 | } 1238 | ----- 1239 | 1240 | To send the messages to the UI, we open the `stream` from the `messageService`, implemented by the `PersistentMessageService `class, and call the `onStart` method to start streaming the events. When a new client connects to the service, it will first receive the messages from the history thanks to the block of code that is supplied to the `onStart` method as an argument: `emitAll(messageService.latest())`. The channel then stays open to stream new messages. 1241 | 1242 | The `HtmlController` class no longer needs to to handle any of the streaming logic. Its purpose is now to serve the static page, so the implementation becomes trivial: 1243 | 1244 | [source,kotlin] 1245 | ----- 1246 | @Controller 1247 | class HtmlController() { 1248 | 1249 | @GetMapping("/") 1250 | fun index(): String { 1251 | // implemented in src/main/resources/templates/chatrs.html 1252 | return "chatrs" 1253 | } 1254 | } 1255 | ----- 1256 | 1257 | Note that the UI template is now `chatrs.html` instead of `chat.html`. The new template includes the JavaScript code that configures a _WebSocket_ connection and interacts directly with the `api.v1.messages.stream` endpoint implemented by the `MessageResource` class. 1258 | 1259 | We need to make one last change to the `application.properties` file for RSocket to work properly. Add the following properties to the configuration: 1260 | 1261 | [source,properties] 1262 | ----- 1263 | spring.rsocket.server.transport=websocket 1264 | spring.rsocket.server.mapping-path=/rsocket 1265 | ----- 1266 | 1267 | The application is ready to start! Messages are now delivered to the chat UI without polling thanks to RSocket. Additionally, the backend of the application is fully asynchronous and non-blocking thanks to Spring WebFlux and Kotlin Coroutines. 1268 | 1269 | The final step for us in this tutorial is to update the tests. 1270 | 1271 | We are going to add one more dependency specifically for tests. https://github.com/cashapp/turbine[Turbine] is a small testing library. It simplifies testing by providing a few useful extensions to the `Flow` interface of kotlinx.coroutines. 1272 | 1273 | [source] 1274 | ----- 1275 | dependencies { 1276 | ... 1277 | testImplementation("app.cash.turbine:turbine:0.4.1") 1278 | ... 1279 | } 1280 | ----- 1281 | 1282 | The entrypoint for the library is the `test()` extension for `Flow<T>`, which accepts a block of code that implements the validation logic. The `test()` extension is a suspending function that will not return until the flow is complete or canceled. We will look at its application in a moment. 1283 | 1284 | Next, update the test dependencies. Instead of autowiring via fields, we’ll use a constructor to inject the dependencies. 1285 | 1286 | [source,kotlin] 1287 | ----- 1288 | class ChatKotlinApplicationTests { 1289 | 1290 | @Autowired 1291 | lateinit var client: TestRestTemplate 1292 | 1293 | @Autowired 1294 | lateinit var messageRepository: MessageRepository 1295 | 1296 | class ChatKotlinApplicationTests( 1297 | @Autowired val rsocketBuilder: RSocketRequester.Builder, 1298 | @Autowired val messageRepository: MessageRepository, 1299 | @LocalServerPort val serverPort: Int 1300 | ) { 1301 | ----- 1302 | 1303 | We use `RSocketRequest.Builder` instead of `TestRestTemplate` since the endpoint that is implemented by `MessageResource` talks over RSocket protocol. In the tests, we need to construct an instance of `RSocketRequester` and use it to make requests. Replace the old tests with the new code below: 1304 | 1305 | [source,kotlin] 1306 | ----- 1307 | @ExperimentalTime 1308 | @ExperimentalCoroutinesApi 1309 | @Test 1310 | fun `test that messages API streams latest messages`() { 1311 | runBlocking { 1312 | val rSocketRequester = 1313 | rsocketBuilder.websocket(URI("ws://localhost:${serverPort}/rsocket")) 1314 | 1315 | rSocketRequester 1316 | .route("api.v1.messages.stream") 1317 | .retrieveFlow() 1318 | .test { 1319 | assertThat(expectItem().prepareForTesting()) 1320 | .isEqualTo( 1321 | MessageVM( 1322 | "*testMessage*", 1323 | UserVM("test", URL("http://test.com")), 1324 | now.minusSeconds(2).truncatedTo(MILLIS) 1325 | ) 1326 | ) 1327 | 1328 | assertThat(expectItem().prepareForTesting()) 1329 | .isEqualTo( 1330 | MessageVM( 1331 | "

testMessage2

", 1332 | UserVM("test1", URL("http://test.com")), 1333 | now.minusSeconds(1).truncatedTo(MILLIS) 1334 | ) 1335 | ) 1336 | assertThat(expectItem().prepareForTesting()) 1337 | .isEqualTo( 1338 | MessageVM( 1339 | "

testMessage3

", 1340 | UserVM("test2", URL("http://test.com")), 1341 | now.truncatedTo(MILLIS) 1342 | ) 1343 | ) 1344 | 1345 | expectNoEvents() 1346 | 1347 | launch { 1348 | rSocketRequester.route("api.v1.messages.stream") 1349 | .dataWithType(flow { 1350 | emit( 1351 | MessageVM( 1352 | "`HelloWorld`", 1353 | UserVM("test", URL("http://test.com")), 1354 | now.plusSeconds(1) 1355 | ) 1356 | ) 1357 | }) 1358 | .retrieveFlow() 1359 | .collect() 1360 | } 1361 | 1362 | assertThat(expectItem().prepareForTesting()) 1363 | .isEqualTo( 1364 | MessageVM( 1365 | "

HelloWorld

", 1366 | UserVM("test", URL("http://test.com")), 1367 | now.plusSeconds(1).truncatedTo(MILLIS) 1368 | ) 1369 | ) 1370 | 1371 | cancelAndIgnoreRemainingEvents() 1372 | } 1373 | } 1374 | } 1375 | 1376 | @ExperimentalTime 1377 | @Test 1378 | fun `test that messages streamed to the API is stored`() { 1379 | runBlocking { 1380 | launch { 1381 | val rSocketRequester = 1382 | rsocketBuilder.websocket(URI("ws://localhost:${serverPort}/rsocket")) 1383 | 1384 | rSocketRequester.route("api.v1.messages.stream") 1385 | .dataWithType(flow { 1386 | emit( 1387 | MessageVM( 1388 | "`HelloWorld`", 1389 | UserVM("test", URL("http://test.com")), 1390 | now.plusSeconds(1) 1391 | ) 1392 | ) 1393 | }) 1394 | .retrieveFlow() 1395 | .collect() 1396 | } 1397 | 1398 | delay(2.seconds) 1399 | 1400 | messageRepository.findAll() 1401 | .first { it.content.contains("HelloWorld") } 1402 | .apply { 1403 | assertThat(this.prepareForTesting()) 1404 | .isEqualTo( 1405 | Message( 1406 | "`HelloWorld`", 1407 | ContentType.MARKDOWN, 1408 | now.plusSeconds(1).truncatedTo(MILLIS), 1409 | "test", 1410 | "http://test.com" 1411 | ) 1412 | ) 1413 | } 1414 | } 1415 | } 1416 | ----- 1417 | 1418 | == Summary 1419 | 1420 | This was the final part in the tutorial. We started with a simple chat application in which the UI was polling for new messages while the backend was blocking when running the database queries. We gradually added features to the application and migrated it to the reactive Spring stack. The backend is now fully asynchronous, making use of Spring WebFlux and Kotlin coroutines. 1421 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("org.springframework.boot") version "3.0.3" 5 | id("io.spring.dependency-management") version "1.1.0" 6 | kotlin("jvm") version "1.7.22" 7 | kotlin("plugin.spring") version "1.7.22" 8 | } 9 | 10 | group = "com.example.kotlin" 11 | version = "0.0.1-SNAPSHOT" 12 | java.sourceCompatibility = JavaVersion.VERSION_17 13 | 14 | 15 | repositories { 16 | mavenCentral() 17 | } 18 | 19 | dependencies { 20 | implementation("org.springframework.boot:spring-boot-starter") 21 | implementation("org.springframework.boot:spring-boot-starter-actuator") 22 | implementation("org.springframework.boot:spring-boot-starter-thymeleaf") 23 | implementation("org.springframework.boot:spring-boot-starter-web") 24 | 25 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 26 | implementation("com.github.javafaker:javafaker:1.0.2") 27 | 28 | implementation("org.jetbrains.kotlin:kotlin-reflect") 29 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 30 | 31 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 32 | exclude(group = "org.junit.vintage", module = "junit-vintage-engine") 33 | } 34 | 35 | } 36 | 37 | tasks.withType { 38 | useJUnitPlatform() 39 | } 40 | 41 | tasks.withType { 42 | kotlinOptions { 43 | freeCompilerArgs = listOf("-Xjsr305=strict") 44 | jvmTarget = "17" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | 88 | @rem Execute Gradle 89 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 90 | 91 | :end 92 | @rem End local scope for the variables with windows NT shell 93 | if "%ERRORLEVEL%"=="0" goto mainEnd 94 | 95 | :fail 96 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 97 | rem the _cmd.exe /c_ return code! 98 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 99 | exit /b 1 100 | 101 | :mainEnd 102 | if "%OS%"=="Windows_NT" endlocal 103 | 104 | :omega 105 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "chat-kotlin" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/ChatKotlinApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class ChatKotlinApplication 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/controller/HtmlController.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat.controller 2 | 3 | import com.example.kotlin.chat.service.MessageService 4 | import com.example.kotlin.chat.service.MessageVM 5 | import org.springframework.stereotype.Controller 6 | import org.springframework.ui.Model 7 | import org.springframework.ui.set 8 | import org.springframework.web.bind.annotation.GetMapping 9 | 10 | @Controller 11 | class HtmlController(val messageService: MessageService) { 12 | 13 | @GetMapping("/") 14 | fun index(model: Model): String { 15 | val messages: List = messageService.latest() 16 | 17 | model["messages"] = messages 18 | model["lastMessageId"] = messages.lastOrNull()?.id ?: "" 19 | 20 | return "chat" 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/controller/MessageResource.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat.controller 2 | 3 | import com.example.kotlin.chat.service.MessageService 4 | import com.example.kotlin.chat.service.MessageVM 5 | import org.springframework.http.ResponseEntity 6 | import org.springframework.web.bind.annotation.* 7 | 8 | @RestController 9 | @RequestMapping("/api/v1/messages") 10 | class MessageResource(val messageService: MessageService) { 11 | 12 | @GetMapping 13 | fun latest(@RequestParam(value = "lastMessageId", defaultValue = "") lastMessageId: String): ResponseEntity> { 14 | val messages = if (lastMessageId.isNotEmpty()) { 15 | messageService.after(lastMessageId) 16 | } else { 17 | messageService.latest() 18 | } 19 | 20 | return if (messages.isEmpty()) { 21 | with(ResponseEntity.noContent()) { 22 | header("lastMessageId", lastMessageId) 23 | build>() 24 | } 25 | } else { 26 | with(ResponseEntity.ok()) { 27 | header("lastMessageId", messages.last().id) 28 | body(messages) 29 | } 30 | } 31 | } 32 | 33 | @PostMapping 34 | fun post(@RequestBody message: MessageVM) { 35 | messageService.post(message) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/service/FakeMessageService.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat.service 2 | 3 | import com.github.javafaker.Faker 4 | import org.springframework.stereotype.Service 5 | import java.net.URL 6 | import java.time.Instant 7 | import kotlin.random.Random 8 | 9 | @Service 10 | class FakeMessageService : MessageService { 11 | 12 | val users: Map = mapOf( 13 | "Shakespeare" to UserVM("Shakespeare", URL("https://blog.12min.com/wp-content/uploads/2018/05/27d-William-Shakespeare.jpg")), 14 | "RickAndMorty" to UserVM("RickAndMorty", URL("http://thecircular.org/wp-content/uploads/2015/04/rick-and-morty-fb-pic1.jpg")), 15 | "Yoda" to UserVM("Yoda", URL("https://news.toyark.com/wp-content/uploads/sites/4/2019/03/SH-Figuarts-Yoda-001.jpg")) 16 | ) 17 | 18 | val usersQuotes: Map String> = mapOf( 19 | "Shakespeare" to { Faker.instance().shakespeare().asYouLikeItQuote() }, 20 | "RickAndMorty" to { Faker.instance().rickAndMorty().quote() }, 21 | "Yoda" to { Faker.instance().yoda().quote() } 22 | ) 23 | 24 | override fun latest(): List { 25 | val count = Random.nextInt(1, 15) 26 | return (0..count).map { 27 | val user = users.values.random() 28 | val userQuote = usersQuotes.getValue(user.name).invoke() 29 | 30 | MessageVM(userQuote, user, Instant.now(), Random.nextBytes(10).toString()) 31 | }.toList() 32 | } 33 | 34 | override fun after(messageId: String): List { 35 | return latest() 36 | } 37 | 38 | override fun post(message: MessageVM) { 39 | TODO("Not yet implemented") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/service/MessageService.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat.service 2 | 3 | interface MessageService { 4 | 5 | fun latest(): List 6 | 7 | fun after(messageId: String): List 8 | 9 | fun post(message: MessageVM) 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/example/kotlin/chat/service/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat.service 2 | 3 | import java.net.URL 4 | import java.time.Instant 5 | 6 | data class MessageVM(val content: String, val user: UserVM, val sent: Instant, val id: String? = null) 7 | 8 | data class UserVM(val name: String, val avatarImageLink: URL) -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/static/rsocket-flowable.js: -------------------------------------------------------------------------------- 1 | var rsocketFlowable = (function (exports) { 2 | 'use strict'; 3 | 4 | function _defineProperty(obj, key, value) { 5 | if (key in obj) { 6 | Object.defineProperty(obj, key, { 7 | value: value, 8 | enumerable: true, 9 | configurable: true, 10 | writable: true, 11 | }); 12 | } else { 13 | obj[key] = value; 14 | } 15 | 16 | return obj; 17 | } 18 | 19 | /** 20 | * Copyright (c) 2013-present, Facebook, Inc. 21 | * 22 | * This source code is licensed under the MIT license found in the 23 | * LICENSE file in the root directory of this source tree. 24 | * 25 | * 26 | */ 27 | var nullthrows = function nullthrows(x) { 28 | if (x != null) { 29 | return x; 30 | } 31 | 32 | throw new Error('Got unexpected null or undefined'); 33 | }; 34 | 35 | var nullthrows_1 = nullthrows; 36 | 37 | /** Copyright (c) Facebook, Inc. and its affiliates. 38 | * 39 | * Licensed under the Apache License, Version 2.0 (the "License"); 40 | * you may not use this file except in compliance with the License. 41 | * You may obtain a copy of the License at 42 | * 43 | * http://www.apache.org/licenses/LICENSE-2.0 44 | * 45 | * Unless required by applicable law or agreed to in writing, software 46 | * distributed under the License is distributed on an "AS IS" BASIS, 47 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 48 | * See the License for the specific language governing permissions and 49 | * limitations under the License. 50 | * 51 | * 52 | */ 53 | 54 | /** 55 | * An operator that acts like Array.map, applying a given function to 56 | * all values provided by its `Subscription` and passing the result to its 57 | * `Subscriber`. 58 | */ 59 | class FlowableMapOperator { 60 | constructor(subscriber, fn) { 61 | this._fn = fn; 62 | this._subscriber = subscriber; 63 | this._subscription = null; 64 | } 65 | 66 | onComplete() { 67 | this._subscriber.onComplete(); 68 | } 69 | 70 | onError(error) { 71 | this._subscriber.onError(error); 72 | } 73 | 74 | onNext(t) { 75 | try { 76 | this._subscriber.onNext(this._fn(t)); 77 | } catch (e) { 78 | nullthrows_1(this._subscription).cancel(); 79 | this._subscriber.onError(e); 80 | } 81 | } 82 | 83 | onSubscribe(subscription) { 84 | this._subscription = subscription; 85 | this._subscriber.onSubscribe(subscription); 86 | } 87 | } 88 | 89 | /** Copyright (c) Facebook, Inc. and its affiliates. 90 | * 91 | * Licensed under the Apache License, Version 2.0 (the "License"); 92 | * you may not use this file except in compliance with the License. 93 | * You may obtain a copy of the License at 94 | * 95 | * http://www.apache.org/licenses/LICENSE-2.0 96 | * 97 | * Unless required by applicable law or agreed to in writing, software 98 | * distributed under the License is distributed on an "AS IS" BASIS, 99 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 100 | * See the License for the specific language governing permissions and 101 | * limitations under the License. 102 | * 103 | * 104 | */ 105 | 106 | /** 107 | * An operator that requests a fixed number of values from its source 108 | * `Subscription` and forwards them to its `Subscriber`, cancelling the 109 | * subscription when the requested number of items has been reached. 110 | */ 111 | class FlowableTakeOperator { 112 | constructor(subscriber, toTake) { 113 | this._subscriber = subscriber; 114 | this._subscription = null; 115 | this._toTake = toTake; 116 | } 117 | 118 | onComplete() { 119 | this._subscriber.onComplete(); 120 | } 121 | 122 | onError(error) { 123 | this._subscriber.onError(error); 124 | } 125 | 126 | onNext(t) { 127 | try { 128 | this._subscriber.onNext(t); 129 | if (--this._toTake === 0) { 130 | this._cancelAndComplete(); 131 | } 132 | } catch (e) { 133 | nullthrows_1(this._subscription).cancel(); 134 | this._subscriber.onError(e); 135 | } 136 | } 137 | 138 | onSubscribe(subscription) { 139 | this._subscription = subscription; 140 | this._subscriber.onSubscribe(subscription); 141 | if (this._toTake <= 0) { 142 | this._cancelAndComplete(); 143 | } 144 | } 145 | 146 | _cancelAndComplete() { 147 | nullthrows_1(this._subscription).cancel(); 148 | this._subscriber.onComplete(); 149 | } 150 | } 151 | 152 | /** Copyright (c) Facebook, Inc. and its affiliates. 153 | * 154 | * Licensed under the Apache License, Version 2.0 (the "License"); 155 | * you may not use this file except in compliance with the License. 156 | * You may obtain a copy of the License at 157 | * 158 | * http://www.apache.org/licenses/LICENSE-2.0 159 | * 160 | * Unless required by applicable law or agreed to in writing, software 161 | * distributed under the License is distributed on an "AS IS" BASIS, 162 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 163 | * See the License for the specific language governing permissions and 164 | * limitations under the License. 165 | * 166 | * 167 | */ 168 | 169 | // $FlowFixMe 170 | class FlowableAsyncIterable { 171 | constructor(source, prefetch = 256) { 172 | this._source = source; 173 | this._prefetch = prefetch; 174 | } 175 | 176 | asyncIterator() { 177 | const asyncIteratorSubscriber = new AsyncIteratorSubscriber( 178 | this._prefetch 179 | ); 180 | this._source.subscribe(asyncIteratorSubscriber); 181 | return asyncIteratorSubscriber; 182 | } 183 | 184 | // $FlowFixMe 185 | [Symbol.asyncIterator]() { 186 | return this.asyncIterator(); 187 | } 188 | } 189 | 190 | // $FlowFixMe 191 | class AsyncIteratorSubscriber { 192 | constructor(prefetch = 256) { 193 | this._prefetch = prefetch; 194 | this._values = []; 195 | this._limit = 196 | prefetch === Number.MAX_SAFE_INTEGER 197 | ? Number.MAX_SAFE_INTEGER 198 | : prefetch - (prefetch >> 2); 199 | this._produced = 0; 200 | } 201 | 202 | onSubscribe(subscription) { 203 | this._subscription = subscription; 204 | subscription.request(this._prefetch); 205 | } 206 | 207 | onNext(value) { 208 | const resolve = this._resolve; 209 | if (resolve) { 210 | this._resolve = undefined; 211 | this._reject = undefined; 212 | 213 | if (++this._produced === this._limit) { 214 | this._produced = 0; 215 | this._subscription.request(this._limit); 216 | } 217 | 218 | resolve({done: false, value}); 219 | return; 220 | } 221 | 222 | this._values.push(value); 223 | } 224 | 225 | onComplete() { 226 | this._done = true; 227 | 228 | const resolve = this._resolve; 229 | if (resolve) { 230 | this._resolve = undefined; 231 | this._reject = undefined; 232 | 233 | resolve({done: true}); 234 | } 235 | } 236 | 237 | onError(error) { 238 | this._done = true; 239 | this._error = error; 240 | 241 | const reject = this._reject; 242 | if (reject) { 243 | this._resolve = undefined; 244 | this._reject = undefined; 245 | 246 | reject(error); 247 | } 248 | } 249 | 250 | next() { 251 | const value = this._values.shift(); 252 | if (value) { 253 | if (++this._produced === this._limit) { 254 | this._produced = 0; 255 | this._subscription.request(this._limit); 256 | } 257 | 258 | return Promise.resolve({done: false, value}); 259 | } else if (this._done) { 260 | if (this._error) { 261 | return Promise.reject(this._error); 262 | } else { 263 | return Promise.resolve({done: true}); 264 | } 265 | } else { 266 | return new Promise((resolve, reject) => { 267 | this._resolve = resolve; 268 | this._reject = reject; 269 | }); 270 | } 271 | } 272 | 273 | return() { 274 | this._subscription.cancel(); 275 | return Promise.resolve({done: true}); 276 | } 277 | 278 | // $FlowFixMe 279 | [Symbol.asyncIterator]() { 280 | return this; 281 | } 282 | } 283 | 284 | /** 285 | * Copyright (c) 2013-present, Facebook, Inc. 286 | * 287 | * This source code is licensed under the MIT license found in the 288 | * LICENSE file in the root directory of this source tree. 289 | * 290 | * 291 | */ 292 | 293 | var validateFormat = 294 | process.env.NODE_ENV !== 'production' 295 | ? function (format) { 296 | if (format === undefined) { 297 | throw new Error( 298 | 'invariant(...): Second argument must be a string.' 299 | ); 300 | } 301 | } 302 | : function (format) {}; 303 | /** 304 | * Use invariant() to assert state which your program assumes to be true. 305 | * 306 | * Provide sprintf-style format (only %s is supported) and arguments to provide 307 | * information about what broke and what you were expecting. 308 | * 309 | * The invariant message will be stripped in production, but the invariant will 310 | * remain to ensure logic does not differ in production. 311 | */ 312 | 313 | function invariant(condition, format) { 314 | for ( 315 | var _len = arguments.length, 316 | args = new Array(_len > 2 ? _len - 2 : 0), 317 | _key = 2; 318 | _key < _len; 319 | _key++ 320 | ) { 321 | args[_key - 2] = arguments[_key]; 322 | } 323 | 324 | validateFormat(format); 325 | 326 | if (!condition) { 327 | var error; 328 | 329 | if (format === undefined) { 330 | error = new Error( 331 | 'Minified exception occurred; use the non-minified dev environment ' + 332 | 'for the full error message and additional helpful warnings.' 333 | ); 334 | } else { 335 | var argIndex = 0; 336 | error = new Error( 337 | format.replace(/%s/g, function () { 338 | return String(args[argIndex++]); 339 | }) 340 | ); 341 | error.name = 'Invariant Violation'; 342 | } 343 | 344 | error.framesToPop = 1; // Skip invariant's own stack frame. 345 | 346 | throw error; 347 | } 348 | } 349 | 350 | var invariant_1 = invariant; 351 | 352 | /** 353 | * Copyright (c) 2013-present, Facebook, Inc. 354 | * 355 | * This source code is licensed under the MIT license found in the 356 | * LICENSE file in the root directory of this source tree. 357 | * 358 | * 359 | */ 360 | function makeEmptyFunction(arg) { 361 | return function () { 362 | return arg; 363 | }; 364 | } 365 | /** 366 | * This function accepts and discards inputs; it has no side effects. This is 367 | * primarily useful idiomatically for overridable function endpoints which 368 | * always need to be callable, since JS lacks a null-call idiom ala Cocoa. 369 | */ 370 | 371 | var emptyFunction = function emptyFunction() {}; 372 | 373 | emptyFunction.thatReturns = makeEmptyFunction; 374 | emptyFunction.thatReturnsFalse = makeEmptyFunction(false); 375 | emptyFunction.thatReturnsTrue = makeEmptyFunction(true); 376 | emptyFunction.thatReturnsNull = makeEmptyFunction(null); 377 | 378 | emptyFunction.thatReturnsThis = function () { 379 | return this; 380 | }; 381 | 382 | emptyFunction.thatReturnsArgument = function (arg) { 383 | return arg; 384 | }; 385 | 386 | var emptyFunction_1 = emptyFunction; 387 | 388 | /** 389 | * Similar to invariant but only logs a warning if the condition is not met. 390 | * This can be used to log issues in development environments in critical 391 | * paths. Removing the logging code for production environments will keep the 392 | * same logic and follow the same code paths. 393 | */ 394 | 395 | function printWarning(format) { 396 | for ( 397 | var _len = arguments.length, 398 | args = new Array(_len > 1 ? _len - 1 : 0), 399 | _key = 1; 400 | _key < _len; 401 | _key++ 402 | ) { 403 | args[_key - 1] = arguments[_key]; 404 | } 405 | 406 | var argIndex = 0; 407 | var message = 408 | 'Warning: ' + 409 | format.replace(/%s/g, function () { 410 | return args[argIndex++]; 411 | }); 412 | 413 | if (typeof console !== 'undefined') { 414 | console.error(message); 415 | } 416 | 417 | try { 418 | // --- Welcome to debugging React --- 419 | // This error was thrown as a convenience so that you can use this stack 420 | // to find the callsite that caused this warning to fire. 421 | throw new Error(message); 422 | } catch (x) {} 423 | } 424 | 425 | var warning = 426 | process.env.NODE_ENV !== 'production' 427 | ? function (condition, format) { 428 | if (format === undefined) { 429 | throw new Error( 430 | '`warning(condition, format, ...args)` requires a warning ' + 431 | 'message argument' 432 | ); 433 | } 434 | 435 | if (!condition) { 436 | for ( 437 | var _len2 = arguments.length, 438 | args = new Array(_len2 > 2 ? _len2 - 2 : 0), 439 | _key2 = 2; 440 | _key2 < _len2; 441 | _key2++ 442 | ) { 443 | args[_key2 - 2] = arguments[_key2]; 444 | } 445 | 446 | printWarning.apply(void 0, [format].concat(args)); 447 | } 448 | } 449 | : emptyFunction_1; 450 | var warning_1 = warning; 451 | 452 | /** Copyright (c) Facebook, Inc. and its affiliates. 453 | * 454 | * Licensed under the Apache License, Version 2.0 (the "License"); 455 | * you may not use this file except in compliance with the License. 456 | * You may obtain a copy of the License at 457 | * 458 | * http://www.apache.org/licenses/LICENSE-2.0 459 | * 460 | * Unless required by applicable law or agreed to in writing, software 461 | * distributed under the License is distributed on an "AS IS" BASIS, 462 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 463 | * See the License for the specific language governing permissions and 464 | * limitations under the License. 465 | * 466 | * 467 | */ 468 | 469 | /** 470 | * Implements the ReactiveStream `Publisher` interface with Rx-style operators. 471 | */ 472 | class Flowable { 473 | static just(...values) { 474 | return new Flowable((subscriber) => { 475 | let cancelled = false; 476 | let i = 0; 477 | subscriber.onSubscribe({ 478 | cancel: () => { 479 | cancelled = true; 480 | }, 481 | request: (n) => { 482 | while (!cancelled && n > 0 && i < values.length) { 483 | subscriber.onNext(values[i++]); 484 | n--; 485 | } 486 | if (!cancelled && i == values.length) { 487 | subscriber.onComplete(); 488 | } 489 | }, 490 | }); 491 | }); 492 | } 493 | 494 | static error(error) { 495 | return new Flowable((subscriber) => { 496 | subscriber.onSubscribe({ 497 | cancel: () => {}, 498 | request: () => { 499 | subscriber.onError(error); 500 | }, 501 | }); 502 | }); 503 | } 504 | 505 | static never() { 506 | return new Flowable((subscriber) => { 507 | subscriber.onSubscribe({ 508 | cancel: emptyFunction_1, 509 | request: emptyFunction_1, 510 | }); 511 | }); 512 | } 513 | 514 | constructor(source, max = Number.MAX_SAFE_INTEGER) { 515 | this._max = max; 516 | this._source = source; 517 | } 518 | 519 | subscribe(subscriberOrCallback) { 520 | let partialSubscriber; 521 | if (typeof subscriberOrCallback === 'function') { 522 | partialSubscriber = this._wrapCallback(subscriberOrCallback); 523 | } else { 524 | partialSubscriber = subscriberOrCallback; 525 | } 526 | const subscriber = new FlowableSubscriber(partialSubscriber, this._max); 527 | this._source(subscriber); 528 | } 529 | 530 | lift(onSubscribeLift) { 531 | return new Flowable((subscriber) => 532 | this._source(onSubscribeLift(subscriber)) 533 | ); 534 | } 535 | 536 | map(fn) { 537 | return this.lift((subscriber) => new FlowableMapOperator(subscriber, fn)); 538 | } 539 | 540 | take(toTake) { 541 | return this.lift( 542 | (subscriber) => new FlowableTakeOperator(subscriber, toTake) 543 | ); 544 | } 545 | 546 | toAsyncIterable(prefetch = 256) { 547 | return new FlowableAsyncIterable(this, prefetch); 548 | } 549 | 550 | _wrapCallback(callback) { 551 | const max = this._max; 552 | return { 553 | onNext: callback, 554 | onSubscribe(subscription) { 555 | subscription.request(max); 556 | }, 557 | }; 558 | } 559 | } 560 | 561 | /** 562 | * @private 563 | */ 564 | class FlowableSubscriber { 565 | constructor(subscriber, max) { 566 | _defineProperty( 567 | this, 568 | '_cancel', 569 | 570 | () => { 571 | if (!this._active) { 572 | return; 573 | } 574 | this._active = false; 575 | if (this._subscription) { 576 | this._subscription.cancel(); 577 | } 578 | } 579 | ); 580 | _defineProperty( 581 | this, 582 | '_request', 583 | 584 | (n) => { 585 | invariant_1( 586 | Number.isInteger(n) && n >= 1, 587 | 'Flowable: Expected request value to be an integer with a value greater than 0, got `%s`.', 588 | n 589 | ); 590 | 591 | if (!this._active) { 592 | return; 593 | } 594 | if (n >= this._max) { 595 | this._pending = this._max; 596 | } else { 597 | this._pending += n; 598 | if (this._pending >= this._max) { 599 | this._pending = this._max; 600 | } 601 | } 602 | if (this._subscription) { 603 | this._subscription.request(n); 604 | } 605 | } 606 | ); 607 | this._active = false; 608 | this._max = max; 609 | this._pending = 0; 610 | this._started = false; 611 | this._subscriber = subscriber || {}; 612 | this._subscription = null; 613 | } 614 | onComplete() { 615 | if (!this._active) { 616 | warning_1( 617 | false, 618 | 'Flowable: Invalid call to onComplete(): %s.', 619 | this._started 620 | ? 'onComplete/onError was already called' 621 | : 'onSubscribe has not been called' 622 | ); 623 | return; 624 | } 625 | this._active = false; 626 | this._started = true; 627 | try { 628 | if (this._subscriber.onComplete) { 629 | this._subscriber.onComplete(); 630 | } 631 | } catch (error) { 632 | if (this._subscriber.onError) { 633 | this._subscriber.onError(error); 634 | } 635 | } 636 | } 637 | onError(error) { 638 | if (this._started && !this._active) { 639 | warning_1( 640 | false, 641 | 'Flowable: Invalid call to onError(): %s.', 642 | this._active 643 | ? 'onComplete/onError was already called' 644 | : 'onSubscribe has not been called' 645 | ); 646 | return; 647 | } 648 | this._active = false; 649 | this._started = true; 650 | this._subscriber.onError && this._subscriber.onError(error); 651 | } 652 | onNext(data) { 653 | if (!this._active) { 654 | warning_1( 655 | false, 656 | 'Flowable: Invalid call to onNext(): %s.', 657 | this._active 658 | ? 'onComplete/onError was already called' 659 | : 'onSubscribe has not been called' 660 | ); 661 | return; 662 | } 663 | if (this._pending === 0) { 664 | warning_1( 665 | false, 666 | 'Flowable: Invalid call to onNext(), all request()ed values have been ' + 667 | 'published.' 668 | ); 669 | return; 670 | } 671 | if (this._pending !== this._max) { 672 | this._pending--; 673 | } 674 | try { 675 | this._subscriber.onNext && this._subscriber.onNext(data); 676 | } catch (error) { 677 | if (this._subscription) { 678 | this._subscription.cancel(); 679 | } 680 | this.onError(error); 681 | } 682 | } 683 | onSubscribe(subscription) { 684 | if (this._started) { 685 | warning_1( 686 | false, 687 | 'Flowable: Invalid call to onSubscribe(): already called.' 688 | ); 689 | return; 690 | } 691 | this._active = true; 692 | this._started = true; 693 | this._subscription = subscription; 694 | try { 695 | this._subscriber.onSubscribe && 696 | this._subscriber.onSubscribe({ 697 | cancel: this._cancel, 698 | request: this._request, 699 | }); 700 | } catch (error) { 701 | this.onError(error); 702 | } 703 | } 704 | } 705 | 706 | /** Copyright (c) Facebook, Inc. and its affiliates. 707 | * 708 | * Licensed under the Apache License, Version 2.0 (the "License"); 709 | * you may not use this file except in compliance with the License. 710 | * You may obtain a copy of the License at 711 | * 712 | * http://www.apache.org/licenses/LICENSE-2.0 713 | * 714 | * Unless required by applicable law or agreed to in writing, software 715 | * distributed under the License is distributed on an "AS IS" BASIS, 716 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 717 | * See the License for the specific language governing permissions and 718 | * limitations under the License. 719 | * 720 | * 721 | */ 722 | 723 | /** 724 | * Represents a lazy computation that will either produce a value of type T 725 | * or fail with an error. Calling `subscribe()` starts the 726 | * computation and returns a subscription object, which has an `unsubscribe()` 727 | * method that can be called to prevent completion/error callbacks from being 728 | * invoked and, where supported, to also cancel the computation. 729 | * Implementations may optionally implement cancellation; if they do not 730 | * `cancel()` is a no-op. 731 | * 732 | * Note: Unlike Promise, callbacks (onComplete/onError) may be invoked 733 | * synchronously. 734 | * 735 | * Example: 736 | * 737 | * ``` 738 | * const value = new Single(subscriber => { 739 | * const id = setTimeout( 740 | * () => subscriber.onComplete('Hello!'), 741 | * 250 742 | * ); 743 | * // Optional: Call `onSubscribe` with a cancellation callback 744 | * subscriber.onSubscribe(() => clearTimeout(id)); 745 | * }); 746 | * 747 | * // Start the computation. onComplete will be called after the timeout 748 | * // with 'hello' unless `cancel()` is called first. 749 | * value.subscribe({ 750 | * onComplete: value => console.log(value), 751 | * onError: error => console.error(error), 752 | * onSubscribe: cancel => ... 753 | * }); 754 | * ``` 755 | */ 756 | class Single { 757 | static of(value) { 758 | return new Single((subscriber) => { 759 | subscriber.onSubscribe(); 760 | subscriber.onComplete(value); 761 | }); 762 | } 763 | 764 | static error(error) { 765 | return new Single((subscriber) => { 766 | subscriber.onSubscribe(); 767 | subscriber.onError(error); 768 | }); 769 | } 770 | 771 | static never() { 772 | return new Single((subscriber) => { 773 | subscriber.onSubscribe(); 774 | }); 775 | } 776 | 777 | constructor(source) { 778 | this._source = source; 779 | } 780 | 781 | subscribe(partialSubscriber) { 782 | const subscriber = new FutureSubscriber(partialSubscriber); 783 | try { 784 | this._source(subscriber); 785 | } catch (error) { 786 | subscriber.onError(error); 787 | } 788 | } 789 | 790 | flatMap(fn) { 791 | return new Single((subscriber) => { 792 | let currentCancel; 793 | const cancel = () => { 794 | currentCancel && currentCancel(); 795 | currentCancel = null; 796 | }; 797 | this._source({ 798 | onComplete: (value) => { 799 | fn(value).subscribe({ 800 | onComplete: (mapValue) => { 801 | subscriber.onComplete(mapValue); 802 | }, 803 | onError: (error) => subscriber.onError(error), 804 | onSubscribe: (_cancel) => { 805 | currentCancel = _cancel; 806 | }, 807 | }); 808 | }, 809 | onError: (error) => subscriber.onError(error), 810 | onSubscribe: (_cancel) => { 811 | currentCancel = _cancel; 812 | subscriber.onSubscribe(cancel); 813 | }, 814 | }); 815 | }); 816 | } 817 | 818 | /** 819 | * Return a new Single that resolves to the value of this Single applied to 820 | * the given mapping function. 821 | */ 822 | map(fn) { 823 | return new Single((subscriber) => { 824 | return this._source({ 825 | onComplete: (value) => subscriber.onComplete(fn(value)), 826 | onError: (error) => subscriber.onError(error), 827 | onSubscribe: (cancel) => subscriber.onSubscribe(cancel), 828 | }); 829 | }); 830 | } 831 | 832 | then(successFn, errorFn) { 833 | this.subscribe({ 834 | onComplete: successFn || emptyFunction_1, 835 | onError: errorFn || emptyFunction_1, 836 | }); 837 | } 838 | } 839 | 840 | /** 841 | * @private 842 | */ 843 | class FutureSubscriber { 844 | constructor(subscriber) { 845 | this._active = false; 846 | this._started = false; 847 | this._subscriber = subscriber || {}; 848 | } 849 | 850 | onComplete(value) { 851 | if (!this._active) { 852 | warning_1( 853 | false, 854 | 'Single: Invalid call to onComplete(): %s.', 855 | this._started 856 | ? 'onComplete/onError was already called' 857 | : 'onSubscribe has not been called' 858 | ); 859 | 860 | return; 861 | } 862 | this._active = false; 863 | this._started = true; 864 | try { 865 | if (this._subscriber.onComplete) { 866 | this._subscriber.onComplete(value); 867 | } 868 | } catch (error) { 869 | if (this._subscriber.onError) { 870 | this._subscriber.onError(error); 871 | } 872 | } 873 | } 874 | 875 | onError(error) { 876 | if (this._started && !this._active) { 877 | warning_1( 878 | false, 879 | 'Single: Invalid call to onError(): %s.', 880 | this._active 881 | ? 'onComplete/onError was already called' 882 | : 'onSubscribe has not been called' 883 | ); 884 | 885 | return; 886 | } 887 | this._active = false; 888 | this._started = true; 889 | this._subscriber.onError && this._subscriber.onError(error); 890 | } 891 | 892 | onSubscribe(cancel) { 893 | if (this._started) { 894 | warning_1( 895 | false, 896 | 'Single: Invalid call to onSubscribe(): already called.' 897 | ); 898 | return; 899 | } 900 | this._active = true; 901 | this._started = true; 902 | try { 903 | this._subscriber.onSubscribe && 904 | this._subscriber.onSubscribe(() => { 905 | if (!this._active) { 906 | return; 907 | } 908 | this._active = false; 909 | cancel && cancel(); 910 | }); 911 | } catch (error) { 912 | this.onError(error); 913 | } 914 | } 915 | } 916 | 917 | class FlowableProcessor { 918 | constructor(source, fn) { 919 | this._source = source; 920 | this._transformer = fn; 921 | this._done = false; 922 | this._mappers = []; //mappers for map function 923 | } 924 | 925 | onSubscribe(subscription) { 926 | this._subscription = subscription; 927 | } 928 | 929 | onNext(t) { 930 | if (!this._sink) { 931 | warning_1('Warning, premature onNext for processor, dropping value'); 932 | return; 933 | } 934 | 935 | let val = t; 936 | if (this._transformer) { 937 | val = this._transformer(t); 938 | } 939 | const finalVal = this._mappers.reduce( 940 | (interimVal, mapper) => mapper(interimVal), 941 | val 942 | ); 943 | 944 | this._sink.onNext(finalVal); 945 | } 946 | 947 | onError(error) { 948 | this._error = error; 949 | if (!this._sink) { 950 | warning_1( 951 | 'Warning, premature onError for processor, marking complete/errored' 952 | ); 953 | } else { 954 | this._sink.onError(error); 955 | } 956 | } 957 | 958 | onComplete() { 959 | this._done = true; 960 | if (!this._sink) { 961 | warning_1('Warning, premature onError for processor, marking complete'); 962 | } else { 963 | this._sink.onComplete(); 964 | } 965 | } 966 | 967 | subscribe(subscriber) { 968 | if (this._source.subscribe) { 969 | this._source.subscribe(this); 970 | } 971 | this._sink = subscriber; 972 | this._sink.onSubscribe(this); 973 | 974 | if (this._error) { 975 | this._sink.onError(this._error); 976 | } else if (this._done) { 977 | this._sink.onComplete(); 978 | } 979 | } 980 | 981 | map(fn) { 982 | this._mappers.push(fn); 983 | return this; 984 | } 985 | 986 | request(n) { 987 | this._subscription && this._subscription.request(n); 988 | } 989 | 990 | cancel() { 991 | this._subscription && this._subscription.cancel(); 992 | } 993 | } 994 | 995 | /** Copyright (c) Facebook, Inc. and its affiliates. 996 | * 997 | * Licensed under the Apache License, Version 2.0 (the "License"); 998 | * you may not use this file except in compliance with the License. 999 | * You may obtain a copy of the License at 1000 | * 1001 | * http://www.apache.org/licenses/LICENSE-2.0 1002 | * 1003 | * Unless required by applicable law or agreed to in writing, software 1004 | * distributed under the License is distributed on an "AS IS" BASIS, 1005 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 1006 | * See the License for the specific language governing permissions and 1007 | * limitations under the License. 1008 | * 1009 | * 1010 | */ 1011 | 1012 | /** 1013 | * Returns a Publisher that provides the current time (Date.now()) every `ms` 1014 | * milliseconds. 1015 | * 1016 | * The timer is established on the first call to `request`: on each 1017 | * interval a value is published if there are outstanding requests, 1018 | * otherwise nothing occurs for that interval. This approach ensures 1019 | * that the interval between `onNext` calls is as regular as possible 1020 | * and means that overlapping `request` calls (ie calling again before 1021 | * the previous values have been vended) behaves consistently. 1022 | */ 1023 | function every(ms) { 1024 | return new Flowable((subscriber) => { 1025 | let intervalId = null; 1026 | let pending = 0; 1027 | subscriber.onSubscribe({ 1028 | cancel: () => { 1029 | if (intervalId != null) { 1030 | clearInterval(intervalId); 1031 | intervalId = null; 1032 | } 1033 | }, 1034 | request: (n) => { 1035 | if (n < Number.MAX_SAFE_INTEGER) { 1036 | pending += n; 1037 | } else { 1038 | pending = Number.MAX_SAFE_INTEGER; 1039 | } 1040 | if (intervalId != null) { 1041 | return; 1042 | } 1043 | intervalId = setInterval(() => { 1044 | if (pending > 0) { 1045 | if (pending !== Number.MAX_SAFE_INTEGER) { 1046 | pending--; 1047 | } 1048 | subscriber.onNext(Date.now()); 1049 | } 1050 | }, ms); 1051 | }, 1052 | }); 1053 | }); 1054 | } 1055 | 1056 | exports.Flowable = Flowable; 1057 | exports.FlowableProcessor = FlowableProcessor; 1058 | exports.Single = Single; 1059 | exports.every = every; 1060 | 1061 | Object.defineProperty(exports, '__esModule', {value: true}); 1062 | 1063 | return exports; 1064 | })({}); 1065 | -------------------------------------------------------------------------------- /src/main/resources/static/rsocket-types.js: -------------------------------------------------------------------------------- 1 | const process = { 2 | "env": { 3 | "NODE_ENV": 'production' 4 | } 5 | } 6 | var rsocketTypes = (function (exports) { 7 | 'use strict'; 8 | 9 | /** Copyright (c) Facebook, Inc. and its affiliates. 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); 12 | * you may not use this file except in compliance with the License. 13 | * You may obtain a copy of the License at 14 | * 15 | * http://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an "AS IS" BASIS, 19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | * See the License for the specific language governing permissions and 21 | * limitations under the License. 22 | * 23 | * 24 | */ 25 | 26 | /** 27 | * A contract providing different interaction models per the [ReactiveSocket protocol] 28 | (https://github.com/ReactiveSocket/reactivesocket/blob/master/Protocol.md). 29 | */ 30 | 31 | /** 32 | * Represents a network connection with input/output used by a ReactiveSocket to 33 | * send/receive data. 34 | */ 35 | 36 | /** 37 | * Describes the connection status of a ReactiveSocket/DuplexConnection. 38 | * - NOT_CONNECTED: no connection established or pending. 39 | * - CONNECTING: when `connect()` has been called but a connection is not yet 40 | * established. 41 | * - CONNECTED: when a connection is established. 42 | * - CLOSED: when the connection has been explicitly closed via `close()`. 43 | * - ERROR: when the connection has been closed for any other reason. 44 | */ 45 | 46 | const CONNECTION_STATUS = { 47 | CLOSED: Object.freeze({kind: 'CLOSED'}), 48 | CONNECTED: Object.freeze({kind: 'CONNECTED'}), 49 | CONNECTING: Object.freeze({kind: 'CONNECTING'}), 50 | NOT_CONNECTED: Object.freeze({kind: 'NOT_CONNECTED'}), 51 | }; 52 | 53 | /** 54 | * A type that can be written to a buffer. 55 | */ 56 | 57 | exports.CONNECTION_STATUS = CONNECTION_STATUS; 58 | 59 | Object.defineProperty(exports, '__esModule', {value: true}); 60 | 61 | return exports; 62 | })({}); 63 | -------------------------------------------------------------------------------- /src/main/resources/static/rsocket-websocket-client.js: -------------------------------------------------------------------------------- 1 | var rsocketWebSocketClient = (function ( 2 | rsocketFlowable, 3 | rsocketCore, 4 | rsocketTypes 5 | ) { 6 | 'use strict'; 7 | 8 | function _defineProperty(obj, key, value) { 9 | if (key in obj) { 10 | Object.defineProperty(obj, key, { 11 | value: value, 12 | enumerable: true, 13 | configurable: true, 14 | writable: true, 15 | }); 16 | } else { 17 | obj[key] = value; 18 | } 19 | 20 | return obj; 21 | } 22 | 23 | /** 24 | * Copyright (c) 2013-present, Facebook, Inc. 25 | * 26 | * This source code is licensed under the MIT license found in the 27 | * LICENSE file in the root directory of this source tree. 28 | * 29 | * 30 | */ 31 | 32 | var validateFormat = 33 | process.env.NODE_ENV !== 'production' 34 | ? function (format) { 35 | if (format === undefined) { 36 | throw new Error( 37 | 'invariant(...): Second argument must be a string.' 38 | ); 39 | } 40 | } 41 | : function (format) {}; 42 | /** 43 | * Use invariant() to assert state which your program assumes to be true. 44 | * 45 | * Provide sprintf-style format (only %s is supported) and arguments to provide 46 | * information about what broke and what you were expecting. 47 | * 48 | * The invariant message will be stripped in production, but the invariant will 49 | * remain to ensure logic does not differ in production. 50 | */ 51 | 52 | function invariant(condition, format) { 53 | for ( 54 | var _len = arguments.length, 55 | args = new Array(_len > 2 ? _len - 2 : 0), 56 | _key = 2; 57 | _key < _len; 58 | _key++ 59 | ) { 60 | args[_key - 2] = arguments[_key]; 61 | } 62 | 63 | validateFormat(format); 64 | 65 | if (!condition) { 66 | var error; 67 | 68 | if (format === undefined) { 69 | error = new Error( 70 | 'Minified exception occurred; use the non-minified dev environment ' + 71 | 'for the full error message and additional helpful warnings.' 72 | ); 73 | } else { 74 | var argIndex = 0; 75 | error = new Error( 76 | format.replace(/%s/g, function () { 77 | return String(args[argIndex++]); 78 | }) 79 | ); 80 | error.name = 'Invariant Violation'; 81 | } 82 | 83 | error.framesToPop = 1; // Skip invariant's own stack frame. 84 | 85 | throw error; 86 | } 87 | } 88 | 89 | var invariant_1 = invariant; 90 | 91 | /** Copyright (c) Facebook, Inc. and its affiliates. 92 | * 93 | * Licensed under the Apache License, Version 2.0 (the "License"); 94 | * you may not use this file except in compliance with the License. 95 | * You may obtain a copy of the License at 96 | * 97 | * http://www.apache.org/licenses/LICENSE-2.0 98 | * 99 | * Unless required by applicable law or agreed to in writing, software 100 | * distributed under the License is distributed on an "AS IS" BASIS, 101 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 102 | * See the License for the specific language governing permissions and 103 | * limitations under the License. 104 | * 105 | * 106 | */ 107 | 108 | /** 109 | * A WebSocket transport client for use in browser environments. 110 | */ 111 | class RSocketWebSocketClient { 112 | constructor(options, encoders) { 113 | _defineProperty( 114 | this, 115 | '_handleClosed', 116 | 117 | (e) => { 118 | this._close( 119 | new Error( 120 | e.reason || 'RSocketWebSocketClient: Socket closed unexpectedly.' 121 | ) 122 | ); 123 | } 124 | ); 125 | _defineProperty( 126 | this, 127 | '_handleError', 128 | 129 | (e) => { 130 | this._close(e.error); 131 | } 132 | ); 133 | _defineProperty( 134 | this, 135 | '_handleOpened', 136 | 137 | () => { 138 | this._setConnectionStatus(rsocketTypes.CONNECTION_STATUS.CONNECTED); 139 | } 140 | ); 141 | _defineProperty( 142 | this, 143 | '_handleMessage', 144 | 145 | (message) => { 146 | try { 147 | const frame = this._readFrame(message); 148 | this._receivers.forEach((subscriber) => subscriber.onNext(frame)); 149 | } catch (error) { 150 | this._close(error); 151 | } 152 | } 153 | ); 154 | this._encoders = encoders; 155 | this._options = options; 156 | this._receivers = new Set(); 157 | this._senders = new Set(); 158 | this._socket = null; 159 | this._status = rsocketTypes.CONNECTION_STATUS.NOT_CONNECTED; 160 | this._statusSubscribers = new Set(); 161 | } 162 | close() { 163 | this._close(); 164 | } 165 | connect() { 166 | invariant_1( 167 | this._status.kind === 'NOT_CONNECTED', 168 | 'RSocketWebSocketClient: Cannot connect(), a connection is already ' + 169 | 'established.' 170 | ); 171 | this._setConnectionStatus(rsocketTypes.CONNECTION_STATUS.CONNECTING); 172 | const wsCreator = this._options.wsCreator; 173 | const url = this._options.url; 174 | this._socket = wsCreator ? wsCreator(url) : new WebSocket(url); 175 | const socket = this._socket; 176 | socket.binaryType = 'arraybuffer'; 177 | socket.addEventListener('close', this._handleClosed); 178 | socket.addEventListener('error', this._handleError); 179 | socket.addEventListener('open', this._handleOpened); 180 | socket.addEventListener('message', this._handleMessage); 181 | } 182 | connectionStatus() { 183 | return new rsocketFlowable.Flowable((subscriber) => { 184 | subscriber.onSubscribe({ 185 | cancel: () => { 186 | this._statusSubscribers.delete(subscriber); 187 | }, 188 | request: () => { 189 | this._statusSubscribers.add(subscriber); 190 | subscriber.onNext(this._status); 191 | }, 192 | }); 193 | }); 194 | } 195 | receive() { 196 | return new rsocketFlowable.Flowable((subject) => { 197 | subject.onSubscribe({ 198 | cancel: () => { 199 | this._receivers.delete(subject); 200 | }, 201 | request: () => { 202 | this._receivers.add(subject); 203 | }, 204 | }); 205 | }); 206 | } 207 | sendOne(frame) { 208 | this._writeFrame(frame); 209 | } 210 | send(frames) { 211 | let subscription; 212 | frames.subscribe({ 213 | onComplete: () => { 214 | subscription && this._senders.delete(subscription); 215 | }, 216 | onError: (error) => { 217 | subscription && this._senders.delete(subscription); 218 | this._close(error); 219 | }, 220 | onNext: (frame) => this._writeFrame(frame), 221 | onSubscribe: (_subscription) => { 222 | subscription = _subscription; 223 | this._senders.add(subscription); 224 | subscription.request(Number.MAX_SAFE_INTEGER); 225 | }, 226 | }); 227 | } 228 | _close(error) { 229 | if (this._status.kind === 'CLOSED' || this._status.kind === 'ERROR') { 230 | // already closed 231 | return; 232 | } 233 | const status = error 234 | ? {error, kind: 'ERROR'} 235 | : rsocketTypes.CONNECTION_STATUS.CLOSED; 236 | this._setConnectionStatus(status); 237 | this._receivers.forEach((subscriber) => { 238 | if (error) { 239 | subscriber.onError(error); 240 | } else { 241 | subscriber.onComplete(); 242 | } 243 | }); 244 | this._receivers.clear(); 245 | this._senders.forEach((subscription) => subscription.cancel()); 246 | this._senders.clear(); 247 | const socket = this._socket; 248 | if (socket) { 249 | socket.removeEventListener('close', this._handleClosed); 250 | socket.removeEventListener('error', this._handleClosed); 251 | socket.removeEventListener('open', this._handleOpened); 252 | socket.removeEventListener('message', this._handleMessage); 253 | socket.close(); 254 | this._socket = null; 255 | } 256 | } 257 | _setConnectionStatus(status) { 258 | this._status = status; 259 | this._statusSubscribers.forEach((subscriber) => 260 | subscriber.onNext(status) 261 | ); 262 | } 263 | _readFrame(message) { 264 | const buffer = rsocketCore.toBuffer(message.data); 265 | const frame = this._options.lengthPrefixedFrames 266 | ? rsocketCore.deserializeFrameWithLength(buffer, this._encoders) 267 | : rsocketCore.deserializeFrame(buffer, this._encoders); 268 | return frame; 269 | } 270 | 271 | _writeFrame(frame) { 272 | try { 273 | if (false); 274 | const buffer = this._options.lengthPrefixedFrames 275 | ? rsocketCore.serializeFrameWithLength(frame, this._encoders) 276 | : rsocketCore.serializeFrame(frame, this._encoders); 277 | invariant_1( 278 | this._socket, 279 | 'RSocketWebSocketClient: Cannot send frame, not connected.' 280 | ); 281 | 282 | this._socket.send(buffer); 283 | } catch (error) { 284 | this._close(error); 285 | } 286 | } 287 | } 288 | 289 | /** Copyright (c) Facebook, Inc. and its affiliates. 290 | * 291 | * Licensed under the Apache License, Version 2.0 (the "License"); 292 | * you may not use this file except in compliance with the License. 293 | * You may obtain a copy of the License at 294 | * 295 | * http://www.apache.org/licenses/LICENSE-2.0 296 | * 297 | * Unless required by applicable law or agreed to in writing, software 298 | * distributed under the License is distributed on an "AS IS" BASIS, 299 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 300 | * See the License for the specific language governing permissions and 301 | * limitations under the License. 302 | * 303 | * 304 | */ 305 | 306 | return RSocketWebSocketClient; 307 | })(rsocketFlowable, rsocketCore, rsocketTypes); 308 | -------------------------------------------------------------------------------- /src/main/resources/templates/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chat 6 | 9 | 10 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 63 | 64 | 65 |
66 |
67 |
68 |
69 |
70 |
    71 |
  • 72 |
    73 |
    74 | 75 | 77 | 78 |
    79 | title 80 |
    81 | 85 |
    86 |
    87 |
    88 |
    89 |
  • 90 |
91 |
92 |
93 |
94 |
95 | 96 | 97 |
98 |
99 |
100 |
101 |
102 | 167 | 168 | -------------------------------------------------------------------------------- /src/main/resources/templates/chatrs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chat 6 | 9 | 10 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 69 | 70 | 71 |
72 |
73 |
74 |
75 |
76 |
    77 |
  • 78 |
    79 |
    80 | 81 | 83 | 84 |
    85 | title 86 |
    87 | 91 |
    92 |
    93 |
    94 |
    95 |
  • 96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 |
104 |
105 |
106 |
107 |
108 | 214 | 215 | -------------------------------------------------------------------------------- /src/test/kotlin/com/example/kotlin/chat/ChatKotlinApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package com.example.kotlin.chat 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class ChatKotlinApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /static/application-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/application-architecture.png -------------------------------------------------------------------------------- /static/chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/chat.gif -------------------------------------------------------------------------------- /static/download-from-vcs-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/download-from-vcs-github.png -------------------------------------------------------------------------------- /static/download-from-vcs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/download-from-vcs.png -------------------------------------------------------------------------------- /static/intellij-git-branches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-git-branches.png -------------------------------------------------------------------------------- /static/intellij-git-compare-with-branch-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-git-compare-with-branch-diff.png -------------------------------------------------------------------------------- /static/intellij-git-compare-with-branch-file-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-git-compare-with-branch-file-diff.png -------------------------------------------------------------------------------- /static/intellij-git-compare-with-branch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-git-compare-with-branch.png -------------------------------------------------------------------------------- /static/intellij-gradle-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-gradle-reload.png -------------------------------------------------------------------------------- /static/intellij-run-app-from-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-run-app-from-main.png -------------------------------------------------------------------------------- /static/intellij-running-tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/intellij-running-tests.png -------------------------------------------------------------------------------- /static/project-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/project-tree.png -------------------------------------------------------------------------------- /static/schema-sql-location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spring-guides/tut-spring-webflux-kotlin-rsocket/97f1c7a086427ff94fd16750f4ed2a932fb93d23/static/schema-sql-location.png --------------------------------------------------------------------------------