diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt index e575470..ce3fe3a 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt @@ -68,7 +68,7 @@ internal class QueryDecoder( override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { if (descriptor.kind == StructureKind.LIST) return QueryListDecoder(params.getAll(currentName)) - if (entered) throw SerializationException("nested objects are not supported by the query format") + if (entered) throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) entered = true return this } @@ -126,6 +126,14 @@ internal class QueryListDecoder( override fun decodeElementIndex(descriptor: SerialDescriptor): Int = if (cursor < values.size) cursor else CompositeDecoder.DECODE_DONE + /** + * A list element is always a scalar/enum in this format's scope, so any call here — a nested + * `@Serializable` object or a nested list — is out of scope and rejected, mirroring + * [QueryDecoder.beginStructure]'s top-level guard. + */ + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = + throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) + private fun next(): String = values[cursor++] ?: throw SerializationException("null list element") override fun decodeString(): String = next() @@ -149,6 +157,9 @@ internal class QueryListDecoder( override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = enumIndex(enumDescriptor, next()) } +/** The nesting-rejection message shared by [QueryDecoder.beginStructure] and [QueryListDecoder.beginStructure]. */ +private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" + /** Converts [raw] with [convert], failing with a [SerializationException] describing [kind] and [context] on error. */ private fun scalarOrFail( kind: String, diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt index 9ab3736..f26e713 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt @@ -32,7 +32,7 @@ internal class QueryEncoder : AbstractEncoder() { fun build(): QueryParameters = builder.build() override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { - if (entered) throw SerializationException("nested objects are not supported by the query format") + if (entered) throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) entered = true return this } @@ -98,6 +98,14 @@ internal class QueryListEncoder( ) : AbstractEncoder() { override val serializersModule: SerializersModule = EmptySerializersModule() + /** + * A list element is always a scalar/enum in this format's scope, so any call here — a nested + * `@Serializable` object or a nested list — is out of scope and rejected, mirroring + * [QueryEncoder.beginStructure]'s top-level guard. + */ + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) + override fun encodeValue(value: Any) { builder.add(name, value.toString()) } @@ -110,6 +118,9 @@ internal class QueryListEncoder( } } +/** The nesting-rejection message shared by [QueryEncoder.beginStructure] and [QueryListEncoder.beginStructure]. */ +private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" + /** * Suffix marking the wire-level "present but empty" sentinel for a list property, appended to its * declared name (e.g. `tags` -> `tags[]`). `[`/`]` are never percent-encoded by the query name encode diff --git a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt index da9f7f3..cf47872 100644 --- a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt +++ b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt @@ -74,6 +74,22 @@ private data class Nested( val inner: Search, ) +@Serializable +private data class Holder( + val friends: List, +) + +@Serializable +private data class Matrix( + val rows: List>, +) + +@Serializable +private data class Profile( + val handle: String, + val bio: String?, +) + @Serializable private data class Foo( val tags: List = listOf("x"), @@ -273,6 +289,36 @@ class SerdeTest { assertEquals(withNickname, QueryParametersFormat.decodeFromQueryString(query)) } + // Profile.bio has no default, unlike Contact.nickname: decodeElementIndex's isElementOptional + // short-circuit only applies to a defaulted element, so these cases force an actual + // decodeNotNullMark()/encodeNull() call instead of the property being skipped upstream. + + @Test + fun `encoding a required nullable property that is null omits its key`() { + val query = QueryParametersFormat.encodeToQueryString(Profile(handle = "ada", bio = null)) + assertEquals("handle=ada", query) + } + + @Test + fun `decoding a required nullable property absent from the query yields null`() { + val profile = QueryParametersFormat.decodeFromQueryString("handle=ada") + assertEquals(Profile(handle = "ada", bio = null), profile) + } + + @Test + fun `decoding a required nullable property present with no value yields an empty string`() { + val profile = QueryParametersFormat.decodeFromQueryString("handle=ada&bio=") + assertEquals(Profile(handle = "ada", bio = ""), profile) + } + + @Test + fun `a required nullable property present but empty round-trips through encode and decode`() { + val original = Profile(handle = "ada", bio = "") + val query = QueryParametersFormat.encodeToQueryString(original) + assertEquals("handle=ada&bio=", query) + assertEquals(original, QueryParametersFormat.decodeFromQueryString(query)) + } + @Test fun `decoding an unrecognized enum value throws`() { assertFailsWith { @@ -454,4 +500,32 @@ class SerdeTest { QueryParametersFormat.encodeToQueryParameters(Nested(Search(q = "x"))) } } + + @Test + fun `encoding a serializable object nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.encodeToQueryParameters(Holder(listOf(Contact(name = "ada", nickname = "countess")))) + } + } + + @Test + fun `decoding a serializable object nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.decodeFromQueryString("friends=ada") + } + } + + @Test + fun `encoding a list nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.encodeToQueryParameters(Matrix(listOf(listOf(1, 2)))) + } + } + + @Test + fun `decoding a list nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.decodeFromQueryString("rows=1") + } + } }