├── .gitignore ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── functional │ │ ├── app │ │ ├── Application.kt │ │ ├── Extensions.kt │ │ ├── Model.kt │ │ └── web │ │ │ ├── Routes.kt │ │ │ ├── UserHandler.kt │ │ │ └── view │ │ │ ├── MustacheResourceTemplateLoader.kt │ │ │ ├── MustacheView.kt │ │ │ └── MustacheViewResolver.kt │ │ └── dsl │ │ ├── WebServer.kt │ │ └── WebfluxApplicationDsl.kt └── resources │ ├── logback.xml │ ├── messages.properties │ ├── static │ ├── spring.png │ └── sse.js │ └── templates │ ├── footer.mustache │ ├── header.mustache │ ├── index.mustache │ ├── sse.mustache │ └── users.mustache └── test ├── kotlin └── functional │ └── app │ └── IntegrationTests.kt └── resources └── junit-platform.properties /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .gradle/ 3 | out/ 4 | build/ 5 | .idea/ 6 | *.iml 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Kotlin dsl to start standalone Spring WebFlux Application 3 | 4 | This project is based on https://github.com/sdeleuze/spring-kotlin-functional 5 | 6 | The Main idea of this project is to provide a beautiful DSL to start a Spring Application on netty : 7 | - Simple DSL 8 | - No Annotation 9 | - No JavaConfig 10 | - No Classpath Scanning 11 | 12 | Application dsl sample : 13 | 14 | ```kotlin 15 | fun main(args: Array) { 16 | webfluxApplication(Server.NETTY) { // or TOMCAT 17 | 18 | // group routers 19 | routes { 20 | router { routerApi(ref()) } 21 | router(routerStatic()) 22 | } 23 | router { routerHtml(ref(), ref()) } 24 | 25 | // group beans 26 | beans { 27 | bean() 28 | bean() // Primary constructor injection 29 | } 30 | bean() 31 | 32 | mustacheTemplate() 33 | 34 | profile("foo") { 35 | bean() 36 | } 37 | }.run() 38 | } 39 | ``` 40 | 41 | This project use : 42 | - Spring WebFlux Reactive web server and client 43 | - [Spring Kotlin support](https://spring.io/blog/2017/01/04/introducing-kotlin-support-in-spring-framework-5-0) 44 | - Reactor Kotlin 45 | - [Functional bean definition with Kotlin DSL](https://github.com/sdeleuze/spring-kotlin-functional/blob/master/src/main/kotlin/functional/Beans.kt) (no reflection, no CGLIB proxies involved) 46 | - [WebFlux functional routing declaration with Kotlin DSL](https://github.com/sdeleuze/spring-kotlin-functional/blob/master/src/main/kotlin/functional/web/Routes.kt) 47 | - WebFlux and Reactor Netty native embedded server capabilities 48 | - [Gradle Kotlin DSL](https://github.com/gradle/kotlin-dsl) 49 | - [Junit 5 `@BeforeAll` and `@AfterAll` on non-static methods in Kotlin](https://github.com/sdeleuze/spring-kotlin-functional/blob/master/src/test/kotlin/functional/IntegrationTests.kt) 50 | 51 | 52 | Current `master` branch is based on standalone WebFlux runtime. Spring Boot is based 53 | on JavaConfig and does not provide specific support functional bean definition yet (see 54 | [this issue](https://github.com/spring-projects/spring-boot/issues/8115) where this is discussed). 55 | That said, it is possible to use experimentally Spring Boot + functional bean definition together 56 | via `ApplicationContextInitializer`, see 57 | [this Spring Boot branch](https://github.com/sdeleuze/spring-kotlin-functional/tree/boot) 58 | for a concrete example. 59 | 60 | Build the project and run tests with `./gradlew build`, create the executable JAR via `./gradlew shadowJar`, and run it via `java -jar build/libs/spring-kotlin-functional-1.0.0-SNAPSHOT-all.jar`. 61 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | application 5 | id("org.jetbrains.kotlin.jvm") version "1.1.51" 6 | id ("com.github.johnrengelman.plugin-shadow") version "2.0.0" 7 | id ("io.spring.dependency-management") version "1.0.3.RELEASE" 8 | } 9 | 10 | buildscript { 11 | repositories { 12 | jcenter() 13 | mavenCentral() 14 | } 15 | 16 | dependencies { 17 | classpath("org.junit.platform:junit-platform-gradle-plugin:1.0.0") 18 | } 19 | } 20 | 21 | apply { 22 | plugin("org.junit.platform.gradle.plugin") 23 | } 24 | 25 | repositories { 26 | jcenter() 27 | mavenCentral() 28 | maven("https://repo.spring.io/milestone") 29 | } 30 | 31 | application { 32 | mainClassName = "functional.ApplicationKt" 33 | } 34 | 35 | tasks { 36 | withType { 37 | kotlinOptions { 38 | jvmTarget = "1.8" 39 | freeCompilerArgs = listOf("-Xjsr305=strict") 40 | } 41 | } 42 | } 43 | 44 | dependencyManagement { 45 | imports { 46 | mavenBom("org.springframework.boot:spring-boot-dependencies:2.0.0.M5") 47 | } 48 | } 49 | 50 | dependencies { 51 | compile("org.jetbrains.kotlin:kotlin-stdlib-jre8") 52 | compile("org.jetbrains.kotlin:kotlin-reflect") 53 | 54 | compile("org.springframework:spring-webflux") 55 | compile("org.springframework:spring-context") { 56 | exclude(module = "spring-aop") 57 | } 58 | compile("org.apache.tomcat.embed:tomcat-embed-core") 59 | compile("io.projectreactor.ipc:reactor-netty") 60 | compile("com.samskivert:jmustache") 61 | 62 | compile("org.slf4j:slf4j-api") 63 | compile("ch.qos.logback:logback-classic") 64 | 65 | compile("com.fasterxml.jackson.module:jackson-module-kotlin") 66 | compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") 67 | 68 | testCompile("io.projectreactor:reactor-test") 69 | 70 | testCompile("org.junit.jupiter:junit-jupiter-api") 71 | testRuntime("org.junit.jupiter:junit-jupiter-engine") 72 | } 73 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.script.lang.kotlin.accessors.auto=true 2 | group=io.spring 3 | version=1.0.0-SNAPSHOT 4 | 5 | kotlinVersion=1.1.51 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgirard12/spring-webflux-kotlin-dsl/cab4c0bb83d2c7dd08242eb80e69c179ef766aa2/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.2-bin.zip 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgirard12/spring-webflux-kotlin-dsl/cab4c0bb83d2c7dd08242eb80e69c179ef766aa2/settings.gradle -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/Application.kt: -------------------------------------------------------------------------------- 1 | package functional.app 2 | 3 | import functional.app.web.UserHandler 4 | import functional.app.web.routerApi 5 | import functional.app.web.routerHtml 6 | import functional.app.web.routerStatic 7 | import functional.dsl.Server 8 | import functional.dsl.webfluxApplication 9 | 10 | val application = webfluxApplication(Server.NETTY) { // or TOMCAT 11 | 12 | // group routers 13 | routes { 14 | router { routerApi(ref()) } 15 | router(routerStatic()) 16 | } 17 | router { routerHtml(ref(), ref()) } 18 | 19 | // group beans 20 | beans { 21 | bean() 22 | bean() // Primary constructor injection 23 | } 24 | bean() 25 | 26 | mustacheTemplate() 27 | 28 | profile("foo") { 29 | bean() 30 | } 31 | } 32 | 33 | 34 | fun main(args: Array) { 35 | application.run() 36 | } 37 | 38 | // Only for profile:"foo" 39 | class Foo 40 | 41 | class Bar 42 | class Baz(val bar: Bar) 43 | -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/Extensions.kt: -------------------------------------------------------------------------------- 1 | package functional.app 2 | 3 | import org.springframework.web.reactive.function.server.ServerRequest 4 | import java.time.LocalDate 5 | import java.time.format.DateTimeFormatterBuilder 6 | import java.time.temporal.ChronoField 7 | import java.util.* 8 | import java.util.stream.Collectors 9 | import java.util.stream.IntStream 10 | 11 | fun ServerRequest.locale() = 12 | this.headers().asHttpHeaders().acceptLanguageAsLocales.firstOrNull() ?: Locale.ENGLISH 13 | 14 | fun LocalDate.formatDate(): String = this.format(englishDateFormatter) 15 | 16 | private val daysLookup: Map = 17 | IntStream.rangeClosed(1, 31).boxed().collect(Collectors.toMap(Int::toLong, ::getOrdinal)) 18 | 19 | private val englishDateFormatter = DateTimeFormatterBuilder() 20 | .appendPattern("MMMM") 21 | .appendLiteral(" ") 22 | .appendText(ChronoField.DAY_OF_MONTH, daysLookup) 23 | .appendLiteral(" ") 24 | .appendPattern("yyyy") 25 | .toFormatter(Locale.ENGLISH) 26 | 27 | private fun getOrdinal(n: Int) = 28 | when { 29 | n in 11..13 -> "${n}th" 30 | n % 10 == 1 -> "${n}st" 31 | n % 10 == 2 -> "${n}nd" 32 | n % 10 == 3 -> "${n}rd" 33 | else -> "${n}th" 34 | } -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/Model.kt: -------------------------------------------------------------------------------- 1 | package functional.app 2 | 3 | import java.time.LocalDate 4 | 5 | data class User(val firstName: String, val lastName: String, val birthDate: LocalDate) -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/web/Routes.kt: -------------------------------------------------------------------------------- 1 | package functional.app.web 2 | 3 | import com.samskivert.mustache.Mustache 4 | import functional.app.locale 5 | import org.springframework.context.MessageSource 6 | import org.springframework.core.io.ClassPathResource 7 | import org.springframework.http.MediaType.* 8 | import org.springframework.web.reactive.function.server.RenderingResponse 9 | import org.springframework.web.reactive.function.server.ServerResponse.ok 10 | import org.springframework.web.reactive.function.server.router 11 | import reactor.core.publisher.toMono 12 | import java.util.* 13 | 14 | fun routerStatic() = router { 15 | resources("/**", ClassPathResource("static/")) 16 | } 17 | 18 | fun routerApi(userHandler: UserHandler) = router { 19 | "/api".nest { 20 | accept(APPLICATION_JSON).nest { 21 | GET("/users", userHandler::findAll) 22 | } 23 | accept(TEXT_EVENT_STREAM).nest { 24 | GET("/users", userHandler::stream) 25 | } 26 | } 27 | } 28 | 29 | fun routerHtml(userHandler: UserHandler, messageSource: MessageSource) = router { 30 | accept(TEXT_HTML).nest { 31 | GET("/") { ok().render("index") } 32 | GET("/sse") { ok().render("sse") } 33 | GET("/users", userHandler::findAllView) 34 | } 35 | resources("/**", ClassPathResource("static/")) 36 | }.filter { request, next -> 37 | next.handle(request).flatMap { 38 | if (it is RenderingResponse) RenderingResponse.from(it).modelAttributes(attributes(request.locale(), messageSource)).build() else it.toMono() 39 | } 40 | } 41 | 42 | private fun attributes(locale: Locale, messageSource: MessageSource) = mutableMapOf( 43 | "i18n" to Mustache.Lambda { frag, out -> 44 | val tokens = frag.execute().split("|") 45 | out.write(messageSource.getMessage(tokens[0], tokens.slice(IntRange(1, tokens.size - 1)).toTypedArray(), locale)) 46 | }) -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/web/UserHandler.kt: -------------------------------------------------------------------------------- 1 | package functional.app.web 2 | 3 | import functional.app.User 4 | import functional.app.formatDate 5 | import org.springframework.web.reactive.function.server.ServerRequest 6 | import org.springframework.web.reactive.function.server.ServerResponse.ok 7 | import org.springframework.web.reactive.function.server.body 8 | import org.springframework.web.reactive.function.server.bodyToServerSentEvents 9 | import reactor.core.publisher.Flux 10 | import java.time.Duration 11 | import java.time.LocalDate 12 | 13 | @Suppress("UNUSED_PARAMETER") 14 | class UserHandler { 15 | 16 | private val users = Flux.just( 17 | User("Foo", "Foo", LocalDate.now().minusDays(1)), 18 | User("Bar", "Bar", LocalDate.now().minusDays(10)), 19 | User("Baz", "Baz", LocalDate.now().minusDays(100))) 20 | 21 | private val userStream = Flux 22 | .zip(Flux.interval(Duration.ofMillis(100)), users.repeat()) 23 | .map { it.t2 } 24 | 25 | fun findAll(req: ServerRequest) = 26 | ok().body(users) 27 | 28 | fun findAllView(req: ServerRequest) = 29 | ok().render("users", mapOf("users" to users.map { it.toDto() })) 30 | 31 | fun stream(req: ServerRequest) = 32 | ok().bodyToServerSentEvents(userStream) 33 | 34 | } 35 | 36 | class UserDto(val firstName: String, val lastName: String, val birthDate: String) 37 | 38 | fun User.toDto() = UserDto(firstName, lastName, birthDate.formatDate()) -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/web/view/MustacheResourceTemplateLoader.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2017 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package functional.app.web.view 17 | 18 | 19 | import com.samskivert.mustache.Mustache 20 | import com.samskivert.mustache.Mustache.Compiler 21 | import com.samskivert.mustache.Mustache.TemplateLoader 22 | import org.springframework.context.ResourceLoaderAware 23 | import org.springframework.core.io.DefaultResourceLoader 24 | import org.springframework.core.io.Resource 25 | import org.springframework.core.io.ResourceLoader 26 | import java.io.InputStreamReader 27 | import java.io.Reader 28 | 29 | /** 30 | * Mustache TemplateLoader implementation that uses a prefix, suffix and the Spring 31 | * Resource abstraction to load a template from a file, classpath, URL etc. A 32 | * [TemplateLoader] is needed in the [Compiler] when you want to render 33 | * partials (i.e. tiles-like features). 34 | 35 | * @author Dave Syer 36 | * @since 1.2.2 37 | * @see Mustache 38 | * @see Resource 39 | */ 40 | class MustacheResourceTemplateLoader(private val prefix: String, private val suffix: String) : TemplateLoader, ResourceLoaderAware { 41 | 42 | private var charSet = "UTF-8" 43 | 44 | private var resourceLoader: ResourceLoader = DefaultResourceLoader() 45 | 46 | /** 47 | * Set the charset. 48 | * @param charSet the charset 49 | */ 50 | fun setCharset(charSet: String) { 51 | this.charSet = charSet 52 | } 53 | 54 | /** 55 | * Set the resource loader. 56 | * @param resourceLoader the resource loader 57 | */ 58 | override fun setResourceLoader(resourceLoader: ResourceLoader) { 59 | this.resourceLoader = resourceLoader 60 | } 61 | 62 | override fun getTemplate(name: String): Reader { 63 | return InputStreamReader(this.resourceLoader 64 | .getResource(this.prefix + name + this.suffix).inputStream, 65 | this.charSet) 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/web/view/MustacheView.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012-2017 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package functional.app.web.view 18 | 19 | import com.samskivert.mustache.Mustache.Compiler 20 | import org.springframework.core.io.Resource 21 | import org.springframework.http.MediaType 22 | import org.springframework.web.reactive.result.view.AbstractUrlBasedView 23 | import org.springframework.web.reactive.result.view.View 24 | import org.springframework.web.server.ServerWebExchange 25 | import reactor.core.publisher.Flux 26 | import reactor.core.publisher.Mono 27 | import java.io.InputStreamReader 28 | import java.io.OutputStreamWriter 29 | import java.io.Reader 30 | import java.nio.charset.Charset 31 | import java.util.* 32 | 33 | /** 34 | * Spring WebFlux [View] using the Mustache template engine. 35 | * @author Brian Clozel 36 | * @author Sebastien Deleuze 37 | */ 38 | class MustacheView : AbstractUrlBasedView() { 39 | 40 | private var compiler: Compiler? = null 41 | 42 | private var charset: String? = null 43 | 44 | /** 45 | * Set the JMustache compiler to be used by this view. Typically this property is not 46 | * set directly. Instead a single [Compiler] is expected in the Spring 47 | * application context which is used to compile Mustache templates. 48 | * @param compiler the Mustache compiler 49 | */ 50 | fun setCompiler(compiler: Compiler) { 51 | this.compiler = compiler 52 | } 53 | 54 | /** 55 | * Set the charset used for reading Mustache template files. 56 | * @param charset the charset to use for reading template files 57 | */ 58 | fun setCharset(charset: String) { 59 | this.charset = charset 60 | } 61 | 62 | @Throws(Exception::class) 63 | override fun checkResourceExists(locale: Locale): Boolean { 64 | return resolveResource() != null 65 | } 66 | 67 | override fun renderInternal(model: Map, contentType: MediaType?, 68 | exchange: ServerWebExchange): Mono { 69 | val resource = resolveResource() ?: return Mono.error(IllegalStateException("Could not find Mustache template with URL [$url]")) 70 | val dataBuffer = exchange.response.bufferFactory().allocateBuffer() 71 | try { 72 | getReader(resource).use { reader -> 73 | val template = this.compiler!!.compile(reader) 74 | val charset = getCharset(contentType).orElse(defaultCharset) 75 | OutputStreamWriter(dataBuffer.asOutputStream(), 76 | charset).use { writer -> 77 | template.execute(model, writer) 78 | writer.flush() 79 | } 80 | } 81 | } catch (ex: Throwable) { 82 | return Mono.error(ex) 83 | } 84 | 85 | return exchange.response.writeWith(Flux.just(dataBuffer)) 86 | } 87 | 88 | private fun resolveResource(): Resource? { 89 | val resource = applicationContext!!.getResource(url!!) 90 | if (!resource.exists()) { 91 | return null 92 | } 93 | return resource 94 | } 95 | 96 | private fun getReader(resource: Resource): Reader { 97 | if (this.charset != null) { 98 | return InputStreamReader(resource.inputStream, this.charset!!) 99 | } 100 | return InputStreamReader(resource.inputStream) 101 | } 102 | 103 | private fun getCharset(mediaType: MediaType?): Optional { 104 | return Optional.ofNullable(mediaType?.charset) 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/main/kotlin/functional/app/web/view/MustacheViewResolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012-2017 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package functional.app.web.view 18 | 19 | import com.samskivert.mustache.Mustache 20 | import com.samskivert.mustache.Mustache.Compiler 21 | 22 | import org.springframework.web.reactive.result.view.AbstractUrlBasedView 23 | import org.springframework.web.reactive.result.view.UrlBasedViewResolver 24 | import org.springframework.web.reactive.result.view.ViewResolver 25 | 26 | /** 27 | * Spring WebFlux [ViewResolver] for Mustache. 28 | * @author Brian Clozel 29 | * @author Sebastien Deleuze 30 | */ 31 | class MustacheViewResolver(private val compiler: Compiler = Mustache.compiler()) : UrlBasedViewResolver() { 32 | 33 | private var charset: String? = null 34 | 35 | init { 36 | viewClass = requiredViewClass() 37 | } 38 | 39 | /** 40 | * Set the charset. 41 | * @param charset the charset 42 | */ 43 | fun setCharset(charset: String) { 44 | this.charset = charset 45 | } 46 | 47 | override fun requiredViewClass(): Class<*> { 48 | return MustacheView::class.java 49 | } 50 | 51 | override fun createView(viewName: String): AbstractUrlBasedView { 52 | val view = super.createView(viewName) as MustacheView 53 | view.setCompiler(this.compiler) 54 | this.charset?.let { view.setCharset(it) } 55 | return view 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/functional/dsl/WebServer.kt: -------------------------------------------------------------------------------- 1 | package functional.dsl 2 | 3 | import org.apache.catalina.connector.Connector 4 | import org.apache.catalina.core.StandardContext 5 | import org.apache.catalina.loader.WebappClassLoader 6 | import org.apache.catalina.loader.WebappLoader 7 | import org.apache.catalina.startup.Tomcat 8 | import org.springframework.http.server.reactive.HttpHandler 9 | import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter 10 | import org.springframework.http.server.reactive.TomcatHttpHandlerAdapter 11 | import org.springframework.util.ClassUtils 12 | import reactor.ipc.netty.http.server.HttpServer 13 | import reactor.ipc.netty.tcp.BlockingNettyContext 14 | 15 | 16 | /** 17 | * List of supported server 18 | */ 19 | enum class Server { 20 | NETTY, TOMCAT 21 | } 22 | 23 | /** 24 | * Common server implementation 25 | */ 26 | interface WebServer { 27 | 28 | var port: Int 29 | fun run(httpHandler: HttpHandler, await: Boolean = true) 30 | fun stop() 31 | } 32 | 33 | /** 34 | * Netty 35 | */ 36 | class NettyWebServer : WebServer { 37 | 38 | override var port: Int = 8080 39 | private val server: HttpServer by lazy { HttpServer.create(port) } 40 | private lateinit var nettyContext: BlockingNettyContext 41 | 42 | override fun run(httpHandler: HttpHandler, await: Boolean) { 43 | if (await) 44 | server.startAndAwait(ReactorHttpHandlerAdapter(httpHandler), { nettyContext = it }) 45 | else 46 | nettyContext = server.start(ReactorHttpHandlerAdapter(httpHandler)) 47 | } 48 | 49 | override fun stop() { 50 | nettyContext.shutdown() 51 | } 52 | } 53 | 54 | /** 55 | * Tomcat 56 | */ 57 | class TomcatWebServer : WebServer { 58 | 59 | override var port: Int = 8080 60 | val tomcat = Tomcat() 61 | 62 | override fun run(httpHandler: HttpHandler, await: Boolean) { 63 | 64 | val servlet = TomcatHttpHandlerAdapter(httpHandler) 65 | 66 | val docBase = createTempDir("tomcat-docbase") 67 | val context = StandardContext() 68 | context.path = "" 69 | context.docBase = docBase.absolutePath 70 | context.addLifecycleListener(Tomcat.FixContextListener()) 71 | context.parentClassLoader = ClassUtils.getDefaultClassLoader() 72 | val loader = WebappLoader(context.parentClassLoader) 73 | loader.loaderClass = WebappClassLoader::class.java.name 74 | loader.delegate = true 75 | context.loader = loader 76 | 77 | Tomcat.addServlet(context, "httpHandlerServlet", servlet) 78 | context.addServletMappingDecoded("/", "httpHandlerServlet") 79 | tomcat.host.addChild(context) 80 | 81 | val baseDir = createTempDir("tomcat") 82 | tomcat.setBaseDir(baseDir.absolutePath) 83 | val connector = Connector("org.apache.coyote.http11.Http11NioProtocol") 84 | tomcat.service.addConnector(connector) 85 | connector.setProperty("bindOnInit", "false") 86 | connector.port = port 87 | tomcat.connector = connector 88 | tomcat.host.autoDeploy = false 89 | 90 | tomcat.server.start() 91 | if (await) 92 | tomcat.server.await() 93 | } 94 | 95 | override fun stop() { 96 | tomcat.stop() 97 | } 98 | } -------------------------------------------------------------------------------- /src/main/kotlin/functional/dsl/WebfluxApplicationDsl.kt: -------------------------------------------------------------------------------- 1 | package functional.dsl 2 | 3 | import com.samskivert.mustache.Mustache 4 | import functional.app.web.view.MustacheResourceTemplateLoader 5 | import functional.app.web.view.MustacheViewResolver 6 | import org.springframework.context.support.BeanDefinitionDsl 7 | import org.springframework.context.support.GenericApplicationContext 8 | import org.springframework.context.support.ReloadableResourceBundleMessageSource 9 | import org.springframework.http.server.reactive.HttpHandler 10 | import org.springframework.web.reactive.function.server.HandlerStrategies 11 | import org.springframework.web.reactive.function.server.RouterFunction 12 | import org.springframework.web.reactive.function.server.RouterFunctions 13 | import org.springframework.web.reactive.function.server.ServerResponse 14 | import org.springframework.web.server.adapter.WebHttpHandlerBuilder 15 | 16 | 17 | class WebfluxApplicationDsl : BeanDefinitionDsl() { 18 | 19 | // Configuration 20 | private var port = 8080 21 | private var server: Server = Server.NETTY 22 | private var hasViewResolver: Boolean = false 23 | 24 | // Spring App 25 | private val context: GenericApplicationContext by lazy { 26 | GenericApplicationContext().apply { 27 | initialize(this) 28 | webHandler.initialize(this) 29 | messageSource.initialize(this) 30 | routes.initialize(this) 31 | refresh() 32 | } 33 | } 34 | private val httpHandler: HttpHandler by lazy { WebHttpHandlerBuilder.applicationContext(context).build() } 35 | private lateinit var webServer: WebServer 36 | 37 | // Beans 38 | private val beanDsl: BeanDefinitionDsl = BeanDefinitionDsl() 39 | private val routesDsl: RoutesDsl = RoutesDsl() 40 | private val routes = beans { 41 | bean { 42 | routesDsl.merge(this) 43 | } 44 | } 45 | private val webHandler = beans { 46 | bean("webHandler") { 47 | RouterFunctions.toWebHandler(ref(), HandlerStrategies.builder().apply { 48 | if (hasViewResolver) viewResolver(ref()) 49 | }.build()) 50 | } 51 | } 52 | private val messageSource = beans { 53 | bean("messageSource") { 54 | ReloadableResourceBundleMessageSource().apply { 55 | setBasename("messages") 56 | setDefaultEncoding("UTF-8") 57 | } 58 | } 59 | } 60 | 61 | fun run(await: Boolean = true, port: Int = this.port) { 62 | this.port = port 63 | 64 | when (server) { 65 | Server.NETTY -> webServer = NettyWebServer() 66 | Server.TOMCAT -> webServer = TomcatWebServer() 67 | } 68 | webServer.port = this.port 69 | webServer.run(httpHandler, await) 70 | } 71 | 72 | fun stop() { 73 | webServer.stop() 74 | } 75 | 76 | // Routes 77 | fun routes(f: RoutesDsl.() -> Unit) = routesDsl.apply(f) 78 | 79 | fun router(router: RouterFunction) = routesDsl.router(router) 80 | fun router(f: BeanDefinitionDsl.BeanDefinitionContext.() -> RouterFunction) = routesDsl.router(f) 81 | 82 | // Beans 83 | fun beans(f: BeanDefinitionDsl.() -> Unit) = beanDsl.apply { f() } 84 | 85 | // Mustache 86 | fun mustacheTemplate(prefix: String = "classpath:/templates/", 87 | suffix: String = ".mustache", 88 | f: MustacheViewResolver.() -> Unit = {}) { 89 | bean { 90 | hasViewResolver = true 91 | MustacheResourceTemplateLoader(prefix, suffix).let { 92 | MustacheViewResolver(Mustache.compiler().withLoader(it)).apply { 93 | setPrefix(prefix) 94 | setSuffix(suffix) 95 | f() 96 | } 97 | } 98 | } 99 | } 100 | 101 | class RoutesDsl { 102 | private val routes = mutableListOf RouterFunction>() 103 | 104 | fun router(router: RouterFunction) { 105 | routes.add({ router }) 106 | } 107 | 108 | fun router(f: BeanDefinitionDsl.BeanDefinitionContext.() -> RouterFunction) { 109 | routes.add(f) 110 | } 111 | 112 | fun merge(f: BeanDefinitionDsl.BeanDefinitionContext): RouterFunction = 113 | routes.map { it.invoke(f) }.reduce(RouterFunction::and) 114 | } 115 | } 116 | 117 | fun webfluxApplication(server: Server, f: WebfluxApplicationDsl.() -> Unit) = WebfluxApplicationDsl().apply(f) -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/messages.properties: -------------------------------------------------------------------------------- 1 | index.welcome=Welcome to this application designed to demonstrate how to build a micro-framework style application with Spring Framework 5 and Kotlin. 2 | index.list=You can have a look to: 3 | index.mustache=An HTML page rendered with JMustache 4 | index.json=An JSON REST endpoint 5 | index.sse=An HTML page using Server-Sent Events to display a stream of data 6 | 7 | sse.list=User stream: 8 | 9 | users.list=Users: 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/static/spring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgirard12/spring-webflux-kotlin-dsl/cab4c0bb83d2c7dd08242eb80e69c179ef766aa2/src/main/resources/static/spring.png -------------------------------------------------------------------------------- /src/main/resources/static/sse.js: -------------------------------------------------------------------------------- 1 | var eventSource = new EventSource("/api/users"); 2 | eventSource.onmessage = function(e) { 3 | var li = document.createElement("li"); 4 | var user = JSON.parse(e.data); 5 | li.innerText = "User: " + user.firstName + " " + user.lastName + " - " + user.birthDate; 6 | document.getElementById("users").appendChild(li); 7 | } -------------------------------------------------------------------------------- /src/main/resources/templates/footer.mustache: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/templates/header.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{#title}}{{title}}{{/title}} 5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 |
13 |

