From ebe4bc7ba23a74303158866ec9e1f2dc44bd214a Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sat, 22 Apr 2023 10:33:34 +0200 Subject: [PATCH 1/9] refactor: move to quarkus - echo endpoint --- app-quarkus/build.gradle.kts | 66 +++++ .../io/apim/samples/core/RequestHelper.kt | 17 ++ .../apim/samples/ports/http/EchoResource.kt | 77 ++++++ .../src/main/resources/application.properties | 1 + .../samples/ports/http/EchoResourceTest.kt | 259 ++++++++++++++++++ build.gradle.kts | 3 + settings.gradle.kts | 26 +- 7 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 app-quarkus/build.gradle.kts create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/http/EchoResource.kt create mode 100644 app-quarkus/src/main/resources/application.properties create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/http/EchoResourceTest.kt create mode 100644 build.gradle.kts diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts new file mode 100644 index 0000000..45e56ec --- /dev/null +++ b/app-quarkus/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.axion) + alias(libs.plugins.kotlin.allopen) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.quarkus) +} + +repositories { + mavenCentral() + maven { + url = uri("https://packages.confluent.io/maven/") + name = "Confluent" + content { + includeGroup("io.confluent") + includeGroup("org.apache.kafka") + } + } +} + +scmVersion { + tag { + prefix.set("") + } +} +project.version = scmVersion.version + +dependencies { + implementation(enforcedPlatform(libs.quarkus.bom)) + implementation(enforcedPlatform(libs.mutiny.clients.bom)) + + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-kotlin") + implementation("io.quarkus:quarkus-reactive-routes") + implementation("io.quarkus:quarkus-resteasy-reactive") + implementation("io.quarkus:quarkus-vertx") + implementation("io.vertx:vertx-lang-kotlin") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.bundles.strikt) + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.smallrye.reactive:smallrye-mutiny-vertx-web-client") + testImplementation("io.vertx:vertx-junit5") + + testRuntimeOnly(libs.junit.jupiter.engine) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +allOpen { + annotation("javax.ws.rs.Path") + annotation("javax.enterprise.context.ApplicationScoped") + annotation("io.quarkus.test.junit.QuarkusTest") +} + +tasks.withType { + systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") +} + +tasks.withType { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + kotlinOptions.javaParameters = true +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt new file mode 100644 index 0000000..a8c28f6 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt @@ -0,0 +1,17 @@ +package io.apim.samples.core + +import io.vertx.core.MultiMap +import io.vertx.ext.web.impl.ParsableMIMEValue + +/** Transform a MultiMap into a simple map. Multiple values are joined in a string separated with ; */ +fun MultiMap.toSimpleMap() = this.entries() + .groupBy { it.key.lowercase() } + .mapValues { it.value.joinToString(";") { h -> h.value } } + +fun ParsableMIMEValue.isText(): Boolean { + return this.component() == "text" +} + +fun ParsableMIMEValue.isJson(): Boolean { + return this.component() == "application" && this.subComponent().contains("json") +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/EchoResource.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/EchoResource.kt new file mode 100644 index 0000000..0b434cf --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/EchoResource.kt @@ -0,0 +1,77 @@ +package io.apim.samples.ports.http + +import io.apim.samples.core.isJson +import io.apim.samples.core.isText +import io.apim.samples.core.toSimpleMap +import io.quarkus.vertx.web.Body +import io.quarkus.vertx.web.Route +import io.quarkus.vertx.web.RouteBase +import io.quarkus.vertx.web.RoutingExchange +import io.vertx.core.buffer.Buffer +import io.vertx.core.http.HttpServerResponse +import io.vertx.core.json.DecodeException +import io.vertx.core.json.JsonObject +import io.vertx.ext.web.impl.ParsableMIMEValue +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.HttpHeaders +import jakarta.ws.rs.core.MediaType + +@RouteBase(path = "/echo") +class EchoResource { + + @Route(methods = [Route.HttpMethod.GET, Route.HttpMethod.DELETE, Route.HttpMethod.HEAD, Route.HttpMethod.OPTIONS], path = "", produces = [MediaType.APPLICATION_JSON], order = 1) + @Produces(MediaType.APPLICATION_JSON) + fun withoutBody(ctx: RoutingExchange): JsonObject { + return json { + obj(initResponseBody(ctx)) + } + } + + @Route(methods = [Route.HttpMethod.POST, Route.HttpMethod.PUT], path = "", produces = [MediaType.APPLICATION_JSON], order = 2) + fun withBody(@Body requestBody: Buffer, ctx: RoutingExchange): JsonObject { + val contentType = ctx.request().getHeader(HttpHeaders.CONTENT_TYPE)?.let { ParsableMIMEValue(it).forceParse() } + val (type, content) = readBody(contentType, requestBody) + + return json { + obj(initResponseBody(ctx)) + .put("body", json { obj("type" to type, "content" to content) }) + } + } + + @Route(type = Route.HandlerType.FAILURE, produces = [MediaType.APPLICATION_JSON], order = 3) + fun exception(e: DecodeException, response: HttpServerResponse) { + response.setStatusCode(400).end( + json { + obj( + "title" to "The request body fail to be parsed", + "detail" to e.cause?.message + ) + }.encode() + ) + } + + private fun initResponseBody(ctx: RoutingExchange) = mutableMapOf( + "method" to ctx.request().method().name(), + "headers" to json { obj(ctx.request().headers().toSimpleMap()) }, + "query_params" to json { obj(ctx.request().params().toSimpleMap()) }, + ) + + + private fun readBody(contentType: ParsableMIMEValue?, body: Buffer): Pair { + if (contentType == null) { + return "unknown" to body.toString() + } + + if (contentType.isJson()) { + return "json" to body.toJsonObject() + } + + if (contentType.isText()) { + return "text" to body.toString() + } + + return "unknown" to body.toString() + } +} diff --git a/app-quarkus/src/main/resources/application.properties b/app-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..c8f1352 --- /dev/null +++ b/app-quarkus/src/main/resources/application.properties @@ -0,0 +1 @@ +quarkus.http.port=8888 diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/EchoResourceTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/EchoResourceTest.kt new file mode 100644 index 0000000..54610e9 --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/EchoResourceTest.kt @@ -0,0 +1,259 @@ +package io.apim.samples.ports.http + +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.common.http.TestHTTPResource +import io.quarkus.test.junit.QuarkusTest +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber +import io.vertx.core.http.HttpMethod +import io.vertx.ext.web.client.WebClientOptions +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import io.vertx.mutiny.core.Vertx +import io.vertx.mutiny.core.buffer.Buffer +import io.vertx.mutiny.ext.web.client.WebClient +import jakarta.inject.Inject +import jakarta.ws.rs.core.HttpHeaders +import jakarta.ws.rs.core.MediaType +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import strikt.api.expectThat +import strikt.assertions.contains +import strikt.assertions.isEqualTo +import strikt.assertions.isNotNull +import strikt.assertions.isNull +import java.net.URL + + +@QuarkusTest +class EchoResourceTest { + @Inject + lateinit var vertx: Vertx + + @TestHTTPEndpoint(EchoResource::class) + @TestHTTPResource + lateinit var url: URL + + lateinit var client: WebClient + + @BeforeEach + fun setUp() { + client = WebClient.create( + vertx, + WebClientOptions() + .setDefaultHost(url.host) + .setDefaultPort(url.port) + ) + } + + @TestFactory + fun `Request without body and without query params`() = listOf( + HttpMethod.GET, + HttpMethod.DELETE, + HttpMethod.OPTIONS, + ).map { method -> + DynamicTest.dynamicTest("should return $method request in response body") { + val response = client + .request(method, url.path) + .send() + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(200) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("method") }.isEqualTo(method.name()) + + get { getJsonObject("headers") }.and { + get { getString(HttpHeaders.USER_AGENT.lowercase()) }.contains("Vert.x-WebClient") + get { getString(HttpHeaders.HOST.lowercase()) }.isEqualTo("${url.host}:${url.port}") + } + } + } + } + } + + @TestFactory + fun `Request without body and with query params`() = listOf( + HttpMethod.GET, + HttpMethod.DELETE, + HttpMethod.OPTIONS, + ).map { method -> + DynamicTest.dynamicTest("should return $method request with query string in response body") { + val response = client + .request(method, url.path) + .addQueryParam("param1", "value1") + .addQueryParam("param2", "value2") + .send() + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(200) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("method") }.isEqualTo(method.name()) + + get { getJsonObject("query_params") }.and { + get { getString("param1") }.isEqualTo("value1") + get { getString("param2") }.isEqualTo("value2") + } + } + } + } + } + + @TestFactory + fun `Request with json body`() = listOf( + HttpMethod.POST to MediaType.APPLICATION_JSON, + HttpMethod.POST to "application/vnd.company.api-v1+json", + HttpMethod.PUT to MediaType.APPLICATION_JSON, + HttpMethod.PUT to "application/vnd.company.api-v1+json", + ).map { (method, contentType) -> + DynamicTest.dynamicTest("should return $method request with '$contentType' body in response") { + val body = json { + obj( + "message" to "hello!", + "attribute" to "value" + ) + } + + val response = client + .request(method, url.path) + .putHeader(HttpHeaders.CONTENT_TYPE, contentType) + .sendJsonObject(body) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(200) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("method") }.isEqualTo(method.name()) + + get { getJsonObject("headers") }.and { + get { getString(HttpHeaders.USER_AGENT.lowercase()) }.contains("Vert.x-WebClient") + get { getString(HttpHeaders.HOST.lowercase()) }.isEqualTo("${url.host}:${url.port}") + get { getString(HttpHeaders.CONTENT_TYPE.lowercase()) }.isEqualTo(contentType) + get { getString(HttpHeaders.CONTENT_LENGTH.lowercase()) }.isEqualTo(body.toString().length.toString()) + } + + get { getJsonObject("body") }.and { + get { getString("type") }.isEqualTo("json") + get { getJsonObject("content") }.isEqualTo(body) + } + } + } + } + } + + @TestFactory + fun `Request with text body`() = listOf( + HttpMethod.POST to "text/plain", + HttpMethod.POST to "text/html", + HttpMethod.POST to "text/xml", + HttpMethod.PUT to "text/plain", + HttpMethod.PUT to "text/html", + HttpMethod.PUT to "text/xml", + ).map { (method, contentType) -> + DynamicTest.dynamicTest("should return $method request with '$contentType' body in response") { + val body = "a random text" + + val response = client + .request(method, url.path) + .putHeader(HttpHeaders.CONTENT_TYPE, contentType) + .sendBuffer(Buffer.buffer(body)) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(200) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("method") }.isEqualTo(method.name()) + + get { getJsonObject("headers") }.and { + get { getString(HttpHeaders.USER_AGENT.lowercase()) }.contains("Vert.x-WebClient") + get { getString(HttpHeaders.HOST.lowercase()) }.isEqualTo("${url.host}:${url.port}") + get { getString(HttpHeaders.CONTENT_TYPE.lowercase()) }.isEqualTo(contentType) + get { getString(HttpHeaders.CONTENT_LENGTH.lowercase()) }.isEqualTo(body.length.toString()) + } + + get { getJsonObject("body") }.and { + get { getString("type") }.isEqualTo("text") + get { getString("content") }.isEqualTo(body) + } + } + } + } + } + + @TestFactory + fun `Request with unknown body`() = listOf( + HttpMethod.POST, + HttpMethod.PUT, + ).map { method -> + DynamicTest.dynamicTest("should return $method request with unknown body in response") { + val body = "a random text" + + val response = client + .request(method, url.path) + .sendBuffer(Buffer.buffer(body)) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(200) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("method") }.isEqualTo(method.name()) + + get { getJsonObject("headers") }.and { + get { getString(HttpHeaders.USER_AGENT.lowercase()) }.contains("Vert.x-WebClient") + get { getString(HttpHeaders.HOST.lowercase()) }.isEqualTo("${url.host}:${url.port}") + get { getString(HttpHeaders.CONTENT_TYPE.lowercase()) }.isNull() + get { getString(HttpHeaders.CONTENT_LENGTH.lowercase()) }.isEqualTo(body.length.toString()) + } + + get { getJsonObject("body") }.and { + get { getString("type") }.isEqualTo("unknown") + get { getString("content") }.isEqualTo(body) + } + } + } + } + } + + @TestFactory + fun `Request with malformed body`() = listOf( + HttpMethod.POST, + HttpMethod.PUT, + ).map { method -> + DynamicTest.dynamicTest("should return bad request when $method request with malformed body") { + val body = "a message" + + val response = client + .request(method, url.path) + .putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .sendBuffer(Buffer.buffer(body)) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(response) { + get { statusCode() }.describedAs("statusCode").isEqualTo(400) + get { bodyAsJsonObject() }.describedAs("body").isNotNull().and { + get { getString("title") }.isEqualTo("The request body fail to be parsed") + get { getString("detail") }.contains("Unrecognized token 'a': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')") + } + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e1a9711 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.kotlin.jvm) apply false +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1727d5a..9921724 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,19 @@ rootProject.name = "apim-samples" include("app") +include("app-quarkus") include("helm") +//pluginManagement { +// repositories { +// mavenCentral() +// gradlePluginPortal() +// mavenLocal() +// +// plugins { +// id("io.quarkus") version "2.16.6.Final" +// } +//} + dependencyResolutionManagement { versionCatalogs { create("libs") { @@ -13,7 +25,9 @@ dependencyResolutionManagement { version("junit", "5.10.1") version("kotlin", "1.9.22") version("logback", "1.4.14") + version("mutiny-clients", "3.3.0") version("protobuf", "3.25.2") + version("quarkus", "3.6.6") version("rxjava", "3.1.8") version("rxkotlin", "3.0.1") version("slf4j", "2.0.11") @@ -30,10 +44,12 @@ dependencyResolutionManagement { library("javax-annotation-api", "javax.annotation", "javax.annotation-api").versionRef("annotation-api") library("junit-jupiter-api", "org.junit.jupiter", "junit-jupiter-api").versionRef("junit") library("junit-jupiter-engine", "org.junit.jupiter", "junit-jupiter-engine").versionRef("junit") + library("mutiny-clients-bom", "io.smallrye.reactive", "vertx-mutiny-clients-bom").versionRef("mutiny-clients") library("protobuf-java", "com.google.protobuf", "protobuf-java").versionRef("protobuf") library("protobuf-kotlin", "com.google.protobuf", "protobuf-kotlin").versionRef("protobuf") library("protobuf-compiler", "com.google.protobuf", "protoc").versionRef("protobuf") library("protoc-gen-java", "io.grpc", "protoc-gen-grpc-java").versionRef("grpc") + library("quarkus-bom", "io.quarkus.platform", "quarkus-bom").versionRef("quarkus") library("rxjava3", "io.reactivex.rxjava3", "rxjava").versionRef("rxjava") library("rxkotlin", "io.reactivex.rxjava3", "rxkotlin").versionRef("rxkotlin") library("strikt-core", "io.strikt", "strikt-core").versionRef("strikt") @@ -44,12 +60,14 @@ dependencyResolutionManagement { bundle("rx", listOf("rxjava3", "rxkotlin")) bundle("strikt", listOf("strikt-core")) - plugin("kotlin-jvm", "org.jetbrains.kotlin.jvm").versionRef("kotlin") - plugin("shadow", "com.github.johnrengelman.shadow").version("8.1.1") - plugin("docker", "com.palantir.docker").version("0.35.0") plugin("axion", "pl.allegro.tech.build.axion-release").version("1.16.1") - plugin("protobuf", "com.google.protobuf").version("0.9.4") + plugin("docker", "com.palantir.docker").version("0.35.0") plugin("helm", "io.github.bullshit.helmng").version("0.1.0") + plugin("kotlin-jvm", "org.jetbrains.kotlin.jvm").versionRef("kotlin") + plugin("kotlin-allopen", "org.jetbrains.kotlin.plugin.allopen").versionRef("kotlin") + plugin("protobuf", "com.google.protobuf").version("0.9.4") + plugin("quarkus", "io.quarkus").versionRef("quarkus") + plugin("shadow", "com.github.johnrengelman.shadow").version("8.1.1") } } } From b803b5a2b0b640ad7ade16d11efb69fcb4046886 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sat, 22 Apr 2023 18:35:36 +0200 Subject: [PATCH 2/9] refactor: move to quarkus - websocket echo endpoint BREAKING CHANGE: websockets moved to http server instead of a dedicated verticle --- app-quarkus/build.gradle.kts | 2 +- .../io/apim/samples/ports/ws/EchoWebSocket.kt | 35 ++++ .../samples/ports/ws/EchoWebSocketTest.kt | 157 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/ws/EchoWebSocket.kt create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/ws/EchoWebSocketTest.kt diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts index 45e56ec..5647b10 100644 --- a/app-quarkus/build.gradle.kts +++ b/app-quarkus/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation("io.quarkus:quarkus-reactive-routes") implementation("io.quarkus:quarkus-resteasy-reactive") implementation("io.quarkus:quarkus-vertx") + implementation("io.quarkus:quarkus-websockets") implementation("io.vertx:vertx-lang-kotlin") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") @@ -40,7 +41,6 @@ dependencies { testImplementation(libs.bundles.strikt) testImplementation("io.quarkus:quarkus-junit5") testImplementation("io.smallrye.reactive:smallrye-mutiny-vertx-web-client") - testImplementation("io.vertx:vertx-junit5") testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/ws/EchoWebSocket.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/ws/EchoWebSocket.kt new file mode 100644 index 0000000..9b506d3 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/ws/EchoWebSocket.kt @@ -0,0 +1,35 @@ +package io.apim.samples.ports.ws + +import io.vertx.core.json.JsonObject +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.websocket.* +import jakarta.websocket.server.ServerEndpoint + +@ServerEndpoint("/ws/echo") +class EchoWebSocket { + @OnMessage + fun processTextMessage(message: String, session: Session) { + processMessage(message, session) + } + + @OnMessage + fun processBinaryMessage(message: ByteArray, session: Session) { + processMessage(message.decodeToString(), session) + } + + private fun processMessage(message: String, session: Session) { + session.asyncRemote.sendText(parseInput(message).toString()) { result -> + if (result.exception != null) { + System.err.println("Unable to send message: " + result.exception) + } + } + } + + private fun parseInput(input: String) = try { + val json = JsonObject(input) + json { obj("type" to "json", "request" to json) } + } catch (e: Exception) { + json { obj("type" to "unknown", "request" to input) } + } +} diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/ws/EchoWebSocketTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/ws/EchoWebSocketTest.kt new file mode 100644 index 0000000..adf0517 --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/ws/EchoWebSocketTest.kt @@ -0,0 +1,157 @@ +package io.apim.samples.ports.ws + +import io.quarkus.test.common.http.TestHTTPResource +import io.quarkus.test.junit.QuarkusTest +import io.smallrye.mutiny.Uni +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber +import io.vertx.core.http.HttpClientOptions +import io.vertx.core.http.UpgradeRejectedException +import io.vertx.core.json.JsonObject +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import io.vertx.mutiny.core.Vertx +import io.vertx.mutiny.core.buffer.Buffer +import io.vertx.mutiny.core.http.HttpClient +import jakarta.inject.Inject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEqualTo +import java.net.URI + + +@QuarkusTest +class EchoWebSocketTest { + + @Inject + lateinit var vertx: Vertx + + @TestHTTPResource("/ws/echo") + lateinit var uri: URI + + lateinit var client: HttpClient + + private val jsonRequest = json { obj("message" to "Hello") } + private val unknownRequest = "unknown message" + + @BeforeEach + fun setUp() { + client = vertx.createHttpClient(HttpClientOptions() + .setDefaultHost(uri.host) + .setDefaultPort(uri.port) + ) + } + + @Test + fun `should reply to a json text message`() { + val response = client.webSocket(uri.path) + .onItem().transformToUni { session -> + Uni.createFrom().emitter { e -> + session.textMessageHandler { message -> e.complete(message) } + + session.writeTextMessage(jsonRequest.encode()) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + } + } + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + checkJsonResponse(JsonObject(response), jsonRequest) + } + + @Test + fun `should reply to a json binary message`() { + val response = client.webSocket(uri.path) + .onItem().transformToUni { session -> + Uni.createFrom().emitter { e -> + session.textMessageHandler { message -> e.complete(message) } + + session.writeBinaryMessage(Buffer.buffer(jsonRequest.encode())) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + } + } + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + checkJsonResponse(JsonObject(response), jsonRequest) + } + + @Test + fun `should reply to an unknown text message`() { + val response = client.webSocket(uri.path) + .onItem().transformToUni { session -> + Uni.createFrom().emitter { e -> + session.textMessageHandler { message -> e.complete(message) } + + session.writeTextMessage(unknownRequest) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + } + } + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + checkUnknownResponse(JsonObject(response)) + } + + @Test + fun `should reply to an unknown binary message`() { + val response = client.webSocket(uri.path) + .onItem().transformToUni { session -> + Uni.createFrom().emitter { e -> + session.textMessageHandler { message -> e.complete(message) } + + session.writeBinaryMessage(Buffer.buffer(unknownRequest)) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + } + } + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + checkUnknownResponse(JsonObject(response)) + } + + @Test + fun `should reject connection on unexpected path`() { + val socket = client.webSocket("/ws/unknown") + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitFailure() + .failure + + expectThat(socket).isA().and { + get { status }.isEqualTo(404) + } + } + + + private fun checkJsonResponse(actual: JsonObject, expected: JsonObject) { + expectThat(actual) { + get { getString("type") }.isEqualTo("json") + get { getJsonObject("request") }.isEqualTo(expected) + } + } + + private fun checkUnknownResponse(actual: JsonObject) { + expectThat(actual) { + get { getString("type") }.isEqualTo("unknown") + get { getString("request") }.isEqualTo(unknownRequest) + } + } +} From 48c8aed63db732357f832dfea9d79958c592a5d9 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sun, 23 Apr 2023 15:29:46 +0200 Subject: [PATCH 3/9] refactor: move to quarkus - grpc service BREAKING CHANGE: grpc server moved to http server instead of a dedicated server --- app-quarkus/build.gradle.kts | 11 + .../samples/ports/grpc/GreeterGrpcService.kt | 19 + .../ports/grpc/RouteGuideGrpcService.kt | 178 +++++ app-quarkus/src/main/proto/helloworld.proto | 37 + app-quarkus/src/main/proto/route_guide.proto | 131 ++++ .../src/main/resources/application.properties | 3 + .../src/main/resources/grpc/route_guide.json | 704 ++++++++++++++++++ .../ports/grpc/GreeterGrpcServiceTest.kt | 52 ++ .../ports/grpc/RouteGuideGrpcServiceTest.kt | 141 ++++ .../src/test/resources/application.properties | 0 10 files changed, 1276 insertions(+) create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/GreeterGrpcService.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt create mode 100644 app-quarkus/src/main/proto/helloworld.proto create mode 100644 app-quarkus/src/main/proto/route_guide.proto create mode 100644 app-quarkus/src/main/resources/grpc/route_guide.json create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/GreeterGrpcServiceTest.kt create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcServiceTest.kt create mode 100644 app-quarkus/src/test/resources/application.properties diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts index 5647b10..d51d2cf 100644 --- a/app-quarkus/build.gradle.kts +++ b/app-quarkus/build.gradle.kts @@ -28,7 +28,9 @@ dependencies { implementation(enforcedPlatform(libs.quarkus.bom)) implementation(enforcedPlatform(libs.mutiny.clients.bom)) + implementation(libs.slf4j.api) implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-grpc") implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-reactive-routes") implementation("io.quarkus:quarkus-resteasy-reactive") @@ -64,3 +66,12 @@ tasks.withType { kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() kotlinOptions.javaParameters = true } + +tasks.register("copyProto", Copy::class.java) { + from("src/main/proto") + into(layout.buildDirectory.dir("resources/main/META-INF/resources/proto")) +} + +tasks.withType { + dependsOn("copyProto") +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/GreeterGrpcService.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/GreeterGrpcService.kt new file mode 100644 index 0000000..120cd5c --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/GreeterGrpcService.kt @@ -0,0 +1,19 @@ +package io.apim.samples.ports.grpc + +import io.grpc.examples.helloworld.Greeter +import io.grpc.examples.helloworld.HelloReply +import io.grpc.examples.helloworld.HelloRequest +import io.quarkus.grpc.GrpcService +import io.smallrye.mutiny.Uni + +@GrpcService +class GreeterGrpcService() : Greeter { + override fun sayHello(request: HelloRequest): Uni { + var name = request.name + if(name.isBlank()) { + name = "Stranger" + } + + return Uni.createFrom().item(HelloReply.newBuilder().setMessage("Hello $name").build()) + } +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt new file mode 100644 index 0000000..3a814ab --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt @@ -0,0 +1,178 @@ +package io.apim.samples.ports.grpc + +import io.grpc.examples.routeguide.* +import io.quarkus.grpc.GrpcService +import io.smallrye.mutiny.Multi +import io.smallrye.mutiny.Uni +import io.vertx.core.json.JsonObject +import io.vertx.mutiny.core.Vertx +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import java.util.concurrent.TimeUnit +import kotlin.math.* + +@GrpcService +class RouteGuideGrpcService(private val vertx: Vertx) : RouteGuide { + private var features: List = emptyList() + private val routeNotes: ConcurrentMap> = ConcurrentHashMap() + + /** + * Obtains the feature at a given position. + * + * @param location the location to check. + * @return The feature object at the point. Note that an empty name indicates no feature. + */ + override fun getFeature(location: Point): Uni { + return features().filter { it.filterByLocation(location) } + .collect().first() + .onItem().ifNull().continueWith { + Feature.newBuilder() + .setName("") + .setLocation(location) + .build() + } + } + + /** + * Obtains the Features available within the given Rectangle. + * + * Results are streamed rather than returned at once (e.g. in a response message with a repeated field), as the + * rectangle may cover a large area and contain a huge number of features. + */ + override fun listFeatures(request: Rectangle): Multi { + val left = min(request.lo.longitude, request.hi.longitude) + val right = max(request.lo.longitude, request.hi.longitude) + val top = max(request.lo.latitude, request.hi.latitude) + val bottom = min(request.lo.latitude, request.hi.latitude) + + return features() + .filter { it.name.isNotBlank() } + .filter { feature -> + val lat = feature.location.latitude + val lon = feature.location.longitude + + lon in left..right && lat >= bottom && lat <= top + } + } + + /** + * Accepts a stream of Points on a route being traversed, returning a RouteSummary when traversal is completed. + */ + override fun recordRoute(request: Multi): Uni { + val recorder = RouteRecorder(features) + return request + .onItem().invoke { point -> recorder.append(point) } + .collect().last() + .map { recorder.buildSummary() } + } + + + /** + * Accepts a stream of RouteNotes sent while a route is being traversed, while receiving other RouteNotes + * (e.g. from other users). + */ + override fun routeChat(request: Multi): Multi { + return request + .onItem().transformToMultiAndConcatenate { + val notes = getOrCreateNotes(it.location) + + Multi.createFrom().items(notes.stream()) + .onCompletion().invoke { notes.add(it) } + } + } + + + /** + * Get the notes list for the given location. If missing, create it. + */ + private fun getOrCreateNotes(location: Point): MutableList { + val notes: MutableList = Collections.synchronizedList(ArrayList()) + return routeNotes.putIfAbsent(location, notes) ?: notes + } + + private fun features() = Multi.createFrom().items(features.stream()) + .onCompletion().ifEmpty().switchTo( + vertx.fileSystem().readFile("grpc/route_guide.json") + .onItem().transform { json -> + JsonObject(json.toString()) + .getJsonArray("features") + .map { f -> (f as JsonObject).toFeature() } + } + .onItem().invoke { list -> this.features = list } + .onItem().transformToMulti { Multi.createFrom().items(it.stream()) } + ) +} + +class RouteRecorder(private val features: List) { + private var pointsCount = 0 + private var featuresCount = 0 + private var distance = 0 + private var previousPoint: Point? = null + private val startTime = System.nanoTime() + + fun append(nextPoint: Point) { + pointsCount++ + + if (features.find { it.filterByLocation(nextPoint) } != null) { + featuresCount++ + } + + if (previousPoint != null) { + distance += calcDistance(previousPoint!!, nextPoint) + } + previousPoint = nextPoint + } + + fun buildSummary(): RouteSummary { + val time = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime) + + return RouteSummary.newBuilder() + .setPointCount(pointsCount) + .setFeatureCount(featuresCount) + .setDistance(distance) + .setElapsedTime(time.toInt()) + .build() + } + + /** + * Calculate the distance between two points using the "haversine" formula. + * This code was taken from http://www.movable-type.co.uk/scripts/latlong.html. + * + * @param start The starting point + * @param end The end point + * @return The distance between the points in meters + */ + private fun calcDistance(start: Point, end: Point): Int { + val lat1: Double = start.decimalLatitude() + val lat2: Double = end.decimalLatitude() + val lon1: Double = start.decimalLongitude() + val lon2: Double = end.decimalLongitude() + val r = 6371000 // Earth radius in meters + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val deltaPhi = Math.toRadians(lat2 - lat1) + val deltaLambda = Math.toRadians(lon2 - lon1) + val a = (sin(deltaPhi / 2) * sin(deltaPhi / 2) + + cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return (r * c).toInt() + } +} + +fun Feature.filterByLocation(point: Point): Boolean = + this.location.latitude == point.latitude && this.location.longitude == point.longitude + +private const val COORDINATE_FACTOR = 1e7 +fun Point.decimalLatitude() = latitude / COORDINATE_FACTOR +fun Point.decimalLongitude() = longitude / COORDINATE_FACTOR + +fun JsonObject.toPoint(): Point = Point.newBuilder() + .setLatitude(getInteger("latitude")) + .setLongitude(getInteger("longitude")) + .build() + +fun JsonObject.toFeature(): Feature = Feature.newBuilder() + .setName(getString("name")) + .setLocation(getJsonObject("location").toPoint()) + .build() diff --git a/app-quarkus/src/main/proto/helloworld.proto b/app-quarkus/src/main/proto/helloworld.proto new file mode 100644 index 0000000..c60d941 --- /dev/null +++ b/app-quarkus/src/main/proto/helloworld.proto @@ -0,0 +1,37 @@ +// Copyright 2015 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/app-quarkus/src/main/proto/route_guide.proto b/app-quarkus/src/main/proto/route_guide.proto new file mode 100644 index 0000000..4c864d6 --- /dev/null +++ b/app-quarkus/src/main/proto/route_guide.proto @@ -0,0 +1,131 @@ +// Copyright 2015, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.routeguide"; +option java_outer_classname = "RouteGuideProto"; +option objc_class_prefix = "RTG"; + +package routeguide; + +// Interface exported by the server. +service RouteGuide { + // A simple RPC. + // + // Obtains the feature at a given position. + // + // A feature with an empty name is returned if there's no feature at the given + // position. + rpc GetFeature(Point) returns (Feature) {} + + // A server-to-client streaming RPC. + // + // Obtains the Features available within the given Rectangle. Results are + // streamed rather than returned at once (e.g. in a response message with a + // repeated field), as the rectangle may cover a large area and contain a + // huge number of features. + rpc ListFeatures(Rectangle) returns (stream Feature) {} + + // A client-to-server streaming RPC. + // + // Accepts a stream of Points on a route being traversed, returning a + // RouteSummary when traversal is completed. + rpc RecordRoute(stream Point) returns (RouteSummary) {} + + // A Bidirectional streaming RPC. + // + // Accepts a stream of RouteNotes sent while a route is being traversed, + // while receiving other RouteNotes (e.g. from other users). + rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} +} + +// Points are represented as latitude-longitude pairs in the E7 representation +// (degrees multiplied by 10**7 and rounded to the nearest integer). +// Latitudes should be in the range +/- 90 degrees and longitude should be in +// the range +/- 180 degrees (inclusive). +message Point { + int32 latitude = 1; + int32 longitude = 2; +} + +// A latitude-longitude rectangle, represented as two diagonally opposite +// points "lo" and "hi". +message Rectangle { + // One corner of the rectangle. + Point lo = 1; + + // The other corner of the rectangle. + Point hi = 2; +} + +// A feature names something at a given point. +// +// If a feature could not be named, the name is empty. +message Feature { + // The name of the feature. + string name = 1; + + // The point where the feature is detected. + Point location = 2; +} + +// Not used in the RPC. Instead, this is here for the form serialized to disk. +message FeatureDatabase { + repeated Feature feature = 1; +} + +// A RouteNote is a message sent while at a given point. +message RouteNote { + // The location from which the message is sent. + Point location = 1; + + // The message to be sent. + string message = 2; +} + +// A RouteSummary is received in response to a RecordRoute rpc. +// +// It contains the number of individual points received, the number of +// detected features, and the total distance covered as the cumulative sum of +// the distance between each point. +message RouteSummary { + // The number of points received. + int32 point_count = 1; + + // The number of known features passed while traversing the route. + int32 feature_count = 2; + + // The distance covered in metres. + int32 distance = 3; + + // The duration of the traversal in seconds. + int32 elapsed_time = 4; +} diff --git a/app-quarkus/src/main/resources/application.properties b/app-quarkus/src/main/resources/application.properties index c8f1352..68808c3 100644 --- a/app-quarkus/src/main/resources/application.properties +++ b/app-quarkus/src/main/resources/application.properties @@ -1 +1,4 @@ quarkus.http.port=8888 + +quarkus.grpc.server.use-separate-server=false +quarkus.grpc.server.enable-reflection-service=true diff --git a/app-quarkus/src/main/resources/grpc/route_guide.json b/app-quarkus/src/main/resources/grpc/route_guide.json new file mode 100644 index 0000000..d4d7750 --- /dev/null +++ b/app-quarkus/src/main/resources/grpc/route_guide.json @@ -0,0 +1,704 @@ +{ + "features": [ + { + "location": { + "latitude": 407838351, + "longitude": -746143763 + }, + "name": "Patriots Path, Mendham, NJ 07945, USA" + }, + { + "location": { + "latitude": 408122808, + "longitude": -743999179 + }, + "name": "101 New Jersey 10, Whippany, NJ 07981, USA" + }, + { + "location": { + "latitude": 413628156, + "longitude": -749015468 + }, + "name": "U.S. 6, Shohola, PA 18458, USA" + }, + { + "location": { + "latitude": 419999544, + "longitude": -740371136 + }, + "name": "5 Conners Road, Kingston, NY 12401, USA" + }, + { + "location": { + "latitude": 414008389, + "longitude": -743951297 + }, + "name": "Mid Hudson Psychiatric Center, New Hampton, NY 10958, USA" + }, + { + "location": { + "latitude": 419611318, + "longitude": -746524769 + }, + "name": "287 Flugertown Road, Livingston Manor, NY 12758, USA" + }, + { + "location": { + "latitude": 406109563, + "longitude": -742186778 + }, + "name": "4001 Tremley Point Road, Linden, NJ 07036, USA" + }, + { + "location": { + "latitude": 416802456, + "longitude": -742370183 + }, + "name": "352 South Mountain Road, Wallkill, NY 12589, USA" + }, + { + "location": { + "latitude": 412950425, + "longitude": -741077389 + }, + "name": "Bailey Turn Road, Harriman, NY 10926, USA" + }, + { + "location": { + "latitude": 412144655, + "longitude": -743949739 + }, + "name": "193-199 Wawayanda Road, Hewitt, NJ 07421, USA" + }, + { + "location": { + "latitude": 415736605, + "longitude": -742847522 + }, + "name": "406-496 Ward Avenue, Pine Bush, NY 12566, USA" + }, + { + "location": { + "latitude": 413843930, + "longitude": -740501726 + }, + "name": "162 Merrill Road, Highland Mills, NY 10930, USA" + }, + { + "location": { + "latitude": 410873075, + "longitude": -744459023 + }, + "name": "Clinton Road, West Milford, NJ 07480, USA" + }, + { + "location": { + "latitude": 412346009, + "longitude": -744026814 + }, + "name": "16 Old Brook Lane, Warwick, NY 10990, USA" + }, + { + "location": { + "latitude": 402948455, + "longitude": -747903913 + }, + "name": "3 Drake Lane, Pennington, NJ 08534, USA" + }, + { + "location": { + "latitude": 406337092, + "longitude": -740122226 + }, + "name": "6324 8th Avenue, Brooklyn, NY 11220, USA" + }, + { + "location": { + "latitude": 406421967, + "longitude": -747727624 + }, + "name": "1 Merck Access Road, Whitehouse Station, NJ 08889, USA" + }, + { + "location": { + "latitude": 416318082, + "longitude": -749677716 + }, + "name": "78-98 Schalck Road, Narrowsburg, NY 12764, USA" + }, + { + "location": { + "latitude": 415301720, + "longitude": -748416257 + }, + "name": "282 Lakeview Drive Road, Highland Lake, NY 12743, USA" + }, + { + "location": { + "latitude": 402647019, + "longitude": -747071791 + }, + "name": "330 Evelyn Avenue, Hamilton Township, NJ 08619, USA" + }, + { + "location": { + "latitude": 412567807, + "longitude": -741058078 + }, + "name": "New York State Reference Route 987E, Southfields, NY 10975, USA" + }, + { + "location": { + "latitude": 416855156, + "longitude": -744420597 + }, + "name": "103-271 Tempaloni Road, Ellenville, NY 12428, USA" + }, + { + "location": { + "latitude": 404663628, + "longitude": -744820157 + }, + "name": "1300 Airport Road, North Brunswick Township, NJ 08902, USA" + }, + { + "location": { + "latitude": 407113723, + "longitude": -749746483 + }, + "name": "" + }, + { + "location": { + "latitude": 402133926, + "longitude": -743613249 + }, + "name": "" + }, + { + "location": { + "latitude": 400273442, + "longitude": -741220915 + }, + "name": "" + }, + { + "location": { + "latitude": 411236786, + "longitude": -744070769 + }, + "name": "" + }, + { + "location": { + "latitude": 411633782, + "longitude": -746784970 + }, + "name": "211-225 Plains Road, Augusta, NJ 07822, USA" + }, + { + "location": { + "latitude": 415830701, + "longitude": -742952812 + }, + "name": "" + }, + { + "location": { + "latitude": 413447164, + "longitude": -748712898 + }, + "name": "165 Pedersen Ridge Road, Milford, PA 18337, USA" + }, + { + "location": { + "latitude": 405047245, + "longitude": -749800722 + }, + "name": "100-122 Locktown Road, Frenchtown, NJ 08825, USA" + }, + { + "location": { + "latitude": 418858923, + "longitude": -746156790 + }, + "name": "" + }, + { + "location": { + "latitude": 417951888, + "longitude": -748484944 + }, + "name": "650-652 Willi Hill Road, Swan Lake, NY 12783, USA" + }, + { + "location": { + "latitude": 407033786, + "longitude": -743977337 + }, + "name": "26 East 3rd Street, New Providence, NJ 07974, USA" + }, + { + "location": { + "latitude": 417548014, + "longitude": -740075041 + }, + "name": "" + }, + { + "location": { + "latitude": 410395868, + "longitude": -744972325 + }, + "name": "" + }, + { + "location": { + "latitude": 404615353, + "longitude": -745129803 + }, + "name": "" + }, + { + "location": { + "latitude": 406589790, + "longitude": -743560121 + }, + "name": "611 Lawrence Avenue, Westfield, NJ 07090, USA" + }, + { + "location": { + "latitude": 414653148, + "longitude": -740477477 + }, + "name": "18 Lannis Avenue, New Windsor, NY 12553, USA" + }, + { + "location": { + "latitude": 405957808, + "longitude": -743255336 + }, + "name": "82-104 Amherst Avenue, Colonia, NJ 07067, USA" + }, + { + "location": { + "latitude": 411733589, + "longitude": -741648093 + }, + "name": "170 Seven Lakes Drive, Sloatsburg, NY 10974, USA" + }, + { + "location": { + "latitude": 412676291, + "longitude": -742606606 + }, + "name": "1270 Lakes Road, Monroe, NY 10950, USA" + }, + { + "location": { + "latitude": 409224445, + "longitude": -748286738 + }, + "name": "509-535 Alphano Road, Great Meadows, NJ 07838, USA" + }, + { + "location": { + "latitude": 406523420, + "longitude": -742135517 + }, + "name": "652 Garden Street, Elizabeth, NJ 07202, USA" + }, + { + "location": { + "latitude": 401827388, + "longitude": -740294537 + }, + "name": "349 Sea Spray Court, Neptune City, NJ 07753, USA" + }, + { + "location": { + "latitude": 410564152, + "longitude": -743685054 + }, + "name": "13-17 Stanley Street, West Milford, NJ 07480, USA" + }, + { + "location": { + "latitude": 408472324, + "longitude": -740726046 + }, + "name": "47 Industrial Avenue, Teterboro, NJ 07608, USA" + }, + { + "location": { + "latitude": 412452168, + "longitude": -740214052 + }, + "name": "5 White Oak Lane, Stony Point, NY 10980, USA" + }, + { + "location": { + "latitude": 409146138, + "longitude": -746188906 + }, + "name": "Berkshire Valley Management Area Trail, Jefferson, NJ, USA" + }, + { + "location": { + "latitude": 404701380, + "longitude": -744781745 + }, + "name": "1007 Jersey Avenue, New Brunswick, NJ 08901, USA" + }, + { + "location": { + "latitude": 409642566, + "longitude": -746017679 + }, + "name": "6 East Emerald Isle Drive, Lake Hopatcong, NJ 07849, USA" + }, + { + "location": { + "latitude": 408031728, + "longitude": -748645385 + }, + "name": "1358-1474 New Jersey 57, Port Murray, NJ 07865, USA" + }, + { + "location": { + "latitude": 413700272, + "longitude": -742135189 + }, + "name": "367 Prospect Road, Chester, NY 10918, USA" + }, + { + "location": { + "latitude": 404310607, + "longitude": -740282632 + }, + "name": "10 Simon Lake Drive, Atlantic Highlands, NJ 07716, USA" + }, + { + "location": { + "latitude": 409319800, + "longitude": -746201391 + }, + "name": "11 Ward Street, Mount Arlington, NJ 07856, USA" + }, + { + "location": { + "latitude": 406685311, + "longitude": -742108603 + }, + "name": "300-398 Jefferson Avenue, Elizabeth, NJ 07201, USA" + }, + { + "location": { + "latitude": 419018117, + "longitude": -749142781 + }, + "name": "43 Dreher Road, Roscoe, NY 12776, USA" + }, + { + "location": { + "latitude": 412856162, + "longitude": -745148837 + }, + "name": "Swan Street, Pine Island, NY 10969, USA" + }, + { + "location": { + "latitude": 416560744, + "longitude": -746721964 + }, + "name": "66 Pleasantview Avenue, Monticello, NY 12701, USA" + }, + { + "location": { + "latitude": 405314270, + "longitude": -749836354 + }, + "name": "" + }, + { + "location": { + "latitude": 414219548, + "longitude": -743327440 + }, + "name": "" + }, + { + "location": { + "latitude": 415534177, + "longitude": -742900616 + }, + "name": "565 Winding Hills Road, Montgomery, NY 12549, USA" + }, + { + "location": { + "latitude": 406898530, + "longitude": -749127080 + }, + "name": "231 Rocky Run Road, Glen Gardner, NJ 08826, USA" + }, + { + "location": { + "latitude": 407586880, + "longitude": -741670168 + }, + "name": "100 Mount Pleasant Avenue, Newark, NJ 07104, USA" + }, + { + "location": { + "latitude": 400106455, + "longitude": -742870190 + }, + "name": "517-521 Huntington Drive, Manchester Township, NJ 08759, USA" + }, + { + "location": { + "latitude": 400066188, + "longitude": -746793294 + }, + "name": "" + }, + { + "location": { + "latitude": 418803880, + "longitude": -744102673 + }, + "name": "40 Mountain Road, Napanoch, NY 12458, USA" + }, + { + "location": { + "latitude": 414204288, + "longitude": -747895140 + }, + "name": "" + }, + { + "location": { + "latitude": 414777405, + "longitude": -740615601 + }, + "name": "" + }, + { + "location": { + "latitude": 415464475, + "longitude": -747175374 + }, + "name": "48 North Road, Forestburgh, NY 12777, USA" + }, + { + "location": { + "latitude": 404062378, + "longitude": -746376177 + }, + "name": "" + }, + { + "location": { + "latitude": 405688272, + "longitude": -749285130 + }, + "name": "" + }, + { + "location": { + "latitude": 400342070, + "longitude": -748788996 + }, + "name": "" + }, + { + "location": { + "latitude": 401809022, + "longitude": -744157964 + }, + "name": "" + }, + { + "location": { + "latitude": 404226644, + "longitude": -740517141 + }, + "name": "9 Thompson Avenue, Leonardo, NJ 07737, USA" + }, + { + "location": { + "latitude": 410322033, + "longitude": -747871659 + }, + "name": "" + }, + { + "location": { + "latitude": 407100674, + "longitude": -747742727 + }, + "name": "" + }, + { + "location": { + "latitude": 418811433, + "longitude": -741718005 + }, + "name": "213 Bush Road, Stone Ridge, NY 12484, USA" + }, + { + "location": { + "latitude": 415034302, + "longitude": -743850945 + }, + "name": "" + }, + { + "location": { + "latitude": 411349992, + "longitude": -743694161 + }, + "name": "" + }, + { + "location": { + "latitude": 404839914, + "longitude": -744759616 + }, + "name": "1-17 Bergen Court, New Brunswick, NJ 08901, USA" + }, + { + "location": { + "latitude": 414638017, + "longitude": -745957854 + }, + "name": "35 Oakland Valley Road, Cuddebackville, NY 12729, USA" + }, + { + "location": { + "latitude": 412127800, + "longitude": -740173578 + }, + "name": "" + }, + { + "location": { + "latitude": 401263460, + "longitude": -747964303 + }, + "name": "" + }, + { + "location": { + "latitude": 412843391, + "longitude": -749086026 + }, + "name": "" + }, + { + "location": { + "latitude": 418512773, + "longitude": -743067823 + }, + "name": "" + }, + { + "location": { + "latitude": 404318328, + "longitude": -740835638 + }, + "name": "42-102 Main Street, Belford, NJ 07718, USA" + }, + { + "location": { + "latitude": 419020746, + "longitude": -741172328 + }, + "name": "" + }, + { + "location": { + "latitude": 404080723, + "longitude": -746119569 + }, + "name": "" + }, + { + "location": { + "latitude": 401012643, + "longitude": -744035134 + }, + "name": "" + }, + { + "location": { + "latitude": 404306372, + "longitude": -741079661 + }, + "name": "" + }, + { + "location": { + "latitude": 403966326, + "longitude": -748519297 + }, + "name": "" + }, + { + "location": { + "latitude": 405002031, + "longitude": -748407866 + }, + "name": "" + }, + { + "location": { + "latitude": 409532885, + "longitude": -742200683 + }, + "name": "" + }, + { + "location": { + "latitude": 416851321, + "longitude": -742674555 + }, + "name": "" + }, + { + "location": { + "latitude": 406411633, + "longitude": -741722051 + }, + "name": "3387 Richmond Terrace, Staten Island, NY 10303, USA" + }, + { + "location": { + "latitude": 413069058, + "longitude": -744597778 + }, + "name": "261 Van Sickle Road, Goshen, NY 10924, USA" + }, + { + "location": { + "latitude": 418465462, + "longitude": -746859398 + }, + "name": "" + }, + { + "location": { + "latitude": 411733222, + "longitude": -744228360 + }, + "name": "" + }, + { + "location": { + "latitude": 410248224, + "longitude": -747127767 + }, + "name": "3 Hasta Way, Newton, NJ 07860, USA" + } + ] +} diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/GreeterGrpcServiceTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/GreeterGrpcServiceTest.kt new file mode 100644 index 0000000..2516c14 --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/GreeterGrpcServiceTest.kt @@ -0,0 +1,52 @@ +package io.apim.samples.ports.grpc + +import io.grpc.examples.helloworld.Greeter +import io.grpc.examples.helloworld.HelloRequest +import io.quarkus.grpc.GrpcClient +import io.quarkus.test.junit.QuarkusTest +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isEqualTo + +@QuarkusTest +class GreeterGrpcServiceTest { + + @GrpcClient("greeter") + lateinit var greeter: Greeter + + @Nested + inner class SayHello { + + @Test + fun `should greet when name provided`() { + val message = HelloRequest.newBuilder().setName("John").build() + + val reply = greeter.sayHello(message) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(reply) { + get { reply.message }.isEqualTo("Hello John") + } + } + + @Test + fun `should greet when no name provided`() { + val message = HelloRequest.newBuilder().build() + + val reply = greeter.sayHello(message) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(reply) { + get { reply.message }.isEqualTo("Hello Stranger") + } + } + } +} diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcServiceTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcServiceTest.kt new file mode 100644 index 0000000..d44569f --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcServiceTest.kt @@ -0,0 +1,141 @@ +package io.apim.samples.ports.grpc + +import io.grpc.examples.routeguide.Point +import io.grpc.examples.routeguide.Rectangle +import io.grpc.examples.routeguide.RouteGuide +import io.grpc.examples.routeguide.RouteNote +import io.quarkus.grpc.GrpcClient +import io.quarkus.test.junit.QuarkusTest +import io.smallrye.mutiny.Multi +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.* + +@QuarkusTest +class RouteGuideGrpcServiceTest { + + @GrpcClient("routeGuide") + lateinit var routeGuide: RouteGuide + + @Nested + inner class GetFeature { + + @Test + fun `should return the feature if the provided point match`() { + val message = Point.newBuilder().setLatitude(407838351).setLongitude(-746143763).build() + + val feature = routeGuide.getFeature(message) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(feature) { + get { feature.name }.isEqualTo("Patriots Path, Mendham, NJ 07945, USA") + get { feature.location }.isEqualTo(message) + } + } + + @Test + fun `should return a nameless feature if the provided point doesn't match`() { + val message = Point.newBuilder().setLatitude(1).setLongitude(2).build() + + val feature = routeGuide.getFeature(message) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(feature) { + get { feature.name }.isEmpty() + get { feature.location }.isEqualTo(message) + } + } + } + + @Nested + inner class ListFeatures { + @Test + fun `should return all the features in the provided rectangle`() { + val message = Rectangle.newBuilder() + .setHi(Point.newBuilder().setLatitude(406500000).setLongitude(-745000000).build()) + .setLo(Point.newBuilder().setLatitude(402300000).setLongitude(-747900000).build()) + .build() + + val features = routeGuide.listFeatures(message).collect().asList() + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(features) { + hasSize(2) + + and { + map { it.name }.containsExactly( + "1 Merck Access Road, Whitehouse Station, NJ 08889, USA", + "330 Evelyn Avenue, Hamilton Township, NJ 08619, USA" + ) + } + } + } + } + + @Nested + inner class RecordRoute { + @Test + fun `should send all routes and return a summary`() { + val request = Multi.createFrom().items( + Point.newBuilder().setLatitude(406337092).setLongitude(-740122226).build(), + Point.newBuilder().setLatitude(406421967).setLongitude(-747727624).build(), + ) + + val summary = routeGuide.recordRoute(request) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(summary) { + get { pointCount }.isEqualTo(2) + get { featureCount }.isEqualTo(2) + get { distance }.isEqualTo(64180) + get { elapsedTime }.isGreaterThanOrEqualTo(0) + } + } + } + + @Nested + inner class RouteChat { + @Test + fun `should send all routes and return a summary`() { + + val request = Multi.createFrom().items( + RouteNote.newBuilder().setLocation(Point.newBuilder().setLatitude(0).setLongitude(0).build()).setMessage("Note 1").build(), + RouteNote.newBuilder().setLocation(Point.newBuilder().setLatitude(0).setLongitude(0).build()).setMessage("Note 2").build(), + RouteNote.newBuilder().setLocation(Point.newBuilder().setLatitude(0).setLongitude(0).build()).setMessage("Note 3").build(), + ) + + val notes = routeGuide.routeChat(request) + .collect().asList() + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .item + + expectThat(notes) { + hasSize(3) + and { + map { it.message }.containsExactly( + "Note 1", + "Note 1", + "Note 2" + ) + } + } + + } + } +} diff --git a/app-quarkus/src/test/resources/application.properties b/app-quarkus/src/test/resources/application.properties new file mode 100644 index 0000000..e69de29 From 28335dfe8886df5e0f78dc3d7fe0cf0f8a8800f4 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sat, 20 Jan 2024 15:55:28 +0100 Subject: [PATCH 4/9] feat: move to quarkus - avro generation endpoint --- app-quarkus/build.gradle.kts | 5 + .../core/avro/AvroGenericDataGenerator.kt | 61 ++++++ .../io/apim/samples/core/avro/AvroSerDe.kt | 7 + .../io/apim/samples/core/avro/SerDeFactory.kt | 21 ++ .../core/avro/impl/AvroSerDeConfluent.kt | 20 ++ .../samples/core/avro/impl/AvroSerDeSimple.kt | 29 +++ .../apim/samples/core/avro/impl/JsonSerDe.kt | 30 +++ .../apim/samples/ports/http/ResponseHelper.kt | 19 ++ .../ports/http/avro/AvroGeneratorResource.kt | 103 +++++++++ .../http/avro/AvroGeneratorResourceTest.kt | 204 ++++++++++++++++++ 10 files changed, 499 insertions(+) create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/SerDeFactory.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/http/ResponseHelper.kt create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResource.kt create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResourceTest.kt diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts index d51d2cf..60caea6 100644 --- a/app-quarkus/build.gradle.kts +++ b/app-quarkus/build.gradle.kts @@ -28,6 +28,9 @@ dependencies { implementation(enforcedPlatform(libs.quarkus.bom)) implementation(enforcedPlatform(libs.mutiny.clients.bom)) + implementation(libs.avro) + implementation(libs.kafka.serializer.avro) + implementation(libs.kotlin.faker) implementation(libs.slf4j.api) implementation("io.quarkus:quarkus-arc") implementation("io.quarkus:quarkus-grpc") @@ -41,6 +44,8 @@ dependencies { testImplementation(libs.junit.jupiter.api) testImplementation(libs.bundles.strikt) + testImplementation("io.rest-assured:rest-assured") + testImplementation("io.rest-assured:kotlin-extensions") testImplementation("io.quarkus:quarkus-junit5") testImplementation("io.smallrye.reactive:smallrye-mutiny-vertx-web-client") diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt new file mode 100644 index 0000000..d140171 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt @@ -0,0 +1,61 @@ +package io.apim.samples.core.avro + +import io.github.serpro69.kfaker.Faker +import org.apache.avro.Schema +import org.apache.avro.generic.GenericData +import org.apache.avro.generic.GenericFixed +import org.apache.avro.generic.GenericRecord +import kotlin.random.Random + +val faker = Faker() + +fun generate(schema: Schema?): Any? = when(schema?.type) { + Schema.Type.BOOLEAN -> faker.random.nextBoolean() + Schema.Type.INT -> faker.random.nextInt() + Schema.Type.LONG -> faker.random.nextLong() + Schema.Type.FLOAT -> faker.random.nextFloat() + Schema.Type.DOUBLE -> faker.random.nextDouble() + Schema.Type.BYTES -> faker.random.randomString().toByteArray() + Schema.Type.STRING -> faker.random.randomString() + Schema.Type.RECORD -> newRecord(schema) + Schema.Type.ENUM -> faker.random.randomValue(schema.enumSymbols) + Schema.Type.ARRAY -> newArray(schema) + Schema.Type.MAP -> newMap(schema) + Schema.Type.UNION -> newUnion(schema) + Schema.Type.FIXED -> newFixed(schema) + Schema.Type.NULL -> null + null -> null +} + +private fun newRecord(schema: Schema): GenericRecord { + val record = GenericData.Record(schema) + + schema.fields.forEach { + record.put(it.name(), generate(it.schema())) + } + + return record +} + +private fun newArray(schema: Schema): List { + val list = mutableListOf() + repeat(3) { list.add(generate(schema.elementType)) } + return list +} + +private fun newMap(schema: Schema): Map { + val map = mutableMapOf() + repeat(3) { map[faker.random.randomString()] = generate(schema.valueType) } + return map +} + +private fun newUnion(schema: Schema): Any? { + val selectedSchema = faker.random.randomValue(schema.types) + return generate(selectedSchema) +} + +private fun newFixed(schema: Schema): GenericFixed { + val bytes = ByteArray(schema.fixedSize) + Random.nextBytes(bytes) + return GenericData.Fixed(schema, bytes) +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt new file mode 100644 index 0000000..de50cc1 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt @@ -0,0 +1,7 @@ +package io.apim.samples.core.avro + +interface AvroSerDe { + fun serialize(data: Any?): ByteArray + + fun deserialize(binary: ByteArray): Any? +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/SerDeFactory.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/SerDeFactory.kt new file mode 100644 index 0000000..93b9a12 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/SerDeFactory.kt @@ -0,0 +1,21 @@ +package io.apim.samples.core.avro + +import io.apim.samples.core.avro.impl.AvroSerDeConfluent +import io.apim.samples.core.avro.impl.AvroSerDeSimple +import io.apim.samples.core.avro.impl.JsonSerDe +import jakarta.enterprise.context.ApplicationScoped +import org.apache.avro.Schema + +enum class SerializationFormat { + CONFLUENT, SIMPLE +} + +@ApplicationScoped +class SerDeFactory { + fun newAvroSerDe(schema: Schema, format: SerializationFormat): AvroSerDe = when (format) { + SerializationFormat.SIMPLE -> AvroSerDeSimple(schema) + SerializationFormat.CONFLUENT -> AvroSerDeConfluent(schema) + } + + fun newJsonSerDe(schema: Schema): JsonSerDe = JsonSerDe(schema) +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt new file mode 100644 index 0000000..154510d --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt @@ -0,0 +1,20 @@ +package io.apim.samples.core.avro.impl + +import io.apim.samples.core.avro.AvroSerDe +import io.confluent.kafka.serializers.KafkaAvroDeserializer +import io.confluent.kafka.serializers.KafkaAvroSerializer +import org.apache.avro.Schema + +class AvroSerDeConfluent(private val schema: Schema) : AvroSerDe { + override fun serialize(data: Any?): ByteArray { + val serializer = KafkaAvroSerializer() + serializer.configure(mapOf("schema.registry.url" to "mock://my-scope"), false) + return serializer.serialize("topic", data) + } + + override fun deserialize(binary: ByteArray): Any? { + val deserializer = KafkaAvroDeserializer() + deserializer.configure(mapOf("schema.registry.url" to "mock://my-scope"), false) + return deserializer.deserialize("topic", binary, schema) + } +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt new file mode 100644 index 0000000..aa5a26d --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt @@ -0,0 +1,29 @@ +package io.apim.samples.core.avro.impl + +import io.apim.samples.core.avro.AvroSerDe +import org.apache.avro.Schema +import org.apache.avro.generic.GenericDatumReader +import org.apache.avro.generic.GenericDatumWriter +import org.apache.avro.io.DecoderFactory +import org.apache.avro.io.EncoderFactory +import java.io.ByteArrayOutputStream + +class AvroSerDeSimple(private val schema: Schema) : AvroSerDe { + override fun serialize(data: Any?): ByteArray { + val writer = GenericDatumWriter(schema) + val output = ByteArrayOutputStream() + val encoder = EncoderFactory.get().binaryEncoder(output, null) + + writer.write(data, encoder) + encoder.flush() + + return output.toByteArray() + } + + override fun deserialize(binary: ByteArray): Any? { + val reader = GenericDatumReader(schema) + val decoder = DecoderFactory.get().binaryDecoder(binary, null) + + return reader.read(null, decoder) + } +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt new file mode 100644 index 0000000..62fed6c --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt @@ -0,0 +1,30 @@ +package io.apim.samples.core.avro.impl + +import org.apache.avro.Schema +import org.apache.avro.generic.GenericDatumReader +import org.apache.avro.generic.GenericDatumWriter +import org.apache.avro.io.DatumReader +import org.apache.avro.io.DecoderFactory +import org.apache.avro.io.EncoderFactory +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets + +class JsonSerDe(private val schema: Schema) { + fun serialize(data: Any?): String { + val writer = GenericDatumWriter(schema) + val output = ByteArrayOutputStream() + val encoder = EncoderFactory.get().jsonEncoder(schema, output) + + writer.write(data, encoder) + encoder.flush() + output.flush() + + return output.toString(StandardCharsets.UTF_8) + } + + fun deserialize(json: String): Any { + val decoder = DecoderFactory.get().jsonDecoder(schema, json) + val reader: DatumReader = GenericDatumReader(schema) + return reader.read(null, decoder) + } +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/ResponseHelper.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/ResponseHelper.kt new file mode 100644 index 0000000..efeeec1 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/ResponseHelper.kt @@ -0,0 +1,19 @@ +package io.apim.samples.ports.http + +import io.vertx.core.http.HttpServerResponse +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.ws.rs.core.HttpHeaders +import jakarta.ws.rs.core.MediaType + +fun HttpServerResponse.sendError(statusCode: Int, title: String, detail: String? = null) { + this.statusCode = statusCode + this.putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + this.end(json { + obj( + "title" to title, + "detail" to detail + ) + } + .encode()) +} diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResource.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResource.kt new file mode 100644 index 0000000..855db3c --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResource.kt @@ -0,0 +1,103 @@ +package io.apim.samples.ports.http.avro + +import io.apim.samples.core.avro.SerDeFactory +import io.apim.samples.core.avro.SerializationFormat +import io.apim.samples.core.avro.impl.JsonSerDe +import io.apim.samples.ports.http.sendError +import io.quarkus.vertx.web.Body +import io.quarkus.vertx.web.Route +import io.quarkus.vertx.web.RouteBase +import io.quarkus.vertx.web.RoutingExchange +import io.vertx.core.buffer.Buffer +import io.vertx.core.http.HttpHeaders +import io.vertx.core.http.HttpServerResponse +import io.vertx.core.json.DecodeException +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.apache.avro.Schema +import org.apache.avro.SchemaParseException +import java.util.* +import kotlin.jvm.optionals.getOrDefault + +enum class OutputFormat { + AVRO, JSON +} + +@RouteBase(path = "/avro/generate") +class AvroGeneratorResource(private val serdeFactory: SerDeFactory) { + + @Route(methods = [Route.HttpMethod.POST], path = "", consumes = [MediaType.APPLICATION_JSON], order = 1) + fun generate(@Body body: Buffer?, ctx: RoutingExchange) { + if (body == null || body.length() == 0) { + ctx.response().sendError(Response.Status.BAD_REQUEST.statusCode, "Provide an avro schema") + return + } + + try { + val output = ctx.getOutputFormatFromQueryParam() + val schema = Schema.Parser().parse(body.toString()) + + when (output) { + OutputFormat.AVRO -> generateAvro(schema, ctx) + OutputFormat.JSON -> generateJson(schema, ctx) + } + } catch (e: SchemaParseException) { + ctx.response().sendError(Response.Status.BAD_REQUEST.statusCode, "Invalid avro schema", e.message) + return + } + } + + @Route(type = Route.HandlerType.FAILURE, produces = [MediaType.APPLICATION_JSON], order = 2) + fun exception(e: DecodeException, response: HttpServerResponse) { + response.setStatusCode(Response.Status.BAD_REQUEST.statusCode).end( + json { + obj( + "title" to "The request body fail to be parsed", + "detail" to e.cause?.message + ) + }.encode() + ) + } + + private fun generateAvro(schema: Schema, ctx: RoutingExchange) { + val data = io.apim.samples.core.avro.generate(schema) + + val format = ctx.getSerializationFormatFromQueryParam() + val serde = serdeFactory.newAvroSerDe(schema, format) + + ctx.response().statusCode = Response.Status.OK.statusCode + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/*+avro") + ctx.response().end(Buffer.buffer(serde.serialize(data))) + } + + private fun generateJson(schema: Schema, ctx: RoutingExchange) { + val data = io.apim.samples.core.avro.generate(schema) + val serde = JsonSerDe(schema) + + ctx.response().statusCode = Response.Status.OK.statusCode + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + ctx.response().end(serde.serialize(data)) + } +} + +fun RoutingExchange.getOutputFormatFromQueryParam(): OutputFormat { + val output = getParam("output").getOrDefault("avro") + try { + return OutputFormat.valueOf(output.uppercase(Locale.getDefault())) + } catch (e: IllegalArgumentException) { + response().sendError(Response.Status.BAD_REQUEST.statusCode, "Invalid output format", "Valid values are: ${OutputFormat.entries.joinToString(", ") { it.name.lowercase() }}") + throw e + } +} + +fun RoutingExchange.getSerializationFormatFromQueryParam(): SerializationFormat { + val format = getParam("format").getOrDefault(SerializationFormat.CONFLUENT.name) + try { + return SerializationFormat.valueOf(format.uppercase(Locale.getDefault())) + } catch (e: IllegalArgumentException) { + response().sendError(Response.Status.BAD_REQUEST.statusCode, "Invalid format", "Valid values are: ${SerializationFormat.entries.joinToString(", ") { it.name.lowercase() }}") + throw e + } +} diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResourceTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResourceTest.kt new file mode 100644 index 0000000..d1343d1 --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroGeneratorResourceTest.kt @@ -0,0 +1,204 @@ +package io.apim.samples.ports.http.avro + +import io.apim.samples.core.avro.SerDeFactory +import io.apim.samples.core.avro.SerializationFormat +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.common.http.TestHTTPResource +import io.quarkus.test.junit.QuarkusTest +import io.restassured.builder.RequestSpecBuilder +import io.restassured.config.LogConfig +import io.restassured.config.RestAssuredConfig +import io.restassured.filter.log.LogDetail +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import io.vertx.core.json.JsonObject +import jakarta.inject.Inject +import jakarta.ws.rs.core.MediaType +import org.apache.avro.Schema +import org.apache.avro.generic.GenericRecord +import org.apache.avro.util.Utf8 +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import strikt.api.expectThat +import strikt.assertions.* +import java.net.URL + +@QuarkusTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AvroGeneratorResourceTest { + private val schema = """ + { + "type": "record", + "name": "Payment", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "amount", + "type": "double" + } + ] + } + """.trimIndent() + + @TestHTTPEndpoint(AvroGeneratorResource::class) + @TestHTTPResource + lateinit var url: URL + + @Inject + lateinit var serdeFactory: SerDeFactory + + val requestSpecification = RequestSpecBuilder() + .setContentType(ContentType.JSON) + .setRelaxedHTTPSValidation() + .setConfig( + RestAssuredConfig.config() + .logConfig( + LogConfig.logConfig() + .enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL) + ) + ) + .build() + + @BeforeEach + fun setUp() { + requestSpecification.baseUri(url.toString()) + } + + @ParameterizedTest + @EnumSource(SerializationFormat::class) + fun `should return a serialized avro`(format: SerializationFormat) { + val serde = serdeFactory.newAvroSerDe(Schema.Parser().parse(schema), format) + + val result = Given { + spec(requestSpecification) + queryParam("format", format.name) + body(schema) + } When { + post() + } Then { + statusCode(200) + contentType("application/*+avro") + } Extract { + body().asByteArray() + } + + val data = serde.deserialize(result) + + expectThat(data).isNotNull().isA().and { + get { get("id") }.isNotNull().isA() + get { get("amount") }.isNotNull().isA() + } + } + + @Test + fun `should return a json matching the schema provided`() { + val result = Given { + spec(requestSpecification) + queryParam("output", "json") + body(schema) + } When { + post() + } Then { + statusCode(200) + contentType(MediaType.APPLICATION_JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)) { + get { getString("id") }.isNotNull().isA() + get { getDouble("amount") }.isNotNull().isA() + } + + } + + @Test + fun `should return an error when no schema is provided`() { + val result = Given { + spec(requestSpecification) + body("") + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Provide an avro schema") + } + } + + @Test + fun `should return an error when schema is invalid`() { + val result = Given { + spec(requestSpecification) + body("""{ "type } """.trimIndent()) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Invalid avro schema") + } + } + + @Test + fun `should return an error when output format is not supported`() { + val result = Given { + spec(requestSpecification) + queryParam("output", "unsupported") + body(schema) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Invalid output format") + get { getString("detail") }.isEqualTo("Valid values are: avro, json") + } + } + + @Test + fun `should return an error when format is not supported`() { + val result = Given { + spec(requestSpecification) + queryParam("format", "unsupported") + body(schema) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Invalid format") + get { getString("detail") }.isEqualTo("Valid values are: confluent, simple") + } + } + +} From 0b099aa0edc8733c78cfbf12c8aa389e4a2e1324 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sat, 20 Jan 2024 16:22:51 +0100 Subject: [PATCH 5/9] feat: move to quarkus - avro serde endpoint --- .../ports/http/avro/AvroSerDeResource.kt | 77 +++++++ .../ports/http/avro/AvroSerDeResourceTest.kt | 208 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResource.kt create mode 100644 app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResourceTest.kt diff --git a/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResource.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResource.kt new file mode 100644 index 0000000..f08ef98 --- /dev/null +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResource.kt @@ -0,0 +1,77 @@ +package io.apim.samples.ports.http.avro + +import io.apim.samples.core.avro.SerDeFactory +import io.apim.samples.core.avro.impl.JsonSerDe +import io.apim.samples.ports.http.sendError +import io.quarkus.vertx.web.Body +import io.quarkus.vertx.web.Route +import io.quarkus.vertx.web.RouteBase +import io.quarkus.vertx.web.RoutingExchange +import io.vertx.core.buffer.Buffer +import io.vertx.core.http.HttpHeaders +import io.vertx.core.http.HttpServerResponse +import io.vertx.core.json.DecodeException +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.apache.avro.Schema +import org.apache.avro.SchemaParseException + +@RouteBase(path = "/avro/serde") +class AvroSerDeResource(private val serdeFactory: SerDeFactory) { + + @Route(methods = [Route.HttpMethod.POST], path = "", consumes = [MediaType.APPLICATION_JSON, "avro/binary"], order = 1) + fun serde(@Body body: Buffer, ctx: RoutingExchange) { + val contentTypeHeader = ctx.request().getHeader(jakarta.ws.rs.core.HttpHeaders.CONTENT_TYPE) + + val schema = ctx.getSchemaFromHeader("X-Avro-Schema") + val jsonSerDe = JsonSerDe(schema) + val avroSerDe = serdeFactory.newAvroSerDe(schema, ctx.getSerializationFormatFromQueryParam()) + + if(contentTypeHeader.contains("json", ignoreCase = true)) { + val data = jsonSerDe.deserialize(body.toString()) + ctx.response().statusCode = Response.Status.OK.statusCode + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "avro/binary") + ctx.response().end(Buffer.buffer(avroSerDe.serialize(data))) + return + } + + if(contentTypeHeader.contains("avro", ignoreCase = true)) { + val data = avroSerDe.deserialize(body.bytes) + ctx.response().statusCode = Response.Status.OK.statusCode + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + ctx.response().end(Buffer.buffer(jsonSerDe.serialize(data))) + return + } + + ctx.response().sendError(Response.Status.BAD_REQUEST.statusCode, "Unsupported content type") + } + + @Route(type = Route.HandlerType.FAILURE, produces = [MediaType.APPLICATION_JSON], order = 2) + fun exception(e: DecodeException, response: HttpServerResponse) { + response.setStatusCode(Response.Status.BAD_REQUEST.statusCode).end( + json { + obj( + "title" to "The request body fail to be parsed", + "detail" to e.cause?.message + ) + }.encode() + ) + } +} + +fun RoutingExchange.getSchemaFromHeader(header: String = "X-Avro-Schema"): Schema { + val schemaString = request().getHeader(header) + if (schemaString == null) { + response().sendError(Response.Status.BAD_REQUEST.statusCode, "Avro schema required in $header header") + throw IllegalArgumentException("Avro schema required in $header header") + } + + return try { + Schema.Parser().parse(schemaString) + } catch (e: SchemaParseException) { + response().sendError(400, "Invalid avro schema", e.message) + throw e + } +} diff --git a/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResourceTest.kt b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResourceTest.kt new file mode 100644 index 0000000..788b31f --- /dev/null +++ b/app-quarkus/src/test/kotlin/io/apim/samples/ports/http/avro/AvroSerDeResourceTest.kt @@ -0,0 +1,208 @@ +package io.apim.samples.ports.http.avro + +import io.apim.samples.core.avro.SerDeFactory +import io.apim.samples.core.avro.SerializationFormat +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.common.http.TestHTTPResource +import io.quarkus.test.junit.QuarkusTest +import io.restassured.builder.RequestSpecBuilder +import io.restassured.config.LogConfig +import io.restassured.config.RestAssuredConfig +import io.restassured.filter.log.LogDetail +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import io.vertx.core.json.JsonObject +import io.vertx.kotlin.core.json.json +import io.vertx.kotlin.core.json.obj +import jakarta.inject.Inject +import jakarta.ws.rs.core.MediaType +import org.apache.avro.Schema +import org.apache.avro.generic.GenericData +import org.apache.avro.generic.GenericRecord +import org.apache.avro.util.Utf8 +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEqualTo +import strikt.assertions.isNotNull +import java.net.URL + +@QuarkusTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AvroSerDeResourceTest { + private val schema = """ + { + "type": "record", + "name": "Payment", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "amount", + "type": "double" + } + ] + } + """.trimIndent() + + @TestHTTPEndpoint(AvroSerDeResource::class) + @TestHTTPResource + lateinit var url: URL + + @Inject + lateinit var serdeFactory: SerDeFactory + + val requestSpecification = RequestSpecBuilder() + .setContentType(ContentType.JSON) + .setRelaxedHTTPSValidation() + .setConfig( + RestAssuredConfig.config() + .logConfig( + LogConfig.logConfig() + .enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL) + ) + ) + .build() + + @BeforeEach + fun setUp() { + requestSpecification.baseUri(url.toString()) + } + + @ParameterizedTest + @EnumSource(SerializationFormat::class) + fun `should return a serialized avro from a json body`(format: SerializationFormat) { + val serde = serdeFactory.newAvroSerDe(Schema.Parser().parse(schema), format) + val json = json { obj("id" to "an-id", "amount" to 10.0) } + + val result = Given { + spec(requestSpecification) + queryParam("format", format.name) + header("X-Avro-Schema", schema) + contentType(MediaType.APPLICATION_JSON) + body(json.encode()) + } When { + post() + } Then { + statusCode(200) + contentType("avro/binary") + } Extract { + body().asByteArray() + } + + val data = serde.deserialize(result) + + expectThat(data).isNotNull().isA().and { + get { get("id") }.isNotNull().isA() + get { get("amount") }.isNotNull().isA() + } + } + + @ParameterizedTest + @EnumSource(SerializationFormat::class) + fun `should return a json from an avro body`(format: SerializationFormat) { + val serde = serdeFactory.newAvroSerDe(Schema.Parser().parse(schema), format) + val datum = GenericData.Record(Schema.Parser().parse(schema)).apply { + put("id", "an-id") + put("amount", 10.0) + } + + + val result = Given { + spec(requestSpecification) + queryParam("format", format.name) + header("X-Avro-Schema", schema) + contentType("avro/binary") + body(serde.serialize(datum)) + } When { + post() + } Then { + statusCode(200) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)) { + get { getString("id") }.isNotNull().isA() + get { getDouble("amount") }.isNotNull().isA() + } + + } + + @Test + fun `should return an error when no schema is provided`() { + val json = json { obj("id" to "an-id", "amount" to 10.0) } + + val result = Given { + spec(requestSpecification) + body(json.encode()) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Avro schema required in X-Avro-Schema header") + } + } + + @Test + fun `should return an error when schema is invalid`() { + val json = json { obj("id" to "an-id", "amount" to 10.0) } + + val result = Given { + spec(requestSpecification) + header("X-Avro-Schema", """{ "type } """.trimIndent()) + body(json.encode()) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Invalid avro schema") + } + } + + @Test + fun `should return an error when incorrect serialization format`() { + val json = json { obj("id" to "an-id", "amount" to 10.0) } + + val result = Given { + spec(requestSpecification) + header("X-Avro-Schema", schema) + queryParam("format", "unsupported") + body(json.encode()) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } Extract { + body().asString() + } + + expectThat(JsonObject(result)).and { + get { getString("title") }.isEqualTo("Invalid format") + get { getString("detail") }.isEqualTo("Valid values are: confluent, simple") + } + } +} From d7be52f7a428bd049d1e91139e58f27cbdcc0176 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sun, 21 Jan 2024 14:33:18 +0100 Subject: [PATCH 6/9] feat: move to quarkus - build docker image --- app-quarkus/Dockerfile | 96 ++++++++++++++++++++++++++++++++++++ app-quarkus/build.gradle.kts | 9 ++++ gradle.properties | 4 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 app-quarkus/Dockerfile diff --git a/app-quarkus/Dockerfile b/app-quarkus/Dockerfile new file mode 100644 index 0000000..9116c1b --- /dev/null +++ b/app-quarkus/Dockerfile @@ -0,0 +1,96 @@ +### +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# +# You can configure the behavior using the following environment properties: +# +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM eclipse-temurin:21-jre-alpine AS builder + +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' + +RUN apk -U upgrade \ + && apk add --no-cache curl \ + && rm -rf /var/cache/apk/* + +ARG RUN_JAVA_VERSION=1.3.8 +RUN curl https://repo1.maven.org/maven2/io/fabric8/run-java-sh/${RUN_JAVA_VERSION}/run-java-sh-${RUN_JAVA_VERSION}-sh.sh -o /tmp/run-java.sh + +RUN mkdir -m 774 -p /quarkus-app/app \ + && mkdir -m 774 -p /quarkus-app/lib \ + && mkdir -m 774 -p /quarkus-app/quarkus \ + && mv /tmp/run-java.sh /quarkus-app/run-java.sh \ + && chmod 774 /quarkus-app/run-java.sh \ + && chown -R guest:users /quarkus-app + +# Make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=guest:users ./quarkus/ /quarkus-app/quarkus/ +COPY --chown=guest:users ./*.jar /quarkus-app/ +COPY --chown=guest:users ./lib/ /quarkus-app/lib/ +COPY --chown=guest:users ./app/ /quarkus-app/app/ + +FROM eclipse-temurin:21-jre-alpine + +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' + +COPY --from=builder --chown=guest:users --chmod=775 /quarkus-app /app + +WORKDIR /app +USER guest + +EXPOSE 8080 +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="quarkus-run.jar" +ENTRYPOINT [ "./run-java.sh" ] diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts index 60caea6..9dbe234 100644 --- a/app-quarkus/build.gradle.kts +++ b/app-quarkus/build.gradle.kts @@ -1,5 +1,8 @@ +import com.palantir.gradle.docker.DockerExtension + plugins { alias(libs.plugins.axion) + alias(libs.plugins.docker) alias(libs.plugins.kotlin.allopen) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.quarkus) @@ -80,3 +83,9 @@ tasks.register("copyProto", Copy::class.java) { tasks.withType { dependsOn("copyProto") } + +configure { + name = "${rootProject.name}:${project.version}" + files(tasks.findByName("quarkusBuild")?.outputs?.files) + tag("DockerHub", "jgiovaresco/${name}") +} diff --git a/gradle.properties b/gradle.properties index 29e08e8..16413e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,3 @@ -kotlin.code.style=official \ No newline at end of file +#Gradle properties +#Sun Jan 21 11:44:56 CET 2024 +kotlin.code.style=official From e7992ced9d6f238dfc32891e19bf7d3593ac3faf Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sun, 21 Jan 2024 14:52:05 +0100 Subject: [PATCH 7/9] feat: move to quarkus - update documentation --- README.adoc | 52 +++++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/README.adoc b/README.adoc index 00a4ccd..0d6d274 100644 --- a/README.adoc +++ b/README.adoc @@ -1,38 +1,37 @@ = apim samples -image:https://img.shields.io/badge/vert.x-4.3.6-purple.svg[link="https://vertx.io"] +This application provides an API sample +that I use to play with https://github.com/gravitee-io/gravitee-api-management[Gravitee APIM]. -This application provide API sample that I use to play with https://github.com/gravitee-io/gravitee-api-management[Gravite APIM]. == Building -To launch your tests: +To launch tests: ---- -./gradlew clean test +./gradlew test ---- -To run your application: +To run application in Dev mode: ---- -./gradlew clean run +./gradlew --console=plain quarkusDev ---- -== Configuration - -Configuration is provided through environment variables. Available variables are defined -link:app/src/main/kotlin/io/apim/samples/Configuration.kt[here] - -[,java] +To build the application: ---- -include::app/src/main/kotlin/io/apim/samples/Configuration.kt[lines=2..] +./gradlew build docker ---- +== Configuration + +Quarkus handles the configuration of the application. + == Available endpoints -It starts 3 http servers +It starts a single http server to handle -- one used to handle regular HTTP request. (default port 8888) -- one used to handle WebSockets. (default port 8890) -- one used to handle GRPC. (default port 8892) +- regular HTTP request. +- WebSockets. +- GRPC services. === HTTP /echo @@ -77,7 +76,7 @@ This endpoint can receive WebSocket request. It will copy the request received i Using https://github.com/vi/websocat[websocat]: ---- -websocat -1 ws://localhost:8890/ws/echo +websocat -1 ws://localhost:8888/ws/echo {"message": "Hello"} ---- @@ -95,28 +94,31 @@ will respond === GRPC -The server provide an adapted example of the Route Guide from https://github.com/grpc/grpc-java/tree/master/examples[gRPC examples] +The application provides 2 gRPC services adapted from https://github.com/grpc/grpc-java/tree/master/examples[gRPC examples]: + +- the Route Guide service +- the Greeter service -The service shows the various kind of gRPC service calls: +The Route Guide service shows the various kind of gRPC service calls: - simple RPC - server-side streaming RPC - client-side streaming RPC - bidirectional streaming RPC -The proto file is available at +Proto files are available at -- link:app/src/main/resources/grpc/route_guide.proto[here] -- or it can be downloaded using the HTTP server: http://localhost:8888/grpc/route_guide.proto +- link:app-quarkus/src/main/proto[here] +- or it can be downloaded using the HTTP server: http://localhost:8888/proto/route_guide.proto or http://localhost:8888/proto/helloworld.proto ==== Example Using https://github.com/fullstorydev/grpcurl[grpcurl]. -(The server does not expose Reflection service, therefore we need to provide the protofile to the client) +(The server exposes Reflection service, therefore no need to provide the protofile to the client) [source,bash] ---- -grpcurl -d '{"latitude": 413628156, "longitude": -749015468}' -import-path app/src/main/resources/grpc -proto route_guide.proto -plaintext localhost:8892 routeguide.RouteGuide/GetFeature +grpcurl -d '{"latitude": 413628156, "longitude": -749015468}' -plaintext localhost:8888 routeguide.RouteGuide/GetFeature ---- will respond From 9d7e460adfdbc078a8ac7b63c4253d20a649b131 Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sun, 21 Jan 2024 15:02:18 +0100 Subject: [PATCH 8/9] feat: move to quarkus - update helm chart --- app-quarkus/build.gradle.kts | 1 + helm/src/main/helm/templates/deployment.yaml | 12 ------------ helm/src/main/helm/templates/service.yaml | 8 -------- helm/src/main/helm/values.yaml | 8 ++------ 4 files changed, 3 insertions(+), 26 deletions(-) diff --git a/app-quarkus/build.gradle.kts b/app-quarkus/build.gradle.kts index 9dbe234..5ec4fed 100644 --- a/app-quarkus/build.gradle.kts +++ b/app-quarkus/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-reactive-routes") implementation("io.quarkus:quarkus-resteasy-reactive") + implementation("io.quarkus:quarkus-smallrye-health") implementation("io.quarkus:quarkus-vertx") implementation("io.quarkus:quarkus-websockets") implementation("io.vertx:vertx-lang-kotlin") diff --git a/helm/src/main/helm/templates/deployment.yaml b/helm/src/main/helm/templates/deployment.yaml index eb848eb..dbb755d 100644 --- a/helm/src/main/helm/templates/deployment.yaml +++ b/helm/src/main/helm/templates/deployment.yaml @@ -36,19 +36,7 @@ spec: - name: http containerPort: {{ .Values.app.http.port }} protocol: TCP - - name: websocket - containerPort: {{ .Values.app.websocket.port }} - protocol: TCP - - name: grpc - containerPort: {{ .Values.app.grpc.port }} - protocol: TCP env: - - name: HTTP_PORT - value: {{ .Values.app.http.port | quote }} - - name: WEBSOCKET_PORT - value: {{ .Values.app.websocket.port | quote }} - - name: GRPC_PORT - value: {{ .Values.app.grpc.port | quote }} {{- if .Values.app.env }} {{ toYaml ( .Values.app.env) | indent 12 }} {{- end }} diff --git a/helm/src/main/helm/templates/service.yaml b/helm/src/main/helm/templates/service.yaml index 19503d6..d37e9be 100644 --- a/helm/src/main/helm/templates/service.yaml +++ b/helm/src/main/helm/templates/service.yaml @@ -11,14 +11,6 @@ spec: targetPort: http protocol: TCP name: http - - port: {{ .Values.service.port.websocket }} - targetPort: websocket - protocol: TCP - name: websocket - - port: {{ .Values.service.port.grpc }} - targetPort: grpc - protocol: TCP - name: grpc selector: {{- include "app.selectorLabels" . | nindent 4 }} diff --git a/helm/src/main/helm/values.yaml b/helm/src/main/helm/values.yaml index f49c9dc..0ee9bb0 100644 --- a/helm/src/main/helm/values.yaml +++ b/helm/src/main/helm/values.yaml @@ -20,13 +20,13 @@ securityContext: probes: liveness: httpGet: - path: /health + path: /q/health/live port: http periodSeconds: 10 failureThreshold: 3 readiness: httpGet: - path: /health + path: /q/health/ready port: http periodSeconds: 10 failureThreshold: 3 @@ -69,7 +69,3 @@ affinity: {} app: http: port: 8080 - websocket: - port: 8090 - grpc: - port: 9000 From 3260e4889e57259837ec5ea10098990405d2d51f Mon Sep 17 00:00:00 2001 From: Julien Giovaresco Date: Sun, 21 Jan 2024 15:41:47 +0100 Subject: [PATCH 9/9] feat: move to quarkus - remove previous app --- app/Dockerfile | 10 - app/build.gradle.kts | 148 ---- .../kotlin/io/apim/samples/Configuration.kt | 10 - .../kotlin/io/apim/samples/MainVerticle.kt | 62 -- .../samples/avro/AvroGenericDataGenerator.kt | 61 -- .../kotlin/io/apim/samples/avro/AvroSerDe.kt | 7 - .../apim/samples/avro/AvroSerDeConfluent.kt | 19 - .../io/apim/samples/avro/AvroSerDeFactory.kt | 9 - .../apim/samples/avro/AvroSerDeFactoryImpl.kt | 10 - .../io/apim/samples/avro/AvroSerDeSimple.kt | 28 - .../kotlin/io/apim/samples/avro/JsonSerDe.kt | 31 - .../io/apim/samples/grpc/GreeterService.kt | 33 - .../apim/samples/grpc/GrpcServerVerticle.kt | 48 -- .../io/apim/samples/grpc/RouteGuideService.kt | 194 ----- .../io/apim/samples/rest/EchoHandler.kt | 68 -- .../apim/samples/rest/ProtobufFileHandler.kt | 45 -- .../io/apim/samples/rest/RequestHelper.kt | 21 - .../io/apim/samples/rest/ResponseHelper.kt | 15 - .../apim/samples/rest/RestServerVerticle.kt | 49 -- .../samples/rest/avro/AvroGeneratorHandler.kt | 65 -- .../samples/rest/avro/AvroRequestHelper.kt | 43 -- .../samples/rest/avro/AvroSerDeHandler.kt | 37 - .../io/apim/samples/websocket/EchoHandler.kt | 37 - .../samples/websocket/WebSockerHandler.kt | 7 - .../websocket/WebSocketServerVerticle.kt | 36 - app/src/main/resources/grpc/helloworld.proto | 37 - app/src/main/resources/grpc/route_guide.json | 704 ------------------ app/src/main/resources/grpc/route_guide.proto | 131 ---- app/src/main/resources/logback.xml | 11 - .../io/apim/samples/MainVerticleTest.kt | 103 --- .../avro/AvroGenericDataGeneratorTest.kt | 173 ----- .../samples/grpc/GrpcServerVerticleTest.kt | 259 ------- .../samples/rest/RestServerVerticleTest.kt | 564 -------------- .../websocket/WebSocketServerVerticleTest.kt | 165 ---- settings.gradle.kts | 12 - 35 files changed, 3252 deletions(-) delete mode 100644 app/Dockerfile delete mode 100644 app/build.gradle.kts delete mode 100644 app/src/main/kotlin/io/apim/samples/Configuration.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/MainVerticle.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactory.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactoryImpl.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/grpc/GreeterService.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/grpc/GrpcServerVerticle.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/EchoHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/ProtobufFileHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/ResponseHelper.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/RestServerVerticle.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/avro/AvroGeneratorHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/avro/AvroRequestHelper.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/rest/avro/AvroSerDeHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/websocket/EchoHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/websocket/WebSockerHandler.kt delete mode 100644 app/src/main/kotlin/io/apim/samples/websocket/WebSocketServerVerticle.kt delete mode 100644 app/src/main/resources/grpc/helloworld.proto delete mode 100644 app/src/main/resources/grpc/route_guide.json delete mode 100644 app/src/main/resources/grpc/route_guide.proto delete mode 100644 app/src/main/resources/logback.xml delete mode 100644 app/src/test/kotlin/io/apim/samples/MainVerticleTest.kt delete mode 100644 app/src/test/kotlin/io/apim/samples/avro/AvroGenericDataGeneratorTest.kt delete mode 100644 app/src/test/kotlin/io/apim/samples/grpc/GrpcServerVerticleTest.kt delete mode 100644 app/src/test/kotlin/io/apim/samples/rest/RestServerVerticleTest.kt delete mode 100644 app/src/test/kotlin/io/apim/samples/websocket/WebSocketServerVerticleTest.kt diff --git a/app/Dockerfile b/app/Dockerfile deleted file mode 100644 index cafbbc0..0000000 --- a/app/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM graviteeio/java:21 - -ARG BUILD_VERSION -ENV JAR_FILE=apim-samples-${BUILD_VERSION}-fat.jar - -WORKDIR /app -COPY app-${BUILD_VERSION}-fat.jar $JAR_FILE - -ENTRYPOINT ["sh", "-c"] -CMD ["exec java -jar $JAR_FILE"] diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index ead9ea8..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,148 +0,0 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import com.google.protobuf.gradle.id -import com.palantir.gradle.docker.DockerExtension -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - alias(libs.plugins.kotlin.jvm) - application - alias(libs.plugins.shadow) - alias(libs.plugins.docker) - alias(libs.plugins.axion) - alias(libs.plugins.protobuf) -} - -repositories { - mavenCentral() - maven { - url = uri("https://packages.confluent.io/maven/") - name = "Confluent" - content { - includeGroup("io.confluent") - includeGroup("org.apache.kafka") - } - } -} - -scmVersion { - tag { - prefix.set("") - } -} -project.version = scmVersion.version - -val jarClassifier = "fat" -val mainVerticleName = "io.apim.samples.MainVerticle" -val launcherClassName = "io.vertx.core.Launcher" -val compileKotlin: KotlinCompile by tasks -compileKotlin.kotlinOptions.jvmTarget = "17" -val compileTestKotlin: KotlinCompile by tasks -compileTestKotlin.kotlinOptions.jvmTarget = "17" - -dependencies { - implementation(kotlin("stdlib-jdk8")) - - implementation(platform("io.vertx:vertx-stack-depchain:${libs.versions.vertx.get()}")) - implementation("io.vertx:vertx-config") - implementation("io.vertx:vertx-grpc") // required for generated stubs - implementation("io.vertx:vertx-grpc-server") - implementation("io.vertx:vertx-health-check") - implementation("io.vertx:vertx-junit5") - implementation("io.vertx:vertx-lang-kotlin") - implementation("io.vertx:vertx-rx-java3") - implementation("io.vertx:vertx-web") - - implementation(libs.bundles.grpc) - implementation(libs.bundles.logback) - implementation(libs.bundles.rx) - - implementation(libs.avro) - implementation(libs.kafka.serializer.avro) - implementation(libs.kotlin.faker) - implementation(libs.slf4j.api) - - testImplementation(libs.junit.jupiter.api) - testImplementation(libs.bundles.strikt) - testImplementation("io.vertx:vertx-grpc-client") - testImplementation("io.vertx:vertx-web-client") - testRuntimeOnly(libs.junit.jupiter.engine) -} - -application { - mainClass.set(launcherClassName) -} - -sourceSets { - main { - proto { - srcDir("src/main/resources/grpc") - } - } -} - -tasks.withType { - archiveClassifier.set(jarClassifier) - manifest { - attributes(mapOf("Main-Verticle" to mainVerticleName)) - } - mergeServiceFiles() -} - -tasks.withType { - val watchForChange = "src/**/*" - val doOnChange = "${projectDir}/gradlew classes" - args = listOf( - "run", - mainVerticleName, - "--redeploy=$watchForChange", - "--launcher-class=$launcherClassName", - "--on-redeploy=$doOnChange" - ) -} - -tasks.test { - useJUnitPlatform() -} - -configure { - name = "${rootProject.name}:${project.version}" - buildArgs(mapOf("BUILD_VERSION" to "${project.version}")) - files(tasks.findByName("shadowJar")?.outputs?.files) - - tag("DockerHub", "jgiovaresco/${name}") -} - -if (hasProperty("buildScan")) { - extensions.findByName("buildScan")?.withGroovyBuilder { - setProperty("termsOfServiceUrl", "https://gradle.com/terms-of-service") - setProperty("termsOfServiceAgree", "yes") - } -} - -protobuf { - protoc { - artifact = libs.protobuf.compiler.get().toString() - } - - plugins { - id("grpc") { - artifact = libs.protoc.gen.java.get().toString() - } - id("vertx") { - artifact = "io.vertx:vertx-grpc-protoc-plugin:${libs.versions.vertx.get()}" - - } - } - - generateProtoTasks { - all().forEach { - it.builtins { - id("kotlin") - } - it.plugins { - id("grpc") {} - id("vertx") {} - } - } - } -} diff --git a/app/src/main/kotlin/io/apim/samples/Configuration.kt b/app/src/main/kotlin/io/apim/samples/Configuration.kt deleted file mode 100644 index 8460841..0000000 --- a/app/src/main/kotlin/io/apim/samples/Configuration.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.apim.samples - -/** Environment variable name to set HTTP server port. The value should be an integer */ -const val httpPort = "HTTP_PORT" - -/** Environment variable name to set GRPC server port. The value should be an integer */ -const val grpcPort = "GRPC_PORT" - -/** Environment variable name to set WebSocket server port. The value should be an integer */ -const val webSocketPort = "WEBSOCKET_PORT" diff --git a/app/src/main/kotlin/io/apim/samples/MainVerticle.kt b/app/src/main/kotlin/io/apim/samples/MainVerticle.kt deleted file mode 100644 index 7314f39..0000000 --- a/app/src/main/kotlin/io/apim/samples/MainVerticle.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.apim.samples - -import io.apim.samples.grpc.GrpcServerVerticle -import io.apim.samples.rest.RestServerVerticle -import io.apim.samples.websocket.WebSocketServerVerticle -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single -import io.vertx.core.Vertx -import io.vertx.ext.healthchecks.Status -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.AbstractVerticle -import io.vertx.rxjava3.ext.healthchecks.HealthChecks - -class MainVerticle : AbstractVerticle() { - override fun rxStart(): Completable { - val configRetriever = ConfigRetriever.create(vertx) - - return buildHealthChecks(configRetriever) - .flatMapCompletable { healthChecks -> - Single.merge( - vertx.deployVerticle(WebSocketServerVerticle(configRetriever)), - vertx.deployVerticle(GrpcServerVerticle(configRetriever)), - vertx.deployVerticle(RestServerVerticle(configRetriever, healthChecks)), - ).ignoreElements() - } - } - - private fun buildHealthChecks(configRetriever: ConfigRetriever): Single { - val client = vertx.createNetClient() - - return configRetriever.config - .map { - HealthChecks.create(vertx) - .register("websocket") { promise -> - client.connect(it.getInteger(webSocketPort, WebSocketServerVerticle.DEFAULT_PORT), "0.0.0.0") - .doOnSuccess { promise.complete(Status.OK()) } - .doOnError { promise.complete(handleConnectionError(it)) } - .flatMapCompletable { it.close() } - .subscribe() - } - .register("grpc") { promise -> - client.connect(it.getInteger(grpcPort, GrpcServerVerticle.DEFAULT_PORT), "0.0.0.0") - .doOnSuccess { promise.complete(Status.OK()) } - .doOnError { promise.complete(handleConnectionError(it)) } - .flatMapCompletable { it.close() } - .subscribe() - } - } - } - - private fun handleConnectionError(throwable: Throwable): Status { - val error = json { obj("message" to throwable.message) } - return Status.KO(error) - } -} - -fun main() { - val vertx = Vertx.vertx() - vertx.deployVerticle(MainVerticle()) -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt deleted file mode 100644 index 5d2d8e0..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt +++ /dev/null @@ -1,61 +0,0 @@ -package io.apim.samples.avro - -import io.github.serpro69.kfaker.Faker -import org.apache.avro.Schema -import org.apache.avro.generic.GenericData -import org.apache.avro.generic.GenericFixed -import org.apache.avro.generic.GenericRecord -import kotlin.random.Random - -val faker = Faker() - -fun generate(schema: Schema?): Any? = when(schema?.type) { - Schema.Type.BOOLEAN -> faker.random.nextBoolean() - Schema.Type.INT -> faker.random.nextInt() - Schema.Type.LONG -> faker.random.nextLong() - Schema.Type.FLOAT -> faker.random.nextFloat() - Schema.Type.DOUBLE -> faker.random.nextDouble() - Schema.Type.BYTES -> faker.random.randomString().toByteArray() - Schema.Type.STRING -> faker.random.randomString() - Schema.Type.RECORD -> newRecord(schema) - Schema.Type.ENUM -> faker.random.randomValue(schema.enumSymbols) - Schema.Type.ARRAY -> newArray(schema) - Schema.Type.MAP -> newMap(schema) - Schema.Type.UNION -> newUnion(schema) - Schema.Type.FIXED -> newFixed(schema) - Schema.Type.NULL -> null - null -> null -} - -private fun newRecord(schema: Schema): GenericRecord { - val record = GenericData.Record(schema) - - schema.fields.forEach { - record.put(it.name(), generate(it.schema())) - } - - return record -} - -private fun newArray(schema: Schema): List { - val list = mutableListOf() - repeat(3) { list.add(generate(schema.elementType)) } - return list -} - -private fun newMap(schema: Schema): Map { - val map = mutableMapOf() - repeat(3) { map[faker.random.randomString()] = generate(schema.valueType) } - return map -} - -private fun newUnion(schema: Schema): Any? { - val selectedSchema = faker.random.randomValue(schema.types) - return generate(selectedSchema) -} - -private fun newFixed(schema: Schema): GenericFixed { - val bytes = ByteArray(schema.fixedSize) - Random.nextBytes(bytes) - return GenericData.Fixed(schema, bytes) -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt deleted file mode 100644 index de2b508..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.apim.samples.avro - -interface AvroSerDe { - fun serialize(data: Any?): ByteArray - - fun deserialize(binary: ByteArray): Any? -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt deleted file mode 100644 index 006019a..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.apim.samples.avro - -import io.confluent.kafka.serializers.KafkaAvroDeserializer -import io.confluent.kafka.serializers.KafkaAvroSerializer -import org.apache.avro.Schema - -class AvroSerDeConfluent(private val schema: Schema) : AvroSerDe { - override fun serialize(data: Any?): ByteArray { - val serializer = KafkaAvroSerializer() - serializer.configure(mapOf("schema.registry.url" to "mock://my-scope"), false) - return serializer.serialize("topic", data) - } - - override fun deserialize(binary: ByteArray): Any? { - val deserializer = KafkaAvroDeserializer() - deserializer.configure(mapOf("schema.registry.url" to "mock://my-scope"), false) - return deserializer.deserialize("topic", binary, schema) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactory.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactory.kt deleted file mode 100644 index 3070a0d..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactory.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.apim.samples.avro - -import org.apache.avro.Schema - -enum class SerializationFormat { CONFLUENT, SIMPLE, } - -interface AvroSerDeFactory { - fun new(schema: Schema, format: SerializationFormat = SerializationFormat.SIMPLE): AvroSerDe -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactoryImpl.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactoryImpl.kt deleted file mode 100644 index 1e32d82..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeFactoryImpl.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.apim.samples.avro - -import org.apache.avro.Schema - -class AvroSerDeFactoryImpl: AvroSerDeFactory { - override fun new(schema: Schema, format: SerializationFormat): AvroSerDe = when(format){ - SerializationFormat.SIMPLE -> AvroSerDeSimple(schema) - SerializationFormat.CONFLUENT -> AvroSerDeConfluent(schema) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt b/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt deleted file mode 100644 index a44c2e6..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.apim.samples.avro - -import org.apache.avro.Schema -import org.apache.avro.generic.GenericDatumReader -import org.apache.avro.generic.GenericDatumWriter -import org.apache.avro.io.DecoderFactory -import org.apache.avro.io.EncoderFactory -import java.io.ByteArrayOutputStream - -class AvroSerDeSimple(private val schema: Schema) : AvroSerDe { - override fun serialize(data: Any?): ByteArray { - val writer = GenericDatumWriter(schema) - val output = ByteArrayOutputStream() - val encoder = EncoderFactory.get().binaryEncoder(output, null) - - writer.write(data, encoder) - encoder.flush() - - return output.toByteArray() - } - - override fun deserialize(binary: ByteArray): Any? { - val reader = GenericDatumReader(schema) - val decoder = DecoderFactory.get().binaryDecoder(binary, null) - - return reader.read(null, decoder) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt b/app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt deleted file mode 100644 index aed9308..0000000 --- a/app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.apim.samples.avro - -import org.apache.avro.Schema -import org.apache.avro.generic.GenericDatumReader -import org.apache.avro.generic.GenericDatumWriter -import org.apache.avro.io.DatumReader -import org.apache.avro.io.DecoderFactory -import org.apache.avro.io.EncoderFactory -import java.io.ByteArrayOutputStream -import java.nio.charset.StandardCharsets - -class JsonSerDe(private val schema: Schema) { - fun serialize(data: Any?): String { - val writer = GenericDatumWriter(schema) - val output = ByteArrayOutputStream() - val encoder = EncoderFactory.get().jsonEncoder(schema, output) - - writer.write(data, encoder) - encoder.flush() - output.flush() - - - return output.toString(StandardCharsets.UTF_8) - } - - fun deserialize(json: String): Any { - val decoder = DecoderFactory.get().jsonDecoder(schema, json) - val reader: DatumReader = GenericDatumReader(schema) - return reader.read(null, decoder) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/grpc/GreeterService.kt b/app/src/main/kotlin/io/apim/samples/grpc/GreeterService.kt deleted file mode 100644 index a1cae1d..0000000 --- a/app/src/main/kotlin/io/apim/samples/grpc/GreeterService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.apim.samples.grpc - -import io.grpc.examples.helloworld.HelloReply -import io.grpc.examples.helloworld.HelloRequest -import io.grpc.examples.helloworld.VertxGreeterGrpc -import io.grpc.examples.helloworld.helloReply -import io.grpc.examples.routeguide.* -import io.vertx.core.Future -import io.vertx.core.Promise -import io.vertx.core.json.JsonObject -import io.vertx.core.streams.ReadStream -import io.vertx.core.streams.WriteStream -import org.slf4j.LoggerFactory -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentMap -import java.util.concurrent.TimeUnit.NANOSECONDS -import kotlin.math.* - -class GreeterService() : VertxGreeterGrpc.GreeterVertxImplBase() { - private val logger = LoggerFactory.getLogger(javaClass) - private val routeNotes: ConcurrentMap> = ConcurrentHashMap() - - - override fun sayHello(request: HelloRequest): Future { - var name = request.name - if(name.isBlank()) { - name = "Stranger" - } - - return Future.succeededFuture(helloReply { message = "Hello $name" }) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/grpc/GrpcServerVerticle.kt b/app/src/main/kotlin/io/apim/samples/grpc/GrpcServerVerticle.kt deleted file mode 100644 index 412a56d..0000000 --- a/app/src/main/kotlin/io/apim/samples/grpc/GrpcServerVerticle.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.apim.samples.grpc - -import io.apim.samples.grpcPort -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single -import io.vertx.core.json.JsonObject -import io.vertx.grpc.server.GrpcServer -import io.vertx.grpc.server.GrpcServiceBridge -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.AbstractVerticle -import org.slf4j.LoggerFactory - -class GrpcServerVerticle(private val configRetriever: ConfigRetriever) : AbstractVerticle() { - companion object { - const val DEFAULT_PORT = 8892 - } - - private val logger = LoggerFactory.getLogger(javaClass) - - override fun rxStart(): Completable { - return Single.zip(configRetriever.config, grpcServer()) { config, grpcServer -> Pair(config, grpcServer) } - .flatMap { - - val (config, grpcServer) = it - Single.fromCompletionStage( - vertx.delegate.createHttpServer() - .requestHandler(grpcServer) - .listen(config.getInteger(grpcPort, DEFAULT_PORT)) - .toCompletionStage() - ) - } - .doOnError { logger.error("Fail to start $javaClass", it) } - .doOnSuccess { logger.info("GRPC server started on port ${it.actualPort()}") } - .ignoreElement() - } - - private fun grpcServer(): Single = vertx.fileSystem().readFile("grpc/route_guide.json") - .map { - val features = JsonObject(it.toString()) - .getJsonArray("feature") - .map { f -> (f as JsonObject).toFeature() } - - val server = GrpcServer.server(vertx.delegate) - GrpcServiceBridge.bridge(RouteGuideService(features)).bind(server) - GrpcServiceBridge.bridge(GreeterService()).bind(server) - server - } -} diff --git a/app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt b/app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt deleted file mode 100644 index afe012a..0000000 --- a/app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt +++ /dev/null @@ -1,194 +0,0 @@ -package io.apim.samples.grpc - -import io.grpc.examples.routeguide.Feature -import io.grpc.examples.routeguide.Point -import io.grpc.examples.routeguide.Rectangle -import io.grpc.examples.routeguide.RouteNote -import io.grpc.examples.routeguide.RouteSummary -import io.grpc.examples.routeguide.VertxRouteGuideGrpc -import io.grpc.examples.routeguide.feature -import io.grpc.examples.routeguide.point -import io.grpc.examples.routeguide.routeSummary -import io.vertx.core.Future -import io.vertx.core.Promise -import io.vertx.core.json.JsonObject -import io.vertx.core.streams.ReadStream -import io.vertx.core.streams.WriteStream -import org.slf4j.LoggerFactory -import java.util.Collections -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentMap -import java.util.concurrent.TimeUnit.NANOSECONDS -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.max -import kotlin.math.min -import kotlin.math.sin -import kotlin.math.sqrt - -class RouteGuideService(private val features: List) : VertxRouteGuideGrpc.RouteGuideVertxImplBase() { - private val logger = LoggerFactory.getLogger(javaClass) - private val routeNotes: ConcurrentMap> = ConcurrentHashMap() - - /** - * Obtains the feature at a given position. - * - * @param location the location to check. - * @return The feature object at the point. Note that an empty name indicates no feature. - */ - override fun getFeature(location: Point): Future { - val found = - features.find { it.filterByLocation(location) } - ?: feature { - name = "" - this.location = point { - latitude = location.latitude - longitude = location.longitude - } - } - - return Future.succeededFuture(found) - } - - /** - * Obtains the Features available within the given Rectangle. - * - * Results are streamed rather than returned at once (e.g. in a response message with a repeated field), as the - * rectangle may cover a large area and contain a huge number of features. - */ - override fun listFeatures(request: Rectangle, response: WriteStream) { - val left = min(request.lo.longitude, request.hi.longitude) - val right = max(request.lo.longitude, request.hi.longitude) - val top = max(request.lo.latitude, request.hi.latitude) - val bottom = min(request.lo.latitude, request.hi.latitude) - - features.filter { it.name.isNotBlank() } - .filter { feature -> - val lat = feature.location.latitude - val lon = feature.location.longitude - - lon in left..right && lat >= bottom && lat <= top - } - .forEach { response.write(it) } - - response.end() - } - - /** - * Accepts a stream of Points on a route being traversed, returning a RouteSummary when traversal is completed. - */ - override fun recordRoute(request: ReadStream): Future { - val response = Promise.promise() - - request.exceptionHandler { - logger.error("Fail to process recordRoute request", it) - response.fail(it) - } - - val routerRecorder = RouteRecorder(features) - request.handler(routerRecorder::append) - request.endHandler { response.complete(routerRecorder.buildSummary()) } - - return response.future() - } - - /** - * Accepts a stream of RouteNotes sent while a route is being traversed, while receiving other RouteNotes - * (e.g. from other users). - */ - override fun routeChat(request: ReadStream, response: WriteStream) { - request.handler { note -> - val locationNotes = getOrCreateNotes(note.location) - - locationNotes.forEach { response.write(it) } - - locationNotes.add(note) - } - - request.exceptionHandler { - logger.error("routeChat cancelled", it) - response.end() - } - - request.endHandler { response.end() } - } - - /** - * Get the notes list for the given location. If missing, create it. - */ - private fun getOrCreateNotes(location: Point): MutableList { - val notes: MutableList = Collections.synchronizedList(ArrayList()) - return routeNotes.putIfAbsent(location, notes) ?: notes - } -} - -class RouteRecorder(private val features: List) { - private var pointsCount = 0 - private var featuresCount = 0 - private var distance = 0 - private var previousPoint: Point? = null - private val startTime = System.nanoTime() - - fun append(nextPoint: Point) { - pointsCount++ - - if (features.find { it.filterByLocation(nextPoint) } != null) { - featuresCount++ - } - - if (previousPoint != null) { - distance += calcDistance(previousPoint!!, nextPoint) - } - previousPoint = nextPoint - } - - fun buildSummary(): RouteSummary { - val time = NANOSECONDS.toSeconds(System.nanoTime() - startTime) - return routeSummary { - pointCount = pointsCount - featureCount = featuresCount - distance = this@RouteRecorder.distance - elapsedTime = time.toInt() - } - } - - /** - * Calculate the distance between two points using the "haversine" formula. - * This code was taken from http://www.movable-type.co.uk/scripts/latlong.html. - * - * @param start The starting point - * @param end The end point - * @return The distance between the points in meters - */ - private fun calcDistance(start: Point, end: Point): Int { - val lat1: Double = start.decimalLatitude() - val lat2: Double = end.decimalLatitude() - val lon1: Double = start.decimalLongitude() - val lon2: Double = end.decimalLongitude() - val r = 6371000 // Earth radius in meters - val phi1 = Math.toRadians(lat1) - val phi2 = Math.toRadians(lat2) - val deltaPhi = Math.toRadians(lat2 - lat1) - val deltaLambda = Math.toRadians(lon2 - lon1) - val a = (sin(deltaPhi / 2) * sin(deltaPhi / 2) - + cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)) - val c = 2 * atan2(sqrt(a), sqrt(1 - a)) - return (r * c).toInt() - } -} - -fun Feature.filterByLocation(point: Point): Boolean = - this.location.latitude == point.latitude && this.location.longitude == point.longitude - -private const val COORDINATE_FACTOR = 1e7 -fun Point.decimalLatitude() = latitude / COORDINATE_FACTOR -fun Point.decimalLongitude() = longitude / COORDINATE_FACTOR - -fun JsonObject.toPoint(): Point = point { - latitude = getInteger("latitude") - longitude = getInteger("longitude") -} -fun JsonObject.toFeature(): Feature = feature { - name = getString("name") - location = getJsonObject("location").toPoint() -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/EchoHandler.kt b/app/src/main/kotlin/io/apim/samples/rest/EchoHandler.kt deleted file mode 100644 index 7fe097f..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/EchoHandler.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.apim.samples.rest - -import io.vertx.core.http.HttpHeaders -import io.vertx.core.json.DecodeException -import io.vertx.core.json.JsonObject -import io.vertx.ext.web.impl.ParsableMIMEValue -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.ext.web.ParsedHeaderValues -import io.vertx.rxjava3.ext.web.RequestBody -import io.vertx.rxjava3.ext.web.RoutingContext - -fun echoHandler(ctx: RoutingContext) { - var body: JsonObject - val response = ctx.response() - .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - - try { - body = json { - obj( - "method" to ctx.request().method().name(), - "headers" to obj(ctx.request().headers().toSimpleMap()), - "query_params" to obj(ctx.request().params().toSimpleMap()), - "body" to handleBody(ctx.body(), ctx.parsedHeaders()) - ) - } - response.statusCode = 200 - } catch (e: DecodeException) { - response.statusCode = 400 - body = json { - obj( - "title" to "The request body fail to be parsed", - "detail" to e.cause?.message - ) - } - } - - response.end(body.toString()).subscribe() -} - -fun handleBody(body: RequestBody, headers: ParsedHeaderValues): JsonObject { - val contentType = (headers.delegate.contentType() as ParsableMIMEValue).forceParse() - - if (contentType.isText()) { - return json { - obj( - "type" to "text", - "content" to body.asString() - ) - } - } - - if (contentType.isJson()) { - return json { - obj( - "type" to "json", - "content" to body.asJsonObject() - ) - } - } - - return json { - obj( - "type" to "unknown", - "content" to body.asString() - ) - } -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/ProtobufFileHandler.kt b/app/src/main/kotlin/io/apim/samples/rest/ProtobufFileHandler.kt deleted file mode 100644 index 1622b92..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/ProtobufFileHandler.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.apim.samples.rest - -import io.reactivex.rxjava3.core.Completable -import io.vertx.kotlin.core.json.array -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.ext.web.RoutingContext -import java.io.FileNotFoundException - -fun protobufFileHandler(ctx: RoutingContext) { - listFilesOrSendProtoFileContent(ctx).subscribe() -} - -fun listFilesOrSendProtoFileContent(route: RoutingContext): Completable { - val req = route.request() - val res = route.response() - - if (req.path().equals("/grpc")) { - val directory = ClassLoader.getSystemResource("grpc") ?: return res.setStatusCode(404).end() - - return route.vertx().fileSystem().readDir(directory.file, ".*\\.proto\$") - .map { files -> - json { - obj("protoFiles" to array(files.map { it.replace(directory.file, req.absoluteURI()) })) - } - } - .flatMapCompletable { - res.putHeader("Content-Type", "application/json") - .end(it.toString()) - } - } - - if(req.path().endsWith("proto")) { - return res.sendFile(req.path().drop(1)) - .onErrorResumeNext { th -> - when(th) { - is FileNotFoundException -> res.setStatusCode(404).end() - else -> res.setStatusCode(500).end() - } - } - } - - return res.setStatusCode(404).end() - -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt b/app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt deleted file mode 100644 index 67c6c55..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.apim.samples.rest - -import io.vertx.ext.web.impl.ParsableMIMEValue -import io.vertx.rxjava3.core.MultiMap - -/** Transform a MultiMap into a simple map. Multiple values are joined in a string separated with ; */ -fun MultiMap.toSimpleMap() = this.entries() - .groupBy { it.key } - .mapValues { it.value.joinToString(";") { h -> h.value } } - -fun ParsableMIMEValue.isText(): Boolean { - return this.component() == "text" -} - -fun ParsableMIMEValue.isJson(): Boolean { - return this.component() == "application" && this.subComponent().contains("json") -} - -fun ParsableMIMEValue.isAvro(): Boolean { - return this.component() == "avro" || this.subComponent().contains("avro") -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/ResponseHelper.kt b/app/src/main/kotlin/io/apim/samples/rest/ResponseHelper.kt deleted file mode 100644 index 85e455e..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/ResponseHelper.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.apim.samples.rest - -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.ext.web.RoutingContext - -fun RoutingContext.sendError(statusCode: Int, title: String, detail: String? = null) { - this.response().statusCode = statusCode - this.end(io.vertx.kotlin.core.json.json { - obj( - "title" to title, - "detail" to detail - ) - } - .toString()).subscribe() -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/RestServerVerticle.kt b/app/src/main/kotlin/io/apim/samples/rest/RestServerVerticle.kt deleted file mode 100644 index d105014..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/RestServerVerticle.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.apim.samples.rest - -import io.apim.samples.httpPort -import io.apim.samples.rest.avro.avroGeneratorHandler -import io.apim.samples.rest.avro.avroSerDeHandler -import io.reactivex.rxjava3.core.Completable -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.AbstractVerticle -import io.vertx.rxjava3.core.Vertx -import io.vertx.rxjava3.ext.healthchecks.HealthCheckHandler -import io.vertx.rxjava3.ext.healthchecks.HealthChecks -import io.vertx.rxjava3.ext.web.Router -import io.vertx.rxjava3.ext.web.handler.BodyHandler -import org.slf4j.LoggerFactory - -class RestServerVerticle( - private val configRetriever: ConfigRetriever, - private val healthChecks: HealthChecks = HealthChecks.create(Vertx.vertx()) -) : - AbstractVerticle() { - companion object { - const val DEFAULT_PORT = 8888 - } - - private val logger = LoggerFactory.getLogger(javaClass) - - override fun rxStart(): Completable = configRetriever.config - .map { it.getInteger(httpPort, DEFAULT_PORT) } - .flatMap { port -> - vertx - .createHttpServer() - .requestHandler(router()) - .listen(port) - } - .doOnSuccess { - logger.info("HTTP server started on port ${it.actualPort()}") - } - .ignoreElement() - - private fun router(): Router = Router.router(vertx).let { router -> - router.route().handler(BodyHandler.create()) - router.route("/echo").handler(::echoHandler) - router.route("/avro/generate").handler(::avroGeneratorHandler) - router.route("/avro/serde").handler(::avroSerDeHandler) - router.route("/grpc*").handler(::protobufFileHandler) - router.route("/health*").handler(HealthCheckHandler.createWithHealthChecks(healthChecks)) - router - } -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroGeneratorHandler.kt b/app/src/main/kotlin/io/apim/samples/rest/avro/AvroGeneratorHandler.kt deleted file mode 100644 index d80ee7d..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroGeneratorHandler.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.apim.samples.rest.avro - -import io.apim.samples.avro.AvroSerDeFactoryImpl -import io.apim.samples.avro.JsonSerDe -import io.apim.samples.avro.generate -import io.apim.samples.rest.isJson -import io.apim.samples.rest.sendError -import io.vertx.core.http.HttpHeaders -import io.vertx.ext.web.impl.ParsableMIMEValue -import io.vertx.rxjava3.core.buffer.Buffer -import io.vertx.rxjava3.ext.web.RoutingContext -import org.apache.avro.Schema -import org.apache.avro.SchemaParseException - -val serdeFactory = AvroSerDeFactoryImpl() - -enum class OutputFormat { - AVRO, JSON -} - -fun avroGeneratorHandler(ctx: RoutingContext) { - val contentType = (ctx.parsedHeaders().delegate.contentType() as ParsableMIMEValue).forceParse() - - if (!contentType.isJson() || ctx.body().isEmpty) { - ctx.sendError(400, "Provide an avro schema") - return - } - - generate(ctx) -} - -private fun generate(ctx: RoutingContext) { - try { - val output = ctx.getOutputFormatFromQueryParam() - val schema = Schema.Parser().parse(ctx.body().asString()) - - when (output) { - OutputFormat.AVRO -> generateAvro(schema, ctx) - OutputFormat.JSON -> generateJson(schema, ctx) - } - } catch (e: SchemaParseException) { - ctx.sendError(400, "Invalid avro schema", e.message) - return - } -} - -private fun generateAvro(schema: Schema, ctx: RoutingContext) { - val data = generate(schema) - - val format = ctx.getSerializationFormatFromQueryParam() - val serde = serdeFactory.new(schema, format) - - ctx.response().statusCode = 200 - ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/*+avro") - ctx.end(Buffer.buffer(serde.serialize(data))).subscribe() -} - -private fun generateJson(schema: Schema, ctx: RoutingContext) { - val data = generate(schema) - val serde = JsonSerDe(schema) - - ctx.response().statusCode = 200 - ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - ctx.end(serde.serialize(data)).subscribe() -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroRequestHelper.kt b/app/src/main/kotlin/io/apim/samples/rest/avro/AvroRequestHelper.kt deleted file mode 100644 index 2140dd9..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroRequestHelper.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.apim.samples.rest.avro - -import io.apim.samples.avro.SerializationFormat -import io.apim.samples.rest.sendError -import io.vertx.rxjava3.ext.web.RoutingContext -import org.apache.avro.Schema -import org.apache.avro.SchemaParseException -import java.util.* - -fun RoutingContext.getOutputFormatFromQueryParam(param: String = "output"): OutputFormat { - val output = queryParam(param).elementAtOrNull(0) ?: "avro" - try { - return OutputFormat.valueOf(output.uppercase(Locale.getDefault())) - } catch (e: IllegalArgumentException) { - sendError(400, "Invalid $param format", "Valid values are: ${OutputFormat.values().joinToString(", ") { it.name.lowercase() }}") - throw e - } -} - -fun RoutingContext.getSerializationFormatFromQueryParam(param: String = "format"): SerializationFormat { - val format = queryParam(param).elementAtOrNull(0) ?: SerializationFormat.CONFLUENT.name - try { - return SerializationFormat.valueOf(format.uppercase(Locale.getDefault())) - } catch (e: IllegalArgumentException) { - sendError(400, "Invalid $param", "Valid values are: ${SerializationFormat.values().joinToString(", ") { it.name.lowercase() }}") - throw e - } -} - -fun RoutingContext.getSchemaFromHeader(header: String = "X-Avro-Schema"): Schema { - val schemaString = request().getHeader(header) - if (schemaString == null) { - sendError(400, "Avro schema required in $header header") - throw IllegalArgumentException("Avro schema required in $header header") - } - - return try { - Schema.Parser().parse(schemaString) - } catch (e: SchemaParseException) { - sendError(400, "Invalid avro schema", e.message) - throw e - } -} diff --git a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroSerDeHandler.kt b/app/src/main/kotlin/io/apim/samples/rest/avro/AvroSerDeHandler.kt deleted file mode 100644 index 1ed6741..0000000 --- a/app/src/main/kotlin/io/apim/samples/rest/avro/AvroSerDeHandler.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.apim.samples.rest.avro - -import io.apim.samples.avro.JsonSerDe -import io.apim.samples.rest.isAvro -import io.apim.samples.rest.isJson -import io.apim.samples.rest.sendError -import io.vertx.core.http.HttpHeaders -import io.vertx.ext.web.impl.ParsableMIMEValue -import io.vertx.rxjava3.core.buffer.Buffer -import io.vertx.rxjava3.ext.web.RoutingContext - -fun avroSerDeHandler(ctx: RoutingContext) { - val contentType = (ctx.parsedHeaders().delegate.contentType() as ParsableMIMEValue).forceParse() - - val schema = ctx.getSchemaFromHeader("X-Avro-Schema") - val jsonSerDe = JsonSerDe(schema) - val avroSerDe = serdeFactory.new(schema, ctx.getSerializationFormatFromQueryParam()) - - if(contentType.isJson()) { - val data = jsonSerDe.deserialize(ctx.body().asString()) - ctx.response().statusCode = 200 - ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "avro/binary") - ctx.end(Buffer.buffer(avroSerDe.serialize(data))).subscribe() - return - } - - if(contentType.isAvro()) { - val data = avroSerDe.deserialize(ctx.body().buffer().bytes) - ctx.response().statusCode = 200 - ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - ctx.end(Buffer.buffer(jsonSerDe.serialize(data))).subscribe() - return - } - - ctx.sendError(400, "Unsupported content type") - return -} diff --git a/app/src/main/kotlin/io/apim/samples/websocket/EchoHandler.kt b/app/src/main/kotlin/io/apim/samples/websocket/EchoHandler.kt deleted file mode 100644 index d9fadc3..0000000 --- a/app/src/main/kotlin/io/apim/samples/websocket/EchoHandler.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.apim.samples.websocket - -import io.reactivex.rxjava3.core.Completable -import io.vertx.core.json.JsonObject -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.core.http.ServerWebSocket - -class EchoHandler(private val ws: ServerWebSocket) : WebSockerHandler { - companion object { - const val ECHO_PATH = "/ws/echo" - } - - override fun handle(): Completable { - ws.binaryMessageHandler { buffer -> - processMessage(ws, buffer.toString()) - } - - ws.textMessageHandler { msg -> - processMessage(ws, msg) - } - - return Completable.complete() - } - - private fun processMessage(ws: ServerWebSocket, message: String) { - ws.writeTextMessage(parseInput(message).toString()) - .subscribe() - } - - private fun parseInput(input: String) = try { - val json = JsonObject(input) - json { obj("type" to "json", "request" to json) } - } catch (e: Exception) { - json { obj("type" to "unknown", "request" to input) } - } -} diff --git a/app/src/main/kotlin/io/apim/samples/websocket/WebSockerHandler.kt b/app/src/main/kotlin/io/apim/samples/websocket/WebSockerHandler.kt deleted file mode 100644 index ece7349..0000000 --- a/app/src/main/kotlin/io/apim/samples/websocket/WebSockerHandler.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.apim.samples.websocket - -import io.reactivex.rxjava3.core.Completable - -interface WebSockerHandler { - fun handle(): Completable -} diff --git a/app/src/main/kotlin/io/apim/samples/websocket/WebSocketServerVerticle.kt b/app/src/main/kotlin/io/apim/samples/websocket/WebSocketServerVerticle.kt deleted file mode 100644 index 3f7fca5..0000000 --- a/app/src/main/kotlin/io/apim/samples/websocket/WebSocketServerVerticle.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.apim.samples.websocket - -import io.apim.samples.webSocketPort -import io.reactivex.rxjava3.core.Completable -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.AbstractVerticle -import io.vertx.rxjava3.core.http.ServerWebSocket -import org.slf4j.LoggerFactory - -class WebSocketServerVerticle(private val configRetriever: ConfigRetriever) : - AbstractVerticle() { - companion object { - const val DEFAULT_PORT = 8890 - } - - private val logger = LoggerFactory.getLogger(javaClass) - - override fun rxStart(): Completable = configRetriever.config - .map { it.getInteger(webSocketPort, DEFAULT_PORT) } - .flatMap { port -> - vertx - .createHttpServer() - .webSocketHandler(::routes) - .listen(port) - } - .doOnSuccess { - logger.info("WebSocket server started on port ${it.actualPort()}") - } - .ignoreElement() - - private fun routes(ws: ServerWebSocket) = - when (ws.path()) { - EchoHandler.ECHO_PATH -> EchoHandler(ws).handle() - else -> ws.close(4404, "Not found") - }.subscribe() -} diff --git a/app/src/main/resources/grpc/helloworld.proto b/app/src/main/resources/grpc/helloworld.proto deleted file mode 100644 index c60d941..0000000 --- a/app/src/main/resources/grpc/helloworld.proto +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2015 The gRPC Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -syntax = "proto3"; - -option java_multiple_files = true; -option java_package = "io.grpc.examples.helloworld"; -option java_outer_classname = "HelloWorldProto"; -option objc_class_prefix = "HLW"; - -package helloworld; - -// The greeting service definition. -service Greeter { - // Sends a greeting - rpc SayHello (HelloRequest) returns (HelloReply) {} -} - -// The request message containing the user's name. -message HelloRequest { - string name = 1; -} - -// The response message containing the greetings -message HelloReply { - string message = 1; -} diff --git a/app/src/main/resources/grpc/route_guide.json b/app/src/main/resources/grpc/route_guide.json deleted file mode 100644 index 53a000c..0000000 --- a/app/src/main/resources/grpc/route_guide.json +++ /dev/null @@ -1,704 +0,0 @@ -{ - "feature": [ - { - "location": { - "latitude": 407838351, - "longitude": -746143763 - }, - "name": "Patriots Path, Mendham, NJ 07945, USA" - }, - { - "location": { - "latitude": 408122808, - "longitude": -743999179 - }, - "name": "101 New Jersey 10, Whippany, NJ 07981, USA" - }, - { - "location": { - "latitude": 413628156, - "longitude": -749015468 - }, - "name": "U.S. 6, Shohola, PA 18458, USA" - }, - { - "location": { - "latitude": 419999544, - "longitude": -740371136 - }, - "name": "5 Conners Road, Kingston, NY 12401, USA" - }, - { - "location": { - "latitude": 414008389, - "longitude": -743951297 - }, - "name": "Mid Hudson Psychiatric Center, New Hampton, NY 10958, USA" - }, - { - "location": { - "latitude": 419611318, - "longitude": -746524769 - }, - "name": "287 Flugertown Road, Livingston Manor, NY 12758, USA" - }, - { - "location": { - "latitude": 406109563, - "longitude": -742186778 - }, - "name": "4001 Tremley Point Road, Linden, NJ 07036, USA" - }, - { - "location": { - "latitude": 416802456, - "longitude": -742370183 - }, - "name": "352 South Mountain Road, Wallkill, NY 12589, USA" - }, - { - "location": { - "latitude": 412950425, - "longitude": -741077389 - }, - "name": "Bailey Turn Road, Harriman, NY 10926, USA" - }, - { - "location": { - "latitude": 412144655, - "longitude": -743949739 - }, - "name": "193-199 Wawayanda Road, Hewitt, NJ 07421, USA" - }, - { - "location": { - "latitude": 415736605, - "longitude": -742847522 - }, - "name": "406-496 Ward Avenue, Pine Bush, NY 12566, USA" - }, - { - "location": { - "latitude": 413843930, - "longitude": -740501726 - }, - "name": "162 Merrill Road, Highland Mills, NY 10930, USA" - }, - { - "location": { - "latitude": 410873075, - "longitude": -744459023 - }, - "name": "Clinton Road, West Milford, NJ 07480, USA" - }, - { - "location": { - "latitude": 412346009, - "longitude": -744026814 - }, - "name": "16 Old Brook Lane, Warwick, NY 10990, USA" - }, - { - "location": { - "latitude": 402948455, - "longitude": -747903913 - }, - "name": "3 Drake Lane, Pennington, NJ 08534, USA" - }, - { - "location": { - "latitude": 406337092, - "longitude": -740122226 - }, - "name": "6324 8th Avenue, Brooklyn, NY 11220, USA" - }, - { - "location": { - "latitude": 406421967, - "longitude": -747727624 - }, - "name": "1 Merck Access Road, Whitehouse Station, NJ 08889, USA" - }, - { - "location": { - "latitude": 416318082, - "longitude": -749677716 - }, - "name": "78-98 Schalck Road, Narrowsburg, NY 12764, USA" - }, - { - "location": { - "latitude": 415301720, - "longitude": -748416257 - }, - "name": "282 Lakeview Drive Road, Highland Lake, NY 12743, USA" - }, - { - "location": { - "latitude": 402647019, - "longitude": -747071791 - }, - "name": "330 Evelyn Avenue, Hamilton Township, NJ 08619, USA" - }, - { - "location": { - "latitude": 412567807, - "longitude": -741058078 - }, - "name": "New York State Reference Route 987E, Southfields, NY 10975, USA" - }, - { - "location": { - "latitude": 416855156, - "longitude": -744420597 - }, - "name": "103-271 Tempaloni Road, Ellenville, NY 12428, USA" - }, - { - "location": { - "latitude": 404663628, - "longitude": -744820157 - }, - "name": "1300 Airport Road, North Brunswick Township, NJ 08902, USA" - }, - { - "location": { - "latitude": 407113723, - "longitude": -749746483 - }, - "name": "" - }, - { - "location": { - "latitude": 402133926, - "longitude": -743613249 - }, - "name": "" - }, - { - "location": { - "latitude": 400273442, - "longitude": -741220915 - }, - "name": "" - }, - { - "location": { - "latitude": 411236786, - "longitude": -744070769 - }, - "name": "" - }, - { - "location": { - "latitude": 411633782, - "longitude": -746784970 - }, - "name": "211-225 Plains Road, Augusta, NJ 07822, USA" - }, - { - "location": { - "latitude": 415830701, - "longitude": -742952812 - }, - "name": "" - }, - { - "location": { - "latitude": 413447164, - "longitude": -748712898 - }, - "name": "165 Pedersen Ridge Road, Milford, PA 18337, USA" - }, - { - "location": { - "latitude": 405047245, - "longitude": -749800722 - }, - "name": "100-122 Locktown Road, Frenchtown, NJ 08825, USA" - }, - { - "location": { - "latitude": 418858923, - "longitude": -746156790 - }, - "name": "" - }, - { - "location": { - "latitude": 417951888, - "longitude": -748484944 - }, - "name": "650-652 Willi Hill Road, Swan Lake, NY 12783, USA" - }, - { - "location": { - "latitude": 407033786, - "longitude": -743977337 - }, - "name": "26 East 3rd Street, New Providence, NJ 07974, USA" - }, - { - "location": { - "latitude": 417548014, - "longitude": -740075041 - }, - "name": "" - }, - { - "location": { - "latitude": 410395868, - "longitude": -744972325 - }, - "name": "" - }, - { - "location": { - "latitude": 404615353, - "longitude": -745129803 - }, - "name": "" - }, - { - "location": { - "latitude": 406589790, - "longitude": -743560121 - }, - "name": "611 Lawrence Avenue, Westfield, NJ 07090, USA" - }, - { - "location": { - "latitude": 414653148, - "longitude": -740477477 - }, - "name": "18 Lannis Avenue, New Windsor, NY 12553, USA" - }, - { - "location": { - "latitude": 405957808, - "longitude": -743255336 - }, - "name": "82-104 Amherst Avenue, Colonia, NJ 07067, USA" - }, - { - "location": { - "latitude": 411733589, - "longitude": -741648093 - }, - "name": "170 Seven Lakes Drive, Sloatsburg, NY 10974, USA" - }, - { - "location": { - "latitude": 412676291, - "longitude": -742606606 - }, - "name": "1270 Lakes Road, Monroe, NY 10950, USA" - }, - { - "location": { - "latitude": 409224445, - "longitude": -748286738 - }, - "name": "509-535 Alphano Road, Great Meadows, NJ 07838, USA" - }, - { - "location": { - "latitude": 406523420, - "longitude": -742135517 - }, - "name": "652 Garden Street, Elizabeth, NJ 07202, USA" - }, - { - "location": { - "latitude": 401827388, - "longitude": -740294537 - }, - "name": "349 Sea Spray Court, Neptune City, NJ 07753, USA" - }, - { - "location": { - "latitude": 410564152, - "longitude": -743685054 - }, - "name": "13-17 Stanley Street, West Milford, NJ 07480, USA" - }, - { - "location": { - "latitude": 408472324, - "longitude": -740726046 - }, - "name": "47 Industrial Avenue, Teterboro, NJ 07608, USA" - }, - { - "location": { - "latitude": 412452168, - "longitude": -740214052 - }, - "name": "5 White Oak Lane, Stony Point, NY 10980, USA" - }, - { - "location": { - "latitude": 409146138, - "longitude": -746188906 - }, - "name": "Berkshire Valley Management Area Trail, Jefferson, NJ, USA" - }, - { - "location": { - "latitude": 404701380, - "longitude": -744781745 - }, - "name": "1007 Jersey Avenue, New Brunswick, NJ 08901, USA" - }, - { - "location": { - "latitude": 409642566, - "longitude": -746017679 - }, - "name": "6 East Emerald Isle Drive, Lake Hopatcong, NJ 07849, USA" - }, - { - "location": { - "latitude": 408031728, - "longitude": -748645385 - }, - "name": "1358-1474 New Jersey 57, Port Murray, NJ 07865, USA" - }, - { - "location": { - "latitude": 413700272, - "longitude": -742135189 - }, - "name": "367 Prospect Road, Chester, NY 10918, USA" - }, - { - "location": { - "latitude": 404310607, - "longitude": -740282632 - }, - "name": "10 Simon Lake Drive, Atlantic Highlands, NJ 07716, USA" - }, - { - "location": { - "latitude": 409319800, - "longitude": -746201391 - }, - "name": "11 Ward Street, Mount Arlington, NJ 07856, USA" - }, - { - "location": { - "latitude": 406685311, - "longitude": -742108603 - }, - "name": "300-398 Jefferson Avenue, Elizabeth, NJ 07201, USA" - }, - { - "location": { - "latitude": 419018117, - "longitude": -749142781 - }, - "name": "43 Dreher Road, Roscoe, NY 12776, USA" - }, - { - "location": { - "latitude": 412856162, - "longitude": -745148837 - }, - "name": "Swan Street, Pine Island, NY 10969, USA" - }, - { - "location": { - "latitude": 416560744, - "longitude": -746721964 - }, - "name": "66 Pleasantview Avenue, Monticello, NY 12701, USA" - }, - { - "location": { - "latitude": 405314270, - "longitude": -749836354 - }, - "name": "" - }, - { - "location": { - "latitude": 414219548, - "longitude": -743327440 - }, - "name": "" - }, - { - "location": { - "latitude": 415534177, - "longitude": -742900616 - }, - "name": "565 Winding Hills Road, Montgomery, NY 12549, USA" - }, - { - "location": { - "latitude": 406898530, - "longitude": -749127080 - }, - "name": "231 Rocky Run Road, Glen Gardner, NJ 08826, USA" - }, - { - "location": { - "latitude": 407586880, - "longitude": -741670168 - }, - "name": "100 Mount Pleasant Avenue, Newark, NJ 07104, USA" - }, - { - "location": { - "latitude": 400106455, - "longitude": -742870190 - }, - "name": "517-521 Huntington Drive, Manchester Township, NJ 08759, USA" - }, - { - "location": { - "latitude": 400066188, - "longitude": -746793294 - }, - "name": "" - }, - { - "location": { - "latitude": 418803880, - "longitude": -744102673 - }, - "name": "40 Mountain Road, Napanoch, NY 12458, USA" - }, - { - "location": { - "latitude": 414204288, - "longitude": -747895140 - }, - "name": "" - }, - { - "location": { - "latitude": 414777405, - "longitude": -740615601 - }, - "name": "" - }, - { - "location": { - "latitude": 415464475, - "longitude": -747175374 - }, - "name": "48 North Road, Forestburgh, NY 12777, USA" - }, - { - "location": { - "latitude": 404062378, - "longitude": -746376177 - }, - "name": "" - }, - { - "location": { - "latitude": 405688272, - "longitude": -749285130 - }, - "name": "" - }, - { - "location": { - "latitude": 400342070, - "longitude": -748788996 - }, - "name": "" - }, - { - "location": { - "latitude": 401809022, - "longitude": -744157964 - }, - "name": "" - }, - { - "location": { - "latitude": 404226644, - "longitude": -740517141 - }, - "name": "9 Thompson Avenue, Leonardo, NJ 07737, USA" - }, - { - "location": { - "latitude": 410322033, - "longitude": -747871659 - }, - "name": "" - }, - { - "location": { - "latitude": 407100674, - "longitude": -747742727 - }, - "name": "" - }, - { - "location": { - "latitude": 418811433, - "longitude": -741718005 - }, - "name": "213 Bush Road, Stone Ridge, NY 12484, USA" - }, - { - "location": { - "latitude": 415034302, - "longitude": -743850945 - }, - "name": "" - }, - { - "location": { - "latitude": 411349992, - "longitude": -743694161 - }, - "name": "" - }, - { - "location": { - "latitude": 404839914, - "longitude": -744759616 - }, - "name": "1-17 Bergen Court, New Brunswick, NJ 08901, USA" - }, - { - "location": { - "latitude": 414638017, - "longitude": -745957854 - }, - "name": "35 Oakland Valley Road, Cuddebackville, NY 12729, USA" - }, - { - "location": { - "latitude": 412127800, - "longitude": -740173578 - }, - "name": "" - }, - { - "location": { - "latitude": 401263460, - "longitude": -747964303 - }, - "name": "" - }, - { - "location": { - "latitude": 412843391, - "longitude": -749086026 - }, - "name": "" - }, - { - "location": { - "latitude": 418512773, - "longitude": -743067823 - }, - "name": "" - }, - { - "location": { - "latitude": 404318328, - "longitude": -740835638 - }, - "name": "42-102 Main Street, Belford, NJ 07718, USA" - }, - { - "location": { - "latitude": 419020746, - "longitude": -741172328 - }, - "name": "" - }, - { - "location": { - "latitude": 404080723, - "longitude": -746119569 - }, - "name": "" - }, - { - "location": { - "latitude": 401012643, - "longitude": -744035134 - }, - "name": "" - }, - { - "location": { - "latitude": 404306372, - "longitude": -741079661 - }, - "name": "" - }, - { - "location": { - "latitude": 403966326, - "longitude": -748519297 - }, - "name": "" - }, - { - "location": { - "latitude": 405002031, - "longitude": -748407866 - }, - "name": "" - }, - { - "location": { - "latitude": 409532885, - "longitude": -742200683 - }, - "name": "" - }, - { - "location": { - "latitude": 416851321, - "longitude": -742674555 - }, - "name": "" - }, - { - "location": { - "latitude": 406411633, - "longitude": -741722051 - }, - "name": "3387 Richmond Terrace, Staten Island, NY 10303, USA" - }, - { - "location": { - "latitude": 413069058, - "longitude": -744597778 - }, - "name": "261 Van Sickle Road, Goshen, NY 10924, USA" - }, - { - "location": { - "latitude": 418465462, - "longitude": -746859398 - }, - "name": "" - }, - { - "location": { - "latitude": 411733222, - "longitude": -744228360 - }, - "name": "" - }, - { - "location": { - "latitude": 410248224, - "longitude": -747127767 - }, - "name": "3 Hasta Way, Newton, NJ 07860, USA" - } - ] -} diff --git a/app/src/main/resources/grpc/route_guide.proto b/app/src/main/resources/grpc/route_guide.proto deleted file mode 100644 index 4c864d6..0000000 --- a/app/src/main/resources/grpc/route_guide.proto +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2015, Google Inc. -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are -// met: -// -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above -// copyright notice, this list of conditions and the following disclaimer -// in the documentation and/or other materials provided with the -// distribution. -// * Neither the name of Google Inc. nor the names of its -// contributors may be used to endorse or promote products derived from -// this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -syntax = "proto3"; - -option java_multiple_files = true; -option java_package = "io.grpc.examples.routeguide"; -option java_outer_classname = "RouteGuideProto"; -option objc_class_prefix = "RTG"; - -package routeguide; - -// Interface exported by the server. -service RouteGuide { - // A simple RPC. - // - // Obtains the feature at a given position. - // - // A feature with an empty name is returned if there's no feature at the given - // position. - rpc GetFeature(Point) returns (Feature) {} - - // A server-to-client streaming RPC. - // - // Obtains the Features available within the given Rectangle. Results are - // streamed rather than returned at once (e.g. in a response message with a - // repeated field), as the rectangle may cover a large area and contain a - // huge number of features. - rpc ListFeatures(Rectangle) returns (stream Feature) {} - - // A client-to-server streaming RPC. - // - // Accepts a stream of Points on a route being traversed, returning a - // RouteSummary when traversal is completed. - rpc RecordRoute(stream Point) returns (RouteSummary) {} - - // A Bidirectional streaming RPC. - // - // Accepts a stream of RouteNotes sent while a route is being traversed, - // while receiving other RouteNotes (e.g. from other users). - rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} -} - -// Points are represented as latitude-longitude pairs in the E7 representation -// (degrees multiplied by 10**7 and rounded to the nearest integer). -// Latitudes should be in the range +/- 90 degrees and longitude should be in -// the range +/- 180 degrees (inclusive). -message Point { - int32 latitude = 1; - int32 longitude = 2; -} - -// A latitude-longitude rectangle, represented as two diagonally opposite -// points "lo" and "hi". -message Rectangle { - // One corner of the rectangle. - Point lo = 1; - - // The other corner of the rectangle. - Point hi = 2; -} - -// A feature names something at a given point. -// -// If a feature could not be named, the name is empty. -message Feature { - // The name of the feature. - string name = 1; - - // The point where the feature is detected. - Point location = 2; -} - -// Not used in the RPC. Instead, this is here for the form serialized to disk. -message FeatureDatabase { - repeated Feature feature = 1; -} - -// A RouteNote is a message sent while at a given point. -message RouteNote { - // The location from which the message is sent. - Point location = 1; - - // The message to be sent. - string message = 2; -} - -// A RouteSummary is received in response to a RecordRoute rpc. -// -// It contains the number of individual points received, the number of -// detected features, and the total distance covered as the cumulative sum of -// the distance between each point. -message RouteSummary { - // The number of points received. - int32 point_count = 1; - - // The number of known features passed while traversing the route. - int32 feature_count = 2; - - // The distance covered in metres. - int32 distance = 3; - - // The duration of the traversal in seconds. - int32 elapsed_time = 4; -} diff --git a/app/src/main/resources/logback.xml b/app/src/main/resources/logback.xml deleted file mode 100644 index 04edc2e..0000000 --- a/app/src/main/resources/logback.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n - - - - - - - diff --git a/app/src/test/kotlin/io/apim/samples/MainVerticleTest.kt b/app/src/test/kotlin/io/apim/samples/MainVerticleTest.kt deleted file mode 100644 index ed0f342..0000000 --- a/app/src/test/kotlin/io/apim/samples/MainVerticleTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package io.apim.samples - -import io.apim.samples.rest.RestServerVerticle -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.vertx.ext.web.client.WebClientOptions -import io.vertx.junit5.VertxExtension -import io.vertx.junit5.VertxTestContext -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.core.Vertx -import io.vertx.rxjava3.ext.web.client.WebClient -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.extension.ExtendWith -import strikt.api.expectThat -import strikt.assertions.contains -import strikt.assertions.isEqualTo - -@ExtendWith(VertxExtension::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MainVerticleTest { - - @Nested - @ExtendWith(VertxExtension::class) - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class WithDefaultConfiguration { - private val vertx: Vertx = Vertx.vertx() - - @BeforeAll - fun setUp(testContext: VertxTestContext) { - vertx.deployVerticle(MainVerticle()) - .subscribeBy { testContext.completeNow() } - } - - @AfterAll - fun tearDown(testContext: VertxTestContext) { - vertx.close() - .subscribeBy { testContext.completeNow() } - } - - @Test - fun `should start an http server to handle rest request`() { - WebClient.create( - vertx, - WebClientOptions() - .setDefaultHost("localhost") - .setDefaultPort(RestServerVerticle.DEFAULT_PORT) - ) - .get("/health").send() - .test() - .await() - .assertNoErrors() - } - - @Test - fun `should start an http server to handle websocket request`() { - WebClient.create( - vertx, - WebClientOptions() - .setDefaultHost("localhost") - .setDefaultPort(RestServerVerticle.DEFAULT_PORT) - ) - .get("/health").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(200) - get { bodyAsJsonObject().getJsonArray("checks").list } - .contains(json { obj("id" to "websocket", "status" to "UP") }.map) - } - true - } - } - - @Test - fun `should start a grpc server`() { - WebClient.create( - vertx, - WebClientOptions() - .setDefaultHost("localhost") - .setDefaultPort(RestServerVerticle.DEFAULT_PORT) - ) - .get("/health").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(200) - get { bodyAsJsonObject().getJsonArray("checks").list } - .contains(json { obj("id" to "grpc", "status" to "UP") }.map) - } - true - } - } - } -} diff --git a/app/src/test/kotlin/io/apim/samples/avro/AvroGenericDataGeneratorTest.kt b/app/src/test/kotlin/io/apim/samples/avro/AvroGenericDataGeneratorTest.kt deleted file mode 100644 index 0c74cef..0000000 --- a/app/src/test/kotlin/io/apim/samples/avro/AvroGenericDataGeneratorTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.apim.samples.avro - -import org.apache.avro.Schema -import org.apache.avro.generic.GenericFixed -import org.apache.avro.generic.GenericRecord -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import strikt.api.expectThat -import strikt.assertions.* - -class AvroGenericDataGeneratorTest { - - @Nested - inner class Primitives { - - @Test - fun `generate from boolean schema`() { - val schema = Schema.Parser().parse("""{"type": "boolean"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from null schema`() { - val schema = Schema.Parser().parse("""{"type": "null"}""") - expectThat(generate(schema)).isNull() - expectThat(generate(null)).isNull() - } - - @Test - fun `generate from int schema`() { - val schema = Schema.Parser().parse("""{"type": "int"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from long schema`() { - val schema = Schema.Parser().parse("""{"type": "long"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from float schema`() { - val schema = Schema.Parser().parse("""{"type": "float"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from double schema`() { - val schema = Schema.Parser().parse("""{"type": "double"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from bytes schema`() { - val schema = Schema.Parser().parse("""{"type": "bytes"}""") - expectThat(generate(schema)).isNotNull().isA() - } - - @Test - fun `generate from string schema`() { - val schema = Schema.Parser().parse("""{"type": "string"}""") - expectThat(generate(schema)).isNotNull().isA() - } - } - - @Nested - inner class Record { - - @Test - fun `generate from record schema`() { - val schema = Schema.Parser().parse(""" - { - "type": "record", - "name": "Payment", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "amount", - "type": "double" - } - ] - } - """.trimIndent()) - expectThat(generate(schema)).isNotNull().isA().and { - get { get("id") }.isNotNull().isA() - get { get("amount") }.isNotNull().isA() - } - } - } - - @Nested - inner class Enum { - @Test - fun `generate from enum schema`() { - val expectedValues = listOf("SPADES", "HEARTS", "DIAMONDS", "CLUBS") - val schema = Schema.Parser().parse(""" - { - "type": "enum", - "name": "Suit", - "symbols" : [${expectedValues.joinToString(",") { "\"$it\"" }}] - } - """.trimIndent()) - expectThat(generate(schema)).isNotNull().isA().isContainedIn(expectedValues) - } - } - - @Nested - inner class Arrays { - @Test - fun `generate from string array schema`() { - val schema = Schema.Parser().parse(""" - { - "type": "array", - "items" : "string", - "default": [] - } - """.trimIndent()) - expectThat(generate(schema)).isNotNull().isA>().hasSize(3) - } - } - - @Nested - inner class Maps { - @Test - fun `generate from long value map schema`() { - val schema = Schema.Parser().parse(""" - { - "type": "map", - "values" : "long", - "default": {} - } - """.trimIndent()) - expectThat(generate(schema)).isNotNull().isA>().hasSize(3) - } - } - - @Nested - inner class Unions { - @Test - fun `generate from union schema`() { - val schema = Schema.Parser().parse(""" - ["null", "string"] - """.trimIndent()) - - repeat(10) { - val result = generate(schema) - - val isNull = result == null - val isString = result != null && result is String - - expectThat(isNull || isString).describedAs("Expecting a null or string but was '$result'").isTrue() - } - } - } - - @Nested - inner class Fixed { - @Test - fun `generate from fixed schema`() { - val schema = Schema.Parser().parse(""" - {"type": "fixed", "size": 16, "name": "md5"} - """.trimIndent()) - - val result = generate(schema) - expectThat(result).isNotNull().isA().and { - get { bytes().asList() }.hasSize(16) - } - } - } -} diff --git a/app/src/test/kotlin/io/apim/samples/grpc/GrpcServerVerticleTest.kt b/app/src/test/kotlin/io/apim/samples/grpc/GrpcServerVerticleTest.kt deleted file mode 100644 index cd846ab..0000000 --- a/app/src/test/kotlin/io/apim/samples/grpc/GrpcServerVerticleTest.kt +++ /dev/null @@ -1,259 +0,0 @@ -package io.apim.samples.grpc - -import io.grpc.examples.routeguide.Feature -import io.grpc.examples.routeguide.RouteGuideGrpc -import io.grpc.examples.routeguide.RouteNote -import io.grpc.examples.routeguide.point -import io.grpc.examples.routeguide.rectangle -import io.grpc.examples.routeguide.routeNote -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.vertx.core.Promise -import io.vertx.core.net.SocketAddress -import io.vertx.grpc.client.GrpcClient -import io.vertx.junit5.VertxExtension -import io.vertx.junit5.VertxTestContext -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.Vertx -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.extension.ExtendWith -import strikt.api.expectThat -import strikt.assertions.containsExactly -import strikt.assertions.hasSize -import strikt.assertions.isEmpty -import strikt.assertions.isEqualTo -import strikt.assertions.isGreaterThanOrEqualTo -import strikt.assertions.map - -@ExtendWith(VertxExtension::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class GrpcServerVerticleTest { - private val vertx: Vertx = Vertx.vertx() - private val configRetriever: ConfigRetriever = ConfigRetriever.create(vertx) - - lateinit var client: GrpcClient - lateinit var server: SocketAddress - - @BeforeAll - fun setUp(testContext: VertxTestContext) { - vertx.deployVerticle(GrpcServerVerticle(configRetriever)) - .subscribeBy { testContext.completeNow() } - - client = GrpcClient.client(vertx.delegate) - server = SocketAddress.inetSocketAddress(GrpcServerVerticle.DEFAULT_PORT, "localhost") - } - - @AfterAll - fun tearDown(testContext: VertxTestContext) { - vertx.close() - .subscribeBy { testContext.completeNow() } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class GetFeature { - private val method = RouteGuideGrpc.getGetFeatureMethod() - - @Test - fun `should return the feature if the provided point match`(context: VertxTestContext) { - val message = point { - latitude = 407838351 - longitude = -746143763 - } - - client.request(server, method).onSuccess { request -> - request.end(message) - - request.response().onSuccess { response -> - response.last().onSuccess { feature -> - expectThat(feature) { - get { feature.name }.isEqualTo("Patriots Path, Mendham, NJ 07945, USA") - get { feature.location }.isEqualTo(message) - } - - context.completeNow() - } - - } - } - } - - @Test - fun `should return a nameless feature if the provided point doesn't match`(context: VertxTestContext) { - val message = point { - latitude = 1 - longitude = 2 - } - - client.request(server, method).onSuccess { request -> - request.end(message) - - request.response().onSuccess { response -> - response.last().onSuccess { feature -> - expectThat(feature) { - get { feature.name }.isEmpty() - get { feature.location }.isEqualTo(message) - } - - context.completeNow() - } - - } - } - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class ListFeatures { - private val method = RouteGuideGrpc.getListFeaturesMethod() - - @Test - fun `should return all the features in the provided rectangle`(context: VertxTestContext) { - val message = rectangle { - hi = point { - latitude = 406500000 - longitude = -745000000 - } - lo = point { - latitude = 402300000 - longitude = -747900000 - } - } - - client.request(server, method) - .compose { request -> - request.end(message) - request.response() - } - .compose { response -> - val promise = Promise.promise>() - - val features = mutableListOf() - response.handler { features.add(it) } - response.endHandler { promise.complete(features) } - response.exceptionHandler { promise.fail(it) } - - promise.future() - } - .onComplete(context.succeeding { features -> - expectThat(features) { - hasSize(2) - - and { - map { it.name }.containsExactly( - "1 Merck Access Road, Whitehouse Station, NJ 08889, USA", - "330 Evelyn Avenue, Hamilton Township, NJ 08619, USA" - ) - } - } - - context.completeNow() - }) - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class RecordRoute { - private val method = RouteGuideGrpc.getRecordRouteMethod() - - @Test - fun `should send all routes and return a summary`(context: VertxTestContext) { - client.request(server, method) - .compose { request -> - listOf( - point { - latitude = 406337092 - longitude = -740122226 - }, - point { - latitude = 406421967 - longitude = -747727624 - }, - ) - .forEach { - request.write(it) - } - request.end() - request.response() - } - .compose { it.last() } - .onComplete(context.succeeding { summary -> - expectThat(summary) { - get { pointCount }.isEqualTo(2) - get { featureCount }.isEqualTo(2) - get { distance }.isEqualTo(64180) - get { elapsedTime }.isGreaterThanOrEqualTo(0) - } - - context.completeNow() - }) - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class RouteChat { - private val method = RouteGuideGrpc.getRouteChatMethod() - - @Test - fun `should send all routes and return a summary`(context: VertxTestContext) { - - client.request(server, method) - .compose { request -> - request.write(routeNote { - location = point { - latitude = 0 - longitude = 0 - } - message = "Note 1" - }) - request.write(routeNote { - location = point { - latitude = 0 - longitude = 0 - } - message = "Note 2" - }) - request.write(routeNote { - location = point { - latitude = 0 - longitude = 0 - } - message = "Note 3" - }) - request.end() - request.response() - } - .compose { response -> - val promise = Promise.promise>() - - val features = mutableListOf() - response.handler { features.add(it) } - response.endHandler { promise.complete(features) } - response.exceptionHandler { promise.fail(it) } - - promise.future() - } - - .onComplete(context.succeeding { notes -> - expectThat(notes) { - hasSize(3) - and { - map { it.message }.containsExactly( - "Note 1", - "Note 1", - "Note 2" - ) - } - } - - context.completeNow() - }) - } - } -} diff --git a/app/src/test/kotlin/io/apim/samples/rest/RestServerVerticleTest.kt b/app/src/test/kotlin/io/apim/samples/rest/RestServerVerticleTest.kt deleted file mode 100644 index ca474ef..0000000 --- a/app/src/test/kotlin/io/apim/samples/rest/RestServerVerticleTest.kt +++ /dev/null @@ -1,564 +0,0 @@ -package io.apim.samples.rest - -import io.apim.samples.avro.AvroSerDeFactoryImpl -import io.apim.samples.avro.SerializationFormat -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.vertx.core.http.HttpHeaders -import io.vertx.core.json.JsonObject -import io.vertx.ext.web.client.WebClientOptions -import io.vertx.junit5.VertxExtension -import io.vertx.junit5.VertxTestContext -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.Vertx -import io.vertx.rxjava3.core.buffer.Buffer -import io.vertx.rxjava3.ext.web.client.WebClient -import org.apache.avro.Schema -import org.apache.avro.generic.GenericData -import org.apache.avro.generic.GenericRecord -import org.apache.avro.util.Utf8 -import org.junit.jupiter.api.* -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource -import org.junit.jupiter.params.provider.ValueSource -import strikt.api.expectThat -import strikt.assertions.* -import kotlin.io.path.Path - -@ExtendWith(VertxExtension::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class RestServerVerticleTest { - private val vertx: Vertx = Vertx.vertx() - private val configRetriever: ConfigRetriever = ConfigRetriever.create(vertx) - - lateinit var client: WebClient - - @BeforeAll - fun setUp(testContext: VertxTestContext) { - vertx.deployVerticle(RestServerVerticle(configRetriever)) - .subscribeBy { testContext.completeNow() } - - client = WebClient.create( - vertx, - WebClientOptions() - .setDefaultHost("localhost") - .setDefaultPort(RestServerVerticle.DEFAULT_PORT) - ) - } - - @AfterAll - fun tearDown(testContext: VertxTestContext) { - vertx.close() - .subscribeBy { testContext.completeNow() } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class EchoHandler { - @Test - fun `should return GET request in response body`() { - client.get("/echo").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getString("method") }.isEqualTo("GET") - - and { - get { getJsonObject("headers").getString("user-agent") }.contains("Vert.x-WebClient") - get { getJsonObject("headers").getString("host") }.isEqualTo("localhost:8888") - } - } - true - } - } - - @Test - fun `should return GET request with query string in response body`() { - client.get("/echo") - .addQueryParam("param1", "value1") - .addQueryParam("param2", "value2") - .send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getJsonObject("query_params").getString("param1") }.isEqualTo("value1") - get { getJsonObject("query_params").getString("param2") }.isEqualTo("value2") - } - true - } - } - - @Nested - inner class PostRequest { - @ParameterizedTest - @ValueSource( - strings = [ - "application/json", - "application/vnd.company.api-v1+json", - ] - ) - fun `should return json request in response body`(contentType: String) { - val body = json { - obj( - "message" to "hello!", - "attribute" to "value" - ) - } - - client.post("/echo") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), contentType) - .sendJsonObject(body) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getString("method") }.isEqualTo("POST") - - and { - get { getJsonObject("headers").getString("user-agent") }.contains("Vert.x-WebClient") - get { getJsonObject("headers").getString("host") }.isEqualTo("localhost:8888") - get { getJsonObject("headers").getString("content-type") }.isEqualTo(contentType) - get { getJsonObject("headers").getString("content-length") }.isEqualTo(body.toString().length.toString()) - } - - and { - get { getJsonObject("body").getString("type") }.isEqualTo("json") - get { getJsonObject("body").getJsonObject("content") }.isEqualTo(body) - } - } - true - } - } - - @ParameterizedTest - @ValueSource( - strings = [ - "text/plain", - "text/html", - "text/xml" - ] - ) - fun `should return text request in response body`(contentType: String) { - val body = "a random text" - - client.post("/echo") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), contentType) - .sendBuffer(Buffer.buffer(body)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getString("method") }.isEqualTo("POST") - - and { - get { getJsonObject("headers").getString("user-agent") }.contains("Vert.x-WebClient") - get { getJsonObject("headers").getString("host") }.isEqualTo("localhost:8888") - get { getJsonObject("headers").getString("content-type") }.isEqualTo(contentType) - get { getJsonObject("headers").getString("content-length") }.isEqualTo(body.length.toString()) - } - - and { - get { getJsonObject("body").getString("type") }.isEqualTo("text") - get { getJsonObject("body").getString("content") }.isEqualTo(body) - } - } - true - } - } - - @Test - fun `should return unknown type body request in response body`() { - val body = "unknown" - - client.post("/echo") - .sendBuffer(Buffer.buffer(body)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getString("method") }.isEqualTo("POST") - - and { - get { getJsonObject("headers").getString("user-agent") }.contains("Vert.x-WebClient") - get { getJsonObject("headers").getString("host") }.isEqualTo("localhost:8888") - get { getJsonObject("headers").getString("content-type") }.isNull() - get { getJsonObject("headers").getString("content-length") }.isEqualTo(body.length.toString()) - } - - and { - get { getJsonObject("body").getString("type") }.isEqualTo("unknown") - get { getJsonObject("body").getString("content") }.isEqualTo(body) - } - } - true - } - } - - @Test - fun `should return a bad request error when malformed Json request`() { - val body = "a message" - - client.post("/echo") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer(body)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getString("title") }.isEqualTo("The request body fail to be parsed") - get { getString("detail") }.contains("Unrecognized token 'a': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')") - } - true - } - } - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class ProtobufFileHandler { - @Test - fun `should list all available protobuf files`() { - client.get("/grpc").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsJsonObject()) { - get { getJsonArray("protoFiles").list }.containsExactlyInAnyOrder("http://localhost:8888/grpc/route_guide.proto", "http://localhost:8888/grpc/helloworld.proto") - } - true - } - } - - @Test - fun `should return the content of a specific proto file`() { - client.get("/grpc/route_guide.proto").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.bodyAsString()).isEqualTo( - Path(ClassLoader.getSystemResource("grpc/route_guide.proto").file).toFile().readText() - ) - true - } - } - - @ParameterizedTest - @ValueSource(strings = [ "unknown", "unknown.proto" ]) - fun `should return 404 when the requested file does not exist`(file: String) { - client.get("/grpc/$file").send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result.statusCode()).isEqualTo(404) - true - } - } - } - - @Nested - inner class HealthCheckHandler { - @Test - fun `should return the status healthcheck`() { - client.get("/health") - .send() - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(204) - } - true - } - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class AvroGeneratorHandler { - private val schema = """ - { - "type": "record", - "name": "Payment", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "amount", - "type": "double" - } - ] - } - """.trimIndent() - - @ParameterizedTest - @EnumSource(SerializationFormat::class) - fun `should return a serialized avro`(format: SerializationFormat) { - val serde = AvroSerDeFactoryImpl().new(Schema.Parser().parse(schema), format) - - client.post("/avro/generate") - .addQueryParam("format", format.name) - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer(schema)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - - val avro = result.bodyAsBuffer().bytes - val data = serde.deserialize(avro) - - expectThat(data).isNotNull().isA().and { - get { get("id") }.isNotNull().isA() - get { get("amount") }.isNotNull().isA() - } - - true - } - } - - @Test - fun `should return a json matching schema provided`() { - client.post("/avro/generate") - .addQueryParam("output", "json") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer(schema)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - - val json = result.bodyAsJsonObject() - - expectThat(json).isNotNull().and { - get { getString("id") }.isNotNull().isA() - get { getDouble("amount") }.isNotNull().isA() - } - - true - } - } - - @Test - fun `should return an error when no schema is provided`() { - client.post("/avro/generate") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer()) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Provide an avro schema") - } - } - true - } - } - - @Test - fun `should return an error when schema is invalid`() { - client.post("/avro/generate") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer("""{ "type } """.trimIndent())) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Invalid avro schema") - } - } - true - } - } - - @Test - fun `should return an error when output format is not supported`() { - client.post("/avro/generate") - .addQueryParam("output", "unknown") - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendBuffer(Buffer.buffer(schema)) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Invalid output format") - get { getString("detail") }.isEqualTo("Valid values are: avro, json") - } - } - true - } - } - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class AvroSerDeHandler { - private val schema = JsonObject(""" - { - "type": "record", - "name": "Payment", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "amount", - "type": "double" - } - ] - } - """.trimIndent()).toString() - - @ParameterizedTest - @EnumSource(SerializationFormat::class) - fun `should return a serialized avro from a json body`(format: SerializationFormat) { - val serde = AvroSerDeFactoryImpl().new(Schema.Parser().parse(schema), format) - val json = json { obj("id" to "an-id", "amount" to 10.0) } - - client.post("/avro/serde") - .addQueryParam("format", format.name) - .putHeader("X-Avro-Schema", schema) - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") - .sendJsonObject(json) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(200) - get { getHeader(HttpHeaders.CONTENT_TYPE.toString()) }.isEqualTo("avro/binary") - } - - val avro = result.bodyAsBuffer().bytes - val data = serde.deserialize(avro) - - expectThat(data).isNotNull().isA().and { - get { get("id").toString() }.isEqualTo("an-id") - get { get("amount") }.isEqualTo(10.0) - } - - true - } - } - - @ParameterizedTest - @EnumSource(SerializationFormat::class) - fun `should return a json from an avro body`(format: SerializationFormat) { - val serde = AvroSerDeFactoryImpl().new(Schema.Parser().parse(schema), format) - val datum = GenericData.Record(Schema.Parser().parse(schema)).apply { - put("id", "an-id") - put("amount", 10.0) - } - - client.post("/avro/serde") - .addQueryParam("format", format.name) - .putHeader("X-Avro-Schema", schema) - .putHeader(HttpHeaders.CONTENT_TYPE.toString(), "avro/binary") - .sendBuffer(Buffer.buffer(serde.serialize(datum))) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(200) - get { getHeader(HttpHeaders.CONTENT_TYPE.toString()) }.isEqualTo("application/json") - get { bodyAsJsonObject() }.and { - get { getString("id") }.isEqualTo("an-id") - get { getDouble("amount") }.isEqualTo(10.0) - } - } - true - } - } - - @Test - fun `should return an error when no schema is provided`() { - val json = json { obj("id" to "an-id", "amount" to 10.0) } - - client.post("/avro/serde") - .sendJsonObject(json) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Avro schema required in X-Avro-Schema header") - } - } - true - } - } - - @Test - fun `should return an error when schema is invalid`() { - val json = json { obj("id" to "an-id", "amount" to 10.0) } - - client.post("/avro/serde") - .putHeader("X-Avro-Schema", """{ "type } """.trimIndent()) - .sendJsonObject(json) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Invalid avro schema") - } - } - true - } - } - - @Test - fun `should return an error when incorrect serialization format`() { - val json = json { obj("id" to "an-id", "amount" to 10.0) } - - client.post("/avro/serde") - .putHeader("X-Avro-Schema", schema) - .addQueryParam("format", "unknown") - .sendJsonObject(json) - .test() - .await() - .assertNoErrors() - .assertValue { result -> - expectThat(result) { - get { statusCode() }.isEqualTo(400) - get { bodyAsJsonObject() }.and { - get { getString("title") }.isEqualTo("Invalid format") - get { getString("detail") }.isEqualTo("Valid values are: confluent, simple") - } - } - true - } - } - } -} diff --git a/app/src/test/kotlin/io/apim/samples/websocket/WebSocketServerVerticleTest.kt b/app/src/test/kotlin/io/apim/samples/websocket/WebSocketServerVerticleTest.kt deleted file mode 100644 index bf8a52c..0000000 --- a/app/src/test/kotlin/io/apim/samples/websocket/WebSocketServerVerticleTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package io.apim.samples.websocket - -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.vertx.core.http.HttpClientOptions -import io.vertx.core.json.JsonObject -import io.vertx.junit5.VertxExtension -import io.vertx.junit5.VertxTestContext -import io.vertx.kotlin.core.json.json -import io.vertx.kotlin.core.json.obj -import io.vertx.rxjava3.config.ConfigRetriever -import io.vertx.rxjava3.core.Vertx -import io.vertx.rxjava3.core.buffer.Buffer -import io.vertx.rxjava3.core.http.HttpClient -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.extension.ExtendWith -import strikt.api.expectThat -import strikt.assertions.isEqualTo -import strikt.assertions.isTrue - -@ExtendWith(VertxExtension::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class WebSocketServerVerticleTest { - private val vertx: Vertx = Vertx.vertx() - private val configRetriever: ConfigRetriever = ConfigRetriever.create(vertx) - - lateinit var client: HttpClient - - @BeforeAll - fun setUp(testContext: VertxTestContext) { - vertx.deployVerticle(WebSocketServerVerticle(configRetriever)) - .subscribeBy { testContext.completeNow() } - - client = vertx.createHttpClient( - HttpClientOptions() - .setDefaultHost("localhost") - .setDefaultPort(WebSocketServerVerticle.DEFAULT_PORT) - ) - } - - @AfterAll - fun tearDown(testContext: VertxTestContext) { - vertx.close() - .subscribeBy { testContext.completeNow() } - } - - @Test - fun `should reject connection on unexpected path`(context: VertxTestContext) { - client.webSocket("/ws/unknown") - .doOnSuccess { ws -> - ws.endHandler { - expectThat(ws) { - get { isClosed }.isTrue() - get { closeReason() }.isEqualTo("Not found") - get { closeStatusCode() }.isEqualTo(4404) - } - context.completeNow() - } - } - .test() - .await() - .assertComplete() - } - - @Nested - @ExtendWith(VertxExtension::class) - inner class EchoHandlerTest { - private val jsonRequest = json { obj("message" to "Hello") } - private val unknownRequest = "unknown message" - - @Test - fun `should reply to a json text message`(context: VertxTestContext) { - - client.webSocket(EchoHandler.ECHO_PATH) - .flatMapCompletable { - it.textMessageHandler { message -> - checkJsonResponse(context, JsonObject(message), jsonRequest) - } - - it.writeTextMessage(jsonRequest.toString()) - - } - .test() - .await() - .assertComplete() - } - - @Test - fun `should reply to a json binary message`(context: VertxTestContext) { - client.webSocket(EchoHandler.ECHO_PATH) - .flatMapCompletable { - it.textMessageHandler { message -> - checkJsonResponse(context, JsonObject(message), jsonRequest) - } - - it.writeBinaryMessage(Buffer.buffer(jsonRequest.toString())) - - } - .test() - .await() - .assertComplete() - } - - @Test - fun `should reply to an unknown text message`(context: VertxTestContext) { - - client.webSocket(EchoHandler.ECHO_PATH) - .flatMapCompletable { - it.textMessageHandler { message -> - checkUnknownResponse(context, JsonObject(message)) - } - - it.writeTextMessage(unknownRequest) - - } - .test() - .await() - .assertComplete() - } - - @Test - fun `should reply to an unknown binary message`(context: VertxTestContext) { - - client.webSocket(EchoHandler.ECHO_PATH) - .flatMapCompletable { - it.textMessageHandler { message -> - checkUnknownResponse(context, JsonObject(message)) - } - - it.writeBinaryMessage(Buffer.buffer(unknownRequest)) - - } - .test() - .await() - .assertComplete() - } - - private fun checkJsonResponse(context: VertxTestContext, actual: JsonObject, expected: JsonObject) { - try { - expectThat(actual) { - get { getString("type") }.isEqualTo("json") - get { getJsonObject("request") }.isEqualTo(expected) - } - context.completeNow() - } catch (e: Throwable) { - context.failNow(e) - } - } - - private fun checkUnknownResponse(context: VertxTestContext, actual: JsonObject) { - try { - expectThat(actual) { - get { getString("type") }.isEqualTo("unknown") - get { getString("request") }.isEqualTo(unknownRequest) - } - context.completeNow() - } catch (e: Throwable) { - context.failNow(e) - } - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9921724..9511052 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,19 +1,7 @@ rootProject.name = "apim-samples" -include("app") include("app-quarkus") include("helm") -//pluginManagement { -// repositories { -// mavenCentral() -// gradlePluginPortal() -// mavenLocal() -// -// plugins { -// id("io.quarkus") version "2.16.6.Final" -// } -//} - dependencyResolutionManagement { versionCatalogs { create("libs") {