Skip to content
Merged
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,18 @@ params["flag"] // null — present, but has no '='...
params.has("flag") // true — ...so check has()
```

**Multi-value query params.** `split(name, delimiter)` reads every pair for a name and splits each
value on a delimiter, so a delimited list in one pair *and* repeated pairs both flatten into a single
list. Splitting is literal and lossless — no trimming, empty tokens kept — and typing is just a `map`.

```kotlin
val q = Url.parseOrThrow("https://h/?roles=admin,user&perm=read|write&id=1,2&id=3").queryParameters
q.split("roles", ',') // ["admin", "user"]
q.split("perm", '|') // ["read", "write"]
q.split("id", ',') // ["1", "2", "3"] — pairs and delimiters both flatten
q.split("id", ',').map(String::toInt) // [1, 2, 3] — conversion is just map()
```

The `Uri` profile computes its query on demand, so there it is a method — `uri.queryParameters()` —
rather than a property.

Expand Down
1 change: 1 addition & 0 deletions kuri-bind/api/kuri-bind.api
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public abstract interface annotation class org/dexpace/kuri/bind/Port : java/lan
}

public abstract interface annotation class org/dexpace/kuri/bind/Query : java/lang/annotation/Annotation {
public abstract fun delimiter ()C
public abstract fun value ()Ljava/lang/String;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,22 @@ public annotation class Path(
val value: String = "",
)

/** One query parameter (scalar/collection) or scalar-scoped recursion (complex); [value] is the name. */
/**
* One query parameter (scalar/collection) or scalar-scoped recursion (complex); [value] is the name.
*
* On an iterable member, [delimiter] switches the parameter from repeated params (`roles=admin&roles=user`,
* the default) to a single joined value (`roles=admin,user`): the non-null elements' scalar text is
* joined with [delimiter], in order. A null element is skipped; an empty or all-null collection emits
* no parameter at all, matching the unjoined fan-out's empty-collection behavior. On a scalar member
* [delimiter] is ignored — a join has no meaning for one value.
*
* The NUL sentinel (`'\u0000'`) marks [delimiter] as unset, since NUL cannot appear in a URL and so
* can never be a real join delimiter. [delimiter] never applies to `@QueryMap` — that stays fan-out only.
*
* A join is lossy if an element's text itself contains [delimiter]: the joined value is then not
* recoverable via `QueryParameters.split`, which re-splits on every occurrence of the delimiter after
* decoding. Choose a delimiter that is absent from the elements' text, the same caveat `split` carries.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(
AnnotationTarget.PROPERTY,
Expand All @@ -141,6 +156,7 @@ public annotation class Path(
)
public annotation class Query(
val value: String = "",
val delimiter: Char = '\u0000',
)

/** A `Map` member: one query parameter per entry. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,14 @@ class AnnotationsTest {
val template = ann.filterIsInstance<PathTemplate>().single()
assertEquals("/x/{id}", template.value)
}

@Test
fun `query delimiter defaults to the nul sentinel`() {
assertEquals('\u0000', Query().delimiter)
}

@Test
fun `query delimiter reflects a configured value`() {
assertEquals(',', Query("n", ',').delimiter)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ internal class BindingExecutor(
ctx: WalkContext,
) {
if ((step.op == LeafOp.QUERY || step.op == LeafOp.PATH) && runtimeValueIsBindable(value, compiler)) {
applyRecurse(BindStep.Recurse(step.member, scopeFor(step.op)), value, ctx)
val scope = if (step.op == LeafOp.QUERY) Scope.QUERY else Scope.PATH
applyRecurse(BindStep.Recurse(step.member, scope), value, ctx)
} else {
LeafBinder.apply(step, value, ctx.sink)
}
Expand Down Expand Up @@ -219,7 +220,7 @@ private object LeafBinder {
LeafOp.PORT -> sink.setPort(convertPort(value, path), path)
LeafOp.FRAGMENT -> sink.setFragment(scalarText(value), path)
LeafOp.PATH -> applyPath(value, sink)
LeafOp.QUERY -> addQueryValues(step.queryName ?: path, value, sink)
LeafOp.QUERY -> addQueryValues(step.queryName ?: path, value, sink, step.queryDelimiter)
LeafOp.QUERY_MAP -> applyQueryMap(value, sink, path)
// Userinfo leaves are paired into one contribution by UserInfoBinder, never applied here.
LeafOp.USERINFO, LeafOp.USERNAME, LeafOp.PASSWORD -> Unit
Expand Down Expand Up @@ -248,24 +249,33 @@ private object LeafBinder {
) {
val name = key?.let(::scalarText) ?: return
if (name.isEmpty()) throw KuriBindException("query parameter name must not be empty", path)
addQueryValues(name, value, sink)
// A `@QueryMap` entry never joins: delimiter is a `@Query`-only concept, so a collection entry
// value always fans out, regardless of any delimiter the enclosing `@Query` might carry.
addQueryValues(name, value, sink, delimiter = null)
}

// Contributes a `@Query` value under [name]: a collection fans out into one parameter per element
// (a null element yields a valueless parameter), any other value becomes a single scalar parameter.
// The direct `@Query` leaf and each `@QueryMap` entry share this; they differ only in how [name] is
// derived. [value] is nullable only so a `@QueryMap` entry's null value can pass through as a
// valueless parameter; the direct `@Query` path always reads a non-null value first.
// Contributes a `@Query` value under [name]. A collection either fans out into one parameter per
// element (a null element yields a valueless parameter, the default) or, when [delimiter] is set,
// joins its non-null elements' scalar text into a single parameter value (an empty/all-null
// collection then emits no parameter, matching the fan-out's empty-collection behavior). Any other
// value becomes a single scalar parameter, [delimiter] ignored. The direct `@Query` leaf and each
// `@QueryMap` entry share this; they differ only in how [name] is derived and that `@QueryMap`
// always passes a null [delimiter]. [value] is nullable only so a `@QueryMap` entry's null value can
// pass through as a valueless parameter; the direct `@Query` path always reads a non-null value first.
private fun addQueryValues(
name: String,
value: Any?,
sink: ComponentSink,
delimiter: Char?,
) {
val iterable = value?.let(::asIterableOrNull)
if (iterable != null) {
iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) }
} else {
sink.addQueryParameter(name, value?.let(::scalarText))
when {
iterable != null && delimiter != null -> {
val tokens = scalarTokens(iterable)
if (tokens.isNotEmpty()) sink.addQueryParameter(name, tokens.joinToString("$delimiter"))
}
iterable != null -> iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) }
else -> sink.addQueryParameter(name, value?.let(::scalarText))
}
}
}
Expand Down Expand Up @@ -364,9 +374,6 @@ private fun runtimeValueIsBindable(
return compiler.planFor(value::class).steps.isNotEmpty()
}

/** The recursion scope matching a scalar-scoped leaf op. */
private fun scopeFor(op: LeafOp): Scope = if (op == LeafOp.QUERY) Scope.QUERY else Scope.PATH

/** Whether this op feeds the single userinfo slot and is therefore paired by [UserInfoBinder]. */
private fun LeafOp.isUserInfo(): Boolean = this == LeafOp.USERINFO || this == LeafOp.USERNAME || this == LeafOp.PASSWORD

Expand Down Expand Up @@ -415,11 +422,21 @@ private fun appendCatchAllSegments(
) {
val iterable = asIterableOrNull(value)
if (iterable != null) {
into.addAll(iterable.filterNotNull().map { scalarText(it) })
into.addAll(scalarTokens(iterable))
} else {
into.addAll(splitPathSegments(scalarText(value)))
}
}

/** Splits a raw slash-path into its non-empty segments, dropping the root and separator empties. */
private fun splitPathSegments(raw: String): List<String> = raw.split('/').filter { it.isNotEmpty() }

/**
* The non-null elements of [iterable] rendered to scalar text, in appearance order.
*
* Shared by a delimited `@Query` join ([BindingExecutor.addQueryValues]) and a `{name...}` catch-all
* path hole ([appendCatchAllSegments]): both need "drop nulls, render each element to its scalar text"
* and differ only in how the resulting tokens are combined (joined by a delimiter vs. appended as
* separate path segments).
*/
private fun scalarTokens(iterable: Iterable<Any?>): List<String> = iterable.filterNotNull().map(::scalarText)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal sealed interface BindStep {
override val member: ScannedMember,
val op: LeafOp,
val queryName: String?,
val queryDelimiter: Char? = null,
) : BindStep

data class Recurse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ internal class PlanCompiler(

// A leaf-like value is one query parameter; a complex member with its own annotated members
// recurses under the QUERY scope. A `Map` here is ambiguous with `@QueryMap` semantics, so it is
// rejected.
// rejected. `marker.delimiter` maps its NUL sentinel to `null` (unset -> repeated params); a set
// delimiter only ever takes effect on an iterable leaf's join branch (see `addQueryValues`).
private fun queryStep(
member: ScannedMember,
marker: Query,
Expand All @@ -168,8 +169,9 @@ internal class PlanCompiler(
if (isMapType(member.declaredType)) {
throw KuriBindException("a Map under @Query is not allowed; use @QueryMap", member.name)
}
val delimiter = marker.delimiter.takeIf { it != '\u0000' }
return if (isLeafLike(member, scalar)) {
BindStep.Leaf(member, LeafOp.QUERY, marker.value.ifEmpty { member.name })
BindStep.Leaf(member, LeafOp.QUERY, marker.value.ifEmpty { member.name }, delimiter)
} else {
BindStep.Recurse(member, Scope.QUERY)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ class AnnotationsJvmTest {
fun `query name defaults to empty for member-name fallback`() {
assertEquals("", Query::class.java.getMethod("value").defaultValue)
}

@Test
fun `query delimiter defaults to the nul sentinel for member-name fallback`() {
assertEquals('\u0000', Query::class.java.getMethod("delimiter").defaultValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,28 @@ private class PercentValue(
@Query("q") val q: String = "a%2Bb",
)

// A delimited `@Query` list: its elements join into one comma-separated parameter value instead of
// fanning out, and `QueryParameters.split` (core, `feat/query-value-split`) is its read-side inverse.
@Url
private class DelimitedTagsRequest(
@Scheme val scheme: String = "https",
@Host val host: String = "h",
@Query("roles", ',') val roles: List<String>,
)

// A delimited `@Query` list nested inside a scoped `@Query` object: QUERY-scoped recursion must still
// honor the join, collapsing the nested list to one comma-joined parameter rather than fanning it out.
private class DelimitedRoleFilter(
@Query("roles", ',') val roles: List<String>,
)

@Url
private class ScopedDelimitedRequest(
@Scheme val scheme: String = "https",
@Host val host: String = "h",
@Query val filter: DelimitedRoleFilter,
)

// A data class mixing primary-constructor `@Path` props with a body-declared one: constructor order
// first (zeta), then the name-sorted remainder (alpha).
@Url
Expand Down Expand Up @@ -922,4 +944,22 @@ class IntegrationTest {
fun `binds a mixed constructor and body path in constructor-then-name order`() {
assertEquals(listOf("z", "a"), KuriBind.toUrl(MixedOrder()).pathSegments)
}

@Test
fun `binds a delimited query list into one joined parameter`() {
val url = KuriBind.toUrl(DelimitedTagsRequest(roles = listOf("admin", "user")))
assertEquals(listOf("admin,user"), url.queryParameters.getAll("roles"))
}

@Test
fun `round trips a delimited query list through split`() {
val url = KuriBind.toUrl(DelimitedTagsRequest(roles = listOf("admin", "user")))
assertEquals(listOf("admin", "user"), url.queryParameters.split("roles", ','))
}

@Test
fun `joins a delimited query list nested in a scoped query object`() {
val request = ScopedDelimitedRequest(filter = DelimitedRoleFilter(listOf("admin", "user")))
assertEquals(listOf("admin,user"), KuriBind.toUrl(request).queryParameters.getAll("roles"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,41 @@ private class SelfRef(
@BindUrl var self: SelfRef?,
)

// A nullable-element list joined by a comma delimiter: covers the join, null-skip, empty, all-null,
// single-element, and order-preservation scenarios with one shared fixture.
private class DelimitedRoles(
@Scheme val scheme: String,
@Host val host: String,
@Query("roles", ',') val roles: List<String?>,
)

private class DelimitedPerms(
@Scheme val scheme: String,
@Host val host: String,
@Query("perm", '|') val perms: List<String>,
)

private class ScalarWithDelimiter(
@Scheme val scheme: String,
@Host val host: String,
@Query("x", ',') val x: String,
)

// An unset delimiter (the default): must still fan out into repeated params, unchanged.
private class FanOutRoles(
@Scheme val scheme: String,
@Host val host: String,
@Query("roles") val roles: List<String>,
)

private enum class Role { ADMIN, USER }

private class DelimitedEnumRoles(
@Scheme val scheme: String,
@Host val host: String,
@Query("roles", ',') val roles: List<Role>,
)

class BindingExecutorTest {
private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner()))

Expand Down Expand Up @@ -121,4 +156,76 @@ class BindingExecutorTest {
bind(node)
}
}

@Test
fun `joins a delimited query list into one comma separated parameter`() {
val target = DelimitedRoles("https", "h", listOf("admin", "user"))
assertEquals("https://h/?roles=admin,user", bind(target).build().toString())
}

@Test
fun `joins a delimited query list with a pipe delimiter`() {
val target = DelimitedPerms("https", "h", listOf("read", "write"))
assertEquals("https://h/?perm=read|write", bind(target).build().toString())
}

@Test
fun `skips a null element when joining a delimited query list`() {
val target = DelimitedRoles("https", "h", listOf("admin", null, "user"))
assertEquals("https://h/?roles=admin,user", bind(target).build().toString())
}

@Test
fun `emits no parameter for an empty delimited query list`() {
val target = DelimitedRoles("https", "h", emptyList())
assertEquals("https://h/", bind(target).build().toString())
}

@Test
fun `emits no parameter for an all null delimited query list`() {
val target = DelimitedRoles("https", "h", listOf(null))
assertEquals("https://h/", bind(target).build().toString())
}

@Test
fun `retains an empty string element when joining a delimited query list`() {
val target = DelimitedRoles("https", "h", listOf("", "user"))
assertEquals("https://h/?roles=,user", bind(target).build().toString())
}

@Test
fun `joins a single empty string element into an empty valued parameter`() {
val target = DelimitedRoles("https", "h", listOf(""))
assertEquals("https://h/?roles=", bind(target).build().toString())
}

@Test
fun `joins a single element delimited query list without a trailing delimiter`() {
val target = DelimitedRoles("https", "h", listOf("solo"))
assertEquals("https://h/?roles=solo", bind(target).build().toString())
}

@Test
fun `preserves element order when joining a delimited query list`() {
val target = DelimitedRoles("https", "h", listOf("user", "admin"))
assertEquals("https://h/?roles=user,admin", bind(target).build().toString())
}

@Test
fun `ignores the delimiter on a scalar query field`() {
val target = ScalarWithDelimiter("https", "h", "solo")
assertEquals("https://h/?x=solo", bind(target).build().toString())
}

@Test
fun `still fans out a query list with an unset delimiter`() {
val target = FanOutRoles("https", "h", listOf("admin", "user"))
assertEquals("https://h/?roles=admin&roles=user", bind(target).build().toString())
}

@Test
fun `joins enum elements by name in a delimited query list`() {
val target = DelimitedEnumRoles("https", "h", listOf(Role.ADMIN, Role.USER))
assertEquals("https://h/?roles=ADMIN,USER", bind(target).build().toString())
}
}
1 change: 1 addition & 0 deletions kuri/api/jvm/kuri.api
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ public final class org/dexpace/kuri/query/QueryParameters : java/lang/Iterable,
public static final fun parse (Ljava/lang/String;)Lorg/dexpace/kuri/query/QueryParameters;
public static final fun parseForm (Ljava/lang/String;)Lorg/dexpace/kuri/query/QueryParameters;
public final fun size ()I
public final fun split (Ljava/lang/String;C)Ljava/util/List;
public final fun toFormUrlEncoded ()Ljava/lang/String;
public final fun toMap ()Ljava/util/Map;
public final fun toQueryString ()Ljava/lang/String;
Expand Down
Loading
Loading