diff --git a/kotest-assertions/kotest-assertions-json/api/kotest-assertions-json.api b/kotest-assertions/kotest-assertions-json/api/kotest-assertions-json.api index 9186941da35..489dcc70e7b 100644 --- a/kotest-assertions/kotest-assertions-json/api/kotest-assertions-json.api +++ b/kotest-assertions/kotest-assertions-json/api/kotest-assertions-json.api @@ -63,6 +63,20 @@ public final class io/kotest/assertions/json/ErrorsKt { public static final fun asString (Lio/kotest/assertions/json/JsonError;)Ljava/lang/String; } +public abstract interface class io/kotest/assertions/json/ExtractValueOutcome { +} + +public final class io/kotest/assertions/json/ExtractedValue : io/kotest/assertions/json/ExtractValueOutcome { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lio/kotest/assertions/json/ExtractedValue; + public static synthetic fun copy$default (Lio/kotest/assertions/json/ExtractedValue;Ljava/lang/Object;ILjava/lang/Object;)Lio/kotest/assertions/json/ExtractedValue; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/kotest/assertions/json/FieldComparison : java/lang/Enum { public static final field Lenient Lio/kotest/assertions/json/FieldComparison; public static final field Strict Lio/kotest/assertions/json/FieldComparison; @@ -351,6 +365,10 @@ public final class io/kotest/assertions/json/JsonNode$StringNode$Companion { public abstract interface class io/kotest/assertions/json/JsonNode$ValueNode { } +public final class io/kotest/assertions/json/JsonPathNotFound : io/kotest/assertions/json/ExtractValueOutcome { + public static final field INSTANCE Lio/kotest/assertions/json/JsonPathNotFound; +} + public final class io/kotest/assertions/json/JsonTree { public fun (Lio/kotest/assertions/json/JsonNode;Ljava/lang/String;)V public final fun component1 ()Lio/kotest/assertions/json/JsonNode; @@ -370,6 +388,11 @@ public final class io/kotest/assertions/json/KeysKt { public static final fun shouldNotContainJsonKey (Ljava/lang/String;Ljava/lang/String;)V } +public final class io/kotest/assertions/json/KeyvaluesKt { + public static final fun findValidSubPath (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public static final fun removeLastPartFromPath (Ljava/lang/String;)Ljava/lang/String; +} + public final class io/kotest/assertions/json/MatchersKt { public static final fun equalJson (Ljava/lang/String;Lio/kotest/assertions/json/CompareJsonOptions;)Lio/kotest/matchers/Matcher; public static final fun shouldEqualJson (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; diff --git a/kotest-assertions/kotest-assertions-json/src/jvmMain/kotlin/io/kotest/assertions/json/keyvalues.kt b/kotest-assertions/kotest-assertions-json/src/jvmMain/kotlin/io/kotest/assertions/json/keyvalues.kt index 8fc0c9d1859..426214994b6 100644 --- a/kotest-assertions/kotest-assertions-json/src/jvmMain/kotlin/io/kotest/assertions/json/keyvalues.kt +++ b/kotest-assertions/kotest-assertions-json/src/jvmMain/kotlin/io/kotest/assertions/json/keyvalues.kt @@ -7,6 +7,7 @@ import io.kotest.assertions.Actual import io.kotest.assertions.Expected import io.kotest.assertions.intellijFormatError import io.kotest.assertions.print.print +import io.kotest.common.KotestInternal import io.kotest.matchers.Matcher import io.kotest.matchers.MatcherResult import io.kotest.matchers.should @@ -25,9 +26,9 @@ inline fun String.shouldNotContainJsonKeyValue(path: String, value: this shouldNot containJsonKeyValue(path, value) inline fun containJsonKeyValue(path: String, t: T) = object : Matcher { - private fun keyIsAbsentFailure() = MatcherResult( + private fun keyIsAbsentFailure(validSubPathDescription: String) = MatcherResult( false, - { "Expected given to contain json key <'$path'> but key was not found." }, + { "Expected given to contain json key <'$path'> but key was not found.$validSubPathDescription" }, { "Expected given to not contain json key <'$path'> but key was found." } ) @@ -54,16 +55,69 @@ inline fun containJsonKeyValue(path: String, t: T) = object : Matche if (value.length < 50) value.trim() else value.substring(0, 50).trim() + "..." - val actualKeyValue = extractKey(value) - val passed = t == actualKeyValue - if (!passed && actualKeyValue == null) return keyIsAbsentFailure() - - return MatcherResult( - passed, - { "Value mismatch at '$path': ${intellijFormatError(Expected(t.print()), Actual(actualKeyValue.print()))}" }, - { - "$sub should not contain the element $path = $t" - } - ) + when(val actualKeyValue = extractByPath(json = value, path = path)) { + is ExtractedValue<*> -> { + val actualValue = actualKeyValue.value + val passed = t == actualValue + return MatcherResult( + passed, + { "Value mismatch at '$path': ${intellijFormatError(Expected(t.print()), Actual(actualValue.print()))}" }, + { + "$sub should not contain the element $path = $t" + } + ) + } + is JsonPathNotFound -> { + val validSubPathDescription = findValidSubPath(value, path)?.let { subpath -> + " Found shorter valid subpath: <'$subpath'>" + } ?: "" + return keyIsAbsentFailure(validSubPathDescription) + } + } + } +} + +@KotestInternal +inline fun extractByPath(json: String?, path: String): ExtractValueOutcome { + val parsedJson = JsonPath.parse(json) + return try { + val extractedValue = parsedJson.read(path, T::class.java) + ExtractedValue(extractedValue) + } catch (e: PathNotFoundException) { + JsonPathNotFound + } catch (e: InvalidPathException) { + throw AssertionError("$path is not a valid JSON path") + } +} + +@KotestInternal +inline fun findValidSubPath(json: String?, path: String): String? { + val parsedJson = JsonPath.parse(json) + var subPath = path + while(subPath.isNotEmpty() && subPath != "$") { + try { + parsedJson.read(subPath, Any::class.java) + return subPath + } catch (e: PathNotFoundException) { + subPath = removeLastPartFromPath(subPath) + } } + return null +} + +@KotestInternal +fun removeLastPartFromPath(path: String): String { + val tokens = path.split(".") + return tokens.take(tokens.size - 1).joinToString(".") } + +@KotestInternal +sealed interface ExtractValueOutcome + +@KotestInternal +data class ExtractedValue( + val value: T +): ExtractValueOutcome + +@KotestInternal +object JsonPathNotFound : ExtractValueOutcome diff --git a/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ContainJsonKeyValueTest.kt b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ContainJsonKeyValueTest.kt index 7a8c44a6a5c..87b2a0d2e07 100644 --- a/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ContainJsonKeyValueTest.kt +++ b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ContainJsonKeyValueTest.kt @@ -31,7 +31,8 @@ class ContainJsonKeyValueTest : StringSpec({ "bicycle": { "color": "red", "price": 19.95, - "code": 1 + "code": 1, + "weight": null } } } @@ -54,6 +55,14 @@ class ContainJsonKeyValueTest : StringSpec({ """.trimIndent() } + "Failure message states if key is missing, shows valid subpath" { + shouldFail { + json.shouldContainJsonKeyValue("$.store.bicycle.engine", "V2") + }.message shouldBe """ + Expected given to contain json key <'$.store.bicycle.engine'> but key was not found. Found shorter valid subpath: <'$.store.bicycle'> + """.trimIndent() + } + "Failure message states states value mismatch if key is present with different value" { shouldFail { json.shouldContainJsonKeyValue("$.store.book[0].price", 9.95) @@ -69,6 +78,7 @@ class ContainJsonKeyValueTest : StringSpec({ json.shouldContainJsonKeyValue("$.store.book[1].author", "Evelyn Waugh") json.shouldContainJsonKeyValue("$.store.book[1].author", "Evelyn Waugh") json.shouldContainJsonKeyValue("$.store.bicycle.code", 1L) + json.shouldContainJsonKeyValue("$.store.bicycle.weight", null) json.shouldNotContainJsonKeyValue("$.store.book[1].author", "JK Rowling") diff --git a/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ExtractByPathTest.kt b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ExtractByPathTest.kt new file mode 100644 index 00000000000..988a02f9a84 --- /dev/null +++ b/kotest-assertions/kotest-assertions-json/src/jvmTest/kotlin/com/sksamuel/kotest/tests/json/ExtractByPathTest.kt @@ -0,0 +1,65 @@ +package com.sksamuel.kotest.tests.json + +import io.kotest.assertions.json.ExtractedValue +import io.kotest.assertions.json.JsonPathNotFound +import io.kotest.assertions.json.extractByPath +import io.kotest.assertions.json.findValidSubPath +import io.kotest.assertions.json.removeLastPartFromPath +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.shouldBe +import org.intellij.lang.annotations.Language + +class ExtractByPathTest: WordSpec() { + @Language("JSON") + private val json = """ + { + "ingredients": ["Rice", "Water", "Salt"], + "appliance": { + "type": "Stove", + "kind": "Electric" + }, + "regime": { + "temperature": { + "degrees": 320, + "unit": "F" + } + }, + "comments": null + } + """.trimIndent() + + init { + "extractByPath" should { + "find not null value by valid path" { + extractByPath(json, "$.regime.temperature.unit") shouldBe ExtractedValue("F") + } + "find null value by valid path" { + extractByPath(json, "$.comments") shouldBe ExtractedValue(null) + } + "path not found" { + extractByPath(json, "$.regime.temperature.unit.name") shouldBe JsonPathNotFound + } + } + + "removeLastPartFromPath" should { + "remove last part" { + removeLastPartFromPath("$.regime.temperature.unit.name") shouldBe "$.regime.temperature.unit" + removeLastPartFromPath("$.regime.temperature.unit") shouldBe "$.regime.temperature" + removeLastPartFromPath("$.regime.temperature") shouldBe "$.regime" + removeLastPartFromPath("$.regime") shouldBe "$" + } + } + + "findValidSubPath" should { + "find valid sub path" { + findValidSubPath(json, "$.regime.temperature.unit.name.some.more.tokens") shouldBe "$.regime.temperature.unit" + findValidSubPath(json, "$.regime.temperature.unit.name") shouldBe "$.regime.temperature.unit" + findValidSubPath(json, "$.regime.temperature.name") shouldBe "$.regime.temperature" + findValidSubPath(json, "$.regime.no_such_element") shouldBe "$.regime" + } + "return null when nothing found" { + findValidSubPath(json, "$.no.such.path") shouldBe null + } + } + } +}