Skip to content

a-sit-plus/propigator

Repository files navigation

Propigator – Typed properties over untamed data

Propagating typed properties over untamed data using kotlinx.serialization

A-SIT Plus Official GitHub license Kotlin Kotlin Java Maven Central

Propigator is a Kotlin Multiplatform library for building typed views over raw object-shaped data while keeping the original payload forward-compatible. Use it when you want Kotlin properties for the fields your code understands, but you must not destroy fields you do not understand yet. This is useful for protocol objects, configuration files, extension points, signed or externally-owned payloads, and versioned data formats where newer producers may send fields older consumers should preserve.

Propigator currently provides object-backed wrappers for:

  • common: format-agnostic delegates and validation hooks.
  • json: JsonObject backed objects.
  • yaml: yamlkt YamlMap backed objects.

What It Does

Normal @Serializable data classes are great when your schema is the whole truth. They parse known fields into constructor parameters and usually ignore or reject the rest depending on format configuration.

Propigator uses a different model:

  1. Keep the raw object map.
  2. Add delegated Kotlin properties for fields you care about.
  3. Decode each property on demand with kotlinx.serialization.
  4. Write changes back into the raw object.
  5. Serialize the raw object again, including unknown fields.

That means a downstream integrator can parse, inspect, edit, and re-emit objects without becoming the schema authority for every field in the document.

Forward Compatibility

Propigator preserves unknown fields by design.

If an incoming JSON object contains:

{
  "id": "42",
  "name": "Grace",
  "futureField": {
    "addedBy": "newer-service"
  }
}

and your wrapper only knows id and name, futureField stays in the backing object and is emitted again when you serialize.

This makes Propigator a good fit for downstream tools that should be conservative:

  • Read a document from an upstream system.
  • Change only the fields your tool owns.
  • Preserve extension fields, vendor fields, and future fields.
  • Avoid forcing a full validation model onto the entire object.

Using it in your Project

Use the module matching the format you need.

dependencies {
    implementation("at.asitplus.propigator:common:<version>")
    implementation("at.asitplus.propigator:json:<version>")
    implementation("at.asitplus.propigator:yaml:<version>")
}

json depends on common and kotlinx-serialization-json.

yaml depends on common and yamlkt.

JSON Quick Start

Define a nominal wrapper class around JsonObjectBacked. Add required properties with jsonProperty() and nullable properties with nullableJsonProperty().

@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(
    raw: JsonObject,
    json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
    var id: String by jsonProperty()
    var name: String by jsonProperty()
    var active: Boolean by jsonProperty("is_active")
    var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)

    override fun validate() {
        id
        name
    }

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

Use it through kotlinx.serialization:

val json = Json { prettyPrint = true }

val person = json.decodeFromString(
    PersonJsonObject.serializer(),
    """
    {
      "id": "42",
      "name": "Grace",
      "is_active": true,
      "futureField": "preserved"
    }
    """.trimIndent()
)

person.name = "Grace Hopper"
person.nickname = "Amazing Grace"

val encoded = json.encodeToString(PersonJsonObject.serializer(), person)

The encoded JSON contains the changed known fields and still contains futureField.

YAML Quick Start

YAML works the same way, using YamlObjectBacked and YAML-specific delegates.

@Serializable(with = ServiceYamlObject.Serializer::class)
class ServiceYamlObject(
    raw: YamlMap,
    yaml: Yaml = Yaml.Default,
) : YamlObjectBacked(raw, YamlBackingCodec(yaml)), ObjectBackedValidated {
    var id: String by yamlProperty()
    var endpoint: String by yamlProperty()
    var description: String? by nullableYamlProperty()

    override fun validate() {
        id
        endpoint
    }

    object Serializer : KSerializer<ServiceYamlObject> by YamlObjectBackedSerializer(create = ::ServiceYamlObject)
}
val yaml = Yaml.Default

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

service.endpoint = "https://api.example.test/payments"

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

x-vendor-option is preserved.

Delegated Properties

Required properties use jsonProperty() or yamlProperty().

var id: String by jsonProperty()
var displayName: String by jsonProperty("display_name")

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 required property throws SerializationException:

val id = person.id // throws if "id" is absent

Nullable properties use nullableJsonProperty() or nullableYamlProperty().

var nickname: String? by nullableJsonProperty("nick")

Missing keys and explicit format-native null values both read as null.

Read-only views are also useful:

val PersonJsonObject.publicName: String by jsonProperty("name")
val PersonJsonObject.optionalNick: String? by nullableJsonProperty("nick")

Whole-Object Slices

Use jsonSlice() or yamlSlice() when you want to decode the entire backing object as an existing @Serializable type instead of defining one delegated property per field.

A slice is a read-only view over rawObject. It uses the wrapper's configured JsonBackingCodec or YamlBackingCodec, so the same format settings and serializers apply.

@Serializable
data class PublicClaims(
    val iss: String,
    val sub: String,
    val aud: String,
)

