From 572c60ebaf1bdc885a626d1b88849b07c7d26f1b Mon Sep 17 00:00:00 2001 From: Nick Ebbitt Date: Tue, 17 Mar 2020 11:50:32 +0000 Subject: [PATCH] Add Spring REST docs capability (#18) --- README.md | 2 +- build.gradle.kts | 32 ++++- src/docs/asciidoc/index.adoc | 70 ++++++++++ .../co/autotrader/application/Application.kt | 2 + .../uk/co/autotrader/application/Failures.kt | 5 +- .../uk/co/autotrader/application/Routes.kt | 10 +- .../co/autotrader/application/SystemExit.kt | 14 ++ .../co/autotrader/application/RoutesTest.kt | 126 +++++++++++++----- 8 files changed, 219 insertions(+), 42 deletions(-) create mode 100644 src/docs/asciidoc/index.adoc create mode 100644 src/main/kotlin/uk/co/autotrader/application/SystemExit.kt diff --git a/README.md b/README.md index 3068687..333871d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Chaos Kraken -Application to simulate JVM based failure scenarios when running on a delivery platform. +An application that can be used to simulate JVM based failure scenarios when running on a delivery platform. > Terrorise the Shipp'ng lanes with the Chaos Kraken, an incomplete FAAS (Failures as a Service). diff --git a/build.gradle.kts b/build.gradle.kts index 88d387b..1981772 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,9 +3,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("org.springframework.boot") version "2.2.5.RELEASE" id("io.spring.dependency-management") version "1.0.9.RELEASE" + id("org.asciidoctor.convert") version "1.5.9.2" + id("maven-publish") kotlin("jvm") version "1.3.61" kotlin("plugin.spring") version "1.3.61" - id("maven-publish") } java { @@ -24,17 +25,16 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.apache.commons:commons-lang3:3.4") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - runtimeOnly("org.springframework.boot:spring-boot-devtools") + asciidoctor("org.springframework.restdocs:spring-restdocs-asciidoctor") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.natpryce:hamkrest:1.7.0.0") testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") + testImplementation("org.springframework.restdocs:spring-restdocs-webtestclient") } -tasks.withType { - useJUnitPlatform() -} +val snippetsDir by extra { file("build/generated-snippets") } tasks.withType() { kotlinOptions { @@ -43,6 +43,28 @@ tasks.withType() { } } +tasks { + test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + outputs.dir(snippetsDir) + } + + asciidoctor { + inputs.dir(snippetsDir) + dependsOn(test) + } + + bootJar { + dependsOn(asciidoctor) + into("static/docs") { + from("build/asciidoc/html5") + } + } +} + publishing { publications { create("bootJava") { diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..44d710c --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,70 @@ += Chaos Kraken +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left + +An application that can be used to simulate JVM based failure scenarios when running on a delivery platform. + + Terrorise the Shipp'ng lanes with the Chaos Kraken, an incomplete FAAS (Failures as a Service). + +The first version of Chaos Kraken was created at https://careers.autotrader.co.uk/[Auto Trader UK] back in 2017. It was +originally built to test various application failure conditions on their private cloud infrastructure. + +As Auto Trader started their migration to public cloud and Kubernetes, the Chaos Kraken evolved to cater for more +failure modes. + +Chaos Kraken is actively used within Auto Trader to verify various behaviours of their +https://cloud.google.com/kubernetes-engine[GKE] based delivery platform. + +== Getting Started + +A https://github.com/autotraderuk/chaos-kraken/packages/143034[GitHub package] containing the executable JAR for Chaos +Kraken is created every time a version is released using a +https://github.com/autotraderuk/chaos-kraken/actions?query=workflow%3A%22Publish+release%22[GitHub Actions workflow]. + +The application JAR can be downloaded from here, or you can clone the repo and build it yourself. + +=== Building + +Chaos Kraken uses https://gradle.org/[Gradle] for dependency management and other build time concerns. It also uses the +https://docs.gradle.org/current/userguide/gradle_wrapper.html[Gradle Wrapper] allowing you to simply clone the repo and +build the project with minimal effort. + +.... +./gradlew build +.... + +This will create an executable Spring Boot JAR providing all the features of the Chaos Kraken, as well as these docs +hosted at `/docs/index.html`. + +=== Running + +Once you've downloaded or built the application JAR simply run it, for example: + +.... +java -jar chaos-kraken-0.1.2.jar +.... + +By default, the application will then be available on port `8080`. + +You can verify this from your terminal: + +include::{snippets}/welcome/curl-request.adoc[] + +Which will give you the following response: + +include::{snippets}/welcome/http-response.adoc[] + +== Simulating behaviours + +Chaos Kraken provides a variety of behaviours that simulate ways in which an application may behave badly, or even fail, +whilst running on a delivery platform. + +=== killapp + +The `killapp` behaviour simulates the JVM process dying by exiting the process. + +include::{snippets}/killapp/curl-request.adoc[] + + diff --git a/src/main/kotlin/uk/co/autotrader/application/Application.kt b/src/main/kotlin/uk/co/autotrader/application/Application.kt index 889ac80..dc16137 100644 --- a/src/main/kotlin/uk/co/autotrader/application/Application.kt +++ b/src/main/kotlin/uk/co/autotrader/application/Application.kt @@ -3,6 +3,7 @@ package uk.co.autotrader.application import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean import javax.annotation.PostConstruct @SpringBootApplication @@ -20,3 +21,4 @@ constructor(private val failureSimulator: FailureSimulator) { fun main(args: Array) { runApplication(*args) } + diff --git a/src/main/kotlin/uk/co/autotrader/application/Failures.kt b/src/main/kotlin/uk/co/autotrader/application/Failures.kt index db01c1f..e62396b 100644 --- a/src/main/kotlin/uk/co/autotrader/application/Failures.kt +++ b/src/main/kotlin/uk/co/autotrader/application/Failures.kt @@ -19,7 +19,6 @@ import java.util.stream.Collectors import java.util.stream.IntStream import java.util.stream.Stream import kotlin.concurrent.timer -import kotlin.system.exitProcess interface Failure { fun fail(params: Map = emptyMap()) @@ -223,11 +222,11 @@ class FileWriter : Failure { } @Component("killapp") -class KillApp : Failure { +class KillApp(val systemExit: SystemExit) : Failure { override fun fail(params: Map) { LOG.error("Application was killed by calling the 'killapp' failure") - exitProcess(1) + systemExit.exitProcess(1) } } diff --git a/src/main/kotlin/uk/co/autotrader/application/Routes.kt b/src/main/kotlin/uk/co/autotrader/application/Routes.kt index 2963c28..0a7383f 100644 --- a/src/main/kotlin/uk/co/autotrader/application/Routes.kt +++ b/src/main/kotlin/uk/co/autotrader/application/Routes.kt @@ -4,19 +4,19 @@ import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpStatus +import org.springframework.http.MediaType.TEXT_HTML import org.springframework.stereotype.Component import org.springframework.web.reactive.function.server.ServerRequest import org.springframework.web.reactive.function.server.ServerResponse import org.springframework.web.reactive.function.server.router import reactor.core.publisher.Mono - @Configuration class Routes(private val echoStatusHandler: EchoStatusHandler, private val failureHandler: FailureHandler) { @Bean fun root() = router { - GET("/") { _ -> ServerResponse.ok().bodyValue("This kraken is running and ready to cause some chaos.") } + GET("/") { _ -> ServerResponse.ok().contentType(TEXT_HTML).bodyValue(WELCOME_MESSAGE) } } @Bean @@ -31,6 +31,12 @@ class Routes(private val echoStatusHandler: EchoStatusHandler, private val failu } +val WELCOME_MESSAGE = """ + This kraken is running and ready to cause some chaos. +

+ Read the docs. + """.trimIndent() + @Component class FailureHandler(private val failureSimulator: FailureSimulator) { diff --git a/src/main/kotlin/uk/co/autotrader/application/SystemExit.kt b/src/main/kotlin/uk/co/autotrader/application/SystemExit.kt new file mode 100644 index 0000000..ac863fc --- /dev/null +++ b/src/main/kotlin/uk/co/autotrader/application/SystemExit.kt @@ -0,0 +1,14 @@ +package uk.co.autotrader.application + +import org.springframework.stereotype.Component + +interface SystemExit { + fun exitProcess(status: Int) +} + +@Component +class SystemExitImpl : SystemExit { + override fun exitProcess(status: Int) { + exitProcess(status) + } +} diff --git a/src/test/kotlin/uk/co/autotrader/application/RoutesTest.kt b/src/test/kotlin/uk/co/autotrader/application/RoutesTest.kt index 624bcaa..47dc167 100644 --- a/src/test/kotlin/uk/co/autotrader/application/RoutesTest.kt +++ b/src/test/kotlin/uk/co/autotrader/application/RoutesTest.kt @@ -1,46 +1,80 @@ package uk.co.autotrader.application -import com.natpryce.hamkrest.assertion.assertThat -import com.natpryce.hamkrest.equalTo +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.* import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.web.server.LocalServerPort +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.documentationConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.reactive.server.expectBody +import java.net.URI +import java.nio.charset.Charset - +@ExtendWith(SpringExtension::class, RestDocumentationExtension::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class DefaultRouteShould(@LocalServerPort val randomServerPort: Int) { +class DefaultRouteShould(private val context: ApplicationContext) { - val webClient = WebTestClient - .bindToServer() - .baseUrl("http://localhost:$randomServerPort") - .build() + private lateinit var webTestClient: WebTestClient + + @BeforeEach + fun setup(restDocumentation: RestDocumentationContextProvider) { + this.webTestClient = webTestClient(context, restDocumentation) + } @Test fun `return a welcome message`() { - webClient.get() + webTestClient + .get() + .uri("/") .exchange() .expectStatus().isOk - .expectBody().isEqualTo("This kraken is running and ready to cause some chaos.") + .expectHeader().contentType(MediaType.TEXT_HTML) + .expectBody() + .consumeWith { exchangeResult -> + val body = exchangeResult.responseBody!!.toString(Charset.forName("UTF-8")) + assertThat(body).contains("This kraken is running and ready to cause some chaos.") + assertThat(body).contains("Read the docs.") + } + .consumeWith(document("welcome")) + } } +@ExtendWith(SpringExtension::class, RestDocumentationExtension::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class SimulateRouteShould(@LocalServerPort val randomServerPort: Int, @Qualifier("custom") val customFailure: CustomFailure) { +class SimulateRouteShould( + private val context: ApplicationContext, + @LocalServerPort val randomServerPort: Int, + @Qualifier("custom") val customFailure: CustomFailure) { - val webClient = WebTestClient - .bindToServer() - .baseUrl("http://localhost:$randomServerPort") - .build() + @MockBean + lateinit var systemExit: SystemExit + + private lateinit var webTestClient: WebTestClient + + @BeforeEach + fun setup(restDocumentation: RestDocumentationContextProvider) { + this.webTestClient = webTestClient(context, restDocumentation) + } @Test fun `respond with bad request for unknown failure`() { - webClient.post().uri("/simulate/unknown") + webTestClient.post().uri("/simulate/unknown") .exchange() .expectStatus().isBadRequest .expectBody().isEqualTo("Unrecognised failure. Failed at failing this service.") @@ -48,13 +82,13 @@ class SimulateRouteShould(@LocalServerPort val randomServerPort: Int, @Qualifier @Test fun `toggle health to service unavailable`() { - webClient.post() + webTestClient.post() .uri("/simulate/toggle-service-health") .exchange() .expectStatus().isOk - webClient.get() - .uri("/actuator/health") + webTestClient.get() + .uri(URI("http://localhost:${randomServerPort}/actuator/health")) .exchange() .expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE) } @@ -63,45 +97,75 @@ class SimulateRouteShould(@LocalServerPort val randomServerPort: Int, @Qualifier fun `delegate to specific failure type`() { val expectedParams = mapOf(Pair("key1", "value1"), Pair("key2", "value2")) - webClient.post() + webTestClient.post() .uri("/simulate/custom?key1=value1&key2=value2") .exchange() .expectStatus().isOk - assertThat(customFailure.actualParams, equalTo(expectedParams)) + assertThat(customFailure.actualParams).isEqualTo(expectedParams) + } + + @Test + fun `trigger killapp failure`() { + webTestClient.post() + .uri("/simulate/killapp") + .exchange() + .expectStatus().isOk + .expectBody() + .consumeWith { exchangeResult -> + assertThat(exchangeResult.status).isEqualTo(HttpStatus.OK) + } + .consumeWith(document("killapp")) + + verify(systemExit, times(1)).exitProcess(1) } } +@ExtendWith(SpringExtension::class, RestDocumentationExtension::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class EchoStatusRouteShould(@LocalServerPort val randomServerPort: Int) { +class EchoStatusRouteShould(private val context: ApplicationContext) { - val webClient = WebTestClient - .bindToServer() - .baseUrl("http://localhost:$randomServerPort") - .build() + private lateinit var webTestClient: WebTestClient + + @BeforeEach + fun setup(restDocumentation: RestDocumentationContextProvider) { + this.webTestClient = webTestClient(context, restDocumentation) + } @Test fun `respond with provided valid status code`() { - webClient.get().uri("/echostatus/418") + webTestClient.get().uri("/echostatus/418") .exchange() .expectStatus().isEqualTo(HttpStatus.I_AM_A_TEAPOT) } @Test fun `respond with bad request for invalid status code`() { - webClient.get().uri("/echostatus/999") + webTestClient.get().uri("/echostatus/999") .exchange() .expectStatus().isBadRequest } @Test fun `respond with bad request non-numeric status code`() { - webClient.get().uri("/echostatus/sdfsdf") + webTestClient.get().uri("/echostatus/sdfsdf") .exchange() .expectStatus().isBadRequest } } +fun webTestClient(context: ApplicationContext, restDocumentation: RestDocumentationContextProvider): WebTestClient { + return WebTestClient + .bindToApplicationContext(context) + .configureClient() + .filter(documentationConfiguration(restDocumentation) + .operationPreprocessors() + .withRequestDefaults(prettyPrint()) + .withResponseDefaults(prettyPrint()) + ) + .build() +} + class CustomFailure : Failure { lateinit var actualParams: Map @@ -113,7 +177,7 @@ class CustomFailure : Failure { @Configuration class TestConfig { @Bean("custom") - fun customFailure(): Failure { + fun custom(): Failure { return CustomFailure() } -} \ No newline at end of file +}