From 82510e003b39cf170f02d99a59ac3991ec5c7b72 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 2 Dec 2024 16:04:27 +0530 Subject: [PATCH 01/25] Swagger UI specification to apply the overlay. - Ensure the overlay content is applied to the specification before sending it back to the Swagger UI upon request. - Correct the Swagger UI path for requesting the specification. --- .../conversions/OpenApiSpecification.kt | 25 +++++++++++-------- .../stub/stateful/StatefulHttpStub.kt | 7 +++++- .../swagger-ui/swagger-initializer.js | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) 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/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: [ From 1caee8553f18412e4c07cb538b4ba3df0ca1ba90 Mon Sep 17 00:00:00 2001 From: Samy Date: Mon, 2 Dec 2024 20:14:01 +0530 Subject: [PATCH 02/25] Changes for HTML Example Code Editor --- .../server/ExamplesInteractiveServer.kt | 19 ++ .../resources/templates/examples/index.html | 213 +++++++++++++++--- 2 files changed, 199 insertions(+), 33 deletions(-) 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..cdfaf3d43 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 @@ -110,6 +110,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() @@ -841,6 +855,11 @@ 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 diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4bb4fd26e..9a599a33b 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: auto; + } + .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; + } + + } + @@ -1080,6 +1137,17 @@

const exampleDetails = /*[[${exampleDetails}]]*/ {}; const testDetails = {}; + + + + + + + + + + + - - - + - - - + + From f878a26fd97cc95c25df1338dabea6fbe96d8cdd Mon Sep 17 00:00:00 2001 From: Samy Date: Thu, 5 Dec 2024 10:58:52 +0530 Subject: [PATCH 14/25] Html Validated --- .../resources/templates/examples/index.html | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index e397888c0..4b43cdc85 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -520,13 +520,7 @@ } } - &#save-examples { - --_content: "Save Example"; - display: none; - &[data-panel="details"] { - --_content: "Save Example"; - } - } + &[data-selected="0"][data-panel="table"], &.bulk-disabled { --_background-color: var(--gray); @@ -1072,7 +1066,7 @@

- + @@ -1168,7 +1162,6 @@

const bulkValidateBtn = document.querySelector("button#bulk-validate"); const bulkGenerateBtn = document.querySelector("button#bulk-generate"); const bulkTestBtn = document.querySelector("button#bulk-test"); - const saveBtn = document.querySelector("button#save-examples") let savedEditorResponse = null; let scrollYPosition = 0; let selectedTableRow = null; @@ -1192,7 +1185,6 @@

window.scrollTo(0, scrollYPosition); console.log("Changes discarded!"); isSaved = true; - saveBtn.style.display = "none"; }, }); @@ -1298,8 +1290,7 @@

case "details": { await validateRowExamples(selectedTableRow); - const originalYScroll = scrollYPosition - await goToDetails(selectedTableRow, extractRowValues(selectedTableRow)); + const originalYScroll = scrollYPosition; scrollYPosition = originalYScroll; break; } @@ -1468,6 +1459,14 @@

async function validateRowExamples(tableRow, bulkMode = false) { tableRow.setAttribute("data-valid", "processing"); const exampleData = getExampleData(tableRow); + let exampleSaved; + if(!isSaved) + { + exampleSaved = await saveExample(exampleData); + if (!exampleSaved) { + return false; + } + } const { exampleAbsPath, error } = await validateExample(exampleData); if (error && !exampleAbsPath) { @@ -1486,7 +1485,7 @@

return false; } - if (!bulkMode) createAlert("Valid Example", `Example name: ${parseFileName(exampleAbsPath)}`, false); + if (!bulkMode) createAlert("Valid Example", `Example name: ${parseFileName(exampleAbsPath)}`, false); return true; } @@ -1766,13 +1765,10 @@

