Skip to main content

JSON Fields

JSON fields implement MatcherField<JsonNode, R> and extract values via Jackson's JsonNode.at(path). No additional JSON library is required beyond Jackson.

All path values are JSONPointer strings — they start with / and use / as a separator (e.g. /profiles/0/supplier).

Artifact: dev.kensa:kensa-kotest-test-support-json

Source

The only difference between the Kotest and Hamkrest variants is the package of the base MatcherField interface.


JsonField<TARGET>

The base class. Takes a path and an extractor function (JsonNode, String) -> TARGET?.

open class JsonField<TARGET>(
val path: String,
private val fn: (JsonNode, String) -> TARGET?
) : MatcherField<JsonNode, TARGET>

description is overridden to return path directly, so failure messages show the JSONPointer rather than a camelCase-split name.

Most users will use a typed subclass. Reach for JsonField directly when none of the typed subclasses fit — for example, to extract a computed value:

val profileCount = JsonField("/profiles") { node, path ->
(node.at(path) as? ArrayNode)?.size()
}

Typed subclasses

Declare these in an object or companion object. Each accepts a JSONPointer path string.

ClassExtracted typeNotes
JsonTextField(path)String?textValue() — null for non-textual nodes
JsonIntField(path)Int?null for absent / non-numeric
JsonLongField(path)Long?null for absent / non-numeric
JsonBooleanField(path)Boolean?null for absent / non-boolean
JsonNodeField(path)JsonNode?null if absent or null-valued
ArrayNodeField(path)ArrayNode?null if absent or not an array
object FeasibilityResponseFields {
val anAddressPostcode = JsonTextField("/address/postcode")
val serviceable = JsonBooleanField("/serviceable")
val profiles = ArrayNodeField("/profiles")
val fastestProfileSupplier = JsonTextField("/profiles/0/supplier")
val fastestProfileDownloadSpeed = JsonIntField("/profiles/0/downloadSpeed")
val fastestProfileUploadSpeed = JsonIntField("/profiles/0/uploadSpeed")
val fastestProfileType = JsonTextField("/profiles/0/type")

// Custom extraction using JsonField directly:
val profileCount = JsonField("/profiles") { node, path ->
(node.at(path) as? ArrayNode)?.size()
}
}

Map navigation — JsonMapField, JsonMapKey, JsonMapKeyField

Use these when an array of objects must be navigated by a key attribute rather than by index.

JsonMapField

open class JsonMapField(val path: String, private val keyName: String)
: MatcherField<JsonNode, Map<String, JsonNode>>

Extracts the array at path, then builds a Map<String, JsonNode> keyed by each element's keyName text attribute.

// JSON: { "items": [ { "code": "A", "price": 10 }, { "code": "B", "price": 20 } ] }
val items = JsonMapField("/items", "code")
// items.extract(json) → { "A" -> {...}, "B" -> {...} }

JsonMapKeyField<TARGET>

open class JsonMapKeyField<TARGET>(
override val keyName: String,
private val fn: (JsonNode) -> TARGET?
) : JsonMapKey, MatcherField<Map<String, JsonNode>, TARGET>

Selects a key from the map extracted by JsonMapField and applies fn to the value node.

val itemAPrice = JsonMapKeyField<Int>("A") { it.at("/price").intValue() }

JsonMapKey

Marker interface. Implement it when you need to reference a key name outside a JsonMapKeyField.


transformX factories

The transformX functions build the extractor (JsonNode, String) -> TARGET? used by JsonField. Use them when you have a domain type and want to map a primitive JSON value to it.

FactorySource typeUse when
transformString { it }String?Text → domain type
transformInt { it }Int?Integer → domain type
transformLong { it }Long?Long → domain type
transformBoolean { it }Boolean?Boolean → domain type
transformDouble { it }Double?Double → domain type
// A domain type backed by a string in JSON:
val aProviderCode = JsonField("/supplier/code", transformString { ProviderCode(it) })

// Combine with toMatcher for a custom infix operator:
infix fun <T : Any> MatcherField<T, ProviderCode>.withCode(expected: String) =
toMatcher(expected) { it.code }

The lambda receives the already-extracted primitive; return null to signal a missing or invalid value.


JsonNode extension helpers

Defined in JsonNodeExtensions.kt. Used internally by the field classes and available for direct use.

FunctionReturns
JsonNode.textValueOrNullAt(path)String?textValue() or null
JsonNode.intValueOrNullAt(path)Int? — null if absent or non-numeric
JsonNode.longValueOrNullAt(path)Long? — null if absent or non-numeric
JsonNode.booleanValueOrNullAt(path)Boolean? — accepts JSON boolean or textual "true"/"false"
JsonNode.doubleValueOrNullAt(path)Double? — null if absent or non-numeric
JsonNode.nodeOrNullAt(path)JsonNode? — null if absent or null-valued
JsonNode.arrayNodeAt(path)ArrayNode — throws IllegalArgumentException if absent or not an array
JsonNode.transform(nodeAccessor, targetTransform)TARGET? — two-stage nullable pipeline; used by transformX

Worked example

import com.clearwave.fields.FeasibilityResponseFields.anAddressPostcode
import com.clearwave.fields.FeasibilityResponseFields.fastestProfileDownloadSpeed
import com.clearwave.fields.FeasibilityResponseFields.fastestProfileSupplier
import com.clearwave.fields.FeasibilityResponseFields.profileCount
import com.clearwave.fields.FeasibilityResponseFields.serviceable
import dev.kensa.kotest.testsupport.field.of

@Test
fun `feasibility response shape — asserted via JsonField DSL`() {
given(bothSuppliersAreServiceable())
whenever(aFeasibilityCheckIsRequestedForTheServiceAddress())

then(theFeasibilityResponseAsJson(),
(anAddressPostcode of fixtures[postcode])
.and(serviceable of true)
.and(profileCount of 3)
.and(fastestProfileSupplier of fixtures[voiceSupplier])
.and(fastestProfileDownloadSpeed of fixtures[voiceDownloadSpeed])
)
}

private fun theFeasibilityResponseAsJson() =
StateCollector { mapper.valueToTree<JsonNode>(outputs[FeasibilityResult]) }

For the full working example see FeasibilityResponseFields.kt and FieldDslExamplesTest.kt in clearwave-example.