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
2 changes: 1 addition & 1 deletion .github/workflows/build-everything.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:

jobs:
build-everything:
uses: a-sit-plus/internal-workflows/.github/workflows/build-everything.yml@xcode-26
uses: a-sit-plus/internal-workflows/.github/workflows/build-everything.yml@v3
with:
matrix-file-name: ".github/config/build-strategy-matrix.json"
kotlin-version: ${{ inputs.kotlin-version }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
TESTBALLOON_VERSION_OVERRIDE: ${{ inputs.testballoon-version }}
steps:
- name: Common Setup
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v2
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v3
with:
override-cache: 'false'
setup-xcode: 'true'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pages-only.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Common Setup
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v2
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v3
with:
override-cache: 'false'
- name: Build Dokka HTML
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
timeout-minutes: 300
steps:
- name: Common Setup
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v2
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v3
with:
override-cache: 'true'
setup-xcode: 'true'
Expand All @@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Common Setup
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v2
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v3
with:
override-cache: 'false'
- name: Build Dokka HTML
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/spotless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ jobs:
timeout-minutes: 300
steps:
- name: Common Setup
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v2
uses: a-sit-plus/internal-workflows/.github/actions/common-setup@v3
with:
override-cache: ${{ github.ref_name == 'main' }}
override-cache: ${{ github.ref_name == 'main' || github.ref_name == 'development'}}
- name: check
run: ./gradlew spotlessCheck
4 changes: 2 additions & 2 deletions .github/workflows/test-everything.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ on:

jobs:
test-everything:
uses: a-sit-plus/internal-workflows/.github/workflows/test-everything.yml@xcode-26
uses: a-sit-plus/internal-workflows/.github/workflows/test-everything.yml@v3
with:
override-cache: ${{ github.ref_name == 'main' }}
override-cache: ${{ github.ref_name == 'main'|| github.ref_name == 'development' }}
matrix-file-name: ".github/config/test-strategy-matrix.json"
kotlin-version: ${{ inputs.kotlin-version }}
testballoon-version: ${{ inputs.testballoon-version }}
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog
### Unreleased
- Updated backed property nullability so nullable fields are declared with nullable Kotlin types; this keeps the same optional-field functionality without a separate nullable delegate.

### Version 0.0.1
- Initial version
- Supports json and yaml
- Supports custom serializers
- Supports backedProperties and slices
- Supports backedProperties and slices
41 changes: 20 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ dependencies {

## JSON Quick Start

Define a nominal wrapper class around `JsonObjectBacked`. Add required properties with `jsonProperty()` and nullable properties with `nullableJsonProperty()`.
Define a nominal wrapper class around `JsonObjectBacked`. Add typed properties with `jsonProperty()`.
The declared Kotlin type controls whether the backing field is required or nullable.

```kotlin
@Serializable(with = PersonJsonObject.Serializer::class)
Expand All @@ -94,7 +95,7 @@ class PersonJsonObject(
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)
var nickname: String? by jsonProperty("nick", nullWriteMode = NullWriteMode.REMOVE_KEY)

override fun validate() {
id
Expand Down Expand Up @@ -142,7 +143,7 @@ class ServiceYamlObject(
) : YamlObjectBacked(raw, YamlBackingCodec(yaml)), ObjectBackedValidated {
var id: String by yamlProperty()
var endpoint: String by yamlProperty()
var description: String? by nullableYamlProperty()
var description: String? by yamlProperty()

override fun validate() {
id
Expand Down Expand Up @@ -174,34 +175,31 @@ val encoded = yaml.encodeToString(ServiceYamlObject.serializer(), service)

## Delegated Properties

Required properties use `jsonProperty()` or `yamlProperty()`.
Required and nullable properties both use `jsonProperty()` or `yamlProperty()`.
The property type, together with the supplied serializer, defines the nullability contract.

```kotlin
var id: String by jsonProperty()
var displayName: String by jsonProperty("display_name")
var 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 required property throws `SerializationException`:
Reading a missing or explicit-null required property throws `SerializationException`:

```kotlin
val id = person.id // throws if "id" is absent
val id = person.id // throws if "id" is absent or null
```

Nullable properties use `nullableJsonProperty()` or `nullableYamlProperty()`.

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

Missing keys and explicit format-native null values both read as `null`.
For nullable properties, missing keys and explicit format-native null values both read as `null`.
The serializer must also be nullable; declaring the Kotlin property as `String?` gives the delegate a nullable serializer.

Read-only views are also useful:

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

## Whole-Object Slices
Expand All @@ -226,7 +224,7 @@ class ClaimsJsonObject(
json: Json = Json.Default,
) : JsonObjectBacked(raw, JsonBackingCodec(json)), ObjectBackedValidated {
val claims: PublicClaims by jsonSlice()
var nonce: String? by nullableJsonProperty()
var nonce: String? by jsonProperty()

override fun validate() {
claims
Expand All @@ -252,7 +250,7 @@ Propigator supports per-property null write behavior.
The default is `NullWriteMode.STORE_NULL`: assigning `null` stores a format-native null value.

```kotlin
var middleName: String? by nullableJsonProperty("middle_name")
var middleName: String? by jsonProperty("middle_name")

person.middleName = null
// JSON: "middle_name": null
Expand All @@ -261,13 +259,14 @@ person.middleName = null
Use `NullWriteMode.REMOVE_KEY` when `null` should mean absence:

```kotlin
var nickname: String? by nullableJsonProperty("nick", NullWriteMode.REMOVE_KEY)
var nickname: String? by jsonProperty("nick", nullWriteMode = 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.
`NullWriteMode` only applies to nullable delegated properties. A non-null property remains required and cannot be assigned `null`.

## Parse, Not Validate

Expand Down Expand Up @@ -305,9 +304,9 @@ class JwsSigned(
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")
var JwsSigned.kid: String? by jsonProperty("kid")
var JwsSigned.trustDomain: String? by jsonProperty("trust_domain")
var JwsSigned.policyVersion: Int? by jsonProperty("policy_version")
```

An integrator can read the fields it needs:
Expand Down Expand Up @@ -347,7 +346,7 @@ Use this sparingly:
You can add semantic fields outside the nominal wrapper class.

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

val PersonJsonObject.displayLabel: String
get() = locale?.let { "$name ($it)" } ?: name
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ plugins {

//work around nexus publish bug
val propigatorVersion: String by extra
group = "at.asitplus.propigator"
version = propigatorVersion
//end work around nexus publish bug

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import kotlinx.serialization.serializer
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

//TODO: this can go, as the underlyign format will do null handling, right!?
enum class NullWriteMode {
/** Store an explicit format-native null value. */
STORE_NULL,
Expand All @@ -18,61 +17,53 @@ enum class NullWriteMode {
REMOVE_KEY,
}

class RequiredBackedProperty<O, K, V, T>(
class BackedProperty<O, K, V, T>(
private val key: K?,
private val serializer: KSerializer<T>,
private val nullWriteMode: NullWriteMode,
) : ReadWriteProperty<O, T> where O : ObjectBacked<K, V> {
override fun getValue(thisRef: O, property: KProperty<*>): T {
val actualKey = (key ?: (property.name as? K))
?: throw SerializationException("property ${property.name} is not identifiable by String")
val actualKey = actualKey(property)
val element = thisRef.getElement(actualKey)
?: throw SerializationException("Missing required backing property: $actualKey")
?: return readNull(actualKey)
if (thisRef.codec.isNull(element)) return readNull(actualKey)
return thisRef.codec.decode(serializer, element)
}

override fun setValue(thisRef: O, property: KProperty<*>, value: T) {
val actualKey = (key ?: (property.name as? K))
?: throw SerializationException("property ${property.name} is not identifiable by String")
val actualKey = actualKey(property)
if (value == null) {
if (!serializer.descriptor.isNullable) {
throw SerializationException("Required backing property cannot be set to null: $actualKey")
}
when (nullWriteMode) {
NullWriteMode.STORE_NULL ->
thisRef.putElement(actualKey, thisRef.codec.nullElement())

NullWriteMode.REMOVE_KEY ->
thisRef.removeElement(actualKey)
}
return
}

thisRef.putElement(actualKey, thisRef.codec.encode(serializer, value))
}
}

class NullableBackedProperty<O, K, V, T>(
private val key: K?,
private val serializer: KSerializer<T>,
private val nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL,
) : ReadWriteProperty<O, T?> where O : ObjectBacked<K, V> {
override fun getValue(thisRef: O, property: KProperty<*>): T? {
val actualKey = (key ?: (property.name as? K))
?: throw SerializationException("property ${property.name} is not identifiable by String")
val element = thisRef.getElement(actualKey) ?: return null
if (thisRef.codec.isNull(element)) return null
return thisRef.codec.decode(serializer, element)
}
@Suppress("UNCHECKED_CAST")
private fun actualKey(property: KProperty<*>): K =
key ?: (property.name as? K)
?: throw SerializationException("property ${property.name} is not identifiable by backing key type")

override fun setValue(thisRef: O, property: KProperty<*>, value: T?) {
val actualKey = (key ?: (property.name as? K))
?: throw SerializationException("property ${property.name} is not identifiable by String")
if (value == null && nullWriteMode == NullWriteMode.REMOVE_KEY) {
thisRef.removeElement(actualKey)
} else {
thisRef.putElement(
actualKey,
if (value == null) thisRef.codec.nullElement() else thisRef.codec.encode(serializer, value),
)
}
@Suppress("UNCHECKED_CAST")
private fun readNull(actualKey: K): T {
if (serializer.descriptor.isNullable) return null as T
throw SerializationException("Missing required backing property: $actualKey")
}
}

inline fun <O, K, V, reified T> backedProperty(
key: K? = null,
serializer: KSerializer<T> = serializer(),
nullWriteMode: NullWriteMode,
): ReadWriteProperty<O, T> where O : ObjectBacked<K, V> =
RequiredBackedProperty(key, serializer)

inline fun <O, K, V, reified T> nullableBackedProperty(
key: K? = null,
nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL,
serializer: KSerializer<T?> = serializer(),
): ReadWriteProperty<O, T?> where O : ObjectBacked<K, V> =
NullableBackedProperty(key, serializer, nullWriteMode)
BackedProperty(key, serializer, nullWriteMode)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package at.asitplus.propigator.common

import at.asitplus.testballoon.matrix.matrixSuite
import io.kotest.matchers.shouldBe

internal val ObjectBackedCommonTest by matrixSuite {
"Object-backed common test data" - {
"exposes a valid reusable person fixture" {
ObjectBackedTestData.validate()

ObjectBackedTestData.id shouldBe "p-1"
ObjectBackedTestData.name shouldBe "Ada"
ObjectBackedTestData.renamed shouldBe true
ObjectBackedTestData.foo shouldBe ObjectBackedTestPerson.Foo(
bar = 2,
baz = "eyz",
)
}
}
}
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ kotlin.native.ignoreDisabledTargets=true
publishVersionCatalog=true


propigatorVersion = 0.0.1-SNAPSHOT
propigatorVersion = 0.0.2-SNAPSHOT

# This is not a well-defined property, the ASP convention plugin respects it, though
jdk.version=17
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[versions]
kotlin="2.3.20"
testballoon = "0.8.4-K2.3.20"
testballoon = "1.0.0-K2.3.20"
yamlkt = "0.13.0"
asp="20260428"
asp="20260610"
agp = "8.12.3"
sbombastic = "0.0.3"
spotless = "8.2.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

package at.asitplus.propigator.json

import at.asitplus.propigator.common.*
import at.asitplus.propigator.common.BackingCodec
import at.asitplus.propigator.common.NullWriteMode
import at.asitplus.propigator.common.ObjectBacked
import at.asitplus.propigator.common.backedProperty
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
Expand Down Expand Up @@ -45,17 +48,20 @@ open class JsonObjectBacked(
}
}

/**
* 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 <reified T> jsonProperty(
key: String? = null,
serializer: KSerializer<T> = serializer(),
nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL
): ReadWriteProperty<JsonObjectBacked, T> =
backedProperty<JsonObjectBacked, String, JsonElement, T>(key, serializer)
backedProperty<JsonObjectBacked, String, JsonElement, T>(key, serializer, nullWriteMode)

inline fun <reified T> jsonSlice(serializer: KSerializer<T> = serializer()): ReadOnlyProperty<JsonObjectBacked, T> =
ReadOnlyProperty { thisRef, _ -> thisRef.codec.decode(serializer, thisRef.rawObject) }

inline fun <reified T> nullableJsonProperty(
key: String? = null,
nullWriteMode: NullWriteMode = NullWriteMode.STORE_NULL,
): ReadWriteProperty<JsonObjectBacked, T?> =
nullableBackedProperty<JsonObjectBacked, String, JsonElement, T>(key, nullWriteMode)
Loading
Loading