Skip to content
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

Stateful stub #1428

Merged
merged 22 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
389b55b
Refactor HttpStub
yogeshnikam671 Nov 13, 2024
723bde6
Implement stateful stub
yogeshnikam671 Nov 13, 2024
98d8517
Add tests for stateful stub
yogeshnikam671 Nov 14, 2024
bea9b5e
Handle DELETE endpoints in the stateful stub
yogeshnikam671 Nov 14, 2024
42d4a1e
Implement attribute selection functionality in stateful stub
yogeshnikam671 Nov 14, 2024
4c6862f
Merge branch 'main' into stateful_stub
joelrosario Nov 14, 2024
ef7f36e
Add StatefulHttpStub and virtual-service command to start a stateful …
yogeshnikam671 Nov 15, 2024
a1869a6
Inject example data as seed data into the stub cache for virtual-service
yogeshnikam671 Nov 18, 2024
0264da9
Handle basic concurrency in the StubCache data structure
yogeshnikam671 Nov 18, 2024
8633eb8
404 response in structured manner from virtual-service
yogeshnikam671 Nov 18, 2024
8c11bc2
400 response in structured manner from virtual-service
yogeshnikam671 Nov 19, 2024
f545a4f
Add tests for 400 and 404 response handling in stateful stub
yogeshnikam671 Nov 19, 2024
5b92ecf
Fix the response body pattern resolution happening in StatefulHttpStu…
yogeshnikam671 Nov 19, 2024
2e83d0e
Ignore unexpected keys in request while serving it from the StatefulH…
yogeshnikam671 Nov 19, 2024
70903ac
Implement filtering in the virtual service for the get all endpoint
yogeshnikam671 Nov 20, 2024
881c1a2
Generate unique id for each post request in stateful http stub
yogeshnikam671 Nov 20, 2024
f2fa9f2
Launch swagger-ui with the loaded specification when a virtual-servic…
yogeshnikam671 Nov 20, 2024
ac1b855
Refactor the satisfiesFilter method used for filtering in virtual-ser…
yogeshnikam671 Nov 20, 2024
a1f3d1a
virtual-service should not patch the keys specified in the 'nonPatcha…
yogeshnikam671 Nov 20, 2024
8f6da19
Fix the bug with non patchable keys
yogeshnikam671 Nov 20, 2024
2e3cd18
Pick up non-202 response if present else pick 202 response while resp…
yogeshnikam671 Nov 21, 2024
c34f805
Fix ResponseBuilder and matchesStub method
yogeshnikam671 Nov 21, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import java.util.concurrent.Callable
ExamplesCommand::class,
SamplesCommand::class,
StubCommand::class,
VirtualServiceCommand::class,
SubscribeCommand::class,
TestCommand::class,
ValidateViaLogs::class,
Expand Down
126 changes: 126 additions & 0 deletions application/src/main/kotlin/application/VirtualServiceCommand.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package application

import io.specmatic.core.Configuration
import io.specmatic.core.Configuration.Companion.DEFAULT_HTTP_STUB_HOST
import io.specmatic.core.Configuration.Companion.DEFAULT_HTTP_STUB_PORT
import io.specmatic.core.DEFAULT_WORKING_DIRECTORY
import io.specmatic.core.Feature
import io.specmatic.core.log.StringLog
import io.specmatic.core.log.consoleLog
import io.specmatic.core.log.logger
import io.specmatic.core.utilities.ContractPathData
import io.specmatic.core.utilities.contractFilePathsFrom
import io.specmatic.core.utilities.contractStubPaths
import io.specmatic.core.utilities.exitIfAnyDoNotExist
import io.specmatic.mock.ScenarioStub
import io.specmatic.stub.stateful.StatefulHttpStub
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import java.util.concurrent.Callable
import java.util.concurrent.CountDownLatch

