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
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<V>`/`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
Expand Down
123 changes: 76 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -72,15 +72,15 @@ class PersonJsonObject(
name
}

object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
object Serializer : KSerializer<PersonJson> by JsonBackedSerializerTemplate(::PersonJson)
}
```

```kotlin
val json = Json.Default

val person = json.decodeFromString(
PersonJsonObject.serializer(),
PersonJson.serializer(),
"""
{
"id": "42",
Expand All @@ -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()
Expand All @@ -115,23 +115,24 @@ class ServiceYamlObject(
endpoint
}

object Serializer : KSerializer<ServiceYamlObject> by YamlObjectBackedSerializer(create = ::ServiceYamlObject)
object Serializer : KSerializer<ServiceYaml> by
Yaml.Default.objectBackedSerializer(::ServiceYaml)
}
```

```kotlin
val yaml = Yaml.Default

val service = yaml.decodeFromString(
ServiceYamlObject.serializer(),
ServiceYaml.serializer(),
"""
id: payments
endpoint: https://example.test/payments
x-vendor-option: keep-me
""".trimIndent()
)

val encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service)
val encoded = yaml.encodeToString(ServiceYaml.serializer(), service)
```

`x-vendor-option` is preserved.
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -181,19 +182,19 @@ 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()

override fun validate() {
claims
}

object Serializer : KSerializer<ClaimsJsonObject> by JsonObjectBackedSerializer(::ClaimsJsonObject)
object Serializer : KSerializer<ClaimsJson> by JsonBackedSerializerTemplate(::ClaimsJson)
}
```

Expand All @@ -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() {
Expand All @@ -225,54 +226,82 @@ 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

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<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
@Serializable(with = PersonJson.Serializer::class)
class PersonJson(...) : JsonBacked(...) {
object Serializer : KSerializer<PersonJson> 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<PersonJsonObject> by JsonObjectBackedSerializer(
create = ::PersonJsonObject,
json = personJson,
)
val person = personJson.decodeFromString<PersonJson>(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:
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<V> {
fun getElement(key: String): V?
fun isNull(element: V): Boolean
fun <T> decode(serializer: KSerializer<T>, element: V): T
}
abstract class ObjectBacked {
abstract val serialFormat: SerialFormat
abstract val backingObject: Any
abstract fun isFormatNull(element: Any?): Boolean
abstract fun <V> getElement(key: String, serializer: KSerializer<V>): V?
abstract fun <S> getSlice(serializer: KSerializer<S>): 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()
}
Loading
Loading