-------------------------------------------------------------------------------- /src/main/resources/templates/index.mustache: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 |
4 |

{{#i18n}}index.welcome{{/i18n}}

5 | 6 | {{#i18n}}index.list{{/i18n}} 7 | 12 |
13 | 14 | {{> footer}} 15 | -------------------------------------------------------------------------------- /src/main/resources/templates/sse.mustache: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | 4 | 5 | {{#i18n}}sse.list{{/i18n}} 6 |
    7 | 8 |
9 | 10 | {{> footer}} -------------------------------------------------------------------------------- /src/main/resources/templates/users.mustache: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | {{#i18n}}users.list{{/i18n}} 4 |
    5 | {{#users}} 6 |
  • {{firstName}} {{lastName}} - {{birthDate}}
  • 7 | {{/users}} 8 |
9 | 10 | {{> footer}} 11 | -------------------------------------------------------------------------------- /src/test/kotlin/functional/app/IntegrationTests.kt: -------------------------------------------------------------------------------- 1 | package functional.app 2 | 3 | import org.junit.jupiter.api.AfterAll 4 | import org.junit.jupiter.api.BeforeAll 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.TestInstance 7 | import org.springframework.http.MediaType.* 8 | import org.springframework.web.reactive.function.client.WebClient 9 | import org.springframework.web.reactive.function.client.bodyToFlux 10 | import org.springframework.web.reactive.function.client.bodyToMono 11 | import reactor.test.test 12 | import java.time.LocalDate 13 | 14 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 15 | class IntegrationTests { 16 | private val client = WebClient.create("http://localhost:8181") 17 | 18 | @BeforeAll 19 | fun beforeAll() { 20 | application.run(await = false, port = 8181) 21 | } 22 | 23 | @Test 24 | fun `Find all users on JSON REST endpoint`() { 25 | client.get().uri("/api/users") 26 | .accept(APPLICATION_JSON) 27 | .retrieve() 28 | .bodyToFlux() 29 | .test() 30 | .expectNextMatches { it.firstName == "Foo" && it.lastName == "Foo" && it.birthDate.isBefore(LocalDate.now()) } 31 | .expectNextMatches { it.firstName == "Bar" && it.lastName == "Bar" && it.birthDate.isBefore(LocalDate.now()) } 32 | .expectNextMatches { it.firstName == "Baz" && it.lastName == "Baz" && it.birthDate.isBefore(LocalDate.now()) } 33 | .verifyComplete() 34 | } 35 | 36 | @Test 37 | fun `Find all users on HTML page`() { 38 | client.get().uri("/users") 39 | .accept(TEXT_HTML) 40 | .retrieve() 41 | .bodyToMono() 42 | .test() 43 | .expectNextMatches { it.contains("Foo") && it.contains("Bar") && it.contains("Baz") } 44 | .verifyComplete() 45 | } 46 | 47 | @Test 48 | fun `Receive a stream of users via Server-Sent-Events`() { 49 | client.get().uri("/api/users") 50 | .accept(TEXT_EVENT_STREAM) 51 | .retrieve() 52 | .bodyToFlux() 53 | .test() 54 | .expectNextMatches { it.firstName == "Foo" && it.lastName == "Foo" && it.birthDate.isBefore(LocalDate.now()) } 55 | .expectNextMatches { it.firstName == "Bar" && it.lastName == "Bar" && it.birthDate.isBefore(LocalDate.now()) } 56 | .expectNextMatches { it.firstName == "Baz" && it.lastName == "Baz" && it.birthDate.isBefore(LocalDate.now()) } 57 | .expectNextMatches { it.firstName == "Foo" && it.lastName == "Foo" && it.birthDate.isBefore(LocalDate.now()) } 58 | .expectNextMatches { it.firstName == "Bar" && it.lastName == "Bar" && it.birthDate.isBefore(LocalDate.now()) } 59 | .expectNextMatches { it.firstName == "Baz" && it.lastName == "Baz" && it.birthDate.isBefore(LocalDate.now()) } 60 | .thenCancel() 61 | .verify() 62 | } 63 | 64 | @AfterAll 65 | fun afterAll() { 66 | application.stop() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | junit.jupiter.testinstance.lifecycle.default = per_class --------------------------------------------------------------------------------