{"id":50827403,"url":"https://github.com/a-sit-plus/propigator","last_synced_at":"2026-06-13T20:07:48.570Z","repository":{"id":357077908,"uuid":"1228479075","full_name":"a-sit-plus/propigator","owner":"a-sit-plus","description":"Propagating typed properties over untamed data using kotlinx.serialization","archived":false,"fork":false,"pushed_at":"2026-05-11T08:44:42.000Z","size":145,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-11T09:16:02.601Z","etag":null,"topics":["json","kotlin","kotlin-multiplatform","kotlinx-serialization","serialization","yaml"],"latest_commit_sha":null,"homepage":"","language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/a-sit-plus.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-04T04:01:42.000Z","updated_at":"2026-05-11T07:06:46.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/a-sit-plus/propigator","commit_stats":null,"previous_names":["a-sit-plus/propigator"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/a-sit-plus/propigator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/a-sit-plus%2Fpropigator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/a-sit-plus%2Fpropigator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/a-sit-plus%2Fpropigator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/a-sit-plus%2Fpropigator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/a-sit-plus","download_url":"https://codeload.github.com/a-sit-plus/propigator/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/a-sit-plus%2Fpropigator/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34298306,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-13T02:00:06.617Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["json","kotlin","kotlin-multiplatform","kotlinx-serialization","serialization","yaml"],"created_at":"2026-06-13T20:07:48.009Z","updated_at":"2026-06-13T20:07:48.562Z","avatar_url":"https://github.com/a-sit-plus.png","language":"Kotlin","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cpicture\u003e\n  \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"propigator-w.png\"\u003e\n  \u003csource media=\"(prefers-color-scheme: light)\" srcset=\"propigator-b.png\"\u003e\n  \u003cimg alt=\"Propigator – Typed properties over untamed data\" src=\"propigator-b.png\"\u003e\n\u003c/picture\u003e\n\n# Propagating typed properties over untamed data using kotlinx.serialization\n\n[![A-SIT Plus Official](https://raw.githubusercontent.com/a-sit-plus/a-sit-plus.github.io/709e802b3e00cb57916cbb254ca5e1a5756ad2a8/A-SIT%20Plus_%20official_opt.svg)](https://plus.a-sit.at/open-source.html)\n[![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-brightgreen.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0)\n[![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-orange.svg?logo=kotlin)](http://kotlinlang.org)\n[![Kotlin](https://img.shields.io/badge/kotlin-2.3.20-blue.svg?logo=kotlin)](http://kotlinlang.org)\n[![Java](https://img.shields.io/badge/java-17+-blue.svg?logo=OPENJDK)](https://www.oracle.com/java/technologies/downloads/#java17)\n[![Maven Central](https://img.shields.io/maven-central/v/at.asitplus.propigator/common)](https://mvnrepository.com/artifact/at.asitplus.propigator/common)\n\n\u003c/div\u003e\n\nPropigator is a Kotlin Multiplatform library for building typed views over raw object-shaped data while keeping the original payload forward-compatible.\nUse 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.\n\nPropigator currently provides object-backed wrappers for:\n\n- `common`: format-agnostic delegates and validation hooks.\n- `json`: `JsonObject` backed objects.\n- `yaml`: [yamlkt](https://github.com/him188/yamlkt) `YamlMap` backed objects.\n\n## What It Does\n\nNormal `@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.\n\nPropigator uses a different model:\n\n1. Keep the raw object map.\n2. Add delegated Kotlin properties for fields you care about.\n3. Decode each property on demand with `kotlinx.serialization`.\n4. Write changes back into the raw object.\n5. Serialize the raw object again, including unknown fields.\n\nThat means a downstream integrator can parse, inspect, edit, and re-emit objects without becoming the schema authority for every field in the document.\n\n## Forward Compatibility\n\nPropigator preserves unknown fields by design.\n\nIf an incoming JSON object contains:\n\n```json\n{\n  \"id\": \"42\",\n  \"name\": \"Grace\",\n  \"futureField\": {\n    \"addedBy\": \"newer-service\"\n  }\n}\n```\n\nand your wrapper only knows `id` and `name`, `futureField` stays in the backing object and is emitted again when you serialize.\n\nThis makes Propigator a good fit for downstream tools that should be conservative:\n\n- Read a document from an upstream system.\n- Change only the fields your tool owns.\n- Preserve extension fields, vendor fields, and future fields.\n- Avoid forcing a full validation model onto the entire object.\n\n## Using it in your Project\n\nUse the module matching the format you need.\n\n```kotlin\ndependencies {\n    implementation(\"at.asitplus.propigator:common:\u003cversion\u003e\")\n    implementation(\"at.asitplus.propigator:json:\u003cversion\u003e\")\n    implementation(\"at.asitplus.propigator:yaml:\u003cversion\u003e\")\n}\n```\n\n`json` depends on `common` and `kotlinx-serialization-json`.\n\n`yaml` depends on `common` and `yamlkt`.\n\n## JSON Quick Start\n\nDefine a nominal wrapper class around `JsonObjectBacked`. Add required properties with `jsonProperty()` and nullable properties with `nullableJsonProperty()`.\n\n```kotlin\n@Serializable(with = PersonJsonObject.Serializer::class)\nclass PersonJsonObject(\n    raw: JsonObject,\n    json: Json = Json.Default,\n) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {\n    var id: String by jsonProperty()\n    var name: String by jsonProperty()\n    var active: Boolean by jsonProperty(\"is_active\")\n    var nickname: String? by nullableJsonProperty(\"nick\", NullWriteMode.REMOVE_KEY)\n\n    override fun validate() {\n        id\n        name\n    }\n\n    object Serializer : KSerializer\u003cPersonJsonObject\u003e by JsonObjectBackedSerializer(::PersonJsonObject)\n}\n```\n\nUse it through `kotlinx.serialization`:\n\n```kotlin\nval json = Json { prettyPrint = true }\n\nval person = json.decodeFromString(\n    PersonJsonObject.serializer(),\n    \"\"\"\n    {\n      \"id\": \"42\",\n      \"name\": \"Grace\",\n      \"is_active\": true,\n      \"futureField\": \"preserved\"\n    }\n    \"\"\".trimIndent()\n)\n\nperson.name = \"Grace Hopper\"\nperson.nickname = \"Amazing Grace\"\n\nval encoded = json.encodeToString(PersonJsonObject.serializer(), person)\n```\n\nThe encoded JSON contains the changed known fields and still contains `futureField`.\n\n## YAML Quick Start\n\nYAML works the same way, using `YamlObjectBacked` and YAML-specific delegates.\n\n```kotlin\n@Serializable(with = ServiceYamlObject.Serializer::class)\nclass ServiceYamlObject(\n    raw: YamlMap,\n    yaml: Yaml = Yaml.Default,\n) : YamlObjectBacked(raw, YamlBackingCodec(yaml)), ObjectBackedValidated {\n    var id: String by yamlProperty()\n    var endpoint: String by yamlProperty()\n    var description: String? by nullableYamlProperty()\n\n    override fun validate() {\n        id\n        endpoint\n    }\n\n    object Serializer : KSerializer\u003cServiceYamlObject\u003e by YamlObjectBackedSerializer(create = ::ServiceYamlObject)\n}\n```\n\n```kotlin\nval yaml = Yaml.Default\n\nval service = yaml.decodeFromString(\n    ServiceYamlObject.serializer(),\n    \"\"\"\n    id: payments\n    endpoint: https://example.test/payments\n    x-vendor-option: keep-me\n    \"\"\".trimIndent()\n)\n\nservice.endpoint = \"https://api.example.test/payments\"\n\nval encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service)\n```\n\n`x-vendor-option` is preserved.\n\n## Delegated Properties\n\nRequired properties use `jsonProperty()` or `yamlProperty()`.\n\n```kotlin\nvar id: String by jsonProperty()\nvar displayName: String by jsonProperty(\"display_name\")\n```\n\nIf no key is supplied, the Kotlin property name is used as the object key. If a key is supplied, that key is used instead.\n\nReading a missing required property throws `SerializationException`:\n\n```kotlin\nval id = person.id // throws if \"id\" is absent\n```\n\nNullable properties use `nullableJsonProperty()` or `nullableYamlProperty()`.\n\n```kotlin\nvar nickname: String? by nullableJsonProperty(\"nick\")\n```\n\nMissing keys and explicit format-native null values both read as `null`.\n\nRead-only views are also useful:\n\n```kotlin\nval PersonJsonObject.publicName: String by jsonProperty(\"name\")\nval PersonJsonObject.optionalNick: String? by nullableJsonProperty(\"nick\")\n```\n\n## Whole-Object Slices\n\nUse `jsonSlice()` or `yamlSlice()` when you want to decode the entire backing object as an existing\n`@Serializable` type instead of defining one delegated property per field.\n\nA slice is a read-only view over `rawObject`. It uses the wrapper's configured `JsonBackingCodec` or\n`YamlBackingCodec`, so the same format settings and serializers apply.\n\n```kotlin\n@Serializable\ndata class PublicClaims(\n    val iss: String,\n    val sub: String,\n    val aud: String,\n)\n\n@Serializable(with = ClaimsJsonObject.Serializer::class)\nclass ClaimsJsonObject(\n    raw: JsonObject,\n    json: Json = Json.Default,\n) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {\n    val claims: PublicClaims by jsonSlice()\n    var nonce: String? by nullableJsonProperty()\n\n    override fun validate() {\n        claims\n    }\n\n    object Serializer : KSerializer\u003cClaimsJsonObject\u003e by JsonObjectBackedSerializer(::ClaimsJsonObject)\n}\n```\n\n`claims` is decoded from the whole JSON object, while `nonce` remains an editable property backed by\nthe same raw object. Unknown fields are still preserved when the wrapper is serialized again.\n\nYAML-backed objects provide the same pattern with `yamlSlice()`:\n\n```kotlin\nval foo: Foo by yamlSlice()\n```\n\n## Null Write Behavior\n\nPropigator supports per-property null write behavior.\n\nThe default is `NullWriteMode.STORE_NULL`: assigning `null` stores a format-native null value.\n\n```kotlin\nvar middleName: String? by nullableJsonProperty(\"middle_name\")\n\nperson.middleName = null\n// JSON: \"middle_name\": null\n```\n\nUse `NullWriteMode.REMOVE_KEY` when `null` should mean absence:\n\n```kotlin\nvar nickname: String? by nullableJsonProperty(\"nick\", NullWriteMode.REMOVE_KEY)\n\nperson.nickname = null\n// JSON: \"nick\" is removed\n```\n\nThis 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.\n\n## Parse, Not Validate\n\nPropigator is designed for parse-not-validate workflows.\n\nParsing 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.\n\nBy default, delegated required fields are checked when read:\n\n```kotlin\nval person = json.decodeFromString(PersonJsonObject.serializer(), payload)\n\n// Missing \"name\" fails here, when the property is needed.\nprintln(person.name)\n```\n\nThis is useful for downstream integrators that should accept future payloads, inspect a small subset, and forward the rest unchanged.\n\n## Prime Example: JOSE\n\nJOSE-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.\n\nFor example, a `JwsSigned` wrapper may need typed access to signature-critical fields while preserving every application-specific field:\n\n```kotlin\n@Serializable(with = JwsSigned.Serializer::class)\nclass JwsSigned(\n    raw: JsonObject,\n    json: Json = Json.Default,\n) : JsonObjectBacked(raw, JsonBackingCodec(json)) {\n    val protectedHeader: String by jsonProperty(\"protected\")\n    val payload: String by jsonProperty()\n    val signature: String by jsonProperty()\n\n    object Serializer : KSerializer\u003cJwsSigned\u003e by JsonObjectBackedSerializer(::JwsSigned)\n}\n\nvar JwsSigned.kid: String? by nullableJsonProperty(\"kid\")\nvar JwsSigned.trustDomain: String? by nullableJsonProperty(\"trust_domain\")\nvar JwsSigned.policyVersion: Int? by nullableJsonProperty(\"policy_version\")\n```\n\nAn integrator can read the fields it needs:\n\n```kotlin\nval jws = json.decodeFromString(JwsSigned.serializer(), incoming)\n\nval signature = jws.signature\njws.trustDomain = \"example.eu\"\n\nval forwarded = json.encodeToString(JwsSigned.serializer(), jws)\n```\n\nFields not modelled by `JwsSigned`, including future JOSE extensions and domain-specific fields, stay in `rawObject` and are emitted again.\n\nThis 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.\n\nWhen you do need parse-time checks for your own mandatory fields, implement `ObjectBackedValidated` and touch those properties in `validate()`:\n\n```kotlin\noverride fun validate() {\n    id\n    name\n}\n```\n\nThe format serializer calls `validate()` after decoding if the object implements `ObjectBackedValidated`.\n\nUse this sparingly:\n\n- Validate fields your component truly requires.\n- Do not validate fields you merely want to preserve.\n- Do not reject unknown fields just because this version of your code does not understand them.\n\n## Extension Properties\n\nYou can add semantic fields outside the nominal wrapper class.\n\n```kotlin\nvar PersonJsonObject.locale: String? by nullableJsonProperty(\"locale\")\n\nval PersonJsonObject.displayLabel: String\n    get() = locale?.let { \"$name ($it)\" } ?: name\n```\n\nThis is useful when several downstream integrations share the same raw object but each integration owns different extension fields.\n\n## Raw Object Access\n\nUse `rawObject` when you need to inspect, pass through, or debug the complete backing object.\n\n```kotlin\nval raw: JsonObject = person.rawObject\n```\n\nFor JSON, `rawObject` is a `JsonObject`.\n\nFor YAML, `rawObject` is a `YamlMap`.\n\nThe wrapper keeps an internal mutable backing map and exposes snapshots through `rawObject`.\n\n## Serializer Pattern\n\nPropigator serializers are attached to each nominal wrapper type:\n\n```kotlin\n@Serializable(with = PersonJsonObject.Serializer::class)\nclass PersonJsonObject(...) : JsonObjectBacked(...) {\n    object Serializer : KSerializer\u003cPersonJsonObject\u003e by JsonObjectBackedSerializer(::PersonJsonObject)\n}\n```\n\nThe 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.\n\n## Choosing Data Classes vs Propigator\n\nUse regular `@Serializable` data classes when:\n\n- Your service owns the full schema.\n- Unknown fields should be ignored or rejected.\n- You want constructor-based validation and immutable values.\n\nUse Propigator when:\n\n- You need to preserve unknown fields.\n- You are building downstream tooling for someone else's schema.\n- The schema is extensible or versioned.\n- You only own a few fields inside a larger object.\n- You need to parse first and validate only the fields your workflow touches.\n\n\n\n## Current Limitations\n\n- Propigator wraps object/map payloads, not arbitrary top-level scalar values.\n- YAML element conversion is intentionally simple: individual values are rendered through YAML and decoded again with `yamlkt`.\n- Delegated properties are runtime accessors. They are not constructor properties and do not appear as separate generated serialization fields.\n- `ObjectBackedValidated` validates only what your `validate()` function reads.\n\n\n## Contributing\n\nExternal contributions are greatly appreciated.\nPlease observe the contribution guidelines (see [CONTRIBUTING.md](CONTRIBUTING.md)).\n\n---\n\n\u003cp align=\"center\"\u003e\nThe 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\nA-SIT/A-SIT Plus GmbH and may not be used in derivative works without explicit permission!\n\u003c/p\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fa-sit-plus%2Fpropigator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fa-sit-plus%2Fpropigator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fa-sit-plus%2Fpropigator/lists"}