Skip to content

Commit

Permalink
Partial key (#65) (#4013)
Browse files Browse the repository at this point in the history
If a key is missing in json, try finding a valid subpath:

```kotlin
   "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()
   }
```

I had to add a few public methods, as I couldn't invoke internal ones
from a public inline function.

Also made sure that we distinguish between these two cases:
* key not found
* key found, the corresponding value is null

Ideally we short also find indexes out of bound such as 
```kotlin
      json.shouldContainJsonKeyValue("$.store.book[2].author", "Evelyn Waugh")

saying <'$.store.book'> has only 2 elements
```
But that might be done later in another PR. WDYT @Kantis ?

---------

Co-authored-by: Sam <sam@sksamuel.com>
  • Loading branch information
AlexCue987 and sksamuel committed May 20, 2024
1 parent 7d07732 commit 6f371fb
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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;
Expand Down Expand Up @@ -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 <init> (Lio/kotest/assertions/json/JsonNode;Ljava/lang/String;)V
public final fun component1 ()Lio/kotest/assertions/json/JsonNode;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,9 +26,9 @@ inline fun <reified T> String.shouldNotContainJsonKeyValue(path: String, value:
this shouldNot containJsonKeyValue(path, value)

inline fun <reified T> containJsonKeyValue(path: String, t: T) = object : Matcher<String?> {
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." }
)

Expand All @@ -54,16 +55,69 @@ inline fun <reified T> 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<T>(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<reified T> 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<T>(
val value: T
): ExtractValueOutcome

@KotestInternal
object JsonPathNotFound : ExtractValueOutcome
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class ContainJsonKeyValueTest : StringSpec({
"bicycle": {
"color": "red",
"price": 19.95,
"code": 1
"code": 1,
"weight": null
}
}
}
Expand All @@ -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)
Expand All @@ -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<Int?>("$.store.bicycle.weight", null)

json.shouldNotContainJsonKeyValue("$.store.book[1].author", "JK Rowling")

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>(json, "$.regime.temperature.unit") shouldBe ExtractedValue("F")
}
"find null value by valid path" {
extractByPath<String>(json, "$.comments") shouldBe ExtractedValue(null)
}
"path not found" {
extractByPath<String>(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
}
}
}
}

0 comments on commit 6f371fb

Please sign in to comment.