8000 Enhance MockEventListener functionality by StarKhan6368 · Pull Request #1912 · specmatic/specmatic · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Enhance MockEventListener functionality #1912

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,12 @@ data class Feature(
}
}

fun identifierMatchingScenario(httpRequest: HttpRequest): Scenario? {
return scenarios.asSequence().filter {
it.httpRequestPattern.matchesPathStructureMethodAndContentType(httpRequest, it.resolver).isSuccess()
}.firstOrNull()
}

private fun getScenarioWithDescription(scenarioResult: ReturnValue<Scenario>): ReturnValue<Scenario> {
return scenarioResult.ifHasValue { result: HasValue<Scenario> ->
val apiDescription = result.value.descriptionFromPlugin ?: result.value.apiDescription
Expand Down
8 changes: 5 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/log/HttpLogMessage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ data class HttpLogMessage(
var scenario: Scenario? = null,
var exception: Exception? = null
) : LogMessage {
fun addRequest(httpRequest: HttpRequest) {

fun addRequestWithCurrentTime(httpRequest: HttpRequest) {
requestTime = CurrentDate()
this.request = httpRequest
}

fun addResponse(httpResponse: HttpResponse) {
fun addResponseWithCurrentTime(httpResponse: HttpResponse) {
responseTime = CurrentDate()
this.response = httpResponse
}
Expand Down Expand Up @@ -97,9 +98,10 @@ data class HttpLogMessage(
}

fun addResponse(stubResponse: HttpStubResponse) {
addResponse(stubResponse.response)
addResponseWithCurrentTime(stubResponse.response)
contractPath = stubResponse.contractPath
examplePath = stubResponse.examplePath
scenario = stubResponse.scenario
}

fun logStartRequestTime() {
Expand Down
45 changes: 22 additions & 23 deletions core/src/main/kotlin/io/specmatic/stub/HttpStub.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import io.specmatic.mock.ScenarioStub
import io.specmatic.mock.TRANSIENT_MOCK
import io.specmatic.mock.mockFromJSON
import io.specmatic.mock.validateMock
import io.specmatic.stub.listener.MockEvent
import io.specmatic.stub.listener.MockEventListener
import io.specmatic.stub.report.StubEndpoint
import io.specmatic.stub.report.StubUsageReport
Expand Down Expand Up @@ -87,14 +88,16 @@ class HttpStub(
log: (event: LogMessage) -> Unit = dontPrintToConsole,
specToStubBaseUrlMap: Map<String, String> = mapOf(
feature.path to endPointFromHostAndPort(host, port, null)
)
),
listeners: List<MockEventListener> = emptyList()
) : this(
listOf(feature),
contractInfoToHttpExpectations(listOf(Pair(feature, scenarioStubs))),
host,
port,
log,
specToStubBaseUrlMap = specToStubBaseUrlMap
specToStubBaseUrlMap = specToStubBaseUrlMap,
listeners = listeners
)

constructor(
Expand Down Expand Up @@ -263,17 +266,15 @@ class HttpStub(

try {
val rawHttpRequest = ktorHttpRequestToHttpRequest(call).also {
httpLogMessage.addRequest(it)
httpLogMessage.addRequestWithCurrentTime(it)
if (it.isHealthCheckRequest()) return@intercept
}

val httpRequest = requestInterceptors.fold(rawHttpRequest) { request, requestInterceptor ->
requestInterceptor.interceptRequest(request) ?: request
}

val responseFromRequestHandler =
requestHandlers.firstNotNullOfOrNull { it.handleRequest(httpRequest) }

val responseFromRequestHandler = requestHandlers.firstNotNullOfOrNull { it.handleRequest(httpRequest) }
val httpStubResponse: HttpStubResponse = when {
isFetchLogRequest(httpRequest) -> handleFetchLogRequest()
isFetchLoadLogRequest(httpRequest) -> handleFetchLoadLogRequest()
Expand All @@ -294,39 +295,34 @@ class HttpStub(
val httpResponse = responseInterceptors.fold(httpStubResponse.response) { response, responseInterceptor ->
responseInterceptor.interceptResponse(httpRequest, response) ?: response
}

if (httpRequest.path!!.startsWith("""/features/default""")) {
handleSse(httpRequest, this@HttpStub, this)
} else {
httpStubResponse.scenario?.let { matchingScenario ->
listeners.forEach { listener ->
listener.call(httpRequest, httpResponse, matchingScenario)
}
}

val updatedHttpStubResponse = httpStubResponse.copy(response = httpResponse)
respondToKtorHttpResponse(call, updatedHttpStubResponse.response, updatedHttpStubResponse.delayInMilliSeconds, specmaticConfig)
httpLogMessage.addResponse(updatedHttpStubResponse)
}
} catch (e: ContractException) {
val response = badRequest(e.report())
httpLogMessage.addResponse(response)
httpLogMessage.addResponseWithCurrentTime(response)
httpLogMessage.scenario = e.scenario as? Scenario
httpLogMessage.addException(e)
respondToKtorHttpResponse(call, response)
} catch (e: CouldNotParseRequest) {
httpLogMessage.addRequest(defensivelyExtractedRequestForLogging(call))

httpLogMessage.addRequestWithCurrentTime(defensivelyExtractedRequestForLogging(call))
val response = badRequest("Could not parse request")
httpLogMessage.addResponse(response)

httpLogMessage.addResponseWithCurrentTime(response)
httpLogMessage.addException(e)
respondToKtorHttpResponse(call, response)
} catch (e: Throwable) {
val response = internalServerError(exceptionCauseMessage(e) + "\n\n" + e.stackTraceToString())
httpLogMessage.addResponse(response)

httpLogMessage.addResponseWithCurrentTime(response)
httpLogMessage.addException(Exception(e))
respondToKtorHttpResponse(call, response)
}

log(httpLogMessage)
MockEvent(httpLogMessage).let { event -> listeners.forEach { it.onRespond(event) } }
}

configureHealthCheckModule()
Expand Down Expand Up @@ -963,7 +959,10 @@ fun getHttpResponse(
)
)
}
if(strictMode) return NotStubbed(HttpStubResponse(strictModeHttp400Response(httpRequest, matchResults)))
if (strictMode) return NotStubbed(HttpStubResponse(
response = strictModeHttp400Response(httpRequest, matchResults),
scenario = features.firstNotNullOfOrNull { it.identifierMatchingScenario(httpRequest) }
))

return fakeHttpResponse(features, httpRequest, specmaticConfig)
} finally {
Expand Down Expand Up @@ -1092,8 +1091,8 @@ fun fakeHttpResponse(
)
} else {
val httpFailureResponse = combinedFailureResult.generateErrorHttpResponse(httpRequest)

NotStubbed(HttpStubResponse(httpFailureResponse))
val nearestScenario = features.firstNotNullOfOrNull { it.identifierMatchingScenario(httpRequest) }
NotStubbed(HttpStubResponse(httpFailureResponse, scenario = nearestScenario))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,54 @@
package io.specmatic.stub.listener

import io.specmatic.core.HttpRequest
import io.specmatic.core.HttpResponse
import io.specmatic.core.Scenario
import io.specmatic.core.*
import io.specmatic.core.log.HttpLogMessage
import io.specmatic.core.utilities.exceptionCauseMessage
import java.io.File

interface MockEventListener {
fun call(
request: HttpRequest,
response: HttpResponse,
scenario: Scenario
fun onRespond(data: MockEvent)
}

data class MockEvent (
val name: String,
val details: String,
val request: HttpRequest,
val requestTime: Long,
val response: HttpResponse?,
val responseTime: Long?,
val scenario: Scenario?,
val result: TestResult,
) {
constructor(logMessage: HttpLogMessage) : this(
name = logMessage.toName(),
details = logMessage.toDetails(),
request = logMessage.request,
requestTime = logMessage.requestTime.toEpochMillis(),
response = logMessage.response,
responseTime = logMessage.responseTime?.toEpochMillis(),
scenario = logMessage.scenario,
result = logMessage.toResult()
)
}
}

private fun HttpLogMessage.toResult(): TestResult {
return when {
this.examplePath != null || this.scenario != null && response?.status !in invalidRequestStatuses -> TestResult.Success
scenario == null -> TestResult.MissingInSpec
else -> TestResult.Failed
}
}

private fun HttpLogMessage.toDetails(): String {
return when {
this.examplePath != null -> "Request Matched Example: ${this.examplePath}"
this.scenario != null && response?.status !in invalidRequestStatuses -> "Request Matched Contract ${scenario?.apiDescription}"
this.exception != null -> "Invalid Request\n${exception?.let(::exceptionCauseMessage)}"
else -> response?.body?.toStringLiteral() ?: "Request Didn't Match Contract"
}
}

private fun HttpLogMessage.toName(): String {
val scenario = this.scenario ?: return "Unknown Request"
return scenario.copy(exampleName = this.examplePath?.let(::File)?.name).testDescription()
}
7 changes: 3 additions & 4 deletions core/src/main/kotlin/io/specmatic/test/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,11 @@ data class HttpClient(
requestWithFileContent.buildKTORRequest(this, url)
}

val outboundRequest: HttpRequest =
ktorHttpRequestToHttpRequestForLogging(ktorResponse.request, requestWithFileContent)
httpLogMessage.addRequest(outboundRequest)
val outboundRequest: HttpRequest = ktorHttpRequestToHttpRequestForLogging(ktorResponse.request, requestWithFileContent)
httpLogMessage.request = outboundRequest

ktorResponseToHttpResponse(ktorResponse).also {
httpLogMessage.addResponse(it)
httpLogMessage.addResponseWithCurrentTime(it)
log(httpLogMessage)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.specmatic.stub.listener

import io.specmatic.core.TestResult
import io.specmatic.core.parseContractFileToFeature
import io.specmatic.core.value.NullValue
import io.specmatic.mock.ScenarioStub
import io.specmatic.stub.HttpStub
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.io.File

class MockEventListenerTest {

companion object {
private val openApiFile = File("src/test/resources/openapi/partial_example_tests/simple.yaml")
val feature = parseContractFileToFeature(openApiFile)
}

@Test
fun `should callback with appropriate data when request matches the contract`() {
val listener = object : MockEventListener {
override fun onRespond(data: MockEvent) {
assertThat(data.name).isEqualToIgnoringWhitespace("Scenario: PATCH /creators/(creatorId:number)/pets/(petId:number) -> 201")
assertThat(data.details).isEqualToIgnoringWhitespace("Request Matched Contract PATCH /creators/(creatorId:number)/pets/(petId:number) -> 201")
assertThat(data.scenario).isEqualTo(feature.scenarios.first())
assertThat(data.response).isNotNull
assertThat(data.responseTime).isNotNull()
assertThat(data.result).isEqualTo(TestResult.Success)
}
}

HttpStub(feature, listeners = listOf(listener)).use { stub ->
val validRequest = feature.scenarios.first().generateHttpRequest()
stub.client.execute(validRequest)
}
}

@Test
fun `should mention matched example in name and details if match occurs`() {
val listener = object : MockEventListener {
override fun onRespond(data: MockEvent) {
assertThat(data.name).contains("EX:example.json")
assertThat(data.details).contains("Request Matched Example: examples/example.json")
assertThat(data.result).isEqualTo(TestResult.Success)
}
}

val (request, response) = feature.scenarios.first().let {
it.generateHttpRequest() to it.generateHttpResponse(emptyMap()).copy(headers = emptyMap())
}
val exampleStub = ScenarioStub(request = request, response = response, filePath = "examples/example.json")
HttpStub(feature, scenarioStubs = listOf(exampleStub), listeners = listOf(listener)).use { stub ->
stub.client.execute(request)
}
}

@Test
fun `should provide nearest matching scenario details for bad request with no examples`() {
val listener = object : MockEventListener {
override fun onRespond(data: MockEvent) {
assertThat(data.name).isEqualToIgnoringWhitespace("Scenario: PATCH /creators/(creatorId:number)/pets/(petId:number) -> 201")
assertThat(data.details).contains("Contract expected json object but request contained an empty string or no body value")
assertThat(data.scenario).isEqualTo(feature.scenarios.first())
assertThat(data.result).isEqualTo(TestResult.Failed)
}
}

HttpStub(feature, listeners = listOf(listener)).use { stub ->
val request = feature.scenarios.first().generateHttpRequest()
stub.client.execute(request.updateBody(NullValue))
}
}

@Test
fun `should return missing-in-spec when request doesn't match any scenario identifiers`() {
val listener = object : MockEventListener {
override fun onRespond(data: MockEvent) {
assertThat(data.name).isEqualTo("Unknown Request")
assertThat(data.details).isEqualTo("No matching REST stub or contract found for method PATCH and path /test")
assertThat(data.scenario).isNull()
assertThat(data.result).isEqualTo(TestResult.MissingInSpec)
}
}

HttpStub(feature, listeners = listOf(listener)).use { stub ->
val request = feature.scenarios.first().generateHttpRequest()
stub.client.execute(request.updatePath("/test"))
}
}
}
Loading
0