From 9fe90db92cd5ad6b641829394e2b35f7dc0c860d Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Wed, 20 Nov 2024 17:18:04 +1100 Subject: [PATCH] chore(pact-jvm-server): Converted Scala Netty to Kotlin KTor server --- .../au/com/dius/pact/server/MainServer.kt | 91 +++++++++++++++++++ .../dius/pact/server/CollectionUtils.scala | 40 -------- .../au/com/dius/pact/server/Conversions.scala | 65 ------------- .../au/com/dius/pact/server/JsonUtils.scala | 35 ------- .../com/dius/pact/server/RequestHandler.scala | 28 ------ .../com/dius/pact/server/ResponseUtils.scala | 7 -- .../au/com/dius/pact/server/Server.scala | 9 +- .../dius/pact/server/ConversionsSpec.groovy | 30 ------ .../com/dius/pact/server/JsonUtilsSpec.groovy | 46 ---------- 9 files changed, 95 insertions(+), 256 deletions(-) create mode 100644 pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MainServer.kt delete mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/CollectionUtils.scala delete mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/Conversions.scala delete mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/JsonUtils.scala delete mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestHandler.scala delete mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/ResponseUtils.scala delete mode 100644 pact-jvm-server/src/test/groovy/au/com/dius/pact/server/ConversionsSpec.groovy delete mode 100644 pact-jvm-server/src/test/groovy/au/com/dius/pact/server/JsonUtilsSpec.groovy diff --git a/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MainServer.kt b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MainServer.kt new file mode 100644 index 0000000000..eb0301a253 --- /dev/null +++ b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MainServer.kt @@ -0,0 +1,91 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.core.model.ContentType +import au.com.dius.pact.core.model.IResponse +import au.com.dius.pact.core.model.OptionalBody +import au.com.dius.pact.core.model.Request +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.ApplicationCallPipeline +import io.ktor.server.application.install +import io.ktor.server.engine.applicationEngineEnvironment +import io.ktor.server.engine.connector +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.callloging.CallLogging +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path +import io.ktor.server.request.receiveStream +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.zip.DeflaterInputStream +import java.util.zip.GZIPInputStream + +data class ServerStateStore(var state: ServerState = ServerState()) + +class MainServer(val store: ServerStateStore, val serverConfig: Config) { + private val env = applicationEngineEnvironment { + connector { + host = serverConfig.host + port = serverConfig.port + } + + module { + install(CallLogging) + intercept(ApplicationCallPipeline.Call) { + val request = toPactRequest(context) + val result = RequestRouter.dispatch(request, store.state, serverConfig) + store.state = result.newState + pactResponseToKTorResponse(result.response, context) + } + } + } + + val server = embeddedServer(Netty, environment = env, configure = {}) + + suspend fun toPactRequest(call: ApplicationCall): Request { + val request = call.request + val headers = request.headers + val bodyContents = withContext(Dispatchers.IO) { + val stream = call.receiveStream() + when (bodyIsCompressed(headers["Content-Encoding"])) { + "gzip" -> GZIPInputStream(stream).readBytes() + "deflate" -> DeflaterInputStream(stream).readBytes() + else -> stream.readBytes() + } + } + val body = if (bodyContents.isEmpty()) { + OptionalBody.empty() + } else { + OptionalBody.body(bodyContents, ContentType.fromString(headers["Content-Type"]).or(ContentType.JSON)) + } + return Request(request.httpMethod.value, request.path(), + request.queryParameters.entries().associate { it.toPair() }.toMutableMap(), + headers.entries().associate { it.toPair() }.toMutableMap(), body) + } + + private fun bodyIsCompressed(encoding: String?): String? { + return if (COMPRESSED_ENCODINGS.contains(encoding)) encoding else null + } + + suspend fun pactResponseToKTorResponse(response: IResponse, call: ApplicationCall) { + response.headers.forEach { entry -> + entry.value.forEach { + call.response.headers.append(entry.key, it, safeOnly = false) + } + } + + val body = response.body + if (body.isPresent()) { + call.respondBytes(status = HttpStatusCode.fromValue(response.status), bytes = body.unwrap()) + } else { + call.respond(HttpStatusCode.fromValue(response.status)) + } + } + + companion object { + private val COMPRESSED_ENCODINGS = setOf("gzip", "deflate") + } +} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/CollectionUtils.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/CollectionUtils.scala deleted file mode 100644 index 42ac479421..0000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/CollectionUtils.scala +++ /dev/null @@ -1,40 +0,0 @@ -package au.com.dius.pact.server - -import java.util - -import scala.collection.JavaConverters._ - -object CollectionUtils { - def javaMMapToScalaMMap(map: java.util.Map[String, java.util.Map[String, AnyRef]]) : Map[String, Map[String, Any]] = { - if (map != null) { - map.asScala.mapValues { - jmap: java.util.Map[String, _] => jmap.asScala.toMap - }.toMap - } else { - Map() - } - } - - def javaLMapToScalaLMap(map: java.util.Map[String, java.util.List[String]]) : Map[String, List[String]] = { - if (map != null) { - map.asScala.mapValues { - jlist: java.util.List[String] => jlist.asScala.toList - }.toMap - } else { - Map() - } - } - - def scalaMMapToJavaMMap(map: Map[String, Map[String, AnyRef]]) : java.util.Map[String, java.util.Map[String, AnyRef]] = { - map.mapValues { - jmap: Map[String, _] => jmap.asJava.asInstanceOf[java.util.Map[String, AnyRef]] - }.asJava - } - - def scalaLMaptoJavaLMap(map: Map[String, List[String]]): util.Map[String, util.List[String]] = { - map.mapValues { - jlist: List[String] => jlist.asJava - }.asJava - } - -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Conversions.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Conversions.scala deleted file mode 100644 index 5d4de6c2af..0000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Conversions.scala +++ /dev/null @@ -1,65 +0,0 @@ -package au.com.dius.pact.server - -import java.net.URI -import java.util.zip.GZIPInputStream -import au.com.dius.pact.core.model.{ContentType, IResponse, OptionalBody, Request} -import com.typesafe.scalalogging.StrictLogging -import io.netty.handler.codec.http.{HttpResponse => NHttpResponse} -import unfiltered.netty.ReceivedMessage -import unfiltered.request.HttpRequest -import unfiltered.response.{ContentEncoding, HttpResponse, ResponseFunction, ResponseString, Status} - -import scala.collection.JavaConverters._ - -object Conversions extends StrictLogging { - - case class Headers(headers: java.util.Map[String, java.util.List[String]]) extends unfiltered.response.Responder[Any] { - def respond(res: HttpResponse[Any]) { - if (headers != null) { - headers.asScala.foreach { case (key, value) => res.header(key, value.asScala.mkString(", ")) } - } - } - } - - def pactToUnfilteredResponse(response: IResponse): ResponseFunction[NHttpResponse] = { - val headers = response.getHeaders - if (response.getBody.isPresent) { - Status(response.getStatus) ~> Headers(headers) ~> ResponseString(response.getBody.valueAsString) - } else Status(response.getStatus) ~> Headers(headers) - } - - def toHeaders(request: HttpRequest[ReceivedMessage]): java.util.Map[String, java.util.List[String]] = { - request.headerNames.map(name => name -> request.headers(name).toList.asJava).toMap.asJava - } - - def toQuery(request: HttpRequest[ReceivedMessage]): java.util.Map[String, java.util.List[String]] = { - request.parameterNames.map(name => name -> request.parameterValues(name).asJava).toMap.asJava - } - - def toPath(uri: String) = new URI(uri).getPath - - private def toBodyInputStream(request: HttpRequest[ReceivedMessage]) = { - val gzip = request.headers(ContentEncoding.GZip.name) - if (gzip.hasNext && gzip.next().contains("gzip")) { - new GZIPInputStream(request.inputStream) - } else { - request.inputStream - } - } - - private def toBody(request: HttpRequest[ReceivedMessage], contentType: ContentType) = { - val inputStream = toBodyInputStream(request) - if (inputStream == null) - OptionalBody.empty() - else - OptionalBody.body(org.apache.commons.io.IOUtils.toByteArray(inputStream), contentType) - } - - def unfilteredRequestToPactRequest(request: HttpRequest[ReceivedMessage]): Request = { - val headers = toHeaders(request) - val contentTypeHeader = request.headers("Content-Type") - val contentType = if (contentTypeHeader.hasNext) new ContentType(contentTypeHeader.next()) - else ContentType.getTEXT_PLAIN - new Request(request.method, toPath(request.uri), toQuery(request), headers, toBody(request, contentType)) - } -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/JsonUtils.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/JsonUtils.scala deleted file mode 100644 index 5a1e9ba1b0..0000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/JsonUtils.scala +++ /dev/null @@ -1,35 +0,0 @@ -package au.com.dius.pact.server - -import au.com.dius.pact.core.support.Json -import au.com.dius.pact.core.support.json.JsonParser - -import scala.collection.JavaConverters._ - -object JsonUtils { - - def parseJsonString(json: String): Any = { - if (json == null || json.trim.isEmpty) null - else javaObjectGraphToScalaObjectGraph(Json.INSTANCE.fromJson(JsonParser.parseString(json))) - } - - def javaObjectGraphToScalaObjectGraph(value: AnyRef): Any = { - value match { - case jmap: java.util.Map[String, AnyRef] => - jmap.asScala.toMap.mapValues(javaObjectGraphToScalaObjectGraph) - case jlist: java.util.List[AnyRef] => - jlist.asScala.map(javaObjectGraphToScalaObjectGraph).toList - case _ => value - } - } - - def scalaObjectGraphToJavaObjectGraph(value: Any): Any = { - value match { - case map: Map[String, Any] => - map.mapValues(scalaObjectGraphToJavaObjectGraph).asJava - case list: List[Any] => - list.map(scalaObjectGraphToJavaObjectGraph).asJava - case _ => value - } - } - -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestHandler.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestHandler.scala deleted file mode 100644 index a0afa359c3..0000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestHandler.scala +++ /dev/null @@ -1,28 +0,0 @@ -package au.com.dius.pact.server - -import io.netty.channel.ChannelHandler.Sharable -import unfiltered.netty.ReceivedMessage -import unfiltered.netty.ServerErrorResponse -import unfiltered.netty.cycle -import unfiltered.request.HttpRequest -import unfiltered.response.ResponseFunction -import scala.collection.immutable.Map - -class ServerStateStore { - var state: ServerState = new ServerState() -} - -@Sharable -case class RequestHandler(store: ServerStateStore, config: Config) extends cycle.Plan - with cycle.SynchronousExecution - with ServerErrorResponse { - import io.netty.handler.codec.http.{ HttpResponse=>NHttpResponse } - - def handle(request: HttpRequest[ReceivedMessage]): ResponseFunction[NHttpResponse] = { - val pactRequest = Conversions.unfilteredRequestToPactRequest(request) - val result = RequestRouter.dispatch(pactRequest, store.state, config) - store.state = result.getNewState - Conversions.pactToUnfilteredResponse(result.getResponse) - } - def intent = PartialFunction[HttpRequest[ReceivedMessage], ResponseFunction[NHttpResponse]](handle) -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/ResponseUtils.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/ResponseUtils.scala deleted file mode 100644 index 195c6cf6d8..0000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/ResponseUtils.scala +++ /dev/null @@ -1,7 +0,0 @@ -package au.com.dius.pact.server - -import scala.collection.JavaConverters._ - -object ResponseUtils { - val CrossSiteHeaders = Map[String, java.util.List[String]]("Access-Control-Allow-Origin" -> List("*").asJava) -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala index a982ebce28..372bafd786 100644 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala +++ b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala @@ -29,18 +29,17 @@ object Server extends App { } else { logger.setLevel(Level.INFO) } - val server = _root_.unfiltered.netty.Server.http(config.getPort, config.getHost) - .handler(RequestHandler(new ServerStateStore(), config)) + val mainServer = new MainServer(new ServerStateStore(), config) if (config.getKeystorePath.nonEmpty) { println(s"Using keystore '${config.getKeystorePath}' for mock https server") } - println(s"starting unfiltered app at ${config.getHost} on port ${config.getPort}") - server.start() + println(s"starting main server at ${config.getHost} on port ${config.getPort}") + mainServer.getServer.start(true) if (!config.getDaemon) { readLine("press enter to stop server:\n") - server.stop() + mainServer.getServer.stop(100, 1000) } case None => diff --git a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/ConversionsSpec.groovy b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/ConversionsSpec.groovy deleted file mode 100644 index cc3789094e..0000000000 --- a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/ConversionsSpec.groovy +++ /dev/null @@ -1,30 +0,0 @@ -package au.com.dius.pact.server - -import au.com.dius.pact.core.model.ContentType -import au.com.dius.pact.core.model.Request -import scala.collection.JavaConverters -import spock.lang.Issue -import spock.lang.Specification -import unfiltered.request.HttpRequest - -class ConversionsSpec extends Specification { - - @Issue('#1008') - def 'unfilteredRequestToPactRequest - handles the case where there is no content type header'() { - given: - def httpRequest = Mock(HttpRequest) { - headers(_) >> JavaConverters.asScalaIterator([].iterator()) - headerNames() >> JavaConverters.asScalaIterator([].iterator()) - uri() >> '/' - parameterNames() >> JavaConverters.asScalaIterator([].iterator()) - method() >> 'GET' - inputStream() >> new ByteArrayInputStream('BOOH!'.bytes) - } - - when: - Request request = Conversions$.MODULE$.unfilteredRequestToPactRequest(httpRequest) - - then: - request.body.contentType == ContentType.TEXT_PLAIN - } -} diff --git a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/JsonUtilsSpec.groovy b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/JsonUtilsSpec.groovy deleted file mode 100644 index c974d15f2a..0000000000 --- a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/JsonUtilsSpec.groovy +++ /dev/null @@ -1,46 +0,0 @@ -package au.com.dius.pact.server - -import scala.collection.JavaConverters -import spock.lang.Specification - -class JsonUtilsSpec extends Specification { - - def "Parsing JSON bodies - handles a normal JSON body"() { - expect: - JavaConverters.mapAsJavaMap(JsonUtils.parseJsonString( - '{"password":"123456","firstname":"Brent","booleam":"true","username":"bbarke","lastname":"Barker"}' - )) == [username: 'bbarke', firstname: 'Brent', lastname: 'Barker', booleam: 'true', password: '123456'] - } - - def "Parsing JSON bodies - handles a String"() { - expect: - JsonUtils.parseJsonString('"I am a string"') == 'I am a string' - } - - def "Parsing JSON bodies - handles a Number"() { - expect: - JsonUtils.parseJsonString('1234').intValue() == 1234 - } - - def "Parsing JSON bodies - handles a Boolean"() { - expect: - JsonUtils.parseJsonString('true') == true - } - - def "Parsing JSON bodies - handles a Null"() { - expect: - JsonUtils.parseJsonString('null') == null - } - - def "Parsing JSON bodies - handles an array"() { - expect: - JavaConverters.seqAsJavaList(JsonUtils.parseJsonString('[1, 2, 3, 4]').toSeq())*.intValue() == - [1, 2, 3, 4] - } - - def "Parsing JSON bodies - handles an empty body"() { - expect: - JsonUtils.parseJsonString('') == null - } - -}