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 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 new file mode 100644 index 0000000..5ec4fed --- /dev/null +++ b/app-quarkus/build.gradle.kts @@ -0,0 +1,92 @@ +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) +} + +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(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") + 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") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + 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") + + 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 +} + +tasks.register("copyProto", Copy::class.java) { + from("src/main/proto") + into(layout.buildDirectory.dir("resources/main/META-INF/resources/proto")) +} + +tasks.withType { + dependsOn("copyProto") +} + +configure { + name = "${rootProject.name}:${project.version}" + files(tasks.findByName("quarkusBuild")?.outputs?.files) + tag("DockerHub", "jgiovaresco/${name}") +} diff --git a/app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt similarity index 68% rename from app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt index 67c6c55..a8c28f6 100644 --- a/app/src/main/kotlin/io/apim/samples/rest/RequestHelper.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/RequestHelper.kt @@ -1,11 +1,11 @@ -package io.apim.samples.rest +package io.apim.samples.core +import io.vertx.core.MultiMap 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 } + .groupBy { it.key.lowercase() } .mapValues { it.value.joinToString(";") { h -> h.value } } fun ParsableMIMEValue.isText(): Boolean { @@ -15,7 +15,3 @@ fun ParsableMIMEValue.isText(): Boolean { 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/avro/AvroGenericDataGenerator.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt similarity index 98% rename from app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt index 5d2d8e0..d140171 100644 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroGenericDataGenerator.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroGenericDataGenerator.kt @@ -1,4 +1,4 @@ -package io.apim.samples.avro +package io.apim.samples.core.avro import io.github.serpro69.kfaker.Faker import org.apache.avro.Schema diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt similarity index 76% rename from app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt index de2b508..de50cc1 100644 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDe.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/AvroSerDe.kt @@ -1,4 +1,4 @@ -package io.apim.samples.avro +package io.apim.samples.core.avro interface AvroSerDe { fun serialize(data: Any?): ByteArray 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/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt similarity index 89% rename from app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt index 006019a..154510d 100644 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeConfluent.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeConfluent.kt @@ -1,5 +1,6 @@ -package io.apim.samples.avro +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 diff --git a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt similarity index 90% rename from app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt index a44c2e6..aa5a26d 100644 --- a/app/src/main/kotlin/io/apim/samples/avro/AvroSerDeSimple.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/AvroSerDeSimple.kt @@ -1,5 +1,6 @@ -package io.apim.samples.avro +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 diff --git a/app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt similarity index 95% rename from app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt index aed9308..62fed6c 100644 --- a/app/src/main/kotlin/io/apim/samples/avro/JsonSerDe.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/core/avro/impl/JsonSerDe.kt @@ -1,4 +1,4 @@ -package io.apim.samples.avro +package io.apim.samples.core.avro.impl import org.apache.avro.Schema import org.apache.avro.generic.GenericDatumReader @@ -19,7 +19,6 @@ class JsonSerDe(private val schema: Schema) { encoder.flush() output.flush() - return output.toString(StandardCharsets.UTF_8) } 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/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt similarity index 55% rename from app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt rename to app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt index afe012a..3a814ab 100644 --- a/app/src/main/kotlin/io/apim/samples/grpc/RouteGuideService.kt +++ b/app-quarkus/src/main/kotlin/io/apim/samples/ports/grpc/RouteGuideGrpcService.kt @@ -1,33 +1,20 @@ -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 +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.core.streams.ReadStream -import io.vertx.core.streams.WriteStream -import org.slf4j.LoggerFactory -import java.util.Collections +import io.vertx.mutiny.core.Vertx +import java.util.* 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) +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() /** @@ -36,18 +23,15 @@ class RouteGuideService(private val features: List) : VertxRouteGuideGr * @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) + override fun getFeature(location: Point): Uni { + return features().filter { it.filterByLocation(location) } + .collect().first() + .onItem().ifNull().continueWith { + Feature.newBuilder() + .setName("") + .setLocation(location) + .build() + } } /** @@ -56,63 +40,49 @@ class RouteGuideService(private val features: List) : VertxRouteGuideGr * 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) { + 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) - features.filter { it.name.isNotBlank() } + 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 } - .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() + 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: ReadStream, response: WriteStream) { - request.handler { note -> - val locationNotes = getOrCreateNotes(note.location) - - locationNotes.forEach { response.write(it) } + override fun routeChat(request: Multi): Multi { + return request + .onItem().transformToMultiAndConcatenate { + val notes = getOrCreateNotes(it.location) - locationNotes.add(note) - } - - request.exceptionHandler { - logger.error("routeChat cancelled", it) - response.end() - } - - request.endHandler { response.end() } + Multi.createFrom().items(notes.stream()) + .onCompletion().invoke { notes.add(it) } + } } + /** * Get the notes list for the given location. If missing, create it. */ @@ -120,6 +90,18 @@ class RouteGuideService(private val features: List) : VertxRouteGuideGr 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) { @@ -143,13 +125,14 @@ class RouteRecorder(private val features: List) { } fun buildSummary(): RouteSummary { - val time = NANOSECONDS.toSeconds(System.nanoTime() - startTime) - return routeSummary { - pointCount = pointsCount - featureCount = featuresCount - distance = this@RouteRecorder.distance - elapsedTime = time.toInt() - } + val time = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime) + + return RouteSummary.newBuilder() + .setPointCount(pointsCount) + .setFeatureCount(featuresCount) + .setDistance(distance) + .setElapsedTime(time.toInt()) + .build() } /** @@ -184,11 +167,12 @@ 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() -} +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/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/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/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/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/src/main/resources/grpc/helloworld.proto b/app-quarkus/src/main/proto/helloworld.proto similarity index 100% rename from app/src/main/resources/grpc/helloworld.proto rename to app-quarkus/src/main/proto/helloworld.proto diff --git a/app/src/main/resources/grpc/route_guide.proto b/app-quarkus/src/main/proto/route_guide.proto similarity index 100% rename from app/src/main/resources/grpc/route_guide.proto rename to app-quarkus/src/main/proto/route_guide.proto diff --git a/app-quarkus/src/main/resources/application.properties b/app-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..68808c3 --- /dev/null +++ b/app-quarkus/src/main/resources/application.properties @@ -0,0 +1,4 @@ +quarkus.http.port=8888 + +quarkus.grpc.server.use-separate-server=false +quarkus.grpc.server.enable-reflection-service=true diff --git a/app/src/main/resources/grpc/route_guide.json b/app-quarkus/src/main/resources/grpc/route_guide.json similarity index 99% rename from app/src/main/resources/grpc/route_guide.json rename to app-quarkus/src/main/resources/grpc/route_guide.json index 53a000c..d4d7750 100644 --- a/app/src/main/resources/grpc/route_guide.json +++ b/app-quarkus/src/main/resources/grpc/route_guide.json @@ -1,5 +1,5 @@ { - "feature": [ + "features": [ { "location": { "latitude": 407838351, 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/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/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") + } + } + +} 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") + } + } +} 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) + } + } +} 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 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/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/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/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/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/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/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/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 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 1727d5a..9511052 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ rootProject.name = "apim-samples" -include("app") +include("app-quarkus") include("helm") dependencyResolutionManagement { @@ -13,7 +13,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 +32,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 +48,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") } } }