Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog
### Unreleased
- Updated backed property nullability so nullable fields are declared with nullable Kotlin types; this keeps the same optional-field functionality without a separate nullable delegate.
- Added default values for backed delegates via `backedProperty`, `jsonProperty`, and `yamlProperty`; missing backing keys can now read from configured defaults, including nullable defaults.
- JSON-backed serialization now respects `Json.encodeDefaults` for defaulted delegated properties, omitting default-valued keys by default and emitting them when default encoding is enabled.

### Version 0.0.1
- Initial version
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class PersonJsonObject(
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
var id: String by jsonProperty()
var name: String by jsonProperty()
var active: Boolean by jsonProperty("is_active")
var active: Boolean by jsonProperty("is_active", defaultValue = true)
var nickname: String? by jsonProperty("nick", nullWriteMode = NullWriteMode.REMOVE_KEY)

override fun validate() {
Expand All @@ -117,7 +117,6 @@ val person = json.decodeFromString(
{
"id": "42",
"name": "Grace",
"is_active": true,
"futureField": "preserved"
}
""".trimIndent()
Expand All @@ -144,6 +143,7 @@ class ServiceYamlObject(
var id: String by yamlProperty()
var endpoint: String by yamlProperty()
var description: String? by yamlProperty()
var enabled: Boolean by yamlProperty(defaultValue = true)

override fun validate() {
id
Expand Down Expand Up @@ -182,6 +182,7 @@ The property type, together with the supplied serializer, defines the nullabilit
var id: String by jsonProperty()
var displayName: String by jsonProperty("display_name")
var nickname: String? by jsonProperty("nick")
var active: Boolean by jsonProperty(defaultValue = true)
```

If no key is supplied, the Kotlin property name is used as the object key. If a key is supplied, that key is used instead.
Expand All @@ -195,6 +196,26 @@ val id = person.id // throws if "id" is absent or null
For nullable properties, missing keys and explicit format-native null values both read as `null`.
The serializer must also be nullable; declaring the Kotlin property as `String?` gives the delegate a nullable serializer.

Delegated properties can also define defaults:

```kotlin
var active: Boolean by jsonProperty(defaultValue = true)
var preferredName: String? by jsonProperty("preferred_name", defaultValue = null)
```

For defaulted properties, a missing backing key reads as the configured default instead of failing. Existing backing values still take precedence, and reading the default does not insert the key into `rawObject`.

JSON-backed serialization respects the wrapper's `Json.encodeDefaults` setting for defaulted delegates:

```kotlin
val json = Json { encodeDefaults = true }
val encoded = json.encodeToString(PersonJsonObject.serializer(), person)
```

With the normal JSON default of `encodeDefaults = false`, properties currently equal to their configured default are omitted from the serialized object. With `encodeDefaults = true`, missing defaulted keys are emitted with their default value.

YAML-backed properties support the same defaulted read behavior with `yamlProperty(defaultValue = ...)`. YAML serialization writes the current backing object, so missing defaulted keys remain absent unless you assign the property.

Read-only views are also useful:

```kotlin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ interface ObjectBacked<K, V> {
fun removeElement(key: K)
}

interface ObjectBackedDefaults<K, V> {
fun registerDefaultElement(key: K, value: V)
}

/** Optional parse-time validation hook for required delegated properties. */
interface ObjectBackedValidated {
fun validate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,35 @@ class BackedProperty<O, K, V, T>(
private val serializer: KSerializer<T>,
private val nullWriteMode: NullWriteMode,
) : ReadWriteProperty<O, T> where O : ObjectBacked<K, V> {
private var hasDefault: Boolean = false
private var defaultValue: T? = null

constructor(
key: K?,
serializer: KSerializer<T>,
nullWriteMode: NullWriteMode,
defaultValue: T,
) : this(key, serializer, nullWriteMode) {
this.hasDefault = true
this.defaultValue = defaultValue
}

operator fun provideDelegate(thisRef: O, property: KProperty<*>): ReadWriteProperty<O, T> {
registerDefault(thisRef, property)
return this
}

override fun getValue(thisRef: O, property: KProperty<*>): T {
registerDefault(thisRef, property)
val actualKey = actualKey(property)
val element = thisRef.getElement(actualKey)
?: return readNull(actualKey)
?: return readMissing(actualKey)
if (thisRef.codec.isNull(element)) return readNull(actualKey)
return thisRef.codec.decode(serializer, element)
}

override fun setValue(thisRef: O, property: KProperty<*>, value: T) {
registerDefault(thisRef, property)
val actualKey = actualKey(property)
if (value == null) {
if (!serializer.descriptor.isNullable) {
Expand All @@ -54,6 +74,35 @@ class BackedProperty<O, K, V, T>(
key ?: (property.name as? K)
?: throw SerializationException("property ${property.name} is not identifiable by backing key type")

private fun registerDefault(thisRef: O, property: KProperty<*>) {
if (!hasDefault) return
val defaults = thisRef as? ObjectBackedDefaults<*, *> ?: return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can this not be prevented in other ways?

@Suppress("UNCHECKED_CAST")
(defaults as ObjectBackedDefaults<K, V>).registerDefaultElement(
actualKey(property),
defaultElement(thisRef),
)
}

private fun defaultElement(thisRef: O): V {
val value = defaultValue()
if (value == null) {
if (!serializer.descriptor.isNullable) {
throw SerializationException("Required backing property cannot default to null")
}
return thisRef.codec.nullElement()
}
return thisRef.codec.encode(serializer, value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is one an accessor and the other an encode call?

}

private fun readMissing(actualKey: K): T {
if (hasDefault) return defaultValue()
return readNull(actualKey)
}

@Suppress("UNCHECKED_CAST")
private fun defaultValue(): T = defaultValue as T

@Suppress("UNCHECKED_CAST")
private fun readNull(actualKey: K): T {
if (serializer.descriptor.isNullable) return null as T
Expand All @@ -67,3 +116,11 @@ inline fun <O, K, V, reified T> backedProperty(
nullWriteMode: NullWriteMode,
): ReadWriteProperty<O, T> where O : ObjectBacked<K, V> =
BackedProperty(key, serializer, nullWriteMode)

inline fun <O, K, V, reified T> backedProperty(
key: K? = null,
serializer: KSerializer<T> = serializer(),
nullWriteMode: NullWriteMode,
defaultValue: T,
): BackedProperty<O, K, V, T> where O : ObjectBacked<K, V> =
BackedProperty(key, serializer, nullWriteMode, defaultValue)
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
package at.asitplus.propigator.json

import at.asitplus.propigator.common.BackingCodec
import at.asitplus.propigator.common.BackedProperty
import at.asitplus.propigator.common.NullWriteMode
import at.asitplus.propigator.common.ObjectBacked
import at.asitplus.propigator.common.ObjectBackedDefaults
import at.asitplus.propigator.common.backedProperty
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
Expand All @@ -32,12 +34,28 @@ class JsonBackingCodec(
open class JsonObjectBacked(
initial: JsonObject,
override val codec: JsonBackingCodec = JsonBackingCodec(),
) : ObjectBacked<String, JsonElement> {
) : ObjectBacked<String, JsonElement>, ObjectBackedDefaults<String, JsonElement> {
private val backing: MutableMap<String, JsonElement> = initial.toMutableMap()
private val defaultElements: MutableMap<String, JsonElement> = mutableMapOf()

val rawObject: JsonObject
get() = JsonObject(backing)

internal fun rawObject(encodeDefaults: Boolean): JsonObject {
if (defaultElements.isEmpty()) return rawObject
val elements = backing.toMutableMap()
if (encodeDefaults) {
defaultElements.forEach { (key, value) ->
if (key !in elements) elements[key] = value
}
} else {
defaultElements.forEach { (key, value) ->
if (elements[key] == value) elements.remove(key)
}
}
return JsonObject(elements)
}

override fun getElement(key: String): JsonElement? = backing[key]
override fun putElement(key: String, value: JsonElement) {
backing[key] = value
Expand All @@ -46,6 +64,10 @@ open class JsonObjectBacked(
override fun removeElement(key: String) {
backing.remove(key)
}

override fun registerDefaultElement(key: String, value: JsonElement) {
defaultElements[key] = value
}
}

/**
Expand All @@ -63,5 +85,13 @@ inline fun <reified T> jsonProperty(
): ReadWriteProperty<JsonObjectBacked, T> =
backedProperty<JsonObjectBacked, String, JsonElement, T>(key, serializer, nullWriteMode)

inline fun <reified T> jsonProperty(
key: String? = null,
serializer: KSerializer<T> = serializer(),
nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL,
defaultValue: T,
): BackedProperty<JsonObjectBacked, String, JsonElement, T> =
backedProperty<JsonObjectBacked, String, JsonElement, T>(key, serializer, nullWriteMode, defaultValue)

inline fun <reified T> jsonSlice(serializer: KSerializer<T> = serializer()): ReadOnlyProperty<JsonObjectBacked, T> =
ReadOnlyProperty { thisRef, _ -> thisRef.codec.decode(serializer, thisRef.rawObject) }
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ class JsonObjectBackedSerializer<T : JsonObjectBacked>(
override fun serialize(encoder: Encoder, value: T) {
val output = encoder as? JsonEncoder
?: error("JsonObjectBackedSerializer only works with kotlinx.serialization JSON")
output.encodeJsonElement(value.rawObject)
output.encodeJsonElement(value.rawObject(output.json.configuration.encodeDefaults))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.*

private val jsonWithDefaults = Json { encodeDefaults = true }

@Serializable(with = PersonJsonObject.Serializer::class)
private class PersonJsonObject(
raw: JsonObject,
Expand All @@ -19,6 +21,8 @@ private class PersonJsonObject(
override var id: String by jsonProperty()
override var name: String by jsonProperty()
override var renamed: Boolean by jsonProperty("some_json_key")
var active: Boolean by jsonProperty(defaultValue = true)
var nullableDefault: String? by jsonProperty("nullable_default", defaultValue = null)
val readOnlyName: String by jsonProperty("name")
override val foo: ObjectBackedTestPerson.Foo by jsonProperty("foo")

Expand Down Expand Up @@ -78,6 +82,59 @@ internal val JsonObjectBackedTest by matrixSuite {
obj.readOnlyNickname shouldBe "countess"
}

"read configured defaults from missing backing keys" {
val obj = PersonJsonObject(buildJsonObject {
put("id", "p-1")
put("name", "Ada")
put("some_json_key", true)
})

obj.active shouldBe true
obj.nullableDefault shouldBe null
obj.rawObject["active"] shouldBe null
obj.rawObject["nullable_default"] shouldBe null

val encodedWithDefaults = jsonWithDefaults.encodeToString(PersonJsonObject.serializer(), obj)
val encodedWithDefaultsObject = Json.parseToJsonElement(encodedWithDefaults).jsonObject
encodedWithDefaultsObject["active"]!!.jsonPrimitive.boolean shouldBe true
encodedWithDefaultsObject["nullable_default"] shouldBe JsonNull
}

"omit default-valued properties unless JSON encodeDefaults is enabled" {
val obj = PersonJsonObject(buildJsonObject {
put("id", "p-1")
put("name", "Ada")
put("some_json_key", true)
put("active", true)
})
obj.nullableDefault = null

val encoded = Json.encodeToString(PersonJsonObject.serializer(), obj)
val encodedObject = Json.parseToJsonElement(encoded).jsonObject
encodedObject["active"] shouldBe null
encodedObject["nullable_default"] shouldBe null

val encodedWithDefaults = jsonWithDefaults.encodeToString(PersonJsonObject.serializer(), obj)
val encodedWithDefaultsObject = Json.parseToJsonElement(encodedWithDefaults).jsonObject
encodedWithDefaultsObject["active"]!!.jsonPrimitive.boolean shouldBe true
encodedWithDefaultsObject["nullable_default"] shouldBe JsonNull
}

"encode non-default values for defaulted properties" {
val obj = PersonJsonObject(buildJsonObject {
put("id", "p-1")
put("name", "Ada")
put("some_json_key", true)
})
obj.active = false
obj.nullableDefault = "present"

val encoded = Json.encodeToString(PersonJsonObject.serializer(), obj)
val encodedObject = Json.parseToJsonElement(encoded).jsonObject
encodedObject["active"]!!.jsonPrimitive.boolean shouldBe false
encodedObject["nullable_default"]!!.jsonPrimitive.content shouldBe "present"
}

"remove nullable keys when configured with REMOVE_KEY mode" {
val obj = PersonJsonObject(buildJsonObject {
put("id", "p-1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import kotlin.time.Instant

internal val keyAttestationJwtClaims = """
Expand Down Expand Up @@ -60,6 +63,18 @@ internal data class KeyAttestation(
private val raw: JsonObject,
private val json: Json = joseCompliantSerializer,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {

constructor(
jwtBase: JsonWebToken,
keyAttestationClaims: KeyAttestationClaims,
misc: Map<String, JsonElement>,
json: Json = joseCompliantSerializer,
) : this(
json.encodeToJsonElement(jwtBase).jsonObject
.strictUnion(json.encodeToJsonElement(keyAttestationClaims).jsonObject)
.strictUnion(JsonObject(misc)),
json
)
/**
* We can serialize into data classes
*/
Expand Down Expand Up @@ -98,3 +113,15 @@ internal data class KeyAttestationClaims(

val certification: String? = null,
)

internal fun JsonObject?.strictUnion(other: JsonObject?): JsonObject {
if (this == null) return other ?: JsonObject(emptyMap())
if (other == null) return this

val duplicates = this.keys intersect other.keys
require(duplicates.isEmpty()) {
"Duplicate keys: ${duplicates.joinToString()}"
}

return JsonObject(this + other)
}
Loading