diff --git a/application/src/main/kotlin/application/ExamplesCommand.kt b/application/src/main/kotlin/application/ExamplesCommand.kt index 39ec9ffaa..c5d63a425 100644 --- a/application/src/main/kotlin/application/ExamplesCommand.kt +++ b/application/src/main/kotlin/application/ExamplesCommand.kt @@ -503,9 +503,11 @@ For example: if (contractFile != null && !contractFile!!.exists()) exitWithMessage("Could not find file ${contractFile!!.path}") + val host = "0.0.0.0" + val port = 9001 server = ExamplesInteractiveServer( - "0.0.0.0", - 9001, + host, + port, testBaseURL, contractFile, filterName, @@ -517,7 +519,7 @@ For example: ) addShutdownHook() - consoleLog(StringLog("Examples Interactive server is running on http://0.0.0.0:9001/_specmatic/examples. Ctrl + C to stop.")) + consoleLog(StringLog("Examples Interactive server is running on ${consolePrintableURL(host, port)}/_specmatic/examples. Ctrl + C to stop.")) while (true) sleep(10000) } catch (e: Exception) { logger.log(exceptionCauseMessage(e)) diff --git a/application/src/main/kotlin/application/HTTPStubEngine.kt b/application/src/main/kotlin/application/HTTPStubEngine.kt index 758f60f19..df0378aa8 100644 --- a/application/src/main/kotlin/application/HTTPStubEngine.kt +++ b/application/src/main/kotlin/application/HTTPStubEngine.kt @@ -5,6 +5,7 @@ import io.specmatic.core.WorkingDirectory import io.specmatic.core.log.NewLineLogMessage import io.specmatic.core.log.StringLog import io.specmatic.core.log.consoleLog +import io.specmatic.core.utilities.consolePrintableURL import io.specmatic.mock.ScenarioStub import io.specmatic.stub.HttpClientFactory import io.specmatic.stub.HttpStub @@ -46,8 +47,7 @@ class HTTPStubEngine { timeoutMillis = gracefulRestartTimeoutInMs ).also { consoleLog(NewLineLogMessage) - val protocol = if (keyStoreData != null) "https" else "http" - consoleLog(StringLog("Stub server is running on ${protocol}://$host:$port. Ctrl + C to stop.")) + consoleLog(StringLog("Stub server is running on ${consolePrintableURL(host, port, keyStoreData)}. Ctrl + C to stop.")) } } } diff --git a/application/src/main/kotlin/application/ProxyCommand.kt b/application/src/main/kotlin/application/ProxyCommand.kt index 6c577c1fc..446aadaca 100644 --- a/application/src/main/kotlin/application/ProxyCommand.kt +++ b/application/src/main/kotlin/application/ProxyCommand.kt @@ -6,6 +6,7 @@ import io.specmatic.core.Configuration.Companion.DEFAULT_PROXY_HOST import io.specmatic.core.Configuration.Companion.DEFAULT_PROXY_PORT import io.specmatic.core.DEFAULT_TIMEOUT_IN_MILLISECONDS import io.specmatic.core.log.* +import io.specmatic.core.utilities.consolePrintableURL import io.specmatic.core.utilities.exceptionCauseMessage import io.specmatic.core.utilities.exitWithMessage import io.specmatic.proxy.Proxy @@ -64,9 +65,7 @@ class ProxyCommand : Callable { proxy = Proxy(host, port, targetBaseURL, proxySpecmaticDataDir, keyStoreData, timeoutInMs) addShutdownHook() - val protocol = keyStoreData?.let { "https" } ?: "http" - - consoleLog(StringLog("Proxy server is running on $protocol://$host:$port. Ctrl + C to stop.")) + consoleLog(StringLog("Proxy server is running on ${consolePrintableURL(host, port, keyStoreData)}. Ctrl + C to stop.")) while(true) sleep(10000) } diff --git a/application/src/main/kotlin/application/VirtualServiceCommand.kt b/application/src/main/kotlin/application/VirtualServiceCommand.kt index cfd83b452..a80cf4a03 100644 --- a/application/src/main/kotlin/application/VirtualServiceCommand.kt +++ b/application/src/main/kotlin/application/VirtualServiceCommand.kt @@ -8,10 +8,7 @@ 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.core.utilities.* import io.specmatic.mock.ScenarioStub import io.specmatic.stub.stateful.StatefulHttpStub import picocli.CommandLine.Command @@ -98,7 +95,7 @@ class VirtualServiceCommand : Callable { Configuration.configFilePath, stubData.flatMap { it.second }.also { it.logExamplesCachedAsSeedData() } ) - logger.log("Virtual service started on http://$host:$port") + logger.log("Virtual service is running on ${consolePrintableURL(host, port)}. Ctrl + C to stop.") latch.await() } diff --git a/core/build.gradle b/core/build.gradle index f67556018..86fc51bec 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -44,6 +44,9 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.24" implementation "org.eclipse.jgit:org.eclipse.jgit:$jgit_version" implementation "org.eclipse.jgit:org.eclipse.jgit.ssh.apache:$jgit_version" + implementation 'com.jayway.jsonpath:json-path:2.8.0' + implementation 'com.fasterxml.jackson.core:jackson-core:2.15.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.0' implementation 'com.flipkart.zjsonpatch:zjsonpatch:0.4.16' diff --git a/core/src/main/kotlin/io/specmatic/conversions/OpenApiSpecification.kt b/core/src/main/kotlin/io/specmatic/conversions/OpenApiSpecification.kt index 3f1947d5b..a2ba91472 100644 --- a/core/src/main/kotlin/io/specmatic/conversions/OpenApiSpecification.kt +++ b/core/src/main/kotlin/io/specmatic/conversions/OpenApiSpecification.kt @@ -108,6 +108,18 @@ class OpenApiSpecification( return OpenAPIV3Parser().read(openApiFilePath, null, resolveExternalReferences()) != null } + fun getImplicitOverlayContent(openApiFilePath: String): String { + return File(openApiFilePath).let { openApiFile -> + if(!openApiFile.isFile) + return@let "" + + val overlayFile = openApiFile.canonicalFile.parentFile.resolve(openApiFile.nameWithoutExtension + "_overlay.yaml") + if(overlayFile.isFile) return@let overlayFile.readText() + + return@let "" + } + } + fun fromYAML( yamlContent: String, openApiFilePath: String, @@ -120,16 +132,7 @@ class OpenApiSpecification( specmaticConfig: SpecmaticConfig = SpecmaticConfig(), overlayContent: String = "" ): OpenApiSpecification { - val implicitOverlayFile = File(openApiFilePath).let { openApiFile -> - if(!openApiFile.isFile) - return@let "" - - val overlayFile = openApiFile.canonicalFile.parentFile.resolve(openApiFile.nameWithoutExtension + "_overlay.yaml") - if(overlayFile.isFile) - return@let overlayFile.readText() - - return@let "" - } + val implicitOverlayFile = getImplicitOverlayContent(openApiFilePath) val parseResult: SwaggerParseResult = OpenAPIV3Parser().readContents( @@ -205,7 +208,7 @@ class OpenApiSpecification( private fun resolveExternalReferences(): ParseOptions = ParseOptions().also { it.isResolve = true } - private fun String.applyOverlay(overlayContent: String): String { + fun String.applyOverlay(overlayContent: String): String { if(overlayContent.isBlank()) return this diff --git a/core/src/main/kotlin/io/specmatic/core/Feature.kt b/core/src/main/kotlin/io/specmatic/core/Feature.kt index fa4114996..4e0227219 100644 --- a/core/src/main/kotlin/io/specmatic/core/Feature.kt +++ b/core/src/main/kotlin/io/specmatic/core/Feature.kt @@ -374,8 +374,8 @@ data class Feature( } != null } - fun matchResultSchemaFlagBased(primaryPatternName: String?, secondaryPatternName: String, value: Value): Result { - val updatedResolver = flagsBased.update(scenarios.last().resolver) + fun matchResultSchemaFlagBased(primaryPatternName: String?, secondaryPatternName: String, value: Value, mismatchMessages: MismatchMessages): Result { + val updatedResolver = flagsBased.update(scenarios.last().resolver).copy(mismatchMessages = mismatchMessages) return try { val pattern = primaryPatternName ?: secondaryPatternName val resolvedPattern = updatedResolver.getPattern(withPatternDelimiters(pattern)) diff --git a/core/src/main/kotlin/io/specmatic/core/KeyError.kt b/core/src/main/kotlin/io/specmatic/core/KeyError.kt index 1b11c0969..b0bd22813 100644 --- a/core/src/main/kotlin/io/specmatic/core/KeyError.kt +++ b/core/src/main/kotlin/io/specmatic/core/KeyError.kt @@ -6,14 +6,23 @@ sealed class KeyError { abstract val name: String abstract fun missingKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure + + abstract fun missingOptionalKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure } data class MissingKeyError(override val name: String) : KeyError() { override fun missingKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages): Failure = Failure(mismatchMessages.expectedKeyWasMissing(keyLabel, name)) + + override fun missingOptionalKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages): Failure { + return Failure(mismatchMessages.optionalKeyMissing(keyLabel, name), isPartial = true) + } } data class UnexpectedKeyError(override val name: String) : KeyError() { override fun missingKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages): Failure = Failure(mismatchMessages.unexpectedKey(keyLabel, name)) + + override fun missingOptionalKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages): Failure = + Failure(mismatchMessages.unexpectedKey(keyLabel, name)) } diff --git a/core/src/main/kotlin/io/specmatic/core/Result.kt b/core/src/main/kotlin/io/specmatic/core/Result.kt index 2de707ccb..a65dafa10 100644 --- a/core/src/main/kotlin/io/specmatic/core/Result.kt +++ b/core/src/main/kotlin/io/specmatic/core/Result.kt @@ -70,6 +70,8 @@ sealed class Result { abstract fun partialSuccess(message: String): Result abstract fun isPartialSuccess(): Boolean + abstract fun isPartialFailure(): Boolean + abstract fun testResult(): TestResult abstract fun withFailureReason(urlPathMisMatch: FailureReason): Result abstract fun throwOnFailure(): Success @@ -111,14 +113,14 @@ sealed class Result { } } - data class Failure(val causes: List = emptyList(), val breadCrumb: String = "", val failureReason: FailureReason? = null) : Result() { - constructor(message: String="", cause: Failure? = null, breadCrumb: String = "", failureReason: FailureReason? = null): this(listOf(FailureCause(message, cause)), breadCrumb, failureReason) + data class Failure(val causes: List = emptyList(), val breadCrumb: String = "", val failureReason: FailureReason? = null, val isPartial: Boolean = false) : Result() { + constructor(message: String="", cause: Failure? = null, breadCrumb: String = "", failureReason: FailureReason? = null, isPartial: Boolean? = false): this(listOf(FailureCause(message, cause)), breadCrumb, failureReason, isPartial ?: false) companion object { fun fromFailures(failures: List): Failure { return Failure(failures.map { it.toFailureCause() - }) + }, isPartial = failures.all { it.isPartial }) } } @@ -135,6 +137,7 @@ sealed class Result { .plus("$prefix$breadCrumb") } + override fun ifSuccess(function: () -> Result) = this override fun withBindings(bindings: Map, response: HttpResponse): Result { return this @@ -149,6 +152,7 @@ sealed class Result { } override fun isPartialSuccess(): Boolean = false + override fun isPartialFailure(): Boolean = isPartial override fun testResult(): TestResult { if(shouldBeIgnored()) return TestResult.Error @@ -169,7 +173,7 @@ sealed class Result { } fun reason(errorMessage: String) = Failure(errorMessage, this) - override fun breadCrumb(breadCrumb: String) = Failure(cause = this, breadCrumb = breadCrumb) + override fun breadCrumb(breadCrumb: String) = Failure(cause = this, breadCrumb = breadCrumb, isPartial = isPartial) override fun failureReason(failureReason: FailureReason?): Result { return this.copy(failureReason = failureReason) } @@ -285,6 +289,7 @@ sealed class Result { } override fun isPartialSuccess(): Boolean = partialSuccessMessage != null + override fun isPartialFailure(): Boolean = false override fun testResult(): TestResult { return TestResult.Success } @@ -333,6 +338,9 @@ interface MismatchMessages { fun mismatchMessage(expected: String, actual: String): String fun unexpectedKey(keyLabel: String, keyName: String): String fun expectedKeyWasMissing(keyLabel: String, keyName: String): String + fun optionalKeyMissing(keyLabel: String, keyName: String): String { + return expectedKeyWasMissing("Optional ${keyLabel.capitalizeFirstChar()}", keyName) + } fun valueMismatchFailure(expected: String, actual: Value?, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure { return mismatchResult(expected, valueError(actual) ?: "null", mismatchMessages) } @@ -350,6 +358,10 @@ object DefaultMismatchMessages: MismatchMessages { override fun expectedKeyWasMissing(keyLabel: String, keyName: String): String { return "Expected ${keyLabel.lowercase()} named \"$keyName\" was missing" } + + override fun optionalKeyMissing(keyLabel: String, keyName: String): String { + return "Expected Optional ${keyLabel.lowercase()} named \"$keyName\" was missing" + } } fun mismatchResult(expected: String, actual: String, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure = Failure(mismatchMessages.mismatchMessage(expected, actual)) diff --git a/core/src/main/kotlin/io/specmatic/core/Results.kt b/core/src/main/kotlin/io/specmatic/core/Results.kt index e9c66e002..0740e7cc0 100644 --- a/core/src/main/kotlin/io/specmatic/core/Results.kt +++ b/core/src/main/kotlin/io/specmatic/core/Results.kt @@ -20,7 +20,7 @@ data class Results(val results: List = emptyList()) { } fun toResultIfAny(): Result { - return results.find { it is Result.Success } ?: Result.Failure(results.joinToString("\n\n") { it.toReport().toText() }) + return results.find { it is Result.Success } ?: Result.Failure(results.joinToString("\n\n") { it.toReport().toText() }, isPartial = results.all { it.isPartialFailure() }) } val failureCount diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/CustomJsonNodeFactory.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/CustomJsonNodeFactory.kt new file mode 100644 index 000000000..7f91034cd --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/CustomJsonNodeFactory.kt @@ -0,0 +1,129 @@ + +import com.fasterxml.jackson.core.JsonLocation +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.* +import com.fasterxml.jackson.databind.util.RawValue +import java.math.BigDecimal +import java.math.BigInteger +import java.util.AbstractMap.SimpleEntry + +class CustomJsonNodeFactory( + nodeFactory: JsonNodeFactory, + private val parserFactory: CustomParserFactory +) : JsonNodeFactory() { + private val delegate: JsonNodeFactory = nodeFactory + + /* + * "Why isn't this a map?" you might be wondering. Well, when the nodes are created, they're all + * empty and a node's hashCode is based on its children. So if you use a map and put the node + * in, then the node's hashCode is based on no children, then when you lookup your node, it is + * *with* children, so the hashcodes are different. Instead of all of this, you have to iterate + * through a listing and find their matches once the objects have been populated, which is only + * after the document has been completely parsed + */ + private val locationMapping: MutableList> = mutableListOf() + + /** + * Given a node, find its location, or null if it wasn't found + * + * @param jsonNode the node to search for + * @return the location of the node or null if not found + */ + fun getLocationForNode(jsonNode: JsonNode?): JsonLocation? { + return locationMapping.filter { e: Pair -> e.first.equals(jsonNode) } + .map { e: Pair -> e.second }.firstOrNull() + } + + /** + * Simple interceptor to mark the node in the lookup list and return it back + * + * @param the type of the JsonNode + * @param node the node itself + * @return the node itself, having marked its location + */ + private fun markNode(node: T?): T { + val loc: JsonLocation = parserFactory.getParser()!!.currentLocation + locationMapping.add(node!! to loc) + return node + } + + public override fun booleanNode(v: Boolean): BooleanNode { + return markNode(delegate.booleanNode(v)) + } + + public override fun nullNode(): NullNode { + return markNode(delegate.nullNode()) + } + + public override fun numberNode(value: Byte?): ValueNode { + return markNode(delegate.numberNode(value)) + } + + public override fun missingNode(): JsonNode { + return super.missingNode() + } + + public override fun numberNode(value: Short?): ValueNode { + return markNode(delegate.numberNode(value)) + } + + public override fun numberNode(v: Int): NumericNode { + return markNode(delegate.numberNode(v)) + } + + public override fun numberNode(value: Long): NumericNode { + return markNode(delegate.numberNode(value)) + } + + public override fun numberNode(v: BigInteger): ValueNode { + return markNode(delegate.numberNode(v)) + } + + public override fun numberNode(value: Float): NumericNode { + return markNode(delegate.numberNode(value)) + } + + public override fun numberNode(value: Double): NumericNode { + return markNode(delegate.numberNode(value)) + } + + public override fun numberNode(v: BigDecimal): ValueNode { + return markNode(delegate.numberNode(v)) + } + + public override fun textNode(text: String?): TextNode { + return markNode(delegate.textNode(text)) + } + + public override fun binaryNode(data: ByteArray?): BinaryNode { + return markNode(delegate.binaryNode(data)) + } + + public override fun binaryNode(data: ByteArray?, offset: Int, length: Int): BinaryNode { + return markNode(delegate.binaryNode(data, offset, length)) + } + + public override fun pojoNode(pojo: Any?): ValueNode { + return markNode(delegate.pojoNode(pojo)) + } + + public override fun rawValueNode(value: RawValue?): ValueNode { + return markNode(delegate.rawValueNode(value)) + } + + public override fun arrayNode(): ArrayNode { + return markNode(delegate.arrayNode()) + } + + public override fun arrayNode(capacity: Int): ArrayNode { + return markNode(delegate.arrayNode(capacity)) + } + + public override fun objectNode(): ObjectNode { + return markNode(delegate.objectNode()) + } + + companion object { + private const val serialVersionUID = 8807395553661461181L + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/CustomParserFactory.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/CustomParserFactory.kt new file mode 100644 index 000000000..277743506 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/CustomParserFactory.kt @@ -0,0 +1,30 @@ + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonParser +import java.io.IOException +import java.io.Reader + +class CustomParserFactory : JsonFactory() { + private var parser: JsonParser? = null + + fun getParser(): JsonParser? { + return this.parser + } + + @Throws(IOException::class, JsonParseException::class) + public override fun createParser(r: Reader?): JsonParser? { + parser = super.createParser(r) + return parser + } + + @Throws(IOException::class, JsonParseException::class) + public override fun createParser(content: String?): JsonParser? { + parser = super.createParser(content) + return parser + } + + companion object { + private val serialVersionUID = -7523974986510864179L + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt index 4526b3feb..d78f1eb14 100644 --- a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt @@ -1,5 +1,6 @@ package io.specmatic.core.examples.server +import com.jayway.jsonpath.JsonPath import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* @@ -31,9 +32,6 @@ import io.specmatic.core.utilities.exceptionCauseMessage import io.specmatic.core.utilities.uniqueNameForApiOperation import io.specmatic.core.value.* import io.specmatic.mock.MOCK_HTTP_REQUEST -import io.specmatic.core.value.JSONArrayValue -import io.specmatic.core.value.JSONObjectValue -import io.specmatic.core.value.Value import io.specmatic.mock.MOCK_HTTP_RESPONSE import io.specmatic.mock.ScenarioStub import io.specmatic.test.ContractTest @@ -110,6 +108,20 @@ class ExamplesInteractiveServer( } } + post("/_specmatic/examples/update") { + val request = call.receive() + try { + val file = File(request.exampleFile) + if (!file.exists()) { + throw FileNotFoundException() + } + file.writeText(request.exampleContent) + call.respond(HttpStatusCode.OK, "File and content updated successfully!") + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, exceptionCauseMessage(e)) + } + } + post("/_specmatic/examples/generate") { val contractFile = getContractFile() @@ -143,8 +155,9 @@ class ExamplesInteractiveServer( val result = validateSingleExample(contractFile, File(request.exampleFile)) if(result.isSuccess()) ValidateExampleResponse(request.exampleFile) - else - ValidateExampleResponse(request.exampleFile, result.reportString()) + else { + ValidateExampleResponse(request.exampleFile, result.reportString(), result.isPartialFailure()) + } } catch (e: FileNotFoundException) { ValidateExampleResponse(request.exampleFile, e.message ?: "File not found") } catch (e: ContractException) { @@ -258,6 +271,50 @@ class ExamplesInteractiveServer( override fun close() { server.stop(0, 0) } + fun extractBreadcrumbs(input: String): List { + val breadCrumbPrefix = ">> " + + val breadcrumbs = input.lines().map { it.trim() }.filter { it.startsWith(breadCrumbPrefix) }.map { it.removePrefix( + breadCrumbPrefix + ) } + + return breadcrumbs + } + + + + fun getJsonNodeLineNumbersUsingJsonPath( + jsonFilePath: String, + jsonPaths: List, + breadcrumbs: List + ): Int? { + if (jsonPaths.size != breadcrumbs.size) { + throw IllegalArgumentException("JSON paths and breadcrumbs lists must be of the same size") + } + + fun transform(path: String): String { + return "$.${path.replace("/", ".")}" + } + + val jsonPathString = jsonPaths.firstOrNull()?.let { transform(it) } ?: return null + + return findLineNumber(File(jsonFilePath), JsonPath.compile(jsonPathString)) + } + + fun transformToJsonPaths(breadcrumbs: List): List { + val jsonPaths: MutableList = ArrayList() + + for (breadcrumb in breadcrumbs) { + val jsonPath = breadcrumb + .replace("RESPONSE", "http-response") + .replace("REQUEST", "http-request") + .replace("BODY", "body") + .replace(".", "/") + jsonPaths.add(jsonPath) + } + + return jsonPaths + } private suspend fun getContractFileOrBadRequest(call: ApplicationCall): File? { return try { @@ -579,7 +636,7 @@ class ExamplesInteractiveServer( validateExample(feature, scenarioStub).toResultIfAny() }.getOrElse { val schemaExample = SchemaExample(exampleFile) - feature.matchResultSchemaFlagBased(schemaExample.discriminatorBasedOn, schemaExample.schemaBasedOn, schemaExample.value) + feature.matchResultSchemaFlagBased(schemaExample.discriminatorBasedOn, schemaExample.schemaBasedOn, schemaExample.value, InteractiveExamplesMismatchMessages) } } @@ -631,7 +688,7 @@ class ExamplesInteractiveServer( }.getOrElse { val schemaExample = SchemaExample(example) if (schemaExample.value !is NullValue) { - updatedFeature.matchResultSchemaFlagBased(schemaExample.discriminatorBasedOn, schemaExample.schemaBasedOn, schemaExample.value) + updatedFeature.matchResultSchemaFlagBased(schemaExample.discriminatorBasedOn, schemaExample.schemaBasedOn, schemaExample.value, InteractiveExamplesMismatchMessages) } else { if (enableLogging) logger.log("Skipping empty schema example ${example.name}"); null } @@ -654,10 +711,10 @@ class ExamplesInteractiveServer( return this.copy(headers = this.headers.minus(SPECMATIC_RESULT_HEADER)) } - fun getExistingExampleFiles(feature: Feature, scenario: Scenario, examples: List): List> { + fun getExistingExampleFiles(feature: Feature, scenario: Scenario, examples: List): List> { return examples.mapNotNull { example -> when (val matchResult = scenario.matches(example.request, example.response, InteractiveExamplesMismatchMessages, feature.flagsBased)) { - is Result.Success -> example to "" + is Result.Success -> example to matchResult is Result.Failure -> { val isFailureRelatedToScenario = matchResult.getFailureBreadCrumbs("").none { breadCrumb -> breadCrumb.contains(PATH_BREAD_CRUMB) @@ -665,7 +722,7 @@ class ExamplesInteractiveServer( || breadCrumb.contains("REQUEST.HEADERS.Content-Type") || breadCrumb.contains("STATUS") } - if (isFailureRelatedToScenario) example to matchResult.reportString() else null + if (isFailureRelatedToScenario) { example to matchResult } else null } } } @@ -688,10 +745,10 @@ class ExamplesInteractiveServer( } ?: emptyList() } - fun File.getSchemaExamplesWithValidation(feature: Feature): List> { + fun File.getSchemaExamplesWithValidation(feature: Feature): List> { return getSchemaExamples().map { it to if(it.value !is NullValue) { - feature.matchResultSchemaFlagBased(it.discriminatorBasedOn, it.schemaBasedOn, it.value).reportString() + feature.matchResultSchemaFlagBased(it.discriminatorBasedOn, it.schemaBasedOn, it.value, InteractiveExamplesMismatchMessages) } else null } } @@ -827,6 +884,10 @@ object InteractiveExamplesMismatchMessages : MismatchMessages { return "${keyLabel.capitalizeFirstChar()} $keyName in the example is not in the specification" } + override fun optionalKeyMissing(keyLabel: String, keyName: String): String { + return "Optional ${keyLabel.capitalizeFirstChar()} $keyName in the specification is missing from the example" + } + override fun expectedKeyWasMissing(keyLabel: String, keyName: String): String { return "${keyLabel.capitalizeFirstChar()} $keyName in the specification is missing from the example" } @@ -841,9 +902,20 @@ data class ValidateExampleRequest( val exampleFile: String ) +data class SaveExampleRequest( + val exampleFile: String, + val exampleContent: String +) + data class ValidateExampleResponse( val absPath: String, - val error: String? = null + val error: String? = null, + val isPartialFailure: Boolean = false +) + +data class ValidateExampleResponseMap( + val absPath: String, + val error: List> = emptyList() ) enum class ValidateExampleVerdict { diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesView.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesView.kt index b8541b16e..38ea63d8d 100644 --- a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesView.kt +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesView.kt @@ -4,6 +4,7 @@ import io.specmatic.conversions.ExampleFromFile import io.specmatic.conversions.convertPathParameterStyle import io.specmatic.core.Feature import io.specmatic.core.Resolver +import io.specmatic.core.Result import io.specmatic.core.Scenario import io.specmatic.core.examples.server.ExamplesInteractiveServer.Companion.getExamplesFromDir import io.specmatic.core.examples.server.ExamplesInteractiveServer.Companion.getExistingExampleFiles @@ -29,7 +30,8 @@ class ExamplesView { responseStatus = scenario.httpResponsePattern.status, contentType = scenario.httpRequestPattern.headersPattern.contentType, exampleFile = example?.first, - exampleMismatchReason = example?.second, + exampleMismatchReason = example?.second?.reportString()?.takeIf { it.isNotBlank() }, + isPartialFailure = example?.second?.isPartialFailure() ?: false, isDiscriminatorBased = scenario.isMultiGen(scenario.resolver) ) }.filterEndpoints() @@ -49,7 +51,7 @@ class ExamplesView { } } - private fun getScenarioExamplesPairs(feature: Feature, examples: List): List?>> { + private fun getScenarioExamplesPairs(feature: Feature, examples: List): List?>> { return feature.scenarios.flatMap { scenario -> getExistingExampleFiles(feature, scenario, examples).map { exRes -> scenario to Pair(exRes.first.file, exRes.second) @@ -106,6 +108,7 @@ class ExamplesView { example = it.exampleFile?.absolutePath, exampleName = it.exampleFile?.nameWithoutExtension, exampleMismatchReason = it.exampleMismatchReason?.takeIf { reason -> reason.isNotBlank() }, + isPartialFailure = it.isPartialFailure, isDiscriminatorBased = it.isDiscriminatorBased ).also { showPath = false; showMethod = false; showStatus = false } } @@ -116,7 +119,7 @@ class ExamplesView { } // SCHEMA EXAMPLE METHODS - private fun getWithMissingDiscriminators(feature: Feature, mainPattern: String, examples: List>): List> { + private fun getWithMissingDiscriminators(feature: Feature, mainPattern: String, examples: List>): List> { val discriminatorValues = feature.getAllDiscriminatorValues(mainPattern) if (discriminatorValues.isEmpty()) return examples @@ -125,11 +128,11 @@ class ExamplesView { } } - private fun List>.groupByPattern(): Map>> { + private fun List>.groupByPattern(): Map>> { return this.groupBy { it.first.discriminatorBasedOn.takeIf { disc -> !disc.isNullOrEmpty() } ?: it.first.schemaBasedOn } } - private fun Map>>.withMissingDiscriminators(feature: Feature): Map>> { + private fun Map>>.withMissingDiscriminators(feature: Feature): Map>> { return this.mapValues { (mainPattern, examples) -> val existingExample = examples.map { example -> if (example.first.value is NullValue) { @@ -140,11 +143,11 @@ class ExamplesView { } } - fun List.withSchemaExamples(feature: Feature, schemaExample: List>): List { + fun List.withSchemaExamples(feature: Feature, schemaExample: List>): List { val groupedSchemaExamples = schemaExample.groupByPattern() return groupedSchemaExamples.withMissingDiscriminators(feature).flatMap { (mainPattern, examples) -> val isDiscriminator = examples.size > 1 - examples.mapIndexed { index, (patternName, exampleFile, mismatchReason) -> + examples.mapIndexed { index, (patternName, exampleFile, result) -> TableRow( rawPath = mainPattern, path = mainPattern, @@ -159,7 +162,8 @@ class ExamplesView { showStatus = false, example = exampleFile?.canonicalPath, exampleName = exampleFile?.nameWithoutExtension, - exampleMismatchReason = mismatchReason.takeIf { !it.isNullOrBlank() }, + exampleMismatchReason = result?.reportString()?.takeIf { it.isNotBlank() }, + isPartialFailure = result?.isPartialFailure() ?: false, isDiscriminatorBased = false, isSchemaBased = true, pathColSpan = if (isDiscriminator) 3 else 5, methodColSpan = if (isDiscriminator) 2 else 1 @@ -185,6 +189,7 @@ data class TableRow( val example: String? = null, val exampleName: String? = null, val exampleMismatchReason: String? = null, + val isPartialFailure: Boolean = false, val isGenerated: Boolean = exampleName != null, val isValid: Boolean = isGenerated && exampleMismatchReason == null, val uniqueKey: String = "${path}_${method}_${responseStatus}", @@ -218,6 +223,7 @@ data class Endpoint( val contentType: String? = null, val exampleFile: File? = null, val exampleMismatchReason: String? = null, + val isPartialFailure: Boolean = false, val isDiscriminatorBased: Boolean ) diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/FindLineNumber.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/FindLineNumber.kt new file mode 100644 index 000000000..670f00e98 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/FindLineNumber.kt @@ -0,0 +1,39 @@ +package io.specmatic.core.examples.server + +import CustomJsonNodeFactory +import CustomParserFactory +import com.fasterxml.jackson.core.JsonLocation +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.jayway.jsonpath.Configuration +import com.jayway.jsonpath.DocumentContext +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.Option +import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider +import java.io.File + +fun findLineNumber(filePath: File, jsonPath: JsonPath): Int? { + val customParserFactory: CustomParserFactory = CustomParserFactory() + val om: ObjectMapper = ObjectMapper(customParserFactory) + val factory = CustomJsonNodeFactory( + om.getDeserializationConfig().getNodeFactory(), + customParserFactory + ) + om.setConfig(om.getDeserializationConfig().with(factory)) + val config = Configuration.builder() + .mappingProvider(JacksonMappingProvider(om)) + .jsonProvider(JacksonJsonNodeJsonProvider(om)) + .options(Option.ALWAYS_RETURN_LIST) + .build() + + val parsedDocument: DocumentContext = JsonPath.parse(filePath, config) + val findings: ArrayNode = parsedDocument.read(jsonPath) + + val lineNumbers = findings.map { finding -> + val location: JsonLocation = factory.getLocationForNode(finding) ?: return@map null + location.getLineNr() + } + + return lineNumbers.filterNotNull().firstOrNull() +} diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index 909fac174..4c6f6ffd4 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -251,6 +251,15 @@ data class JSONObjectPattern( return !resolver.hasSeenPattern(patternToCheck) } + private fun addPatternToSeen(pattern: Pattern, resolver: Resolver): Resolver { + val patternToAdd = when(pattern) { + is ListPattern -> pattern.typeAlias?.let { pattern } ?: pattern.pattern + else -> pattern.typeAlias?.let { pattern } ?: this + } + + return resolver.addPatternAsSeen(patternToAdd) + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { val resolverWithNullType = withNullPattern(resolver) if (sampleData !is JSONObjectValue) @@ -273,10 +282,11 @@ data class JSONObjectPattern( } else it.key } - val keyErrors: List = - resolverWithNullType.findKeyErrorList(adjustedPattern, sampleData.jsonObject).map { + val keyErrors: List = resolverWithNullType.findKeyErrorList(adjustedPattern, sampleData.jsonObject).map { + if (pattern[it.name] != null) { it.missingKeyToResult("key", resolver.mismatchMessages).breadCrumb(it.name) - } + } else it.missingOptionalKeyToResult("key", resolver.mismatchMessages).breadCrumb(it.name) + } val updatedResolver = resolverWithNullType.addPatternAsSeen(this) @@ -284,7 +294,7 @@ data class JSONObjectPattern( val resultsWithDiscriminator: List = mapZip(pattern, sampleData.jsonObject).map { (key, patternValue, sampleValue) -> - val innerResolver = updatedResolver.addPatternAsSeen(patternValue) + val innerResolver = addPatternToSeen(patternValue, updatedResolver) val result = innerResolver.matchesPattern(key, patternValue, sampleValue).breadCrumb(key) val isDiscrimintor = patternValue.isDiscriminator() diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt index cbd31eaab..23a55b661 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt @@ -77,11 +77,12 @@ data class ListPattern( } val resolverWithEmptyType = withEmptyType(pattern, resolver) - if (resolverWithEmptyType.allPatternsAreMandatory && !resolverWithEmptyType.hasSeenPattern(this) && sampleData.list.isEmpty()) { + val patternToCheck = this.typeAlias?.let { this } ?: this.pattern + if (resolverWithEmptyType.allPatternsAreMandatory && !resolverWithEmptyType.hasSeenPattern(patternToCheck) && sampleData.list.isEmpty()) { return Result.Failure(message = "List cannot be empty") } - val updatedResolver = resolverWithEmptyType.addPatternAsSeen(this.typeAlias?.let { this } ?: this.pattern) + val updatedResolver = resolverWithEmptyType.addPatternAsSeen(this) val failures: List = sampleData.list.map { updatedResolver.matchesPattern(null, pattern, it) }.mapIndexed { index, result -> diff --git a/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt b/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt index 12e8cd752..83a605361 100644 --- a/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt +++ b/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt @@ -11,6 +11,7 @@ import org.w3c.dom.Node import org.xml.sax.InputSource import io.specmatic.core.log.consoleLog import io.specmatic.core.* +import io.specmatic.core.Configuration.Companion.DEFAULT_HTTP_STUB_HOST import io.specmatic.core.Configuration.Companion.configFilePath import io.specmatic.core.azure.AzureAuthCredentials import io.specmatic.core.git.GitCommand @@ -376,3 +377,9 @@ fun uniqueNameForApiOperation(httpRequest: HttpRequest, baseURL: String, respons if (formattedPath.isEmpty()) return "${method}_${responseStatus}" return "${formattedPath}_${method}_${responseStatus}$contentType" } + +fun consolePrintableURL(host: String, port: Int, keyStoreData: KeyData? = null): String { + val protocol = keyStoreData?.let { "https" } ?: "http" + val displayableHost = if (host == DEFAULT_HTTP_STUB_HOST) "localhost" else host + return "$protocol://$displayableHost:$port" +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index e2fd7d9bc..a8fd60d23 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -9,6 +9,8 @@ import io.ktor.server.plugins.cors.* import io.ktor.server.plugins.doublereceive.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.specmatic.conversions.OpenApiSpecification +import io.specmatic.conversions.OpenApiSpecification.Companion.applyOverlay import io.specmatic.core.Feature import io.specmatic.core.HttpRequest import io.specmatic.core.HttpRequestPattern @@ -86,7 +88,10 @@ class StatefulHttpStub( staticResources("/", "swagger-ui") get("/openapi.yaml") { - call.respondFile(File(features.first().path)) + val openApiFilePath = features.first().path + val overlayContent = OpenApiSpecification.getImplicitOverlayContent(openApiFilePath) + val openApiSpec = File(openApiFilePath).readText().applyOverlay(overlayContent) + call.respond(openApiSpec) } } diff --git a/core/src/main/resources/swagger-ui/swagger-initializer.js b/core/src/main/resources/swagger-ui/swagger-initializer.js index b69533079..cbd4061e2 100644 --- a/core/src/main/resources/swagger-ui/swagger-initializer.js +++ b/core/src/main/resources/swagger-ui/swagger-initializer.js @@ -3,7 +3,7 @@ window.onload = function() { // the following lines will be replaced by docker/configurator, when it runs in a docker-container window.ui = SwaggerUIBundle({ - url: "/openapi.yaml", + url: "openapi.yaml", dom_id: '#swagger-ui', deepLinking: true, presets: [ diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4bb4fd26e..566eb5954 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -369,6 +369,45 @@ [hidden] { display: none; } + + #error-log { + color: red; + margin-top: 10px; + font-family: monospace; + white-space: pre-wrap; + } + .CodeMirror { + border: 1px solid #ccc; + height: 500px !important; + } + .cm-lint-marker-error { + background-color: red; + color: white; + font-size: 12px; + width: 20px; + height: 20px; + line-height: 20px; + text-align: center; + border-radius: 50%; + margin-left: -10px; + display: inline-block; + cursor: default; + } + .cm-lint-message { + color: red; + background-color: rgba(255, 0, 0, 0.2); + padding: 5px; + font-size: 12px; + border-radius: 3px; + margin-left: 30px; /* Move it a bit to the right of the gutter marker */ + position: absolute; + } + .CodeMirror-gutter-wrapper { + position: relative; + } + + } +