editor.on("change", (instance, changes) => { isSaved = false; - saveBtn.style.display = "inline-block"; updateBorderColorExampleBlock(editor, examplePreDiv); savedEditorResponse = editor.getValue(); }); - saveBtn.addEventListener("click", () => { - saveExample(example.absPath); -}); + if (example.test) { const testPara = document.createElement("p"); testPara.textContent = "Test Log: "; @@ -1789,12 +1785,12 @@

return dropDownDiv; } -function saveExample(examplePath) { +async function saveExample(examplePath) { const editedText = savedEditorResponse; try { const parsedContent = JSON.parse(editedText); - fetch("/_specmatic/examples/update", { + const response = await fetch("/_specmatic/examples/update", { method: "POST", headers: { "Content-Type": "application/json", @@ -1803,25 +1799,25 @@

exampleFile: examplePath, exampleContent: editedText, }), - }) - .then(response => { - if (response.ok) { - createAlert("Saved", "Example saved to file", false); - isSaved = true; - saveBtn.style.display = "none"; - } else { - createAlert("Failed to save example.", "Failed to save example to " + examplePath, true); - console.error("Error saving example:", response.status); - } - }) - .catch(error => { - console.error("Error during save request:", error); - createAlert("Failed to save example.", "An error occurred while saving example to " + examplePath, true); }); + + if (response.ok) { + createAlert("Saved", "Example saved to file", false); + isSaved = true; + return true; + } else { + const errorMessage = await response.text(); + createAlert("Failed to save example.", `Failed to save example to ${examplePath}: ${errorMessage}`, true); + console.error("Error saving example:", response.status); + return false; + } } catch (e) { - createAlert("Failed to save example.", "The content in file " + examplePath + " is not valid JSON", true); + console.error("Error during save request:", e); + createAlert("Failed to save example.", `An error occurred while saving example to ${examplePath}: ${e.message}`, true); + return false; } } + function parseErrorResponse(errors) { const metadata = errors.map(err => ({ lineNumber: err.lineNumber, From 7bd00ff8e56e04f05c098327a1eac8ce4cdd3a5f Mon Sep 17 00:00:00 2001 From: Samy Date: Fri, 6 Dec 2024 10:08:43 +0530 Subject: [PATCH 15/25] Html changed --- .../resources/templates/examples/index.html | 41 ++++--------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4b43cdc85..fa8261831 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1467,8 +1467,8 @@

return false; } } - const { exampleAbsPath, error } = await validateExample(exampleData); - + const { response, error } = await validateExample(exampleData); + const exampleAbsPath = response.absPath if (error && !exampleAbsPath) { if (!bulkMode) createAlert("Validation Failed", `Error: ${error ?? "Unknown Error"}`, true); tableRow.setAttribute("data-valid", "failed"); @@ -1588,43 +1588,18 @@

async function goToDetails(tableRow, rowValues) { const exampleAbsPath = getExampleData(tableRow); let docFragment = []; - const validateExampleRequest = { - exampleFile: exampleAbsPath - }; - - if (exampleAbsPath) { - try { - const response = await fetch(`${getHostPort()}/_specmatic/examples/validate`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(validateExampleRequest) - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const validationResult = await response.json(); - const { example, error } = await getExampleContent(exampleAbsPath); - - docFragment = createExampleRows([{ + const { example,err } = await getExampleContent(exampleAbsPath); + const { response, error } = await validateExample(exampleAbsPath); + docFragment = createExampleRows([{ absPath: exampleAbsPath, exampleJson: example, error: getExampleValidationData(tableRow) || error, hasBeenValidated: tableRow.getAttribute("data-valid") !== defaultAttrs["data-valid"], test: getExampleTestData(tableRow), - response: validationResult + response: response }]); - console.log("Validation Result:", validationResult); - } catch (error) { - console.error("Error during validation:", error); - } - } - - bulkTestBtn.classList.toggle("bulk-disabled", tableRow.getAttribute("data-valid") !== "success") pathSummaryUl.replaceChildren(createPathSummary(rowValues)); examplesOl.replaceChildren(docFragment); @@ -1953,9 +1928,9 @@

throw new Error(data.error); } - return { exampleAbsPath: data.absPath, error: data.error }; + return { response: data, error: data.error }; } catch (error) { - return { error: error.message, exampleAbsPath: null }; + return { error: error.message, response: null }; } } From 6df008610710d1474de8543596edf4a4af211ff0 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 15:04:29 +0530 Subject: [PATCH 16/25] Modify result logic when all keys are mandatory. - Partial Failure when optional key is missing and full pattern validation is enabled. - Result.Failure can be a partial - Add new OptionalMissingKeyError in MismatchMessages and KeyError --- .../src/main/kotlin/io/specmatic/core/KeyError.kt | 11 +++++++++++ core/src/main/kotlin/io/specmatic/core/Result.kt | 15 +++++++++++---- core/src/main/kotlin/io/specmatic/core/Results.kt | 2 +- .../specmatic/core/pattern/JSONObjectPattern.kt | 7 ++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/KeyError.kt b/core/src/main/kotlin/io/specmatic/core/KeyError.kt index 1b11c0969..27ea4828e 100644 --- a/core/src/main/kotlin/io/specmatic/core/KeyError.kt +++ b/core/src/main/kotlin/io/specmatic/core/KeyError.kt @@ -1,19 +1,30 @@ package io.specmatic.core import io.specmatic.core.Result.Failure +import io.specmatic.core.pattern.ContractException 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 { + throw ContractException("This should never happen") + } } diff --git a/core/src/main/kotlin/io/specmatic/core/Result.kt b/core/src/main/kotlin/io/specmatic/core/Result.kt index 2de707ccb..ad50c4eb1 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 }) } } @@ -149,6 +151,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 +172,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 +288,7 @@ sealed class Result { } override fun isPartialSuccess(): Boolean = partialSuccessMessage != null + override fun isPartialFailure(): Boolean = false override fun testResult(): TestResult { return TestResult.Success } @@ -333,6 +337,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", keyName) + } fun valueMismatchFailure(expected: String, actual: Value?, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure { return mismatchResult(expected, valueError(actual) ?: "null", mismatchMessages) } 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/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index 909fac174..589f62b6a 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -273,10 +273,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) From 7c6f228a144249a6ac120be7ecdfdce1bab01820 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 15:05:13 +0530 Subject: [PATCH 17/25] Interactive Server partially valid examples. - Example can be partially valid. --- .../server/ExamplesInteractiveServer.kt | 15 +++---- .../core/examples/server/ExamplesView.kt | 22 ++++++---- .../resources/templates/examples/index.html | 41 ++++++++++++++----- 3 files changed, 52 insertions(+), 26 deletions(-) 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..658eae24c 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 @@ -144,7 +144,7 @@ class ExamplesInteractiveServer( if(result.isSuccess()) ValidateExampleResponse(request.exampleFile) else - ValidateExampleResponse(request.exampleFile, result.reportString()) + ValidateExampleResponse(request.exampleFile, result.reportString(), result.isPartialFailure()) } catch (e: FileNotFoundException) { ValidateExampleResponse(request.exampleFile, e.message ?: "File not found") } catch (e: ContractException) { @@ -654,10 +654,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 Result.Success() is Result.Failure -> { val isFailureRelatedToScenario = matchResult.getFailureBreadCrumbs("").none { breadCrumb -> breadCrumb.contains(PATH_BREAD_CRUMB) @@ -665,7 +665,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 +688,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) } else null } } @@ -843,7 +843,8 @@ data class ValidateExampleRequest( data class ValidateExampleResponse( val absPath: String, - val error: String? = null + val error: String? = null, + val isPartiallyValid: Boolean = error != null ) 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/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4bb4fd26e..4818116e4 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -382,6 +382,7 @@ --white: 255, 255, 255; --black: 0, 0, 0; --slate: 241, 245, 249; + --yellow: 255, 207, 51; --blue: 52, 115, 217; --green: 34, 197, 94; --red: 239, 68, 68; @@ -640,6 +641,11 @@ } } + &[data-valid="partial"] button.validate { + --_background-color: var(--yellow); + --_text-color: var(--smoky-black); + } + &[data-valid="failed"] button.validate, &[data-generate="failed"] button.generate, &[data-test="failed"] button.test { --_background-color: var(--red); } @@ -903,6 +909,10 @@ color: black; } + .pill.yellow { + --_background-color: rgb(var(--yellow)); + color: black; + } /* ALERT STYLES */ @@ -1033,7 +1043,7 @@

th:attr="data-raw-path=${row.rawPath}, data-key=${row.uniqueKey}, data-schema-based=${row.isSchemaBased}, data-example=${row.example}, data-main=${row.isMainRow}, data-disc=${row.isDiscriminatorBased}, data-generate=${row.isGenerated ? 'success' : 'not-generated'}, data-test='not-tested', - data-valid=${row.isGenerated ? (row.isValid ? 'success' : 'failed') : 'not-validated'}"> + data-valid=${row.isGenerated ? (row.isValid ? 'success' : row.isPartialFailure ? 'partial' : 'failed') : 'not-validated'}"> @@ -1097,6 +1107,7 @@

let selectedTableRow = null; let blockGenValidate = false; const defaultAttrs = {"data-generate": "not-generated", "data-valid": "not-validated", "data-test": "not-tested", "data-main": "false"} + const dataValidationSuccessValues = ["success", "partial"] backBtn.addEventListener("click", () => { examplesOl.replaceChildren(); @@ -1166,7 +1177,7 @@

const testPreSelectCount = getPreSelectCount(bulkTestBtn, "data-selected"); const hasBeenGenerated = nearestTableRow.getAttribute("data-generate") === "success"; - const hasBeenValidated = nearestTableRow.getAttribute("data-valid") === "success"; + const hasBeenValidated = dataValidationSuccessValues.includes(nearestTableRow.getAttribute("data-valid")); const isDiscriminatorRow = nearestTableRow.getAttribute("data-disc") === "true"; const isMainRow = nearestTableRow.getAttribute("data-main") === "true"; @@ -1278,7 +1289,7 @@

async function testAllSelected() { const selectedRows = Array.from(table.querySelectorAll("td > input[type=checkbox]:checked")).map((checkbox) => checkbox.closest("tr")); - const rowsWithValidations = selectedRows.filter(row => row.getAttribute("data-valid") === "success"); + const rowsWithValidations = selectedRows.filter(row => dataValidationSuccessValues.includes(row.getAttribute("data-valid"))); for (const row of rowsWithValidations) { row.setAttribute("data-test", "processing"); @@ -1309,7 +1320,7 @@

const tableRows = table.querySelectorAll("tbody > tr"); return Array.from(tableRows).reduce((acc, row) => { const isRowGenerated = row.getAttribute("data-generate") === "success"; - const isRowValidated = row.getAttribute("data-valid") === "success"; + const isRowValidated = dataValidationSuccessValues.includes(row.getAttribute("data-valid")); const isRowDiscAndMain = row.getAttribute("data-main") === "true" && row.getAttribute("data-disc") === "true"; acc.validatedCount += isRowValidated ? 1 : 0; @@ -1367,7 +1378,7 @@

async function validateRowExamples(tableRow, bulkMode = false) { tableRow.setAttribute("data-valid", "processing"); const exampleData = getExampleData(tableRow); - const { exampleAbsPath, error } = await validateExample(exampleData); + const { exampleAbsPath, error, isPartiallyValid } = await validateExample(exampleData); if (error && !exampleAbsPath) { if (!bulkMode) createAlert("Validation Failed", `Error: ${error ?? "Unknown Error"}`, true); @@ -1375,12 +1386,12 @@

return false; } - tableRow.setAttribute("data-valid", error ? "failed": "success"); + tableRow.setAttribute("data-valid", isPartiallyValid ? "partial": error ? "failed": "success"); storeExampleValidationData(tableRow, error); storeExampleTestData(tableRow, null); tableRow.setAttribute("data-test", defaultAttrs["data-test"]); - if (error) { + if (error && isPartiallyValid === false) { if (!bulkMode) createAlert("Invalid Example", `Example name: ${parseFileName(exampleAbsPath)}`, true); return false; } @@ -1496,6 +1507,7 @@

exampleJson: example, error: getExampleValidationData(tableRow) || error, hasBeenValidated: tableRow.getAttribute("data-valid") !== defaultAttrs["data-valid"], + isPartiallyValid: tableRow.getAttribute("data-valid") === "partial", test: getExampleTestData(tableRow) }]); } @@ -1526,6 +1538,12 @@

function createPathSummary(rowValues) { const docFragment = document.createDocumentFragment(); + if (rowValues.isSchemaBased) { + rowValues = {"schema": rowValues["path"], ...rowValues }; + delete rowValues["path"]; + delete rowValues["isSchemaBased"]; + } + for (const [key, value] of Object.entries(rowValues)) { if (!value) continue; @@ -1550,11 +1568,12 @@

const testBadge = document.createElement("span"); exampleDiv.classList.add("example"); - exampleBadge.classList.add("pill", example.hasBeenValidated ? example.error ? "red" : "green" : "blue"); + exampleBadge.classList.add("pill", example.hasBeenValidated ? example.isPartiallyValid ? "yellow" : example.error ? "red" : "green" : "blue") exampleName.textContent = example.absPath; if (example.hasBeenValidated) { - exampleBadge.textContent = `${example.error ? "Invalid" : "Valid"} Example`; + exampleBadge.textContent = example.isPartiallyValid ? "Valid" : example.error ? "Invalid" : "Valid"; + exampleBadge.textContent += " Example"; } else { exampleBadge.textContent = "Example"; } @@ -1706,9 +1725,9 @@

throw new Error(data.error); } - return { exampleAbsPath: data.absPath, error: data.error }; + return { exampleAbsPath: data.absPath, error: data.error, isPartiallyValid : data.isPartiallyValid }; } catch (error) { - return { error: error.message, exampleAbsPath: null }; + return { error: error.message, exampleAbsPath: null, isPartiallyValid: false }; } } From 08614ff3d504cf4fe04eccc842f7d7237fc06d9a Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 16:53:12 +0530 Subject: [PATCH 18/25] better variable namings and partial handling --- .../examples/server/ExamplesInteractiveServer.kt | 4 ++-- .../main/resources/templates/examples/index.html | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) 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 658eae24c..def5df3ab 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 @@ -657,7 +657,7 @@ class ExamplesInteractiveServer( 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 Result.Success() + is Result.Success -> example to matchResult is Result.Failure -> { val isFailureRelatedToScenario = matchResult.getFailureBreadCrumbs("").none { breadCrumb -> breadCrumb.contains(PATH_BREAD_CRUMB) @@ -844,7 +844,7 @@ data class ValidateExampleRequest( data class ValidateExampleResponse( val absPath: String, val error: String? = null, - val isPartiallyValid: Boolean = error != null + val isPartialFailure: Boolean = false ) enum class ValidateExampleVerdict { diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4818116e4..dc427e736 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1378,7 +1378,7 @@

async function validateRowExamples(tableRow, bulkMode = false) { tableRow.setAttribute("data-valid", "processing"); const exampleData = getExampleData(tableRow); - const { exampleAbsPath, error, isPartiallyValid } = await validateExample(exampleData); + const { exampleAbsPath, error, isPartialFailure } = await validateExample(exampleData); if (error && !exampleAbsPath) { if (!bulkMode) createAlert("Validation Failed", `Error: ${error ?? "Unknown Error"}`, true); @@ -1386,12 +1386,12 @@

return false; } - tableRow.setAttribute("data-valid", isPartiallyValid ? "partial": error ? "failed": "success"); + tableRow.setAttribute("data-valid", error ? isPartialFailure ? "partial" : "failed" : "success"); storeExampleValidationData(tableRow, error); storeExampleTestData(tableRow, null); tableRow.setAttribute("data-test", defaultAttrs["data-test"]); - if (error && isPartiallyValid === false) { + if (error && !isPartialFailure) { if (!bulkMode) createAlert("Invalid Example", `Example name: ${parseFileName(exampleAbsPath)}`, true); return false; } @@ -1507,7 +1507,7 @@

exampleJson: example, error: getExampleValidationData(tableRow) || error, hasBeenValidated: tableRow.getAttribute("data-valid") !== defaultAttrs["data-valid"], - isPartiallyValid: tableRow.getAttribute("data-valid") === "partial", + isPartialFailure: tableRow.getAttribute("data-valid") === "partial", test: getExampleTestData(tableRow) }]); } @@ -1568,11 +1568,11 @@

const testBadge = document.createElement("span"); exampleDiv.classList.add("example"); - exampleBadge.classList.add("pill", example.hasBeenValidated ? example.isPartiallyValid ? "yellow" : example.error ? "red" : "green" : "blue") + exampleBadge.classList.add("pill", example.hasBeenValidated ? example.error ? example.isPartialFailure ? "yellow" : "red" : "green" : "blue"); exampleName.textContent = example.absPath; if (example.hasBeenValidated) { - exampleBadge.textContent = example.isPartiallyValid ? "Valid" : example.error ? "Invalid" : "Valid"; + exampleBadge.textContent = example.error && !example.isPartialFailure ? "Invalid" : "Valid"; exampleBadge.textContent += " Example"; } else { exampleBadge.textContent = "Example"; @@ -1725,9 +1725,9 @@

throw new Error(data.error); } - return { exampleAbsPath: data.absPath, error: data.error, isPartiallyValid : data.isPartiallyValid }; + return { exampleAbsPath: data.absPath, error: data.error, isPartialFailure : data.isPartialFailure }; } catch (error) { - return { error: error.message, exampleAbsPath: null, isPartiallyValid: false }; + return { error: error.message, exampleAbsPath: null, isPartialFailure: false }; } } From c941b4f54cd6aef31654b6bde48ea027721a2886 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 17:10:59 +0530 Subject: [PATCH 19/25] Better mismatchMessages for missing optional keys. --- core/src/main/kotlin/io/specmatic/core/Feature.kt | 4 ++-- core/src/main/kotlin/io/specmatic/core/KeyError.kt | 6 ++---- .../core/examples/server/ExamplesInteractiveServer.kt | 10 +++++++--- 3 files changed, 11 insertions(+), 9 deletions(-) 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 27ea4828e..b0bd22813 100644 --- a/core/src/main/kotlin/io/specmatic/core/KeyError.kt +++ b/core/src/main/kotlin/io/specmatic/core/KeyError.kt @@ -1,7 +1,6 @@ package io.specmatic.core import io.specmatic.core.Result.Failure -import io.specmatic.core.pattern.ContractException sealed class KeyError { abstract val name: String @@ -24,7 +23,6 @@ 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 { - throw ContractException("This should never happen") - } + override fun missingOptionalKeyToResult(keyLabel: String, mismatchMessages: MismatchMessages): Failure = + Failure(mismatchMessages.unexpectedKey(keyLabel, name)) } 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 def5df3ab..3f2cf1885 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 @@ -579,7 +579,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 +631,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 } @@ -691,7 +691,7 @@ class ExamplesInteractiveServer( fun File.getSchemaExamplesWithValidation(feature: Feature): List> { return getSchemaExamples().map { it to if(it.value !is NullValue) { - feature.matchResultSchemaFlagBased(it.discriminatorBasedOn, it.schemaBasedOn, it.value) + feature.matchResultSchemaFlagBased(it.discriminatorBasedOn, it.schemaBasedOn, it.value, InteractiveExamplesMismatchMessages) } else null } } @@ -827,6 +827,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" } From c611a28347e6f82e60ebbcfd8499c2e9bc42d39d Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 18:26:53 +0530 Subject: [PATCH 20/25] Add tests for partially valid examples. - Fix ListPattern adding its pattern as seen. --- .../io/specmatic/core/pattern/ListPattern.kt | 2 +- .../server/ExamplesInteractiveServerTest.kt | 200 ++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) 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..b4b807259 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt @@ -81,7 +81,7 @@ data class ListPattern( 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/test/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServerTest.kt b/core/src/test/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServerTest.kt index 2017c84cc..2dc22a6ba 100644 --- a/core/src/test/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServerTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServerTest.kt @@ -1,16 +1,22 @@ package io.specmatic.core.examples.server import io.specmatic.conversions.ExampleFromFile +import io.specmatic.conversions.OpenApiSpecification import io.specmatic.core.HttpRequest import io.specmatic.core.HttpResponse +import io.specmatic.core.Result +import io.specmatic.core.utilities.Flags.Companion.ALL_PATTERNS_MANDATORY +import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.NumberValue import io.specmatic.core.value.StringValue +import io.specmatic.mock.ScenarioStub import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir import java.io.File class ExamplesInteractiveServerTest { @@ -124,4 +130,198 @@ class ExamplesInteractiveServerTest { assertThat(generatedPatchExamples.first().path).contains("PATCH") } } + + @Nested + inner class AllPatternsMandatoryTests { + private val spec = """ + openapi: 3.0.0 + info: + title: test + version: 1.0.0 + paths: + /products: + post: + requestBody: + required: true + content: + application/json: + schema: + ${'$'}ref: '#/components/schemas/ProductRequest' + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + ${'$'}ref: '#/components/schemas/Product' + components: + schemas: + ProductRequest: + type: object + properties: + name: + type: string + type: + type: string + inventory: + type: number + required: + - name + Product: + type: object + properties: + id: + type: number + name: + type: string + type: + type: string + inventory: + type: number + required: + - id + - name + """.trimIndent() + + @BeforeEach + fun setup() { + System.setProperty(ALL_PATTERNS_MANDATORY, "true") + } + + @AfterEach + fun reset() { + System.clearProperty(ALL_PATTERNS_MANDATORY) + } + + @Test + fun `should warn about missing optional keys`(@TempDir tempDir: File) { + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + + val exampleFile = tempDir.resolve("example.json") + val example = ScenarioStub( + request = HttpRequest(path = "/products", method = "POST", body = JSONObjectValue(mapOf("name" to StringValue("iPhone")))), + response = HttpResponse(status = 200, body = JSONArrayValue( + List(2) { JSONObjectValue(mapOf("id" to NumberValue(1), "name" to StringValue("iPhone")))} + )) + ) + exampleFile.writeText(example.toJSON().toStringLiteral()) + + val result = ExamplesInteractiveServer.validateSingleExample(feature, exampleFile) + println(result.reportString()) + + assertThat(result).isInstanceOf(Result.Failure::class.java) + assertThat(result.isPartialFailure()).isTrue() + assertThat(result.reportString()).containsIgnoringWhitespaces(""" + >> REQUEST.BODY.type + Optional Key type in the specification is missing from the example + >> REQUEST.BODY.inventory + Optional Key inventory in the specification is missing from the example + + >> RESPONSE.BODY[0].type + Optional Key type in the specification is missing from the example + >> RESPONSE.BODY[0].inventory + Optional Key inventory in the specification is missing from the example + + >> RESPONSE.BODY[1].type + Optional Key type in the specification is missing from the example + >> RESPONSE.BODY[1].inventory + Optional Key inventory in the specification is missing from the example + """.trimIndent()) + } + + @Test + fun `should not be partial failure when mandatory key is missing`(@TempDir tempDir: File) { + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + + val exampleFile = tempDir.resolve("example.json") + val example = ScenarioStub( + request = HttpRequest(path = "/products", method = "POST", body = JSONObjectValue(mapOf("name" to StringValue("iPhone")))), + response = HttpResponse(status = 200, body = JSONArrayValue( + List(1) { JSONObjectValue(mapOf("id" to NumberValue(1))) } + )) + ) + exampleFile.writeText(example.toJSON().toStringLiteral()) + + val result = ExamplesInteractiveServer.validateSingleExample(feature, exampleFile) + assertThat(result).isInstanceOf(Result.Failure::class.java) + assertThat(result.isPartialFailure()).isFalse() + + val report = result.reportString() + println(report) + assertThat(report).containsIgnoringWhitespaces(""" + >> REQUEST.BODY.type + Optional Key type in the specification is missing from the example + >> REQUEST.BODY.inventory + Optional Key inventory in the specification is missing from the example + + >> RESPONSE.BODY[0].name + Key name in the specification is missing from the example + >> RESPONSE.BODY[0].type + Optional Key type in the specification is missing from the example + >> RESPONSE.BODY[0].inventory + Optional Key inventory in the specification is missing from the example + """.trimIndent()) + } + + @Test + fun `should not be partial failure when type mismatch`(@TempDir tempDir: File) { + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + + val exampleFile = tempDir.resolve("example.json") + val example = ScenarioStub( + request = HttpRequest(path = "/products", method = "POST", body = JSONObjectValue( + mapOf("name" to StringValue("iPhone"), "type" to StringValue("phone"), "inventory" to NumberValue(1)) + )), + response = HttpResponse(status = 200, body = JSONArrayValue( + List(2) { JSONObjectValue(mapOf("id" to NumberValue(1), "name" to NumberValue(123))) } + )) + ) + exampleFile.writeText(example.toJSON().toStringLiteral()) + + val result = ExamplesInteractiveServer.validateSingleExample(feature, exampleFile) + assertThat(result).isInstanceOf(Result.Failure::class.java) + assertThat(result.isPartialFailure()).isFalse() + + val report = result.reportString() + println(report) + assertThat(report).containsIgnoringWhitespaces(""" + >> RESPONSE.BODY[0].type + Optional Key type in the specification is missing from the example + >> RESPONSE.BODY[0].inventory + Optional Key inventory in the specification is missing from the example + >> RESPONSE.BODY[0].name + Specification expected string but example contained 123 (number) + + >> RESPONSE.BODY[1].type + Optional Key type in the specification is missing from the example + >> RESPONSE.BODY[1].inventory + Optional Key inventory in the specification is missing from the example + >> RESPONSE.BODY[1].name + Specification expected string but example contained 123 (number) + """.trimIndent()) + } + + @Test + fun `should not complain about missing optional keys when all patterns mandatory is false`(@TempDir tempDir: File) { + System.setProperty(ALL_PATTERNS_MANDATORY, "false") + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + + val exampleFile = tempDir.resolve("example.json") + val example = ScenarioStub( + request = HttpRequest(path = "/products", method = "POST", body = JSONObjectValue(mapOf("name" to StringValue("iPhone")))), + response = HttpResponse(status = 200, body = JSONArrayValue( + List(2) { JSONObjectValue(mapOf("id" to NumberValue(1), "name" to StringValue("iPhone")))} + )) + ) + exampleFile.writeText(example.toJSON().toStringLiteral()) + + val result = ExamplesInteractiveServer.validateSingleExample(feature, exampleFile) + println(result.reportString()) + + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat(result.reportString()).isEmpty() + } + } } \ No newline at end of file From 79246ef03aaf849bdc3ff302ba3f4ee526862f47 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Mon, 9 Dec 2024 20:26:51 +0530 Subject: [PATCH 21/25] Fix full pattern validation with ListPattern. --- .../main/kotlin/io/specmatic/core/Result.kt | 6 ++- .../core/pattern/JSONObjectPattern.kt | 11 +++++- .../io/specmatic/core/pattern/ListPattern.kt | 3 +- .../core/pattern/JSONObjectPatternTest.kt | 14 +++---- .../specmatic/core/pattern/ListPatternTest.kt | 39 +++++++++---------- 5 files changed, 43 insertions(+), 30 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/Result.kt b/core/src/main/kotlin/io/specmatic/core/Result.kt index ad50c4eb1..f58644e05 100644 --- a/core/src/main/kotlin/io/specmatic/core/Result.kt +++ b/core/src/main/kotlin/io/specmatic/core/Result.kt @@ -338,7 +338,7 @@ interface MismatchMessages { 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", keyName) + return expectedKeyWasMissing("Optional ${keyLabel.capitalizeFirstChar()}", keyName) } fun valueMismatchFailure(expected: String, actual: Value?, mismatchMessages: MismatchMessages = DefaultMismatchMessages): Failure { return mismatchResult(expected, valueError(actual) ?: "null", mismatchMessages) @@ -357,6 +357,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/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index 589f62b6a..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) @@ -285,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 b4b807259..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,7 +77,8 @@ 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") } diff --git a/core/src/test/kotlin/io/specmatic/core/pattern/JSONObjectPatternTest.kt b/core/src/test/kotlin/io/specmatic/core/pattern/JSONObjectPatternTest.kt index 66a7a11a1..93794f61f 100644 --- a/core/src/test/kotlin/io/specmatic/core/pattern/JSONObjectPatternTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/pattern/JSONObjectPatternTest.kt @@ -1082,13 +1082,13 @@ internal class JSONObjectPatternTest { assertThat(result).isInstanceOf(Result.Failure::class.java) assertThat(result.reportString()).containsIgnoringWhitespaces(""" - >> topLevelOptionalKey - Expected key named "topLevelOptionalKey" was missing - >> subMandatoryObject.subOptionalKey - Expected key named "subOptionalKey" was missing - >> subOptionalObject.subOptionalKey - Expected key named "subOptionalKey" was missing - """.trimIndent()) + >> topLevelOptionalKey + Expected Optional key named "topLevelOptionalKey" was missing + >> subMandatoryObject.subOptionalKey + Expected Optional key named "subOptionalKey" was missing + >> subOptionalObject.subOptionalKey + Expected key named "subOptionalKey" was missing + """.trimIndent()) } @Test diff --git a/core/src/test/kotlin/io/specmatic/core/pattern/ListPatternTest.kt b/core/src/test/kotlin/io/specmatic/core/pattern/ListPatternTest.kt index 74a21c61e..1e1c0d754 100644 --- a/core/src/test/kotlin/io/specmatic/core/pattern/ListPatternTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/pattern/ListPatternTest.kt @@ -202,44 +202,43 @@ Feature: Recursive test } @Test - fun `should not result in failure when list is empty but pattern is cycling and resolver is set to allPatternsAsMandatory`() { + fun `should not result in failure when list is empty but pattern is cycling and failure when not and resolver is set to allPatternsAsMandatory`() { val basePattern = ListPattern(parsedPattern("""{ "topLevelMandatoryKey": "(number)", "topLevelOptionalKey?": "(string)", - "subMandatoryObject": { - "subMandatoryKey": "(string)", - "subOptionalKey?": "(number)" - } + "subList": "(baseListPattern)" } """.trimIndent(), typeAlias = "(baseJsonPattern)"), typeAlias = "(baseListPattern)") - val listPattern = ListPattern(basePattern, typeAlias = "(baseListPattern)") + val listPattern = ListPattern(basePattern) val matchingValue = parsedValue("""[ [ { "topLevelMandatoryKey": 10, "topLevelOptionalKey": "abc", - "subMandatoryObject": { - "subMandatoryKey": "abc", - "subOptionalKey": 10 - } + "subList": [] }, { "topLevelMandatoryKey": 10, "topLevelOptionalKey": "abc", - "subMandatoryObject": { - "subMandatoryKey": "abc", - "subOptionalKey": 10 - } + "subList": [] } - ], - [] + ] ] + """.trimIndent()) as JSONArrayValue + val matchingResult = listPattern.matches(matchingValue, Resolver(newPatterns = mapOf("(baseListPattern)" to basePattern)).withAllPatternsAsMandatory()) + println(matchingResult.reportString()) + assertThat(matchingResult).isInstanceOf(Result.Success::class.java) + assertThat(matchingResult.reportString()).isEmpty() + + val nonMatchingValue = JSONArrayValue(matchingValue.list.plus(JSONArrayValue(emptyList()))) + val nonMatchingResult = listPattern.matches(nonMatchingValue, Resolver(newPatterns = mapOf("(baseListPattern)" to basePattern)).withAllPatternsAsMandatory()) + println(nonMatchingResult.reportString()) + assertThat(nonMatchingResult).isInstanceOf(Result.Failure::class.java) + assertThat(nonMatchingResult.reportString()).containsIgnoringWhitespaces(""" + >> [1] + List cannot be empty """.trimIndent()) - val result = listPattern.matches(matchingValue, Resolver().withAllPatternsAsMandatory()) - println(result.reportString()) - - assertThat(result).isInstanceOf(Result.Success::class.java) } @Test From 991fd5539c0e01e0a38f00f388f4881820f949f2 Mon Sep 17 00:00:00 2001 From: Samy Date: Tue, 10 Dec 2024 12:32:28 +0530 Subject: [PATCH 22/25] Editor syntax highlighting --- .../resources/templates/examples/index.html | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index fa8261831..93c0ab928 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1687,29 +1687,29 @@

const examplePreDiv = document.createElement("div"); examplePreDiv.setAttribute("id", "example-pre"); examplePreDiv.classList.add("language-json"); - let metadata; - if(example.response.error) - { - const errors = [ - { - lineNumber: 5, - description: "Missing closing bracket for the object." - }, - { - lineNumber: 12, - description: "Unexpected string value, expected a number." - }, - { - lineNumber: 13, - description: "Invalid key name; keys should be in camelCase." - }, - { - lineNumber: 14, - description: "Extra comma at the end of the array." - } - ]; - metadata= parseErrorResponse(example.response.error) - } + + + + + + + + + + + + + + + + + + + + + + + const detailsPre = document.createElement("pre"); if (example.hasBeenValidated) { detailsPre.textContent = example.error ? example.error : `${parseFileName(example.absPath)} IS VALID`; @@ -1734,9 +1734,9 @@

gutters: ["CodeMirror-lint-markers"] }); - if(metadata) { - highlightErrorLines(editor,metadata); - } + + + editor.on("change", (instance, changes) => { isSaved = false; From 3ddbafcee9aa4391ce459aa0bc6899d9097bbb96 Mon Sep 17 00:00:00 2001 From: Samy Date: Tue, 10 Dec 2024 12:55:52 +0530 Subject: [PATCH 23/25] Removed highlighting of specmatic errors in editor --- .../server/ExamplesInteractiveServer.kt | 12 +++--- .../resources/templates/examples/index.html | 43 ++++++------------- 2 files changed, 20 insertions(+), 35 deletions(-) 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 c5ea7ec8f..7acf307fc 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 @@ -157,12 +157,12 @@ class ExamplesInteractiveServer( if(result.isSuccess()) ValidateExampleResponse(request.exampleFile) else { - val breadCrumbs = extractBreadcrumbs(result.reportString()) - val transformedPath = transformToJsonPaths(breadCrumbs) - val lineNumber = getJsonNodeLineNumbersUsingJsonPath(request.exampleFile,transformedPath,breadCrumbs) -// val map = mapOf(lineNumber to result.reportString()) - val map: List> = listOf(mapOf("lineNumber" to lineNumber, "description" to result.reportString())) - ValidateExampleResponseMap(request.exampleFile, map) +// val breadCrumbs = extractBreadcrumbs(result.reportString()) +// val transformedPath = transformToJsonPaths(breadCrumbs) +// val lineNumber = getJsonNodeLineNumbersUsingJsonPath(request.exampleFile,transformedPath,breadCrumbs) +//// val map = mapOf(lineNumber to result.reportString()) +// val map: List> = listOf(mapOf("lineNumber" to lineNumber, "description" to result.reportString())) + ValidateExampleResponse(request.exampleFile, result.reportString()) } } catch (e: FileNotFoundException) { ValidateExampleResponse(request.exampleFile, e.message ?: "File not found") diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 93c0ab928..4214ccf0b 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1291,6 +1291,7 @@

case "details": { await validateRowExamples(selectedTableRow); const originalYScroll = scrollYPosition; + await goToDetails(selectedTableRow, extractRowValues(selectedTableRow)); scrollYPosition = originalYScroll; break; } @@ -1687,29 +1688,13 @@

const examplePreDiv = document.createElement("div"); examplePreDiv.setAttribute("id", "example-pre"); examplePreDiv.classList.add("language-json"); - - - - - - - - - - - - - - - - - - - - - - - + /* let metadata; + if(example.response.error) + { + + metadata= parseErrorResponse(example.response.error) + } + */ const detailsPre = document.createElement("pre"); if (example.hasBeenValidated) { detailsPre.textContent = example.error ? example.error : `${parseFileName(example.absPath)} IS VALID`; @@ -1733,11 +1718,11 @@

lint: true, gutters: ["CodeMirror-lint-markers"] }); - - - - - +/* + if(metadata) { + highlightErrorLines(editor,metadata); + } +*/ editor.on("change", (instance, changes) => { isSaved = false; updateBorderColorExampleBlock(editor, examplePreDiv); @@ -1753,7 +1738,7 @@

detailsPreDiv.appendChild(testPre); } - + // dropDownDiv.appendChild(detailsPreDiv); dropDownDiv.appendChild(examplePara); dropDownDiv.appendChild(examplePreDiv); From 6159e3730f678a3ce3d758f9d43148a68eed5d1d Mon Sep 17 00:00:00 2001 From: Samy Date: Tue, 10 Dec 2024 13:23:53 +0530 Subject: [PATCH 24/25] Added Details Pre Div --- core/src/main/resources/templates/examples/index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 4214ccf0b..61cfb123c 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1688,6 +1688,7 @@

const examplePreDiv = document.createElement("div"); examplePreDiv.setAttribute("id", "example-pre"); examplePreDiv.classList.add("language-json"); + const detailsPreDiv = document.createElement("div"); /* let metadata; if(example.response.error) { @@ -1706,6 +1707,9 @@

} + detailsPreDiv.appendChild(detailsPara); + detailsPreDiv.appendChild(detailsPre); + const editor = CodeMirror(examplePreDiv, { value: example.exampleJson, autoRefresh: true, @@ -1738,7 +1742,7 @@

detailsPreDiv.appendChild(testPre); } - // dropDownDiv.appendChild(detailsPreDiv); + dropDownDiv.appendChild(detailsPreDiv); dropDownDiv.appendChild(examplePara); dropDownDiv.appendChild(examplePreDiv); From 98dec09220a80c9df5ba07ecd682ca65f6155f3e Mon Sep 17 00:00:00 2001 From: Samy Date: Tue, 10 Dec 2024 14:27:39 +0530 Subject: [PATCH 25/25] Changes for GoBack --- .../resources/templates/examples/index.html | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/core/src/main/resources/templates/examples/index.html b/core/src/main/resources/templates/examples/index.html index 2618b2507..566eb5954 100644 --- a/core/src/main/resources/templates/examples/index.html +++ b/core/src/main/resources/templates/examples/index.html @@ -1180,10 +1180,6 @@

if(!isSaved) { const modalContainer = createModal({ - onSave:() => { - const exampleAbsPath = getExampleData(selectedTableRow); - saveExample(exampleAbsPath); - }, onDiscard: () => { examplesOl.replaceChildren(); mainElement.setAttribute("data-panel", "table"); @@ -2043,9 +2039,9 @@

function createModal({ onSave, onDiscard }) { // Data for modal const title = "Unsaved Changes"; - const saveText = "Save"; - const discardText = "Go Back"; - const bodyText = "Your changes are not saved yet. Do you want to save them?"; + const closeText = "No"; + const discardText = "Yes"; + const bodyText = "Your changes are not saved & validated. Go Back Anyway?"; // Create modal container dynamically const modalContainer = document.createElement("div"); @@ -2087,15 +2083,15 @@

// Create modal footer const modalFooter = document.createElement("div"); - const saveButton = document.createElement("button"); - saveButton.textContent = saveText; - saveButton.style.backgroundColor = "#007bff"; - saveButton.style.color = "#fff"; - saveButton.style.border = "none"; - saveButton.style.padding = "8px 16px"; - saveButton.style.borderRadius = "4px"; - saveButton.style.cursor = "pointer"; - saveButton.style.marginRight = "10px"; + const closeButton = document.createElement("button"); + closeButton.textContent = closeText; + closeButton.style.backgroundColor = "#007bff"; + closeButton.style.color = "#fff"; + closeButton.style.border = "none"; + closeButton.style.padding = "8px 16px"; + closeButton.style.borderRadius = "4px"; + closeButton.style.cursor = "pointer"; + closeButton.style.marginRight = "10px"; const discardButton = document.createElement("button"); discardButton.textContent = discardText; @@ -2107,7 +2103,7 @@

discardButton.style.cursor = "pointer"; // Add buttons to footer - modalFooter.appendChild(saveButton); + modalFooter.appendChild(closeButton); modalFooter.appendChild(discardButton); // Append everything to the modal content @@ -2120,8 +2116,7 @@

// Button event listeners - saveButton.addEventListener("click", () => { - onSave(); + closeButton.addEventListener("click", () => { closeModal(); });