@Command(
name = "virtual-service",
mixinStandardHelpOptions = true,
description = ["Start a stateful virtual service with contract"]
)
class VirtualServiceCommand : Callable<Int> {

@Option(names = ["--host"], description = ["Host for the virtual service"], defaultValue = DEFAULT_HTTP_STUB_HOST)
lateinit var host: String

@Option(names = ["--port"], description = ["Port for the virtual service"], defaultValue = DEFAULT_HTTP_STUB_PORT)
var port: Int = 0

@Option(names = ["--examples"], description = ["Directories containing JSON examples"], required = false)
var exampleDirs: List<String> = mutableListOf()

private val stubLoaderEngine = StubLoaderEngine()
private var server: StatefulHttpStub? = null
private val latch = CountDownLatch(1)
private val newLine = System.lineSeparator()

override fun call(): Int {
setup()

try {
startServer()
} catch(e: Exception) {
logger.log("An error occurred while starting the virtual service: ${e.message}")
return 1
}

return 0
}

private fun setup() {
exitIfContractPathsDoNotExist()
addShutdownHook()
}

private fun exitIfContractPathsDoNotExist() {
val contractPaths = contractStubPaths(Configuration.configFilePath).map { it.path }
exitIfAnyDoNotExist("The following specifications do not exist", contractPaths)
}

private fun addShutdownHook() {
Runtime.getRuntime().addShutdownHook(object : Thread() {
override fun run() {
try {
latch.countDown()
consoleLog(StringLog("Shutting down the virtual service"))
server?.close()
} catch (e: InterruptedException) {
currentThread().interrupt()
}
}
})
}

private fun stubContractPathData(): List<ContractPathData> {
return contractFilePathsFrom(Configuration.configFilePath, DEFAULT_WORKING_DIRECTORY) {
source -> source.stubContracts
}
}

private fun startServer() {
val stubData: List<Pair<Feature, List<ScenarioStub>>> = stubLoaderEngine.loadStubs(
stubContractPathData(),
exampleDirs,
Configuration.configFilePath,
false
)

server = StatefulHttpStub(
host,
port,
stubData.map { it.first },
Configuration.configFilePath,
stubData.flatMap { it.second }.also { it.logExamplesCachedAsSeedData() }
)
logger.log("Virtual service started on http://$host:$port")
latch.await()
}

private fun List<ScenarioStub>.logExamplesCachedAsSeedData() {
logger.log("${newLine}Injecting the data read from the following stub files into the stub server's state..".prependIndent(" "))
this.forEach { logger.log(it.filePath.orEmpty().prependIndent(" ")) }
}
}

















