From a5f64b042e65409ca8b2999aa8da0a414e9e5dd7 Mon Sep 17 00:00:00 2001 From: skyecodes Date: Thu, 19 Feb 2026 21:14:44 +0100 Subject: [PATCH] feat: Add list parameters --- api/src/commonMain/kotlin/Parameters.kt | 180 ++++++++++++++++---- api/src/commonTest/kotlin/ParametersTest.kt | 64 +++++-- client/src/commonMain/kotlin/Client.kt | 5 +- docs/website/docs/parameters.md | 12 ++ server/src/commonMain/kotlin/Server.kt | 2 +- server/src/commonTest/kotlin/RouteTest.kt | 34 +++- 6 files changed, 234 insertions(+), 63 deletions(-) diff --git a/api/src/commonMain/kotlin/Parameters.kt b/api/src/commonMain/kotlin/Parameters.kt index 376b614..da18a4b 100644 --- a/api/src/commonMain/kotlin/Parameters.kt +++ b/api/src/commonMain/kotlin/Parameters.kt @@ -8,7 +8,7 @@ import kotlin.reflect.KProperty * * Library integrations may use this directly when constructing parameter bundles. */ -typealias ParameterStorage = MutableMap +typealias ParameterStorage = MutableMap> /** * Factory function that builds a [Parameters] subtype backed by a [ParameterStorage]. @@ -124,6 +124,38 @@ abstract class Parameters( */ protected fun parameter(name: String) = Parameter(name, null) + /** + * Declares a list of parameters of type [T]. + * + * If the parameter is missing, reading it will return [emptyList] instead. + * + * The parameter is automatically named after the variable it is assigned to. + * + * ### Example + * + * ```kotlin + * class ListUsers(data: ParameterStorage) : Parameters(data) { + * var tags by listParameter() + * } + * ``` + */ + protected fun listParameter() = UnnamedListParameter() + + /** + * Declares a list of parameters [name] of type [T]. + * + * If the parameter is missing, reading it will return [emptyList] instead. + * + * ### Example + * + * ```kotlin + * class ListUsers(data: ParameterStorage) : Parameters(data) { + * var tags by listParameter("search_tags") + * } + * ``` + */ + protected fun listParameter(name: String) = ListParameter(name) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false @@ -171,6 +203,30 @@ abstract class Parameters( } } + /** + * Internal type used by the parameter declaration syntax. + * + * See [Parameters]. + */ + class UnnamedListParameter + + /** + * A declared query list parameter in an API schema. + * + * See [Parameters]. + */ + class ListParameter( + /** + * Name of the parameter as it appears in the URL. + */ + val name: String, + ) { + init { + require(name.isNotBlank()) { "The name of a parameter cannot be empty: '$name'" } + require(name.none { it.isWhitespace() }) { "The name of a parameter cannot contain whitespace: '$name'" } + } + } + /** * The default parameter instance. * @@ -187,34 +243,71 @@ abstract class Parameters( inline operator fun Parameters.UnnamedParameter.provideDelegate(thisRef: Parameters, property: KProperty<*>) = Parameters.Parameter(property.name, this.defaultValue) +/** + * Internal method used by the parameter declaration syntax. + * + * See [Parameters.parameter]. + */ +inline operator fun Parameters.UnnamedListParameter.provideDelegate(thisRef: Parameters, property: KProperty<*>) = + Parameters.ListParameter(property.name) + /** * Internal method used by the parameter declaration syntax. * * See [Parameters.parameter]. */ inline operator fun Parameters.Parameter.getValue(thisRef: Parameters, property: KProperty<*>): T { - val value = thisRef.data[name] ?: return run { + val value = thisRef.data[name]?.let { + if (it.size == 1) + it[0] + else + throw IllegalArgumentException( + """ + The parameter '$name' should not contain multiple values. + If you want to accept multiple parameters, use listParameters() instead of parameters(). + """.trimIndent() + ) + } ?: return run { if (defaultValue is T) defaultValue else - throw NoSuchElementException("The parameter '${name}' is mandatory, but no value was provided.") + throw NoSuchElementException("The parameter '$name' is mandatory, but no value was provided.") } - return when (T::class) { - String::class -> value as T - Boolean::class -> value.toBooleanStrict() as T - Byte::class -> value.toByte() as T - Short::class -> value.toShort() as T - Int::class -> value.toInt() as T - Long::class -> value.toLong() as T - UByte::class -> value.toUByte() as T - UShort::class -> value.toUShort() as T - UInt::class -> value.toUInt() as T - ULong::class -> value.toULong() as T - Float::class -> value.toFloat() as T - Double::class -> value.toDouble() as T - else -> throw UnsupportedOperationException("The type ${T::class.simpleName ?: T::class.toString()} is not currently supported in parameters.") - } + return stringToValue(value) +} + +/** + * Internal method used by the parameter declaration syntax. + * + * See [Parameters.listParameter]. + */ +inline operator fun Parameters.ListParameter.getValue(thisRef: Parameters, property: KProperty<*>): List { + val value = thisRef.data[name] ?: return emptyList() + + return value.map { stringToValue(it) } +} + +/** + * Internal method used by the parameter declaration syntax. + * + * See [Parameters.parameter]. + */ +@PublishedApi +internal inline fun stringToValue(value: String) = when (T::class) { + String::class -> value as T + Boolean::class -> value.toBooleanStrict() as T + Byte::class -> value.toByte() as T + Short::class -> value.toShort() as T + Int::class -> value.toInt() as T + Long::class -> value.toLong() as T + UByte::class -> value.toUByte() as T + UShort::class -> value.toUShort() as T + UInt::class -> value.toUInt() as T + ULong::class -> value.toULong() as T + Float::class -> value.toFloat() as T + Double::class -> value.toDouble() as T + else -> throw UnsupportedOperationException("The type ${T::class.simpleName ?: T::class.toString()} is not currently supported in parameters.") } /** @@ -223,23 +316,40 @@ inline operator fun Parameters.Parameter.getValue(thisRef: Parame * See [Parameters.parameter]. */ inline operator fun Parameters.Parameter.setValue(thisRef: Parameters, property: KProperty<*>, value: T) { - if (value == null) return - - thisRef.data[name] = when (value) { - is String -> value - is Boolean -> value.toString() - is Byte -> value.toString() - is Short -> value.toString() - is Int -> value.toString() - is Long -> value.toString() - is UByte -> value.toString() - is UShort -> value.toString() - is UInt -> value.toString() - is ULong -> value.toString() - is Float -> value.toString() - is Double -> value.toString() - else -> throw UnsupportedOperationException("The type ${T::class.simpleName ?: T::class.toString()} is not currently supported in parameters.") - } + if (value == null) thisRef.data -= name + else thisRef.data[name] = listOf(valueToString(value)) +} + +/** + * Internal method used by the parameter declaration syntax. + * + * See [Parameters.listParameter]. + */ +inline operator fun Parameters.ListParameter.setValue(thisRef: Parameters, property: KProperty<*>, value: List) { + if (value.isEmpty()) thisRef.data -= name + else thisRef.data[name] = value.map(::valueToString) +} + +/** + * Internal method used by the parameter declaration syntax. + * + * See [Parameters.parameter]. + */ +@PublishedApi +internal inline fun valueToString(value: T) = when (value) { + is String -> value + is Boolean -> value.toString() + is Byte -> value.toString() + is Short -> value.toString() + is Int -> value.toString() + is Long -> value.toString() + is UByte -> value.toString() + is UShort -> value.toString() + is UInt -> value.toString() + is ULong -> value.toString() + is Float -> value.toString() + is Double -> value.toString() + else -> throw UnsupportedOperationException("The type ${T::class.simpleName ?: T::class.toString()} is not currently supported in parameters.") } /** diff --git a/api/src/commonTest/kotlin/ParametersTest.kt b/api/src/commonTest/kotlin/ParametersTest.kt index 23b8ede..4fb24aa 100644 --- a/api/src/commonTest/kotlin/ParametersTest.kt +++ b/api/src/commonTest/kotlin/ParametersTest.kt @@ -17,7 +17,7 @@ fun SuiteDsl.parameters() = suite("Endpoint parameters") { check(params.archived) checkThrows { params.private } - check(params.data == mapOf("archived" to "true")) + check(params.data == mapOf("archived" to listOf("true"))) } test("Optional parameters") { @@ -33,7 +33,7 @@ fun SuiteDsl.parameters() = suite("Endpoint parameters") { check(params.archived == true) check(params.private == null) - check(params.data == mapOf("archived" to "true")) + check(params.data == mapOf("archived" to listOf("true"))) } test("Optional parameters with explicit null values") { @@ -50,7 +50,7 @@ fun SuiteDsl.parameters() = suite("Endpoint parameters") { check(params.archived == true) check(params.private == null) - check(params.data == mapOf("archived" to "true")) + check(params.data == mapOf("archived" to listOf("true"))) } test("Optional parameters with default values") { @@ -66,7 +66,39 @@ fun SuiteDsl.parameters() = suite("Endpoint parameters") { check(params.archived) check(!params.private) - check(params.data == mapOf("archived" to "true")) + check(params.data == mapOf("archived" to listOf("true"))) + } + + test("List parameters") { + class MandatoryParams(data: ParameterStorage) : Parameters(data) { + var categories: List by listParameter("search_categories") + var tags by listParameter() + } + + val params = buildParameters(::MandatoryParams) { + categories = listOf("category a") + } + + check(params.categories == listOf("category a")) + check(params.tags.isEmpty()) + + check(params.data == mapOf("search_categories" to listOf("category a"))) + } + + test("List parameters with empty value") { + class DefaultParams(data: ParameterStorage) : Parameters(data) { + var categories: List by listParameter("categories") + var tags by listParameter() + } + + val params = buildParameters(::DefaultParams) { + tags = emptyList() + } + + check(params.categories == emptyList()) + check(params.tags == emptyList()) + + check(params.data == emptyMap()) } test("Supported types are mapped correctly") { @@ -122,21 +154,21 @@ fun SuiteDsl.parameters() = suite("Endpoint parameters") { check(params.double == 10.0) check(params.data == mapOf( - "string" to "thing", - "bool" to "true", + "string" to listOf("thing"), + "bool" to listOf("true"), - "byte" to "1", - "short" to "2", - "int" to "3", - "long" to "4", + "byte" to listOf("1"), + "short" to listOf("2"), + "int" to listOf("3"), + "long" to listOf("4"), - "u_byte" to "5", - "u_short" to "6", - "uint" to "7", - "ulong" to "8", + "u_byte" to listOf("5"), + "u_short" to listOf("6"), + "uint" to listOf("7"), + "ulong" to listOf("8"), - "float" to "${9.0}", // JVM: "9.0" — JS: "9" - "double" to "${10.0}", + "float" to listOf("${9.0}"), // JVM: "9.0" — JS: "9" + "double" to listOf("${10.0}"), )) } } diff --git a/client/src/commonMain/kotlin/Client.kt b/client/src/commonMain/kotlin/Client.kt index a43e6b6..06ed990 100644 --- a/client/src/commonMain/kotlin/Client.kt +++ b/client/src/commonMain/kotlin/Client.kt @@ -63,8 +63,9 @@ suspend inline fun `) by calling the `by listParameter()` function. + +List parameters are not nullable and are empty by default. + +```kotlin +var tags: List by listParameter() //(1)! +var categories: List by listParameter(name = "search_categories") //(2)! +``` + +1. Declares the query list parameter `tags`, which is empty when omitted by the client. +2. Declares the query list parameter `search_categories`, which is empty when omitted by the client. + ## Server-side On the server-side, parameters are accessed via the special variable `parameters`: diff --git a/server/src/commonMain/kotlin/Server.kt b/server/src/commonMain/kotlin/Server.kt index 5015c3b..722a29a 100644 --- a/server/src/commonMain/kotlin/Server.kt +++ b/server/src/commonMain/kotlin/Server.kt @@ -16,7 +16,7 @@ inline fun ) @Serializable private data class NotFound(val id: String) { @@ -58,6 +58,7 @@ private data class NotAllowed(val reason: String) { private class UserSearchParams(data: ParameterStorage) : Parameters(data) { var includeDisabled by parameter(false) var name: String? by parameter() + var tags by listParameter() } private object Routes : RootResource("routes") { @@ -102,7 +103,8 @@ private val server by preparedServer { dataLock.withLock("list") { data.filter { (it.enabled || parameters.includeDisabled) && - (parameters.name == null || it.name == parameters.name) + (parameters.name == null || it.name == parameters.name) && + (parameters.tags.isEmpty() || it.tags.any { tag -> tag in parameters.tags }) } } ) @@ -149,11 +151,12 @@ val client by server.preparedClient { } } -private suspend fun HttpClient.listUsers(includeDisabled: Boolean = false, name: String? = null) = request( +private suspend fun HttpClient.listUsers(includeDisabled: Boolean = false, name: String? = null, tags: List = emptyList()) = request( endpoint = Routes / Users / Users.list, parameters = { this.includeDisabled = includeDisabled this.name = name + this.tags = tags }, ).bodyOrThrow() @@ -184,30 +187,35 @@ fun SuiteDsl.routeTest() = suite("Route test") { } test("Creating a user") { - client().createUser(UserDto(userId(), "test", true)) + client().createUser(UserDto(userId(), "test", true, emptyList())) } test("Cannot create two users with the same ID") { - client().createUser(UserDto(userId(), "test", true)) + client().createUser(UserDto(userId(), "test", true, emptyList())) val e = checkThrows { - client().createUser(UserDto(userId(), "test", true)) + client().createUser(UserDto(userId(), "test", true, emptyList())) } check(e.message == "Could not find user ${userId()}") } val enabledUser by prepared { - UserDto(random.nextInt(0, 999).toString(), "enabled user", true) + UserDto(random.nextInt(0, 999).toString(), "enabled user", true, listOf("tag 1", "tag 2")) .also { client().createUser(it) } } val disabledUser by prepared { - UserDto(random.nextInt(0, 999).toString(), "disabled user", false) + UserDto(random.nextInt(0, 999).toString(), "disabled user", false, emptyList()) .also { client().createUser(it) } } val enabledUser2 by prepared { - UserDto(random.nextInt(0, 999).toString(), "enabled user 2", true) + UserDto(random.nextInt(0, 999).toString(), "enabled user 2", true, listOf("tag 2", "tag 3")) + .also { client().createUser(it) } + } + + val enabledUser3 by prepared { + UserDto(random.nextInt(0, 999).toString(), "enabled user 3", true, listOf("tag 4")) .also { client().createUser(it) } } @@ -232,6 +240,14 @@ fun SuiteDsl.routeTest() = suite("Route test") { check(client().listUsers(name = user.name) == listOf(user)) } + test("Listing users by tag") { + enabledUser() + enabledUser2() + enabledUser3() + + check(client().listUsers(tags = listOf("tag 1", "tag 4")) == listOf(enabledUser(), enabledUser3())) + } + test("Accessing the details of a user") { val user = enabledUser() -- 2.51.2