diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b0b74..22d64a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # Changelog ### Unreleased -- Removed write-through backing logic. Backed properties are now read-only delegates over the preserved raw object. +- Renamed the JSON and YAML format-specific APIs to shorter names. +- Replaced the generic `ObjectBacked`/`ObjectBackedValidated` split with an abstract `ObjectBacked` base class that owns `serialFormat`, `backingObject`, validation, equality, hashing, and string rendering. +- Renamed format wrapper state from `rawObject`/`json`/`yaml` to `backingObject`/`serialFormat` for JSON and YAML backed objects. +- Missing non-null delegated properties now throw `NoSuchElementException` instead of `SerializationException`. +- Removed write-through backing logic. Backed properties are now read-only delegates over the preserved backing object. - Removed the format-agnostic backing codec layer; JSON and YAML wrappers now decode values directly with their configured format instance. -- 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. -- Captured the default JSON/YAML format at object-backed serializer construction time and compare format configuration content, not format instance identity, before serialization. Serializer modules are intentionally not part of this comparison. -- Added JSON-backed property defaults via `jsonProperty(defaultValue = ...)` for absent keys. Serialization still emits the preserved raw `JsonObject` unchanged. +- Updated backed property nullability so nullable fields are declared with nullable Kotlin types. +- Added backed-property defaults through the common delegate layer and `jsonProperty(defaultValue = ...)` for absent JSON keys. Serialization still emits the preserved backing `JsonObject` unchanged. +- Added `JsonObject?.strictUnion(...)` to combine JSON objects while rejecting duplicate keys. +- `JsonBackedSerializerTemplate` now uses the active `JsonDecoder` configuration when creating decoded wrappers instead of accepting a separate `Json` instance. +- Object-backed serializers now compare JSON/YAML configuration content, not format instance identity, before serialization. Serializer modules are intentionally not part of this comparison. ### Version 0.0.1 - Initial version diff --git a/README.md b/README.md index 8230431..603fb2a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ For example, if an incoming JSON object contains a future field: { "id": "42", "name": "Grace", "futureField": "preserved" } ``` -and your wrapper only exposes `id` and `name`, `futureField` stays in `rawObject` and is serialized again unchanged. Delegated properties never rebuild, overwrite, or remove backing fields. +and your wrapper only exposes `id` and `name`, `futureField` stays in `backingObject` and is serialized again unchanged. Delegated properties never rebuild, overwrite, or remove backing fields. ## Using it in your Project @@ -54,14 +54,14 @@ Use `json` for `kotlinx-serialization-json` backed objects and `yaml` for yamlkt ## JSON Quick Start -Define a wrapper around `JsonObjectBacked` and add typed properties with `jsonProperty()`. +Define a wrapper around `JsonBacked` and add typed properties with `jsonProperty()`. ```kotlin -@Serializable(with = PersonJsonObject.Serializer::class) -class PersonJsonObject( - raw: JsonObject, - json: Json = Json.Default, -) : JsonObjectBacked(raw, json), ObjectBackedValidated { +@Serializable(with = PersonJson.Serializer::class) +class PersonJson( + backingObject: JsonObject, + serialFormat: Json = Json.Default, +) : JsonBacked(backingObject, serialFormat) { val id: String by jsonProperty() val name: String by jsonProperty() val active: Boolean by jsonProperty("is_active") @@ -72,7 +72,7 @@ class PersonJsonObject( name } - object Serializer : KSerializer by JsonObjectBackedSerializer(::PersonJsonObject) + object Serializer : KSerializer by JsonBackedSerializerTemplate(::PersonJson) } ``` @@ -80,7 +80,7 @@ class PersonJsonObject( val json = Json.Default val person = json.decodeFromString( - PersonJsonObject.serializer(), + PersonJson.serializer(), """ { "id": "42", @@ -91,21 +91,21 @@ val person = json.decodeFromString( """.trimIndent() ) -val encoded = json.encodeToString(PersonJsonObject.serializer(), person) +val encoded = json.encodeToString(PersonJson.serializer(), person) ``` The encoded JSON still contains `futureField`. ## YAML Quick Start -YAML uses the same pattern with `YamlObjectBacked` and `yamlProperty()`. +YAML uses the same pattern with `YamlBacked` and `yamlProperty()`. ```kotlin -@Serializable(with = ServiceYamlObject.Serializer::class) -class ServiceYamlObject( - raw: YamlMap, - yaml: Yaml = Yaml.Default, -) : YamlObjectBacked(raw, yaml), ObjectBackedValidated { +@Serializable(with = ServiceYaml.Serializer::class) +class ServiceYaml( + backingObject: YamlMap, + serialFormat: Yaml = Yaml.Default, +) : YamlBacked(backingObject, serialFormat) { val id: String by yamlProperty() val endpoint: String by yamlProperty() val description: String? by yamlProperty() @@ -115,7 +115,8 @@ class ServiceYamlObject( endpoint } - object Serializer : KSerializer by YamlObjectBackedSerializer(create = ::ServiceYamlObject) + object Serializer : KSerializer by + Yaml.Default.objectBackedSerializer(::ServiceYaml) } ``` @@ -123,7 +124,7 @@ class ServiceYamlObject( val yaml = Yaml.Default val service = yaml.decodeFromString( - ServiceYamlObject.serializer(), + ServiceYaml.serializer(), """ id: payments endpoint: https://example.test/payments @@ -131,7 +132,7 @@ val service = yaml.decodeFromString( """.trimIndent() ) -val encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service) +val encoded = yaml.encodeToString(ServiceYaml.serializer(), service) ``` `x-vendor-option` is preserved. @@ -151,13 +152,13 @@ val nickname: String? by jsonProperty("nick") 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. -Reading a missing or explicit-null required property throws `SerializationException`: +Reading a missing required property throws `NoSuchElementException`: ```kotlin -val id = person.id // throws if "id" is absent or null +val id = person.id // throws if "id" is absent ``` -For nullable properties, missing keys and explicit format-native null values both read as `null`. +For nullable properties, missing keys read as `null`. The serializer must also be nullable; declaring the Kotlin property as `String?` gives the delegate a nullable serializer. JSON-backed properties can also define a read default for missing keys: @@ -167,7 +168,7 @@ val active: Boolean by jsonProperty(defaultValue = true) val displayName: String by jsonProperty("display_name", defaultValue = "Anonymous") ``` -This mirrors Kotlin serialization's absent-field default semantics for reads. It does not add the field to `rawObject`, and serialization still emits the preserved raw JSON object unchanged. If `Json { encodeDefaults = true }` should affect the serialized payload, construct or receive the backing `JsonObject` with those default fields already present. +This mirrors Kotlin serialization's absent-field default semantics for reads. It does not add the field to `backingObject`, and serialization still emits the preserved backing JSON object unchanged. If `Json { encodeDefaults = true }` should affect the serialized payload, construct or receive the backing `JsonObject` with those default fields already present. ## Whole-Object Slices @@ -181,11 +182,11 @@ data class PublicClaims( val aud: String, ) -@Serializable(with = ClaimsJsonObject.Serializer::class) -class ClaimsJsonObject( - raw: JsonObject, - json: Json = Json.Default, -) : JsonObjectBacked(raw, json), ObjectBackedValidated { +@Serializable(with = ClaimsJson.Serializer::class) +class ClaimsJson( + backingObject: JsonObject, + serialFormat: Json = Json.Default, +) : JsonBacked(backingObject, serialFormat) { val claims: PublicClaims by jsonSlice() val nonce: String? by jsonProperty() @@ -193,7 +194,7 @@ class ClaimsJsonObject( claims } - object Serializer : KSerializer by JsonObjectBackedSerializer(::ClaimsJsonObject) + object Serializer : KSerializer by JsonBackedSerializerTemplate(::ClaimsJson) } ``` @@ -210,13 +211,13 @@ val foo: Foo by yamlSlice() Propigator is designed for parse-not-validate workflows: parse the raw object, expose the fields your workflow needs, and keep the rest untouched. Required delegated fields are checked when read: ```kotlin -val person = json.decodeFromString(PersonJsonObject.serializer(), payload) +val person = json.decodeFromString(PersonJson.serializer(), payload) // Missing "name" fails here, when the property is needed. println(person.name) ``` -For parse-time checks, implement `ObjectBackedValidated` and touch only the fields your component requires: +For parse-time checks, override `validate()` and touch only the fields your component requires: ```kotlin override fun validate() { @@ -225,7 +226,7 @@ override fun validate() { } ``` -The format serializer calls `validate()` after decoding if the object implements `ObjectBackedValidated`. +The format serializer calls `validate()` after decoding. The default implementation does nothing. This is useful for open-ended formats such as JOSE, where one layer may need typed access to a few fields while preserving claims, headers, or extensions for a later validation layer. ## Extension Properties @@ -233,46 +234,74 @@ This is useful for open-ended formats such as JOSE, where one layer may need typ You can add semantic fields outside the nominal wrapper class: ```kotlin -val PersonJsonObject.locale: String? by jsonProperty("locale") +val PersonJson.locale: String? by jsonProperty("locale") -val PersonJsonObject.displayLabel: String +val PersonJson.displayLabel: String get() = locale?.let { "$name ($it)" } ?: name ``` ## Raw Objects and Serializers -Use `rawObject` to inspect, pass through, or debug the complete backing object: +Use `backingObject` to inspect, pass through, or debug the complete backing object: ```kotlin -val raw: JsonObject = person.rawObject +val raw: JsonObject = person.backingObject ``` -For JSON, `rawObject` is a `JsonObject`; for YAML, it is a `YamlMap`. To create a modified payload, build a new format object and wrap it. +For JSON, `backingObject` is a `JsonObject`; for YAML, it is a `YamlMap`. To create a modified payload, build a new format object and wrap it. Attach a serializer to each wrapper type: ```kotlin -@Serializable(with = PersonJsonObject.Serializer::class) -class PersonJsonObject(...) : JsonObjectBacked(...) { - object Serializer : KSerializer by JsonObjectBackedSerializer(::PersonJsonObject) +@Serializable(with = PersonJson.Serializer::class) +class PersonJson(...) : JsonBacked(...) { + object Serializer : KSerializer by JsonBackedSerializerTemplate(::PersonJson) } ``` -The serializer wraps the raw object on decode and serializes the same raw object on encode. Delegated properties are semantic accessors, not constructor properties. +The serializer wraps the backing object on decode and serializes the same backing object on encode. Delegated properties are semantic accessors, not constructor properties. -Each wrapper keeps the `Json` or `Yaml` instance passed to its constructor. Pass a custom format to the object-backed serializer when delegated properties need non-default settings or serializers: +Each wrapper keeps the `Json` or `Yaml` instance passed to its constructor as `serialFormat`. When a JSON-backed wrapper is deserialized, it retains the `Json` instance performing the decode. Delegated properties therefore use the same configuration and serializers module as the outer decode: ```kotlin private val personJson = Json { ignoreUnknownKeys = true } -object Serializer : KSerializer by JsonObjectBackedSerializer( - create = ::PersonJsonObject, - json = personJson, -) +val person = personJson.decodeFromString(payload) +``` + +YAMLKt does not expose the active `Yaml` instance through its decoder. The default serializer can +bind `Yaml.Default`, as in the quick start above. For custom configuration, bind the serializer +explicitly and use the same `Yaml` instance for both decoding and encoding: + +```kotlin +private val serviceYaml = Yaml { /* custom configuration */ } +private val serviceSerializer = serviceYaml.objectBackedSerializer(::ServiceYaml) + +val service = serviceYaml.decodeFromString(serviceSerializer, payload) +val encoded = serviceYaml.encodeToString(serviceSerializer, service) ``` Encoding rejects mismatching format configuration content because the wrapper's configured format owns the serialization shape. Separate `Json` or `Yaml` instances with the same relevant settings are accepted. +## Building Backing JSON + +When constructing a JSON-backed object from existing serializable values, encode those values to `JsonObject`s and merge them before wrapping. `strictUnion()` combines two JSON objects and rejects duplicate keys: + +```kotlin +constructor( + personData: PersonData, + addressData: AddressData? = null, + serialFormat: Json = Json.Default, +) : this( + backingObject = serialFormat.encodeToJsonElement(personData).jsonObject.strictUnion( + addressData?.let { serialFormat.encodeToJsonElement(it).jsonObject } + ), + serialFormat = serialFormat, +) +``` + +`strictUnion()` accepts `null` on either side. Duplicate keys throw `IllegalArgumentException` so overlapping generated fields cannot silently overwrite each other. + ## Choosing Data Classes vs Propigator Use regular `@Serializable` data classes when: @@ -293,7 +322,7 @@ Use Propigator when: - Propigator wraps object/map payloads, not arbitrary top-level scalar values. - YAML element conversion is intentionally simple: individual values are rendered through YAML and decoded again with `yamlkt`. - Delegated properties are runtime accessors. They are not constructor properties and do not appear as separate generated serialization fields. -- `ObjectBackedValidated` validates only what your `validate()` function reads. +- `validate()` checks only what your override reads. ## Contributing 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 1dc50a7..3850b0a 100644 --- a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt +++ b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBacked.kt @@ -4,15 +4,23 @@ package at.asitplus.propigator.common import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialFormat -/** Minimal read-only key/value view needed by the delegates. */ -interface ObjectBacked { - fun getElement(key: String): V? - fun isNull(element: V): Boolean - fun decode(serializer: KSerializer, element: V): T -} +abstract class ObjectBacked { + abstract val serialFormat: SerialFormat + abstract val backingObject: Any + abstract fun isFormatNull(element: Any?): Boolean + abstract fun getElement(key: String, serializer: KSerializer): V? + abstract fun getSlice(serializer: KSerializer): S + + /** Optional parse-time validation hook for required delegated properties. */ + open fun validate(): Unit = Unit + + override fun equals(other: Any?): Boolean { + if (other !is ObjectBacked) return false + return backingObject == other.backingObject + } -/** Optional parse-time validation hook for required delegated properties. */ -interface ObjectBackedValidated { - fun validate() + override fun hashCode(): Int = backingObject.hashCode() + override fun toString(): String = backingObject.toString() } 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 8a3a27a..a8d5dfa 100644 --- a/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt +++ b/common/src/commonMain/kotlin/at/asitplus/propigator/common/ObjectBackedProperty.kt @@ -4,31 +4,62 @@ package at.asitplus.propigator.common import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException import kotlinx.serialization.serializer import kotlin.properties.ReadOnlyProperty @PublishedApi -internal fun createBackedProperty( +internal fun createBackedProperty( key: String?, - serializer: KSerializer, -): ReadOnlyProperty where O : ObjectBacked = + serializer: KSerializer +): ReadOnlyProperty = ReadOnlyProperty { thisRef, property -> val actualKey = key ?: property.name - val element = thisRef.getElement(actualKey) - ?: return@ReadOnlyProperty readNull(serializer, actualKey) - if (thisRef.isNull(element)) return@ReadOnlyProperty readNull(serializer, actualKey) - thisRef.decode(serializer, element) + val element: V = thisRef.getElement(actualKey, serializer) + ?: return@ReadOnlyProperty missingValue(actualKey, serializer) + if (thisRef.isFormatNull(element)) return@ReadOnlyProperty missingValue(actualKey, serializer) + element } -@Suppress("UNCHECKED_CAST") -private fun readNull(serializer: KSerializer, actualKey: String): T { - if (serializer.descriptor.isNullable) return null as T - throw SerializationException("Missing required backing property: $actualKey") +@PublishedApi +internal fun createDefaultProperty( + key: String?, + defaultValue: V, + serializer: KSerializer, +): ReadOnlyProperty = + ReadOnlyProperty { thisRef, property -> + val actualKey = key ?: property.name + val element: V = thisRef.getElement(actualKey, serializer) + ?: return@ReadOnlyProperty defaultValue + if (thisRef.isFormatNull(element)) return@ReadOnlyProperty missingValue(actualKey, serializer) + element + } + +private fun missingValue( + key: String, + serializer: KSerializer +): V { + if (serializer.descriptor.isNullable) { + @Suppress("UNCHECKED_CAST") + return null as V + } + throw NoSuchElementException("Missing property: $key") } -inline fun backedProperty( +inline fun backedProperty( key: String? = null, - serializer: KSerializer = serializer(), -): ReadOnlyProperty where O : ObjectBacked = + serializer: KSerializer = serializer(), +): ReadOnlyProperty = createBackedProperty(key, serializer) + +inline fun backedProperty( + key: String? = null, + defaultValue: V, + serializer: KSerializer = serializer(), +): ReadOnlyProperty = + createDefaultProperty(key, defaultValue, serializer) + +inline fun slice( + serializer: KSerializer = serializer() +): ReadOnlyProperty = ReadOnlyProperty { thisRef, _ -> + thisRef.getSlice(serializer) +} \ No newline at end of file diff --git a/common/src/commonTest/kotlin/at/asitplus/propigator/common/ObjectBackedTestData.kt b/common/src/commonTest/kotlin/at/asitplus/propigator/common/ObjectBackedTestData.kt index 5f95990..12a501a 100644 --- a/common/src/commonTest/kotlin/at/asitplus/propigator/common/ObjectBackedTestData.kt +++ b/common/src/commonTest/kotlin/at/asitplus/propigator/common/ObjectBackedTestData.kt @@ -2,13 +2,13 @@ package at.asitplus.propigator.common import kotlinx.serialization.Serializable -internal interface ObjectBackedTestPerson : ObjectBackedValidated { +internal interface ObjectBackedTestPerson { val id: String val name: String val renamed: Boolean val foo: Foo - override fun validate() { + fun validate() { id name foo diff --git a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBacked.kt b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBacked.kt new file mode 100644 index 0000000..c54c333 --- /dev/null +++ b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBacked.kt @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH +// SPDX-License-Identifier: Apache-2.0 + +package at.asitplus.propigator.json + +import at.asitplus.propigator.common.ObjectBacked +import at.asitplus.propigator.common.backedProperty +import at.asitplus.propigator.common.slice +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer +import kotlin.properties.ReadOnlyProperty + +typealias JsonProperty = + ReadOnlyProperty + +open class JsonBacked( + override val backingObject: JsonObject, + override val serialFormat: Json = Json.Default, +) : ObjectBacked() { + override fun isFormatNull(element: Any?): Boolean = element is JsonNull + + override fun getElement(key: String, serializer: KSerializer): V? = + backingObject[key]?.let { serialFormat.decodeFromJsonElement(serializer, it) } + + override fun getSlice(serializer: KSerializer) = + serialFormat.decodeFromJsonElement(serializer, backingObject) +} + +/** + * Optional fields are backed as nullable type. + * Example + * ```val foo: String? by jsonProperty("foo")``` + * + * Required fields are backed as strict types + * ```val bar: Bar by jsonProperty("bar_obj", CustomBarSerializer)``` + */ +inline fun jsonProperty( + key: String? = null, + serializer: KSerializer = serializer(), +): JsonProperty = + backedProperty(key, serializer) + +/** + * Reads [defaultValue] when the backing object does not contain the property key. + * + * Serialization still emits the raw backing object unchanged. If defaults should be encoded, + * construct or receive the backing object with those default fields already present. + */ + +inline fun jsonProperty( + key: String? = null, + defaultValue: V, + serializer: KSerializer = serializer(), +): JsonProperty = backedProperty(key, defaultValue, serializer) + +inline fun jsonSlice(serializer: KSerializer = serializer()): JsonProperty = + slice(serializer) + +/** + * Returns the combined content of two JsonObjects. + * If both inputs are zero returns the empty JsonObject + */ +@Throws(IllegalArgumentException::class) +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/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedSerializerTemplate.kt b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedSerializerTemplate.kt new file mode 100644 index 0000000..1acdc0a --- /dev/null +++ b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonBackedSerializerTemplate.kt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH +// SPDX-License-Identifier: Apache-2.0 + +package at.asitplus.propigator.json + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* + +class JsonBackedSerializerTemplate( + private val create: (JsonObject, Json) -> T, +) : KSerializer { + override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor + + override fun deserialize(decoder: Decoder): T { + decoder as? JsonDecoder + ?: error("JsonObjectBackedSerializer only works with kotlinx.serialization JSON") + return create(decoder.decodeJsonElement().jsonObject, decoder.json).also { it.validate() } + } + + override fun serialize(encoder: Encoder, value: T) { + encoder as? JsonEncoder + ?: error("JsonObjectBackedSerializer only works with kotlinx.serialization JSON") + require(encoder.json.hasSameConfigurationAs(value.serialFormat)) { + "Mismatching JsonConfiguration. By default, the object owns the serialization shape." + } + encoder.encodeJsonElement(value.backingObject) + } +} + +@OptIn(ExperimentalSerializationApi::class) +private fun Json.hasSameConfigurationAs(other: Json): Boolean { + val left = configuration + val right = other.configuration + return left.encodeDefaults == right.encodeDefaults && + left.ignoreUnknownKeys == right.ignoreUnknownKeys && + left.isLenient == right.isLenient && + left.allowStructuredMapKeys == right.allowStructuredMapKeys && + left.prettyPrint == right.prettyPrint && + left.explicitNulls == right.explicitNulls && + left.prettyPrintIndent == right.prettyPrintIndent && + left.coerceInputValues == right.coerceInputValues && + left.useArrayPolymorphism == right.useArrayPolymorphism && + left.classDiscriminator == right.classDiscriminator && + left.allowSpecialFloatingPointValues == right.allowSpecialFloatingPointValues && + left.useAlternativeNames == right.useAlternativeNames && + left.namingStrategy == right.namingStrategy && + left.decodeEnumsCaseInsensitive == right.decodeEnumsCaseInsensitive && + left.allowTrailingComma == right.allowTrailingComma && + left.allowComments == right.allowComments && + left.classDiscriminatorMode == right.classDiscriminatorMode && + left.exceptionsWithDebugInfo == right.exceptionsWithDebugInfo +} diff --git a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBacked.kt b/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBacked.kt deleted file mode 100644 index fe6cc29..0000000 --- a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBacked.kt +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH -// SPDX-License-Identifier: Apache-2.0 - -package at.asitplus.propigator.json - -import at.asitplus.propigator.common.ObjectBacked -import at.asitplus.propigator.common.backedProperty -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.serializer -import kotlin.properties.ReadOnlyProperty - -open class JsonObjectBacked( - val rawObject: JsonObject, - internal val json: Json = Json.Default, -) : ObjectBacked { - override fun equals(other: Any?): Boolean { - if (other !is JsonObjectBacked) return false - return rawObject == other.rawObject - } - override fun hashCode(): Int = rawObject.hashCode() - override fun toString(): String = rawObject.toString() - - override fun decode(serializer: KSerializer, element: JsonElement): T = - json.decodeFromJsonElement(serializer, element) - - override fun isNull(element: JsonElement): Boolean = element is JsonNull - - override fun getElement(key: String): JsonElement? = rawObject[key] -} - -@PublishedApi -internal fun createDefaultJsonProperty( - key: String?, - serializer: KSerializer, - defaultValue: T, -): ReadOnlyProperty = - ReadOnlyProperty { thisRef, property -> - val actualKey = key ?: property.name - val element = thisRef.getElement(actualKey) - ?: return@ReadOnlyProperty defaultValue - if (thisRef.isNull(element)) return@ReadOnlyProperty readNull(serializer, actualKey) - thisRef.decode(serializer, element) - } - -@Suppress("UNCHECKED_CAST") -private fun readNull(serializer: KSerializer, actualKey: String): T { - if (serializer.descriptor.isNullable) return null as T - throw SerializationException("Missing required backing property: $actualKey") -} - -/** - * Optional fields are backed as nullable type. - * Example - * ```val foo: String? by jsonProperty("foo")``` - * - * Required fields are backed as strict types - * ```val bar: Bar by jsonProperty("bar_obj", CustomBarSerializer)``` - */ -inline fun jsonProperty( - key: String? = null, - serializer: KSerializer = serializer(), -): ReadOnlyProperty = - backedProperty(key, serializer) - -/** - * Reads [defaultValue] when the backing object does not contain the property key. - * - * Serialization still emits the raw backing object unchanged. If defaults should be encoded, - * construct or receive the backing object with those default fields already present. - */ -inline fun jsonProperty( - key: String? = null, - defaultValue: T, - serializer: KSerializer = serializer(), -): ReadOnlyProperty = - createDefaultJsonProperty(key, serializer, defaultValue) - -inline fun jsonSlice(serializer: KSerializer = serializer()): ReadOnlyProperty = - ReadOnlyProperty { thisRef, _ -> thisRef.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 deleted file mode 100644 index 6f0b58f..0000000 --- a/json/src/commonMain/kotlin/at/asitplus/propigator/json/JsonObjectBackedSerializer.kt +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH -// SPDX-License-Identifier: Apache-2.0 - -package at.asitplus.propigator.json - -import at.asitplus.propigator.common.ObjectBackedValidated -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject - -class JsonObjectBackedSerializer( - private val create: (JsonObject, Json) -> T, - private val json: Json = Json.Default, -) : KSerializer { - override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor - - override fun deserialize(decoder: Decoder): T { - decoder as? JsonDecoder - ?: error("JsonObjectBackedSerializer only works with kotlinx.serialization JSON") - return create(decoder.decodeJsonElement().jsonObject, json).also { - (it as? ObjectBackedValidated)?.validate() - } - } - - override fun serialize(encoder: Encoder, value: T) { - encoder as? JsonEncoder - ?: error("JsonObjectBackedSerializer only works with kotlinx.serialization JSON") - require(encoder.json.hasSameConfigurationAs(value.json)) { - "Mismatching JsonConfiguration. By default, the object owns the serialization shape." - } - encoder.encodeJsonElement(value.rawObject) - } -} - -@OptIn(ExperimentalSerializationApi::class) -private fun Json.hasSameConfigurationAs(other: Json): Boolean { - val left = configuration - val right = other.configuration - return left.encodeDefaults == right.encodeDefaults && - left.ignoreUnknownKeys == right.ignoreUnknownKeys && - left.isLenient == right.isLenient && - left.allowStructuredMapKeys == right.allowStructuredMapKeys && - left.prettyPrint == right.prettyPrint && - left.explicitNulls == right.explicitNulls && - left.prettyPrintIndent == right.prettyPrintIndent && - left.coerceInputValues == right.coerceInputValues && - left.useArrayPolymorphism == right.useArrayPolymorphism && - left.classDiscriminator == right.classDiscriminator && - left.allowSpecialFloatingPointValues == right.allowSpecialFloatingPointValues && - left.useAlternativeNames == right.useAlternativeNames && - left.namingStrategy == right.namingStrategy && - left.decodeEnumsCaseInsensitive == right.decodeEnumsCaseInsensitive && - left.allowTrailingComma == right.allowTrailingComma && - left.allowComments == right.allowComments && - left.classDiscriminatorMode == right.classDiscriminatorMode && - left.exceptionsWithDebugInfo == right.exceptionsWithDebugInfo -} diff --git a/json/src/commonTest/kotlin/at/asitplus/propigator/json/EncodeDefaultsTest.kt b/json/src/commonTest/kotlin/at/asitplus/propigator/json/EncodeDefaultsTest.kt new file mode 100644 index 0000000..83b6ac9 --- /dev/null +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/EncodeDefaultsTest.kt @@ -0,0 +1,121 @@ +package at.asitplus.propigator.json + +import at.asitplus.testballoon.matrix.matrixSuite +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* + +@Serializable +private data class AddressData( + val street: String, + val city: String, + val zipCode: Int, +) + +object FancySerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = String.serializer().descriptor + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value.reversed()) + } + + override fun deserialize(decoder: Decoder): String { + return decoder.decodeString().reversed() + } +} + +private val Person.externalParameter: String by jsonProperty("didntseeitcoming") + +@Serializable +private data class PersonData( + val id: Int, + val name: String = "Bob", + + @SerialName("is_cool") + val isCool: Boolean, + + @SerialName("fancy") + @Serializable(with = FancySerializer::class) + val fancyString: String = "Fancy", +) + +@Serializable(with = Person.Serializer::class) +private class Person( + raw: JsonObject, + json: Json = Json.Default, +) : JsonBacked(raw, json) { + val personData: PersonData by jsonSlice() + val addressData: AddressData? by jsonSlice() + + constructor(personData: PersonData, addressData: AddressData? = null, serialFormat: Json = Json.Default) : this( + raw = serialFormat.encodeToJsonElement(personData).jsonObject.strictUnion( + addressData?.let { serialFormat.encodeToJsonElement(it).jsonObject } + ), + json = serialFormat, + ) + + object Serializer : KSerializer by JsonBackedSerializerTemplate(::Person) +} + +private val encodeDefaultsJson = Json { encodeDefaults = true } +private val ignoreDefaultsJson = Json { encodeDefaults = false } + +/** This is Bob */ +val encodedWithoutDefaults = buildJsonObject { + put("id", 1) + put("is_cool", true) +} + +/** + * Test for compatability with kotlinx.serialization. + * + * [JsonConfiguration.encodeDefaults] only affects encoding but not decoding. + * When decoding, irrespective of the serializer setting, default values need to be present in the class + * but not in the raw object (if they were absent) + */ +internal val EncodeDefaultsTest by matrixSuite { + listOf(encodeDefaultsJson, ignoreDefaultsJson).asData( + nameFn = { "encodeDefaults = ${it.configuration.encodeDefaults}" } + ) - { serializer -> + "Default values are being honored during decoding" { + val person = serializer.decodeFromJsonElement(encodedWithoutDefaults) + person.personData.name shouldBe "Bob" + person.backingObject.keys.shouldNotContain("name") + } + + "Default values are correctly encoded" { + val person = Person( + personData = PersonData( + id = 5, + isCool = true + ), + serialFormat = serializer + ) + val encoded = serializer.encodeToJsonElement(Person.serializer(), person).jsonObject + val expected = buildJsonObject { + put("id", 5) + put("is_cool", true) + if (serializer.configuration.encodeDefaults) { + put("name", "Bob") + put("fancy", "ycnaF") + } + } + + encoded shouldBe expected + person.personData shouldBe PersonData( + id = 5, + isCool = true, + ) + person.backingObject.contains("name") shouldBe serializer.configuration.encodeDefaults + (person.backingObject["fancy"] == JsonPrimitive("ycnaF")) shouldBe serializer.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 34293fd..c222439 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/JsonObjectBackedTest.kt @@ -2,62 +2,44 @@ package at.asitplus.propigator.json import at.asitplus.propigator.common.ObjectBackedTestData import at.asitplus.propigator.common.ObjectBackedTestPerson -import at.asitplus.propigator.common.ObjectBackedValidated import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* -@Serializable(with = PersonJsonObject.Serializer::class) -private class PersonJsonObject( +@Serializable(with = PersonJson.Serializer::class) +private class PersonJson( raw: JsonObject, json: Json = Json.Default, -) : JsonObjectBacked(raw, json), ObjectBackedTestPerson { +) : JsonBacked(raw, json), ObjectBackedTestPerson { override val id: String by jsonProperty() override val name: String by jsonProperty() override val renamed: Boolean by jsonProperty("some_json_key") val readOnlyName: String by jsonProperty("name") override val foo: ObjectBackedTestPerson.Foo by jsonProperty("foo") - object Serializer : KSerializer by JsonObjectBackedSerializer(::PersonJsonObject) -} - -private val PersonJsonObject.nickname: String? by jsonProperty("nick") - -@Serializable -private data class NestedName( - val name: String, -) - -@Serializable(with = FormatJsonObject.Serializer::class) -private class FormatJsonObject( - raw: JsonObject, - json: Json = Json.Default, -) : JsonObjectBacked(raw, json) { - val nested: NestedName by jsonProperty() + override fun validate() { + super.validate() + } - object Serializer : KSerializer by JsonObjectBackedSerializer(::FormatJsonObject) + object Serializer : KSerializer by JsonBackedSerializerTemplate(::PersonJson) } -private val ignoreUnknownJson = Json { ignoreUnknownKeys = true } - -private object IgnoreUnknownFormatJsonObjectSerializer : - KSerializer by JsonObjectBackedSerializer(::FormatJsonObject, ignoreUnknownJson) +private val PersonJson.nickname: String? by jsonProperty("nick") private val encodeDefaultsJson = Json { encodeDefaults = true } -@Serializable(with = DefaultedJsonObject.Serializer::class) -private class DefaultedJsonObject( +@Serializable(with = DefaultedJson.Serializer::class) +private class DefaultedJson( raw: JsonObject, json: Json = Json.Default, -) : JsonObjectBacked(raw, json), ObjectBackedValidated { +) : JsonBacked(raw, json) { val id: String by jsonProperty() val status: String by jsonProperty(defaultValue = "active") val customScore: Int by jsonProperty("custom_score", defaultValue = 7, serializer = StringBackedIntSerializer) @@ -68,7 +50,7 @@ private class DefaultedJsonObject( customScore } - object Serializer : KSerializer by JsonObjectBackedSerializer(::DefaultedJsonObject) + object Serializer : KSerializer by JsonBackedSerializerTemplate(::DefaultedJson) } private object StringBackedIntSerializer : KSerializer { @@ -83,14 +65,14 @@ private object StringBackedIntSerializer : KSerializer { } private object EncodeDefaultsDefaultedJsonObjectSerializer : - KSerializer by JsonObjectBackedSerializer(::DefaultedJsonObject, encodeDefaultsJson) + KSerializer by JsonBackedSerializerTemplate(::DefaultedJson) internal val JsonObjectBackedTest by matrixSuite { "JSON-backed objects" - { "round-trip known properties while preserving unknown fields" { val json = Json { prettyPrint = false } val decoded = json.decodeFromString( - PersonJsonObject.serializer(), + PersonJson.serializer(), """{"id":"p-1", "name":"Ada", "some_json_key":true, @@ -106,17 +88,17 @@ internal val JsonObjectBackedTest by matrixSuite { decoded.name shouldBe ObjectBackedTestData.name decoded.renamed shouldBe ObjectBackedTestData.renamed decoded.foo shouldBe ObjectBackedTestData.foo - decoded.rawObject["unknown"]!!.jsonPrimitive.content shouldBe "42" + decoded.backingObject["unknown"]!!.jsonPrimitive.content shouldBe "42" - val encoded = json.encodeToString(PersonJsonObject.serializer(), decoded) - val reparsed = json.decodeFromString(PersonJsonObject.serializer(), encoded) + val encoded = json.encodeToString(PersonJson.serializer(), decoded) + val reparsed = json.decodeFromString(PersonJson.serializer(), encoded) reparsed.name shouldBe ObjectBackedTestData.name - reparsed.rawObject["unknown"]!!.jsonPrimitive.content shouldBe "42" + reparsed.backingObject["unknown"]!!.jsonPrimitive.content shouldBe "42" reparsed.foo shouldBe ObjectBackedTestData.foo } "read member and extension val delegates from the backing object" { - val obj = PersonJsonObject(buildJsonObject { + val obj = PersonJson(buildJsonObject { put("id", "p-1") put("name", "Ada") put("some_json_key", true) @@ -127,58 +109,39 @@ internal val JsonObjectBackedTest by matrixSuite { obj.nickname shouldBe "countess" } - "decode delegated properties with the serializer construction Json by default" { - val payload = """{"nested":{"name":"Ada","unknown":true}}""" - val decodedWithDefaultFormat = ignoreUnknownJson.decodeFromString( - FormatJsonObject.serializer(), - payload, - ) - - shouldThrow { - decodedWithDefaultFormat.nested - } - - val decodedWithCustomFormat = Json.Default.decodeFromString( - IgnoreUnknownFormatJsonObjectSerializer, - payload, - ) - - decodedWithCustomFormat.nested shouldBe NestedName("Ada") - } - "allow serialization with an equivalent Json configuration" { - val obj = PersonJsonObject(buildJsonObject { + val obj = PersonJson(buildJsonObject { put("id", "p-1") put("name", "Ada") put("some_json_key", true) }) - val encoded = Json { }.encodeToString(PersonJsonObject.serializer(), obj) + val encoded = Json.encodeToString(PersonJson.serializer(), obj) Json.parseToJsonElement(encoded).jsonObject["id"]!!.jsonPrimitive.content shouldBe "p-1" } "reject serialization with a mismatching Json configuration" { - val obj = PersonJsonObject(buildJsonObject { + val obj = PersonJson(buildJsonObject { put("id", "p-1") put("name", "Ada") put("some_json_key", true) }) shouldThrow { - Json { prettyPrint = true }.encodeToString(PersonJsonObject.serializer(), obj) + Json { prettyPrint = true }.encodeToString(PersonJson.serializer(), obj) } } "reject payloads missing mandatory delegated properties" { - shouldThrow { - Json.decodeFromString(PersonJsonObject.serializer(), """{"id":"p-1"}""") + shouldThrow { + Json.decodeFromString(PersonJson.serializer(), """{"id":"p-1"}""") } } "read missing defaulted delegated properties from their defaults" { val decoded = Json.decodeFromString( - DefaultedJsonObject.serializer(), + DefaultedJson.serializer(), """{"id":"p-1"}""", ) @@ -188,7 +151,7 @@ internal val JsonObjectBackedTest by matrixSuite { "decode present defaulted delegated properties from the backing object" { val decoded = Json.decodeFromString( - DefaultedJsonObject.serializer(), + DefaultedJson.serializer(), """{"id":"p-1","status":"inactive","custom_score":"9"}""", ) @@ -199,11 +162,11 @@ internal val JsonObjectBackedTest by matrixSuite { "encode defaulted delegated properties from the raw backing object only" { val jsonWithoutDefaults = Json.Default val decodedWithoutDefaults = jsonWithoutDefaults.decodeFromString( - DefaultedJsonObject.serializer(), + DefaultedJson.serializer(), """{"id":"p-1","unknown":42}""", ) val encodedWithoutDefaults = jsonWithoutDefaults.encodeToString( - DefaultedJsonObject.serializer(), + DefaultedJson.serializer(), decodedWithoutDefaults, ) val reparsedWithoutDefaults = Json.parseToJsonElement(encodedWithoutDefaults).jsonObject 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 83ce77c..0432420 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/KeyAttestation.kt @@ -1,6 +1,5 @@ package at.asitplus.propigator.json -import at.asitplus.propigator.common.ObjectBackedValidated import at.asitplus.signum.indispensable.io.InstantLongSerializer import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JsonWebToken @@ -59,7 +58,7 @@ internal val keyAttestationJwtClaims = """ internal data class KeyAttestation( private val raw: JsonObject, private val jsonFormat: Json = joseCompliantSerializer, -) : JsonObjectBacked(raw, jsonFormat), ObjectBackedValidated { +) : JsonBacked(raw, jsonFormat) { /** * We can serialize into data classes */ @@ -82,9 +81,8 @@ internal data class KeyAttestation( issuedAt } - object Serializer : KSerializer by JsonObjectBackedSerializer( - create = ::KeyAttestation, - json = joseCompliantSerializer, + object Serializer : KSerializer by JsonBackedSerializerTemplate( + create = ::KeyAttestation ) } 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..5ef6137 100644 --- a/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt +++ b/json/src/commonTest/kotlin/at/asitplus/propigator/json/SignumInteropTest.kt @@ -5,7 +5,6 @@ import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer 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.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject @@ -19,7 +18,7 @@ internal val SignumInteropTest by matrixSuite { joseCompliantSerializer.decodeFromString(keyAttestationJwtClaims) keyAttestation.toKeyAttestationJwt() shouldBe josefKeyAttestation - keyAttestation.rawObject["future_claim"] shouldBe JsonObject( + keyAttestation.backingObject["future_claim"] shouldBe JsonObject( mapOf("nested" to JsonPrimitive(true)), ) @@ -35,7 +34,7 @@ internal val SignumInteropTest by matrixSuite { } "rejects claims missing mandatory key-attestation fields" { - shouldThrow { + shouldThrow { joseCompliantSerializer.decodeFromString( """ { diff --git a/json/src/jvmTest/kotlin/at/asitplus/propigator/json/NimbusSignumInteropTest.kt b/json/src/jvmTest/kotlin/at/asitplus/propigator/json/NimbusSignumInteropTest.kt index a74f61e..cd76bdf 100644 --- a/json/src/jvmTest/kotlin/at/asitplus/propigator/json/NimbusSignumInteropTest.kt +++ b/json/src/jvmTest/kotlin/at/asitplus/propigator/json/NimbusSignumInteropTest.kt @@ -23,12 +23,11 @@ import kotlin.random.Random private class NimbusJwtClaims( raw: JsonObject, json: Json = joseCompliantSerializer, -) : JsonObjectBacked(raw, json) { +) : JsonBacked(raw, json) { val jsonWebToken: JsonWebToken by jsonSlice() - object Serializer : KSerializer by JsonObjectBackedSerializer( + object Serializer : KSerializer by JsonBackedSerializerTemplate( create = ::NimbusJwtClaims, - json = joseCompliantSerializer, ) } @@ -62,9 +61,9 @@ internal val NimbusSignumInteropTest by matrixSuite { claims.jsonWebToken.issuer shouldBe issuer claims.jsonWebToken.subject shouldBe subject - claims.rawObject["foo"] shouldBe JsonPrimitive(foo) - claims.rawObject["bar"] shouldBe JsonPrimitive(bar) - claims.rawObject["baz"] shouldBe JsonPrimitive(baz) + claims.backingObject["foo"] shouldBe JsonPrimitive(foo) + claims.backingObject["bar"] shouldBe JsonPrimitive(bar) + claims.backingObject["baz"] shouldBe JsonPrimitive(baz) val forwardedClaims = joseCompliantSerializer .parseToJsonElement(joseCompliantSerializer.encodeToString(claims)) diff --git a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBacked.kt b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBacked.kt new file mode 100644 index 0000000..a9ecc54 --- /dev/null +++ b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBacked.kt @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH +// SPDX-License-Identifier: Apache-2.0 + +package at.asitplus.propigator.yaml + +import at.asitplus.propigator.common.ObjectBacked +import at.asitplus.propigator.common.backedProperty +import at.asitplus.propigator.common.slice +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import net.mamoe.yamlkt.Yaml +import net.mamoe.yamlkt.YamlMap +import net.mamoe.yamlkt.YamlPrimitive +import kotlin.properties.ReadOnlyProperty + +typealias YamlProperty = + ReadOnlyProperty + +open class YamlBacked( + override val backingObject: YamlMap, + override val serialFormat: Yaml = Yaml.Default, +) : ObjectBacked() { + override fun isFormatNull(element: Any?): Boolean = element is YamlPrimitive && element.content == null + override fun getElement(key: String, serializer: KSerializer): V? = + backingObject[key]?.let { + //????????... needs `Yaml.decodeFromYamlElement()` functionality in base package? + val yamlToString = serialFormat.encodeToString(it) + serialFormat.decodeFromString(serializer, yamlToString) + } + + override fun getSlice(serializer: KSerializer): S { + //????????... needs `Yaml.decodeFromYamlElement()` functionality in base package? + val yamlToString = serialFormat.encodeToString(backingObject) + return serialFormat.decodeFromString(serializer, yamlToString) + } +} + +inline fun yamlProperty( + key: String? = null, + serializer: KSerializer = serializer(), +): YamlProperty = + backedProperty(key, serializer) + +inline fun yamlSlice(serializer: KSerializer = serializer()): YamlProperty = + slice(serializer) diff --git a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedSerializer.kt b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedSerializerTemplate.kt similarity index 50% rename from yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedSerializer.kt rename to yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedSerializerTemplate.kt index b752603..b9c3d9d 100644 --- a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedSerializer.kt +++ b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlBackedSerializerTemplate.kt @@ -3,7 +3,6 @@ package at.asitplus.propigator.yaml -import at.asitplus.propigator.common.ObjectBackedValidated import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -11,7 +10,7 @@ import kotlinx.serialization.encoding.Encoder import net.mamoe.yamlkt.Yaml import net.mamoe.yamlkt.YamlMap -class YamlObjectBackedSerializer( +class YamlBackedSerializerTemplate( private val yaml: Yaml = Yaml.Default, private val create: (YamlMap, Yaml) -> T, ) : KSerializer { @@ -19,27 +18,40 @@ class YamlObjectBackedSerializer( override fun deserialize(decoder: Decoder): T { val map = YamlMap.serializer().deserialize(decoder) - return create(map, yaml).also { (it as? ObjectBackedValidated)?.validate() } + return create(map, yaml).also { it.validate() } } override fun serialize(encoder: Encoder, value: T) { - require(yaml.hasSameConfigurationAs(value.yaml)) { + require(yaml.hasSameConfigurationAs(value.serialFormat)) { "Mismatching Yaml configuration. By default, the object owns the serialization shape." } - YamlMap.serializer().serialize(encoder, value.rawObject) + YamlMap.serializer().serialize(encoder, value.backingObject) } } +/** + * Creates an object-backed serializer bound to this [Yaml] instance. + * + * YAMLKt does not expose the originating [Yaml] through its decoder, so the returned serializer + * should be used with this same instance for both decoding and encoding. + */ +fun Yaml.objectBackedSerializer( + create: (YamlMap, Yaml) -> T, +): KSerializer = YamlBackedSerializerTemplate( + yaml = this, + create = create, +) + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") private fun Yaml.hasSameConfigurationAs(other: Yaml): Boolean { val left = configuration val right = other.configuration return left.nonStrictNullability == right.nonStrictNullability && - left.nonStrictNumber == right.nonStrictNumber && - left.encodeDefaultValues == right.encodeDefaultValues && - left.stringSerialization == right.stringSerialization && - left.nullSerialization == right.nullSerialization && - left.mapSerialization == right.mapSerialization && - left.classSerialization == right.classSerialization && - left.listSerialization == right.listSerialization + left.nonStrictNumber == right.nonStrictNumber && + left.encodeDefaultValues == right.encodeDefaultValues && + left.stringSerialization == right.stringSerialization && + left.nullSerialization == right.nullSerialization && + left.mapSerialization == right.mapSerialization && + left.classSerialization == right.classSerialization && + left.listSerialization == right.listSerialization } diff --git a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBacked.kt b/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBacked.kt deleted file mode 100644 index b53e00d..0000000 --- a/yaml/src/commonMain/kotlin/at/asitplus/propigator/yaml/YamlObjectBacked.kt +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) A-SIT Plus GmbH -// SPDX-License-Identifier: Apache-2.0 - -package at.asitplus.propigator.yaml - -import at.asitplus.propigator.common.ObjectBacked -import at.asitplus.propigator.common.backedProperty -import kotlinx.serialization.KSerializer -import kotlinx.serialization.serializer -import net.mamoe.yamlkt.Yaml -import net.mamoe.yamlkt.YamlElement -import net.mamoe.yamlkt.YamlMap -import net.mamoe.yamlkt.YamlPrimitive -import kotlin.properties.ReadOnlyProperty - -open class YamlObjectBacked( - val rawObject: YamlMap, - internal val yaml: Yaml = Yaml.Default, -) : ObjectBacked { - override fun decode(serializer: KSerializer, element: YamlElement): T = - yaml.decodeFromString(serializer, element.toString()) - - override fun isNull(element: YamlElement): Boolean = element is YamlPrimitive && element.content == null - - override fun getElement(key: String): YamlElement? = rawObject[key] -} - -inline fun yamlProperty( - key: String? = null, - serializer: KSerializer = serializer(), -): ReadOnlyProperty = - backedProperty(key, serializer) - -inline fun yamlSlice(serializer: KSerializer = serializer()): ReadOnlyProperty = - ReadOnlyProperty { thisRef, _ -> thisRef.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 59cbc59..aed2526 100644 --- a/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt +++ b/yaml/src/commonTest/kotlin/at/asitplus/propigator/yaml/YamlObjectBackedTest.kt @@ -7,33 +7,36 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException import net.mamoe.yamlkt.Yaml import net.mamoe.yamlkt.YamlBuilder import net.mamoe.yamlkt.YamlMap import net.mamoe.yamlkt.YamlPrimitive -@Serializable(with = PersonYamlObject.Serializer::class) -private class PersonYamlObject( +@Serializable(with = PersonYaml.Serializer::class) +private class PersonYaml( raw: YamlMap, yaml: Yaml = Yaml.Default, -) : YamlObjectBacked(raw, yaml), ObjectBackedTestPerson { +) : YamlBacked(raw, yaml), ObjectBackedTestPerson { override val id: String by yamlProperty() override val name: String by yamlProperty() override val renamed: Boolean by yamlProperty("some_yaml_key") val readOnlyName: String by yamlProperty("name") override val foo: ObjectBackedTestPerson.Foo by yamlProperty("foo") - object Serializer : KSerializer by YamlObjectBackedSerializer(create = ::PersonYamlObject) + override fun validate() { + super.validate() + } + + object Serializer : KSerializer by Yaml.Default.objectBackedSerializer(::PersonYaml) } -private val PersonYamlObject.nickname: String? by yamlProperty("nick") +private val PersonYaml.nickname: String? by yamlProperty("nick") internal val YamlObjectBackedTest by matrixSuite { "YAML-backed objects" - { "round-trip known properties while preserving unknown fields" { val decoded = Yaml.decodeFromString( - PersonYamlObject.serializer(), + PersonYaml.serializer(), """ id: p-1 name: Ada @@ -49,17 +52,17 @@ internal val YamlObjectBackedTest by matrixSuite { decoded.name shouldBe ObjectBackedTestData.name decoded.renamed shouldBe ObjectBackedTestData.renamed decoded.foo shouldBe ObjectBackedTestData.foo - decoded.rawObject["unknown"]!!.content shouldBe "42" + decoded.backingObject["unknown"]!!.content shouldBe "42" - val encoded = Yaml.encodeToString(PersonYamlObject.serializer(), decoded) - val reparsed = Yaml.decodeFromString(PersonYamlObject.serializer(), encoded) + val encoded = Yaml.encodeToString(PersonYaml.serializer(), decoded) + val reparsed = Yaml.decodeFromString(PersonYaml.serializer(), encoded) reparsed.name shouldBe ObjectBackedTestData.name - reparsed.rawObject["unknown"]!!.content shouldBe "42" + reparsed.backingObject["unknown"]!!.content shouldBe "42" reparsed.foo shouldBe ObjectBackedTestData.foo } "read member and extension val delegates from the backing object" { - val obj = PersonYamlObject( + val obj = PersonYaml( YamlMap( mapOf( YamlPrimitive("id") to YamlPrimitive("p-1"), @@ -74,8 +77,28 @@ internal val YamlObjectBackedTest by matrixSuite { obj.nickname shouldBe "countess" } + "retain the Yaml instance bound to the serializer" { + val yaml = Yaml { + stringSerialization = YamlBuilder.StringSerialization.DOUBLE_QUOTATION + } + val serializer = yaml.objectBackedSerializer(::PersonYaml) + val decoded = yaml.decodeFromString( + serializer, + """ + id: p-1 + name: Ada + some_yaml_key: true + foo: + bar: 2 + baz: eyz + """.trimIndent(), + ) + + (decoded.serialFormat === yaml) shouldBe true + } + "allow serialization when the object carries an equivalent Yaml configuration" { - val obj = PersonYamlObject( + val obj = PersonYaml( YamlMap( mapOf( YamlPrimitive("id") to YamlPrimitive("p-1"), @@ -92,14 +115,14 @@ internal val YamlObjectBackedTest by matrixSuite { Yaml { }, ) - val encoded = Yaml.encodeToString(PersonYamlObject.serializer(), obj) - val reparsed = Yaml.decodeFromString(PersonYamlObject.serializer(), encoded) + val encoded = Yaml.encodeToString(PersonYaml.serializer(), obj) + val reparsed = Yaml.decodeFromString(PersonYaml.serializer(), encoded) reparsed.id shouldBe "p-1" } "reject serialization when the object carries a different Yaml configuration" { - val obj = PersonYamlObject( + val obj = PersonYaml( YamlMap( mapOf( YamlPrimitive("id") to YamlPrimitive("p-1"), @@ -111,12 +134,12 @@ internal val YamlObjectBackedTest by matrixSuite { ) shouldThrow { - Yaml.encodeToString(PersonYamlObject.serializer(), obj) + Yaml.encodeToString(PersonYaml.serializer(), obj) } } "resolve slice from the backing object" { - val obj = object : YamlObjectBacked( + val obj = object : YamlBacked( YamlMap( mapOf( YamlPrimitive("bar") to YamlPrimitive("2"), @@ -131,8 +154,8 @@ internal val YamlObjectBackedTest by matrixSuite { } "reject payloads missing mandatory delegated properties" { - shouldThrow { - Yaml.decodeFromString(PersonYamlObject.serializer(), "id: p-1") + shouldThrow { + Yaml.decodeFromString(PersonYaml.serializer(), "id: p-1") } } }