56 changes: 50 additions & 6 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,7 @@ data class Feature(
mismatchMessages: MismatchMessages = DefaultMismatchMessages
): Pair<ResponseBuilder?, Results> {
try {
val scenarioSequence = scenarios.asSequence()

val localCopyOfServerState = serverState
val resultList = scenarioSequence.zip(scenarioSequence.map {
it.matchesStub(httpRequest, localCopyOfServerState, mismatchMessages)
})
val resultList = matchingScenarioToResultList(httpRequest, serverState, mismatchMessages)

return matchingScenario(resultList)?.let {
Pair(ResponseBuilder(it, serverState), Results())
Expand All @@ -234,6 +229,49 @@ data class Feature(
}
}

fun stubResponseMap(
httpRequest: HttpRequest,
mismatchMessages: MismatchMessages = DefaultMismatchMessages,
unexpectedKeyCheck: UnexpectedKeyCheck
): Map<Int, Pair<ResponseBuilder?, Results>> {
try {
val resultList = matchingScenarioToResultList(httpRequest, serverState, mismatchMessages, unexpectedKeyCheck)
val matchingScenarios = matchingScenarios(resultList)

if(matchingScenarios.toList().isEmpty()) {
val results = Results(
resultList.map { it.second }.toList()
).withoutFluff()
return mapOf(
400 to Pair(
ResponseBuilder(null, serverState),
results
)
)
}

return matchingScenarios.map { (status, scenario) ->
status to Pair(ResponseBuilder(scenario, serverState), Results())
}.toMap()

} finally {
serverState = emptyMap()
}
}

private fun matchingScenarioToResultList(
httpRequest: HttpRequest,
serverState: Map<String, Value>,
mismatchMessages: MismatchMessages,
unexpectedKeyCheck: UnexpectedKeyCheck = ValidateUnexpectedKeys
): Sequence<Pair<Scenario, Result>> {
val scenarioSequence = scenarios.asSequence()

return scenarioSequence.zip(scenarioSequence.map {
it.matchesStub(httpRequest, serverState, mismatchMessages, unexpectedKeyCheck)
})
}

fun compatibilityLookup(httpRequest: HttpRequest, mismatchMessages: MismatchMessages = NewAndOldContractRequestMismatches): List<Pair<Scenario, Result>> {
try {
val resultList = lookupAllScenarios(httpRequest, scenarios, mismatchMessages, IgnoreUnexpectedKeys)
Expand Down Expand Up @@ -275,6 +313,12 @@ data class Feature(
}?.first
}

private fun matchingScenarios(resultList: Sequence<Pair<Scenario, Result>>): Sequence<Pair<Int, Scenario>> {
return resultList.filter { it.second is Result.Success }.map {
Pair(it.first.status, it.first)
}
}

private fun lookupScenario(
httpRequest: HttpRequest,
scenarios: List<Scenario>
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ data class HttpRequestPattern(
val multiPartFormDataPattern: List<MultiPartFormDataPattern> = emptyList(),
val securitySchemes: List<OpenAPISecurityScheme> = listOf(NoSecurityScheme())
) {

fun getPathSegmentPatterns() = httpPathPattern?.pathSegmentPatterns

fun getHeaderKeys() = headersPattern.headerNames

fun getQueryParamKeys() = httpQueryParamPattern.queryKeyNames
Expand Down
14 changes: 11 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/ResponseBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package io.specmatic.core

import io.specmatic.core.pattern.resolvedHop
import io.specmatic.core.value.Value
import io.specmatic.stub.RequestContext

class ResponseBuilder(val scenario: Scenario, val serverState: Map<String, Value>) {
fun build(requestContext: RequestContext): HttpResponse {
return scenario.generateHttpResponse(serverState, requestContext)
class ResponseBuilder(val scenario: Scenario?, val serverState: Map<String, Value>) {
val responseBodyPattern = scenario?.resolver?.withCyclePrevention(
scenario.httpResponsePattern.body
) {
resolvedHop(scenario.httpResponsePattern.body, it)
}
val resolver = scenario?.resolver

fun build(requestContext: RequestContext): HttpResponse? {
return scenario?.generateHttpResponse(serverState, requestContext)
}
}
18 changes: 15 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/Scenario.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ data class Scenario(
fun matchesStub(
httpRequest: HttpRequest,
serverState: Map<String, Value>,
mismatchMessages: MismatchMessages = DefaultMismatchMessages
mismatchMessages: MismatchMessages = DefaultMismatchMessages,
unexpectedKeyCheck: UnexpectedKeyCheck? = null
): Result {
val headersResolver = Resolver(serverState, false, patterns).copy(mismatchMessages = mismatchMessages)
val nonHeadersResolver = headersResolver.disableOverrideUnexpectedKeycheck()

val nonHeadersResolver = if(unexpectedKeyCheck != null) {
headersResolver.withUnexpectedKeyCheck(unexpectedKeyCheck)
} else {
headersResolver
}.disableOverrideUnexpectedKeycheck()

return matches(httpRequest, serverState, nonHeadersResolver, headersResolver)
}
Expand Down Expand Up @@ -208,6 +214,12 @@ data class Scenario(
httpResponsePattern.generateResponseV2(updatedResolver)
}

fun resolvedResponseBodyPattern(): Pattern {
return resolver.withCyclePrevention(httpResponsePattern.body) {
resolvedHop(httpResponsePattern.body, it)
}
}

private fun combineFacts(
expected: Map<String, Value>,
actual: Map<String, Value>,
Expand Down Expand Up @@ -790,7 +802,7 @@ data class Scenario(
)
}

private fun getFieldsToBeMadeMandatoryBasedOnAttributeSelection(queryParams: QueryParameters?): Set<String> {
fun getFieldsToBeMadeMandatoryBasedOnAttributeSelection(queryParams: QueryParameters?): Set<String> {
val defaultAttributeSelectionFields = attributeSelectionPattern.defaultFields.toSet()
val attributeSelectionQueryParamKey = attributeSelectionPattern.queryParamKey

Expand Down
9 changes: 8 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ fun String.loadContract(): Feature {
data class StubConfiguration(
val generative: Boolean? = false,
val delayInMilliseconds: Long? = getLongValue(SPECMATIC_STUB_DELAY),
val dictionary: String? = getStringValue(SPECMATIC_STUB_DICTIONARY)
val dictionary: String? = getStringValue(SPECMATIC_STUB_DICTIONARY),
val includeMandatoryAndRequestedKeysInResponse: Boolean? = true
)

data class VirtualServiceConfiguration(
val nonPatchableKeys: Set<String> = emptySet()
)

data class WorkflowIDOperation(
Expand Down Expand Up @@ -113,6 +118,8 @@ data class SpecmaticConfig(
val security: SecurityConfiguration? = null,
val test: TestConfiguration? = TestConfiguration(),
val stub: StubConfiguration = StubConfiguration(),
@field:JsonAlias("virtual_service")
val virtualService: VirtualServiceConfiguration = VirtualServiceConfiguration(),
val examples: List<String> = getStringValue(EXAMPLE_DIRECTORIES)?.split(",") ?: emptyList(),
val workflow: WorkflowConfiguration? = null,
val ignoreInlineExamples: Boolean = getBooleanValue(Flags.IGNORE_INLINE_EXAMPLES),
Expand Down
9 changes: 9 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ data class AnyPattern(
return this
}

override fun jsonObjectPattern(resolver: Resolver): JSONObjectPattern? {
if (this.hasNoAmbiguousPatterns().not()) return null

val pattern = this.pattern.first { it !is NullPattern }
if (pattern is JSONObjectPattern) return pattern
if (pattern is PossibleJsonObjectPatternContainer) return pattern.jsonObjectPattern(resolver)
return null
}

override fun eliminateOptionalKey(value: Value, resolver: Resolver): Value {
val matchingPattern = pattern.find { it.matches(value, resolver) is Result.Success } ?: return value
return matchingPattern.eliminateOptionalKey(value, resolver)
Expand Down
11 changes: 11 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ data class DeferredPattern(
return this
}

override fun jsonObjectPattern(resolver: Resolver): JSONObjectPattern? {
val resolvedPattern = resolver.withCyclePrevention(this) { updatedResolver ->
resolvedHop(this, updatedResolver)
}
if(resolvedPattern is JSONObjectPattern) return resolvedPattern
if(resolvedPattern is PossibleJsonObjectPatternContainer) {
return resolvedPattern.jsonObjectPattern(resolver)
}
return null
}

override fun equals(other: Any?): Boolean = when(other) {
is DeferredPattern -> other.pattern == pattern
else -> false
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ internal fun withoutOptionality(key: String): String {
}
}

internal fun withOptionality(key: String): String {
return when {
key.endsWith(DEFAULT_OPTIONAL_SUFFIX) -> key
else -> "$key$DEFAULT_OPTIONAL_SUFFIX"
}
}

internal fun isOptional(key: String): Boolean =
key.endsWith(DEFAULT_OPTIONAL_SUFFIX) || key.endsWith(XML_ATTR_OPTIONAL_SUFFIX)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ data class JSONObjectPattern(
})
}

override fun jsonObjectPattern(resolver: Resolver): JSONObjectPattern? {
return this
}

override fun equals(other: Any?): Boolean = when (other) {
is JSONObjectPattern -> this.pattern == other.pattern
else -> false
Expand Down Expand Up @@ -393,6 +397,14 @@ data class JSONObjectPattern(
}

override val typeName: String = "json object"

fun keysInNonOptionalFormat(): Set<String> {
return this.pattern.map { withoutOptionality(it.key) }.toSet()
}

fun patternForKey(key: String): Pattern? {
return pattern[withoutOptionality(key)] ?: pattern[withOptionality(key)]
}
}

fun generate(jsonPattern: Map<String, Pattern>, resolver: Resolver, typeAlias: String?): Map<String, Value> {
Expand Down
4 changes: 4 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ data class ListPattern(
}
return this
}

override fun jsonObjectPattern(resolver: Resolver): JSONObjectPattern? {
return null
}
}

private fun withEmptyType(pattern: Pattern, resolver: Resolver): Resolver {
Expand Down
Loading