diff --git a/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/Complete.kt b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/Complete.kt new file mode 100644 index 000000000..b06f47de7 --- /dev/null +++ b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/Complete.kt @@ -0,0 +1,81 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.core.model.DefaultPactWriter +import au.com.dius.pact.core.model.OptionalBody +import au.com.dius.pact.core.model.Pact +import au.com.dius.pact.core.model.PactSpecVersion +import au.com.dius.pact.core.model.PactWriter +import au.com.dius.pact.core.model.Request +import au.com.dius.pact.core.model.Response +import au.com.dius.pact.core.support.Json +import au.com.dius.pact.core.support.json.JsonParser +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File + +private val logger = KotlinLogging.logger {} + +object Complete { + + var pactWriter: PactWriter = DefaultPactWriter + private val CrossSiteHeaders = mapOf("Access-Control-Allow-Origin" to listOf("*")) + + fun getPort(j: Any?): String? = when(j) { + is Map<*, *> -> { + if (j.contains("port")) j["port"].toString() + else null + } + else -> null + } + + fun toJson(error: VerificationResult) = + OptionalBody.body(("{\"error\": \"$error\"}").toByteArray()) + + fun parseJsonString(json: String?) = + if (json == null || json.trim().isEmpty()) null + else Json.fromJson(JsonParser.parseString(json)) + + @JvmStatic + fun apply(request: Request, oldState: ServerState): Result { + fun pactWritten(response: Response, port: String) = run { + val serverState = oldState.state + val server = serverState[port] + val newState = ServerState(oldState.state.filter { it.value != server }) + Result(response, newState) + } + + val port = getPort(parseJsonString(request.body.valueAsString())) + if (port != null) { + val mockProvider = oldState.state[port] + if (mockProvider != null) { + val sessionResults = mockProvider.session.remainingResults() + val pact = mockProvider.pact + if (pact != null) { + mockProvider.stop() + + return when (val result = writeIfMatching(pact, sessionResults, mockProvider.config.pactVersion)) { + is VerificationResult.PactVerified -> pactWritten(Response(200, CrossSiteHeaders.toMutableMap()), + mockProvider.config.port.toString()) + else -> pactWritten(Response(400, mapOf("Content-Type" to listOf("application/json")).toMutableMap(), + toJson(result)), mockProvider.config.port.toString()) + } + } + } + } + + return Result(Response(400), oldState) + } + + fun writeIfMatching(pact: Pact, results: PactSessionResults, pactVersion: PactSpecVersion): VerificationResult { + if (results.allMatched()) { + val pactFile = destinationFileForPact(pact) + pactWriter.writePact(pactFile, pact, pactVersion) + } + return VerificationResult.apply(au.com.dius.pact.core.support.Result.Ok(results)) + } + + fun defaultFilename(pact: Pact): String = "${pact.consumer.name}-${pact.provider.name}.json" + + fun destinationFileForPact(pact: Pact): File = destinationFile(defaultFilename(pact)) + + fun destinationFile(filename: String) = File("${System.getProperty("pact.rootDir", "target/pacts")}/$filename") +} diff --git a/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MockProvider.kt b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MockProvider.kt index 5ee33b2f6..fb45fc462 100644 --- a/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MockProvider.kt +++ b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/MockProvider.kt @@ -45,7 +45,7 @@ abstract class StatefulMockProvider: MockProvider { override val session: PactSession get() = sessionVar - val pact: Pact? + open val pact: Pact? get() = pactVar abstract fun start() diff --git a/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/VerificationResult.kt b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/VerificationResult.kt new file mode 100644 index 000000000..ca1884b2f --- /dev/null +++ b/pact-jvm-server/src/main/kotlin/au/com/dius/pact/server/VerificationResult.kt @@ -0,0 +1,54 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.core.support.Result + +sealed class VerificationResult { + object PactVerified: VerificationResult() + + data class PactMismatch(val results: PactSessionResults, val userError: Throwable? = null): VerificationResult() { + override fun toString(): String { + var s = "Pact verification failed for the following reasons:\n" + for (mismatch in results.almostMatched) { + s += mismatch.description() + } + if (results.unexpected.isNotEmpty()) { + s += "\nThe following unexpected results were received:\n" + for (unexpectedResult in results.unexpected) { + s += unexpectedResult.toString() + } + } + if (results.missing.isNotEmpty()) { + s += "\nThe following requests were not received:\n" + for (unexpectedResult in results.missing) { + s += unexpectedResult.toString() + } + } + return s + } + } + + data class PactError(val error: Throwable): VerificationResult() + + data class UserCodeFailed(val error: T): VerificationResult() + + // Temporary. Should belong somewhere else. + override fun toString(): String = when(this) { + is PactVerified -> "Pact verified." + is PactMismatch -> """ + |Missing: ${results.missing.mapNotNull { it.asSynchronousRequestResponse() }.map { it.request }}\n + |AlmostMatched: ${results.almostMatched}\n + |Unexpected: ${results.unexpected}\n""" + is PactError -> "${error.javaClass.getName()} ${error.message}" + is UserCodeFailed<*> -> "${error?.javaClass?.getName()} $error" + } + + companion object { + fun apply(r: Result): VerificationResult = when (r) { + is Result.Ok -> { + if (r.value.allMatched()) PactVerified + else PactMismatch(r.value) + } + is Result.Err -> PactError(r.error) + } + } +} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Complete.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Complete.scala deleted file mode 100644 index cca506f63..000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Complete.scala +++ /dev/null @@ -1,65 +0,0 @@ -package au.com.dius.pact.server - -import java.io.File -import au.com.dius.pact.core.model._ -import com.typesafe.scalalogging.StrictLogging - -import scala.collection.JavaConverters._ -import scala.util.Success - -object Complete extends StrictLogging { - - def getPort(j: Any): Option[String] = j match { - case map: Map[AnyRef, AnyRef] => { - if (map.contains("port")) Some(map("port").toString) - else None - } - case _ => None - } - - def toJson(error: VerificationResult) = { - OptionalBody.body(("{\"error\": \"" + error + "\"}").getBytes) - } - - def apply(request: Request, oldState: ServerState): Result = { - def clientError = new Result(new Response(400), oldState) - def pactWritten(response: Response, port: String) = { - val server = oldState.getState.asScala(port) - val newState = new ServerState(oldState.getState.asScala.filter(p => p._2 != server).asJava) - new Result(response, newState) - } - - val result = for { - port <- getPort(JsonUtils.parseJsonString(request.getBody.valueAsString())) - mockProvider <- oldState.getState.asScala.get(port) - sessionResults = mockProvider.getSession.remainingResults - pact <- Option(mockProvider.getPact) - } yield { - mockProvider.stop() - - writeIfMatching(pact, sessionResults, mockProvider.getConfig.getPactVersion) match { - case PactVerified => pactWritten(new Response(200, ResponseUtils.CrossSiteHeaders.asJava), - mockProvider.getConfig.getPort.toString) - case error => pactWritten(new Response(400, - Map("Content-Type" -> List("application/json").asJava).asJava, toJson(error)), - mockProvider.getConfig.getPort.toString) - } - } - - result getOrElse clientError - } - - def writeIfMatching(pact: Pact, results: PactSessionResults, pactVersion: PactSpecVersion) = { - if (results.allMatched) { - val pactFile = destinationFileForPact(pact) - DefaultPactWriter.INSTANCE.writePact(pactFile, pact, pactVersion) - } - VerificationResult(Success(results)) - } - - def defaultFilename[I <: Interaction](pact: Pact): String = s"${pact.getConsumer.getName}-${pact.getProvider.getName}.json" - - def destinationFileForPact[I <: Interaction](pact: Pact): File = destinationFile(defaultFilename(pact)) - - def destinationFile(filename: String) = new File(s"${System.getProperty("pact.rootDir", "target/pacts")}/$filename") -} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala index 939ee8a7c..0c7d6a363 100644 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala +++ b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala @@ -32,7 +32,7 @@ object RequestRouter extends StrictLogging { val urlPattern(action) = request.getPath action match { case "create" => Create.apply(request, oldState, config) - case "complete" => Complete(request, oldState) + case "complete" => Complete.apply(request, oldState) case "publish" => Publish(request, oldState, config) case "" => ListServers(oldState) case _ => new Result(pactDispatch(request, oldState), oldState) diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/VerificationResult.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/VerificationResult.scala deleted file mode 100644 index 8add849ad..000000000 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/VerificationResult.scala +++ /dev/null @@ -1,57 +0,0 @@ -package au.com.dius.pact.server - -import au.com.dius.pact.core.model.RequestResponseInteraction - -import scala.collection.JavaConverters._ -import scala.util.Failure -import scala.util.Success -import scala.util.Try - -object VerificationResult { - def apply(r: Try[PactSessionResults]): VerificationResult = r match { - case Success(results) if results.allMatched => PactVerified - case Success(results) => PactMismatch(results) - case Failure(error) => PactError(error) - } -} - -sealed trait VerificationResult { - // Temporary. Should belong somewhere else. - override def toString() = this match { - case PactVerified => "Pact verified." - case PactMismatch(results, error) => s""" - |Missing: ${results.getMissing.asScala.map(_.asInstanceOf[RequestResponseInteraction].getRequest)}\n - |AlmostMatched: ${results.getAlmostMatched.asScala}\n - |Unexpected: ${results.getUnexpected.asScala}\n""" - case PactError(error) => s"${error.getClass.getName} ${error.getMessage}" - case UserCodeFailed(error) => s"${error.getClass.getName} $error" - } -} - -object PactVerified extends VerificationResult - -case class PactMismatch(results: PactSessionResults, userError: Option[Throwable] = None) extends VerificationResult { - override def toString() = { - var s = "Pact verification failed for the following reasons:\n" - for (mismatch <- results.getAlmostMatched.asScala) { - s += mismatch.description() - } - if (results.getUnexpected.asScala.nonEmpty) { - s += "\nThe following unexpected results were received:\n" - for (unexpectedResult <- results.getUnexpected.asScala) { - s += unexpectedResult.toString() - } - } - if (results.getMissing.asScala.nonEmpty) { - s += "\nThe following requests were not received:\n" - for (unexpectedResult <- results.getMissing.asScala) { - s += unexpectedResult.toString() - } - } - s - } -} - -case class PactError(error: Throwable) extends VerificationResult - -case class UserCodeFailed[T](error: T) extends VerificationResult diff --git a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CompleteSpec.groovy b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CompleteSpec.groovy new file mode 100644 index 000000000..2743ec4ca --- /dev/null +++ b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CompleteSpec.groovy @@ -0,0 +1,150 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.consumer.model.MockProviderConfig +import au.com.dius.pact.core.model.Request +import au.com.dius.pact.core.model.OptionalBody +import au.com.dius.pact.core.model.RequestResponsePact +import au.com.dius.pact.core.model.Consumer +import au.com.dius.pact.core.model.Provider +import au.com.dius.pact.core.model.DefaultPactWriter +import au.com.dius.pact.core.model.PactWriter +import au.com.dius.pact.core.model.Pact +import spock.lang.Specification + +class CompleteSpec extends Specification { + def 'getPort'() { + expect: + Complete.INSTANCE.getPort(input) == result + + where: + + input | result + null | null + "null" | null + [:] | null + [a: "b"] | null + [port: "1234"] | "1234" + [port: 1234] | "1234" + } + + def 'apply returns an error if the port is not in the request body'() { + given: + def request = new Request() + def state = new ServerState() + + when: + def result = Complete.apply(request, state) + + then: + result.response.status == 400 + } + + def 'apply returns an error if the port is not mapped to a server'() { + given: + def request = new Request() + request.body = OptionalBody.body('{"port": "1234"}') + def state = new ServerState(["45454": Mock(StatefulMockProvider)]) + + when: + def result = Complete.apply(request, state) + + then: + result.response.status == 400 + } + + def 'apply returns an error if the corresponding server has no Pact'() { + given: + def request = new Request() + request.body = OptionalBody.body('{"port": "1234"}') + def mockProvider = Mock(StatefulMockProvider) { + getPact() >> null + getSession() >> PactSession.empty + } + def state = new ServerState(["1234": mockProvider]) + + when: + def result = Complete.apply(request, state) + + then: + result.response.status == 400 + } + + def 'apply calls stop on the matching mock server'() { + given: + def request = new Request() + request.body = OptionalBody.body('{"port": "1234"}') + def pact = new RequestResponsePact(new Provider(), new Consumer()) + def mockProvider = Mock(StatefulMockProvider) { + getPact() >> pact + getSession() >> PactSession.empty + getConfig() >> new MockProviderConfig('localhost', 1234) + } + def state = new ServerState(["1234": mockProvider]) + + when: + Complete.apply(request, state) + + then: + 1 * mockProvider.stop() + } + + def 'apply writes out the Pact file and returns a success if all requests matched'() { + given: + def request = new Request() + request.body = OptionalBody.body('{"port": "1234"}') + def pact = Mock(Pact) { + getConsumer() >> new Consumer() + getProvider() >> new Provider() + } + def session = PactSession.empty + def mockProvider = Mock(StatefulMockProvider) { + getPact() >> pact + getSession() >> session + getConfig() >> new MockProviderConfig('localhost', 1234) + } + def state = new ServerState(["1234": mockProvider]) + def mockWriter = Mock(PactWriter) + Complete.INSTANCE.pactWriter = mockWriter + + when: + def result = Complete.apply(request, state) + + then: + 1 * mockWriter.writePact(_, pact, _) + result.response.status == 200 + result.newState.state.isEmpty() + + cleanup: + Complete.INSTANCE.pactWriter = DefaultPactWriter.INSTANCE + } + + def 'apply does not write out the Pact file and returns an error if not all requests matched'() { + given: + def request = new Request() + request.body = OptionalBody.body('{"port": "1234"}') + def pact = Mock(Pact) { + getConsumer() >> new Consumer() + getProvider() >> new Provider() + } + def session = PactSession.empty.recordUnexpected(new Request()) + def mockProvider = Mock(StatefulMockProvider) { + getPact() >> pact + getSession() >> session + getConfig() >> new MockProviderConfig('localhost', 1234) + } + def state = new ServerState(["1234": mockProvider]) + def mockWriter = Mock(PactWriter) + Complete.INSTANCE.pactWriter = mockWriter + + when: + def result = Complete.apply(request, state) + + then: + 0 * mockWriter.writePact(_, pact, _) + result.response.status == 400 + result.newState.state.isEmpty() + + cleanup: + Complete.INSTANCE.pactWriter = DefaultPactWriter.INSTANCE + } +}