diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a8488..2feaa04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3de2ebc..990512b 100644 --- a/README.md +++ b/README.md @@ -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() { @@ -117,7 +117,6 @@ val person = json.decodeFromString( { "id": "42", "name": "Grace", - "is_active": true, "futureField": "preserved" } """.trimIndent() @@ -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 @@ -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. @@ -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 diff --git a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt index 3b0aac2..247d560 100644 --- a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt +++ b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt @@ -15,6 +15,10 @@ interface ObjectBacked { fun removeElement(key: K) } +interface ObjectBackedDefaults { + fun registerDefaultElement(key: K, value: V) +} + /** Optional parse-time validation hook for required delegated properties. */ interface ObjectBackedValidated { fun validate() diff --git a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt index d9dc10f..e7980b6 100644 --- a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt +++ b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt @@ -22,15 +22,35 @@ class BackedProperty( private val serializer: KSerializer, private val nullWriteMode: NullWriteMode, ) : ReadWriteProperty where O : ObjectBacked { + private var hasDefault: Boolean = false + private var defaultValue: T? = null + + constructor( + key: K?, + serializer: KSerializer, + nullWriteMode: NullWriteMode, + defaultValue: T, + ) : this(key, serializer, nullWriteMode) { + this.hasDefault = true + this.defaultValue = defaultValue + } + + operator fun provideDelegate(thisRef: O, property: KProperty<*>): ReadWriteProperty { + 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) { @@ -54,6 +74,35 @@ class BackedProperty( 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 + @Suppress("UNCHECKED_CAST") + (defaults as ObjectBackedDefaults).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) + } + + 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 @@ -67,3 +116,11 @@ inline fun backedProperty( nullWriteMode: NullWriteMode, ): ReadWriteProperty where O : ObjectBacked = BackedProperty(key, serializer, nullWriteMode) + +inline fun backedProperty( + key: K? = null, + serializer: KSerializer = serializer(), + nullWriteMode: NullWriteMode, + defaultValue: T, +): BackedProperty where O : ObjectBacked = + BackedProperty(key, serializer, nullWriteMode, defaultValue) diff --git a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedObject.kt b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedObject.kt index 9600025..55f2b48 100644 --- a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedObject.kt +++ b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedObject.kt @@ -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 @@ -32,12 +34,28 @@ class JsonBackingCodec( open class JsonObjectBacked( initial: JsonObject, override val codec: JsonBackingCodec = JsonBackingCodec(), -) : ObjectBacked { +) : ObjectBacked, ObjectBackedDefaults { private val backing: MutableMap = initial.toMutableMap() + private val defaultElements: MutableMap = 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 @@ -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 + } } /** @@ -63,5 +85,13 @@ inline fun jsonProperty( ): ReadWriteProperty = backedProperty(key, serializer, nullWriteMode) +inline fun jsonProperty( + key: String? = null, + serializer: KSerializer = serializer(), + nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL, + defaultValue: T, +): BackedProperty = + backedProperty(key, serializer, nullWriteMode, defaultValue) + inline fun jsonSlice(serializer: KSerializer = serializer()): ReadOnlyProperty = ReadOnlyProperty { thisRef, _ -> thisRef.codec.decode(serializer, thisRef.rawObject) } diff --git a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBackedSerializer.kt b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBackedSerializer.kt index d341d32..bbfc538 100644 --- a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBackedSerializer.kt +++ b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBackedSerializer.kt @@ -30,6 +30,6 @@ class JsonObjectBackedSerializer( 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)) } } diff --git a/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt b/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt index 0e238d7..3d41561 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt @@ -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, @@ -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") @@ -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") diff --git a/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt b/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt index e7d2063..135e7d8 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt @@ -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 = """ @@ -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, + json: Json = joseCompliantSerializer, + ) : this( + json.encodeToJsonElement(jwtBase).jsonObject + .strictUnion(json.encodeToJsonElement(keyAttestationClaims).jsonObject) + .strictUnion(JsonObject(misc)), + json + ) /** * We can serialize into data classes */ @@ -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) +} diff --git a/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt b/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt index 11b8b1e..a3d6893 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt @@ -6,6 +6,7 @@ import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject @@ -34,6 +35,36 @@ internal val SignumInteropTest by matrixSuite { joseCompliantSerializer.decodeFromString(forwarded) shouldBe josefKeyAttestation } + "keeps slice defaults read-only and absent from forwarded claims" { + val source = joseCompliantSerializer.decodeFromString(keyAttestationJwtClaims) + val futureClaim = JsonObject(mapOf("nested" to JsonPrimitive(true))) + val keyAttestation = KeyAttestation( + jwtBase = source.jsonWebToken, + keyAttestationClaims = source.keyAttestationClaims.copy( + keyStorage = null, + userAuthentication = null, + certification = null, + ), + misc = mapOf("future_claim" to futureClaim), + ) + + keyAttestation.keyAttestationClaims.keyStorage shouldBe null + keyAttestation.keyAttestationClaims.userAuthentication shouldBe null + keyAttestation.keyAttestationClaims.certification shouldBe null + keyAttestation.rawObject["key_storage"] shouldBe null + keyAttestation.rawObject["user_authentication"] shouldBe null + keyAttestation.rawObject["certification"] shouldBe null + + val jsonWithDefaults = Json { encodeDefaults = true } + val forwarded = jsonWithDefaults.encodeToString(KeyAttestation.serializer(), keyAttestation) + val forwardedClaims = jsonWithDefaults.parseToJsonElement(forwarded).jsonObject + + forwardedClaims["key_storage"] shouldBe null + forwardedClaims["user_authentication"] shouldBe null + forwardedClaims["certification"] shouldBe null + forwardedClaims["future_claim"] shouldBe futureClaim + } + "rejects claims missing mandatory key-attestation fields" { shouldThrow { joseCompliantSerializer.decodeFromString( diff --git a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedObject.kt b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedObject.kt index ce8d58f..420cfb2 100644 --- a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedObject.kt +++ b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedObject.kt @@ -26,8 +26,9 @@ class YamlBackingCodec( open class YamlObjectBacked( initial: YamlMap, override val codec: YamlBackingCodec = YamlBackingCodec(), -) : ObjectBacked { +) : ObjectBacked, ObjectBackedDefaults { private val backing: MutableMap = initial.content.toMutableMap() + private val defaultElements: MutableMap = mutableMapOf() val rawObject: YamlMap get() = YamlMap(backing.toMap()) @@ -43,6 +44,10 @@ open class YamlObjectBacked( findKey(key)?.let { backing.remove(it) } } + override fun registerDefaultElement(key: String, value: YamlElement) { + defaultElements[key] = value + } + private fun findKey(key: String): YamlElement? = backing.keys.firstOrNull { it.content == key } } @@ -53,5 +58,13 @@ inline fun yamlProperty( ): ReadWriteProperty = backedProperty(key, serializer, nullWriteMode) +inline fun yamlProperty( + key: String? = null, + serializer: KSerializer = serializer(), + nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL, + defaultValue: T, +): BackedProperty = + backedProperty(key, serializer, nullWriteMode, defaultValue) + inline fun yamlSlice(serializer: KSerializer = serializer()): ReadOnlyProperty = - ReadOnlyProperty { thisRef, _ -> thisRef.codec.decode(serializer, thisRef.rawObject) } \ No newline at end of file + ReadOnlyProperty { thisRef, _ -> thisRef.codec.decode(serializer, thisRef.rawObject) } diff --git a/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt b/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt index a4d126b..9b42b92 100644 --- a/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt +++ b/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt @@ -22,6 +22,8 @@ private class PersonYamlObject( override var id: String by yamlProperty() override var name: String by yamlProperty() override var renamed: Boolean by yamlProperty("some_yaml_key") + var active: Boolean by yamlProperty(defaultValue = true) + var nullableDefault: String? by yamlProperty("nullable_default", defaultValue = null) val readOnlyName: String by yamlProperty("name") override val foo: ObjectBackedTestPerson.Foo by yamlProperty("foo") @@ -81,6 +83,23 @@ internal val YamlObjectBackedTest by matrixSuite { obj.readOnlyNickname shouldBe "countess" } + "read configured defaults from missing backing keys" { + val obj = PersonYamlObject( + YamlMap( + mapOf( + YamlPrimitive("id") to YamlPrimitive("p-1"), + YamlPrimitive("name") to YamlPrimitive("Ada"), + YamlPrimitive("some_yaml_key") to YamlPrimitive("true"), + ), + ), + ) + + obj.active shouldBe true + obj.nullableDefault shouldBe null + obj.rawObject["active"] shouldBe null + obj.rawObject["nullable_default"] shouldBe null + } + "resolve slice from the backing object" { val obj = object : YamlObjectBacked( YamlMap(