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).
- Kotest
- Hamkrest
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.
| Class | Extracted type | Notes |
|---|---|---|
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.
| Factory | Source type | Use 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.
| Function | Returns |
|---|---|
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
- Kotest
- Hamkrest
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]) }
import com.natpryce.hamkrest.and
import com.natpryce.hamkrest.assertion.assertThat
import dev.kensa.hamkrest.testsupport.field.of
import com.example.fields.FeasibilityResponseFields.anAddressPostcode
import com.example.fields.FeasibilityResponseFields.fastestProfileDownloadSpeed
import com.example.fields.FeasibilityResponseFields.fastestProfileSupplier
import com.example.fields.FeasibilityResponseFields.profileCount
import com.example.fields.FeasibilityResponseFields.serviceable
@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.