@Serializable(with = ClaimsJsonObject.Serializer::class)
class ClaimsJsonObject(
    raw: JsonObject,
    json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
    val claims: PublicClaims by jsonSlice()
    var nonce: String? by nullableJsonProperty()

    override fun validate() {
        claims
    }

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

claims is decoded from the whole JSON object, while nonce remains an editable property backed by the same raw object. Unknown fields are still preserved when the wrapper is serialized again.

YAML-backed objects provide the same pattern with yamlSlice():

val foo: Foo by yamlSlice()

Null Write Behavior

Propigator supports per-property null write behavior.

The default is NullWriteMode.STORE_NULL: assigning null stores a format-native null value.

var middleName: String? by nullableJsonProperty("middle_name")

person.middleName = null
// JSON: "middle_name": null

Use NullWriteMode.REMOVE_KEY when null should mean absence:

var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)

person.nickname = null
// JSON: "nick" is removed

This is intentionally per property. Some formats or schemas distinguish explicit null from an absent key; others do not. Propigator lets the wrapper encode that decision where the semantic meaning is known.

Parse, Not Validate

Propigator is designed for parse-not-validate workflows.

Parsing creates a typed view over the raw object. It does not require you to model every field in the payload. Unknown fields are kept as raw data.

By default, delegated required fields are checked when read:

val person = json.decodeFromString(PersonJsonObject.serializer(), payload)

// Missing "name" fails here, when the property is needed.
println(person.name)

This is useful for downstream integrators that should accept future payloads, inspect a small subset, and forward the rest unchanged.

Prime Example: JOSE

JOSE-style objects are a prime Propigator use case. They have a few core fields that many libraries need to understand, but they are also intentionally open-ended: deployments add domain-specific parameters, claims, headers, and policy fields.

For example, a JwsSigned wrapper may need typed access to signature-critical fields while preserving every application-specific field:

@Serializable(with = JwsSigned.Serializer::class)
class JwsSigned(
    raw: JsonObject,
    json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)) {
    val protectedHeader: String by jsonProperty("protected")
    val payload: String by jsonProperty()
    val signature: String by jsonProperty()

    object Serializer : KSerializer<JwsSigned> by JsonObjectBackedSerializer(::JwsSigned)
}

var JwsSigned.kid: String? by nullableJsonProperty("kid")
var JwsSigned.trustDomain: String? by nullableJsonProperty("trust_domain")
var JwsSigned.policyVersion: Int? by nullableJsonProperty("policy_version")

An integrator can read the fields it needs:

val jws = json.decodeFromString(JwsSigned.serializer(), incoming)

val signature = jws.signature
jws.trustDomain = "example.eu"

val forwarded = json.encodeToString(JwsSigned.serializer(), jws)

Fields not modelled by JwsSigned, including future JOSE extensions and domain-specific fields, stay in rawObject and are emitted again.

This is parse-not-validate by design. A component that routes or annotates a JOSE object may need payload and signature, but it should not reject an object because it does not understand a domain-specific property. Full JOSE validation belongs to the layer that has the keys, algorithms, policy, critical-header handling, and domain rules. Propigator keeps the object editable and forward-compatible until that layer needs to make a decision.

When you do need parse-time checks for your own mandatory fields, implement ObjectBackedValidated and touch those properties in validate():

override fun validate() {
    id
    name
}

The format serializer calls validate() after decoding if the object implements ObjectBackedValidated.

Use this sparingly:

  • Validate fields your component truly requires.
  • Do not validate fields you merely want to preserve.
  • Do not reject unknown fields just because this version of your code does not understand them.

Extension Properties

You can add semantic fields outside the nominal wrapper class.

var PersonJsonObject.locale: String? by nullableJsonProperty("locale")

val PersonJsonObject.displayLabel: String
    get() = locale?.let { "$name ($it)" } ?: name

This is useful when several downstream integrations share the same raw object but each integration owns different extension fields.

Raw Object Access

Use rawObject when you need to inspect, pass through, or debug the complete backing object.

val raw: JsonObject = person.rawObject

For JSON, rawObject is a JsonObject.

For YAML, rawObject is a YamlMap.

The wrapper keeps an internal mutable backing map and exposes snapshots through rawObject.

Serializer Pattern

Propigator serializers are attached to each nominal wrapper type:

@Serializable(with = PersonJsonObject.Serializer::class)
class PersonJsonObject(...) : JsonObjectBacked(...) {
    object Serializer : KSerializer<PersonJsonObject> by JsonObjectBackedSerializer(::PersonJsonObject)
}

The serializer reads and writes the raw object. Delegated properties are not discovered by the Kotlin serialization compiler plugin as constructor properties. They are semantic accessors over the backing object.

Choosing Data Classes vs Propigator

Use regular @Serializable data classes when:

  • Your service owns the full schema.
  • Unknown fields should be ignored or rejected.
  • You want constructor-based validation and immutable values.

Use Propigator when:

  • You need to preserve unknown fields.
  • You are building downstream tooling for someone else's schema.
  • The schema is extensible or versioned.
  • You only own a few fields inside a larger object.
  • You need to parse first and validate only the fields your workflow touches.

Current Limitations

  • 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.

Contributing

External contributions are greatly appreciated. Please observe the contribution guidelines (see CONTRIBUTING.md).


The Apache License does not apply to the logos (including the A-SIT logo) and the project/module name(s), as these are the sole property of A-SIT/A-SIT Plus GmbH and may not be used in derivative works without explicit permission!

About

Propagating typed properties over untamed data using kotlinx.serialization

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Contributors

Languages