diff --git a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt deleted file mode 100644 index 7e21c5e..0000000 --- a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.bind - -import org.dexpace.kuri.Uri -import org.dexpace.kuri.Url - -/** Which profile a bind targets. Fixed by the entry point / base-builder type. */ -internal enum class Profile { URL, URI } - -/** Binds an annotated object onto a `Url.Builder`. Implemented by reflection now; by codegen later. */ -internal interface UrlBinder { - fun bind( - base: Url.Builder, - target: Any, - options: BindOptions, - ): Url.Builder -} - -/** Binds an annotated object onto a `Uri.Builder`. */ -internal interface UriBinder { - fun bind( - base: Uri.Builder, - target: Any, - options: BindOptions, - ): Uri.Builder -} diff --git a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt new file mode 100644 index 0000000..bb46941 --- /dev/null +++ b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.bind + +/** Which profile a bind targets. Fixed by the entry point / base-builder type. */ +internal enum class Profile { URL, URI } diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt index 07f1486..c027460 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt @@ -6,11 +6,11 @@ package org.dexpace.kuri.bind import org.dexpace.kuri.Uri import org.dexpace.kuri.Url +import org.dexpace.kuri.bind.internal.BindingExecutor import org.dexpace.kuri.bind.internal.KotlinReflectMemberScanner -import org.dexpace.kuri.bind.internal.PlanCache import org.dexpace.kuri.bind.internal.PlanCompiler -import org.dexpace.kuri.bind.internal.ReflectiveUriBinder -import org.dexpace.kuri.bind.internal.ReflectiveUrlBinder +import org.dexpace.kuri.bind.internal.UriBuilderSink +import org.dexpace.kuri.bind.internal.UrlBuilderSink import kotlin.reflect.full.hasAnnotation import org.dexpace.kuri.bind.Uri as UriMarker import org.dexpace.kuri.bind.Url as UrlMarker @@ -43,11 +43,11 @@ import org.dexpace.kuri.bind.Url as UrlMarker * All methods are stateless and safe to call concurrently. */ public object KuriBind { - // One profile-agnostic plan cache backs both binders: a plan describes a `Url` and a `Uri` bind - // alike, so sharing it means each root type is compiled exactly once (see PlanCache). - private val cache = PlanCache(PlanCompiler(KotlinReflectMemberScanner())) - private val urlBinder = ReflectiveUrlBinder(cache) - private val uriBinder = ReflectiveUriBinder(cache) + // One shared executor backs every bind through either profile: its [PlanCompiler] caches a compiled + // plan per root type, and a plan describes a `Url` and a `Uri` bind alike, so each root type is + // compiled exactly once regardless of profile. The executor is stateless; each `bindInto` overload + // projects its accumulated result through the sink for the profile it targets. + private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner())) /** * Binds [target] onto [base], returning the same builder so calls can chain. @@ -73,9 +73,8 @@ public object KuriBind { options: BindOptions = BindOptions.DEFAULT, ): Url.Builder { requireRoot(target, Profile.URL) - val bound = urlBinder.bind(base, target, options) - check(bound === base) { "url binder must return the same base builder it was given" } - return bound + executor.execute(target, options).projectInto(UrlBuilderSink(base)) + return base } /** @@ -96,9 +95,8 @@ public object KuriBind { options: BindOptions = BindOptions.DEFAULT, ): Uri.Builder { requireRoot(target, Profile.URI) - val bound = uriBinder.bind(base, target, options) - check(bound === base) { "uri binder must return the same base builder it was given" } - return bound + executor.execute(target, options).projectInto(UriBuilderSink(base)) + return base } /** diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt similarity index 90% rename from kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt rename to kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt index 7ce910f..b365686 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt @@ -4,12 +4,8 @@ */ package org.dexpace.kuri.bind.internal -import org.dexpace.kuri.Uri -import org.dexpace.kuri.Url import org.dexpace.kuri.bind.BindOptions import org.dexpace.kuri.bind.KuriBindException -import org.dexpace.kuri.bind.UriBinder -import org.dexpace.kuri.bind.UrlBinder import java.util.Collections import java.util.IdentityHashMap @@ -24,7 +20,7 @@ import java.util.IdentityHashMap * the graph traversal itself. */ internal class BindingExecutor( - private val cache: PlanCache, + private val compiler: PlanCompiler, ) { /** Executes [target]'s plan into a fresh [ComponentSink], honoring [options]. */ fun execute( @@ -37,7 +33,7 @@ internal class BindingExecutor( // merge scope are suppressed for the entire walk; the flag rides along in [WalkContext]. The // root plan is fetched once here and threaded into [walk] (each merge step re-fetches its own // child plan), so the root type is not looked up twice. - val rootPlan = cache.planFor(target::class) + val rootPlan = compiler.planFor(target::class) val ctx = WalkContext(sink, options, active, depth = 0, pathTemplated = rootPlan.template != null) walk(target, rootPlan, ctx) return sink @@ -95,7 +91,7 @@ internal class BindingExecutor( value: Any, ctx: WalkContext, ) { - if ((step.op == LeafOp.QUERY || step.op == LeafOp.PATH) && runtimeValueIsBindable(value, cache)) { + if ((step.op == LeafOp.QUERY || step.op == LeafOp.PATH) && runtimeValueIsBindable(value, compiler)) { applyRecurse(BindStep.Recurse(step.member, scopeFor(step.op)), value, ctx) } else { LeafBinder.apply(step, value, ctx.sink) @@ -117,7 +113,7 @@ internal class BindingExecutor( ctx: WalkContext, ) { when (step.scope) { - Scope.MERGE -> walk(value, cache.planFor(value::class), ctx.deeper()) + Scope.MERGE -> walk(value, compiler.planFor(value::class), ctx.deeper()) Scope.QUERY -> recurseScoped(value, ctx, LeafOp.QUERY) // A templated root owns the path (see [applyLeafStep]); skip scoped path recursion too. Scope.PATH -> if (!ctx.pathTemplated) recurseScoped(value, ctx, LeafOp.PATH) @@ -142,7 +138,7 @@ internal class BindingExecutor( op: LeafOp, ) { require(op == LeafOp.QUERY || op == LeafOp.PATH) { "scoped op must be QUERY or PATH: $op" } - val plan = cache.planFor(nested::class) + val plan = compiler.planFor(nested::class) // A scoped `@Query` object contributes every query leaf it declares — both its `@Query` // params and its `@QueryMap` entries; a scoped `@Path` object contributes its path segments. val leaves = @@ -180,38 +176,6 @@ private class WalkContext( fun deeper(): WalkContext = WalkContext(sink, options, active, depth + 1, pathTemplated) } -/** Binds an annotated object onto a `Url.Builder` by reflection. */ -internal class ReflectiveUrlBinder( - cache: PlanCache, -) : UrlBinder { - private val executor = BindingExecutor(cache) - - override fun bind( - base: Url.Builder, - target: Any, - options: BindOptions, - ): Url.Builder { - executor.execute(target, options).projectInto(UrlBuilderSink(base)) - return base - } -} - -/** Binds an annotated object onto a `Uri.Builder` by reflection. */ -internal class ReflectiveUriBinder( - cache: PlanCache, -) : UriBinder { - private val executor = BindingExecutor(cache) - - override fun bind( - base: Uri.Builder, - target: Any, - options: BindOptions, - ): Uri.Builder { - executor.execute(target, options).projectInto(UriBuilderSink(base)) - return base - } -} - /** * Resolves a root `@PathTemplate` against its holes and emits the filled path in template order. * @@ -255,7 +219,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 -> applyQuery(step.queryName ?: path, value, sink) + LeafOp.QUERY -> addQueryValues(step.queryName ?: path, value, sink) 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 @@ -276,19 +240,6 @@ private object LeafBinder { sink: ComponentSink, ) = forEachScoped(value) { sink.addPathSegment(scalarText(it)) } - private fun applyQuery( - name: String, - value: Any, - sink: ComponentSink, - ) { - val iterable = asIterableOrNull(value) - if (iterable != null) { - iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) } - } else { - sink.addQueryParameter(name, scalarText(value)) - } - } - private fun applyQueryMapEntry( key: Any?, value: Any?, @@ -297,6 +248,19 @@ 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) + } + + // 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. + private fun addQueryValues( + name: String, + value: Any?, + sink: ComponentSink, + ) { val iterable = value?.let(::asIterableOrNull) if (iterable != null) { iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) } @@ -393,11 +357,11 @@ private fun forEachScoped( */ private fun runtimeValueIsBindable( value: Any, - cache: PlanCache, + compiler: PlanCompiler, ): Boolean { val scalarLike = value is CharSequence || value is Number || value is Char || value is Boolean || value is Enum<*> if (scalarLike || asIterableOrNull(value) != null || asMapOrNull(value) != null) return false - return cache.planFor(value::class).steps.isNotEmpty() + return compiler.planFor(value::class).steps.isNotEmpty() } /** The recursion scope matching a scalar-scoped leaf op. */ diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt deleted file mode 100644 index fe8a4a7..0000000 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.bind.internal - -import java.util.concurrent.ConcurrentHashMap -import kotlin.reflect.KClass - -/** - * Compiles each type's [TypePlan] once and caches it, keyed by type; safe for concurrent binds. - * - * The cache is profile-agnostic: a plan describes both a `Url` and a `Uri` binding, since the profile - * only narrows which components project later (see [PlanCompiler]). One shared cache therefore backs - * both [ReflectiveUrlBinder] and [ReflectiveUriBinder], which differ only by the sink they project - * through — so a type compiled for a URL bind is reused verbatim for a URI bind. - */ -internal class PlanCache( - private val compiler: PlanCompiler, -) { - private val plans = ConcurrentHashMap, TypePlan>() - - /** - * The compiled plan for [type], computing and caching it on first use (compile-once per type). - * - * @throws KuriBindException when [type] cannot be compiled into a valid plan (delegated to - * [PlanCompiler.compile]). - */ - fun planFor(type: KClass<*>): TypePlan { - require(type != Any::class) { "cannot bind Any" } - val plan = plans.getOrPut(type) { compiler.compile(type) } - check(plan.type == type) { "plan cache returned a plan for ${plan.type} under key $type" } - return plan - } -} diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt index 8515449..f41264b 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt @@ -15,6 +15,7 @@ import org.dexpace.kuri.bind.QueryMap import org.dexpace.kuri.bind.Scheme import org.dexpace.kuri.bind.UserInfo import org.dexpace.kuri.bind.Username +import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass import kotlin.reflect.full.findAnnotation import org.dexpace.kuri.bind.PathTemplate as PathTemplateAnn @@ -22,10 +23,33 @@ import org.dexpace.kuri.bind.Uri as UriMarker import org.dexpace.kuri.bind.Url as UrlMarker import org.dexpace.kuri.host.Host as KuriHost -/** Compiles a type into a validated [TypePlan], resolving any root `@PathTemplate` against its holes. */ +/** + * Compiles a type into a validated [TypePlan], resolving any root `@PathTemplate` against its holes, + * and caches the result so each type compiles at most once. + * + * The cache is profile-agnostic: a plan describes both a `Url` and a `Uri` binding, since the profile + * only narrows which components project later. One [PlanCompiler] therefore backs every bind through + * both profiles, and a type compiled for a URL bind is reused verbatim for a URI bind. Concurrent binds + * are safe: [planFor] memoizes through a [ConcurrentHashMap], and a rare double-compile under a race is + * harmless because compilation is pure and both racers produce an equal plan. + */ internal class PlanCompiler( private val scanner: MemberScanner, ) { + private val plans = ConcurrentHashMap, TypePlan>() + + /** + * The compiled plan for [type], computing and caching it on first use (compile-once per type). + * + * @throws KuriBindException when [type] cannot be compiled into a valid plan (delegated to + * [compile]). + */ + fun planFor(type: KClass<*>): TypePlan { + val plan = plans.getOrPut(type) { compile(type) } + check(plan.type == type) { "plan cache returned a plan for ${plan.type} under key $type" } + return plan + } + // The plan is profile-agnostic: the same steps describe a `Url` and a `Uri` binding, since the // profile only narrows which components project later. So `compile` takes just the type. // @@ -178,18 +202,6 @@ internal class PlanCompiler( private fun hasBindingMembers(type: KClass<*>): Boolean = scanner.scan(type).any { member -> member.annotations.any { it.isBindingMarker() } } - - private fun bindingAnnotation(member: ScannedMember): Annotation? { - val markers = member.annotations.filter { it.isBindingMarker() } - if (markers.size > 1) { - throw KuriBindException( - "member '${member.name}' has more than one binding annotation: " + - markers.joinToString { it.annotationClass.simpleName ?: "?" }, - member.name, - ) - } - return markers.singleOrNull() - } } // Enforces a bijection between the template's holes and the named-`@Path` providers reachable through @@ -242,6 +254,20 @@ private fun mergeStep(member: ScannedMember): BindStep { return BindStep.Recurse(member, Scope.MERGE) } +// The single binding marker on [member], or `null` when it carries none; more than one is a +// misconfiguration rejected at compile. Pure (it only inspects the member's annotations), so top-level. +private fun bindingAnnotation(member: ScannedMember): Annotation? { + val markers = member.annotations.filter { it.isBindingMarker() } + if (markers.size > 1) { + throw KuriBindException( + "member '${member.name}' has more than one binding annotation: " + + markers.joinToString { it.annotationClass.simpleName ?: "?" }, + member.name, + ) + } + return markers.singleOrNull() +} + private fun Annotation.isBindingMarker(): Boolean = when (this) { is Scheme, is UserInfo, is Username, is Password, is Host, is Port, diff --git a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt similarity index 71% rename from kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt rename to kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt index 90ed485..e429e8c 100644 --- a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt +++ b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt @@ -66,38 +66,42 @@ private class SelfRef( @BindUrl var self: SelfRef?, ) -class ReflectiveBinderTest { - private val binder = ReflectiveUrlBinder(PlanCache(PlanCompiler(KotlinReflectMemberScanner()))) +class BindingExecutorTest { + private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner())) + + // Exercises the reflective walk end to end into a URL: this is exactly what the `@Url` entry point + // does after root validation — run the executor, then project the accumulated components onto a + // fresh Url.Builder through the URL sink. + private fun bind( + target: Any, + options: BindOptions = BindOptions.DEFAULT, + ): Url.Builder { + val base = Url.Builder() + executor.execute(target, options).projectInto(UrlBuilderSink(base)) + return base + } @Test fun `binds a templated request end to end`() { val target = Req("https", "h", "shoes", listOf("a", "b"), "x y") - val url = binder.bind(Url.Builder(), target, BindOptions.DEFAULT).build() - assertEquals("https://h/search/shoes/a/b?q=x%20y", url.toString()) + assertEquals("https://h/search/shoes/a/b?q=x%20y", bind(target).build().toString()) } @Test fun `combines sibling username and password into one userinfo`() { - val url = binder.bind(Url.Builder(), Creds("https", "h", "bob", "s3cret"), BindOptions.DEFAULT).build() + val url = bind(Creds("https", "h", "bob", "s3cret")).build() assertEquals("https://bob:s3cret@h/", url.toString()) } @Test fun `recurses a scoped query into the nested object's query members`() { val target = ScopedSearch("https", "h", QueryFilters("red", "large")) - val url = binder.bind(Url.Builder(), target, BindOptions.DEFAULT).build() - assertEquals("https://h/?color=red&size=large", url.toString()) + assertEquals("https://h/?color=red&size=large", bind(target).build().toString()) } @Test fun `merges a nested @Url sub-object host and port into the parent`() { - val url = - binder - .bind( - Url.Builder(), - MergedCall("https", HostPortEndpoint("h", 8443)), - BindOptions.DEFAULT, - ).build() + val url = bind(MergedCall("https", HostPortEndpoint("h", 8443))).build() assertEquals("https://h:8443/", url.toString()) } @@ -105,7 +109,7 @@ class ReflectiveBinderTest { fun `fails when the bind depth is exceeded`() { val deep = Chain(Chain(Chain(null))) assertFailsWith { - binder.bind(Url.Builder(), deep, BindOptions(maxDepth = 1)) + bind(deep, BindOptions(maxDepth = 1)) } } @@ -114,7 +118,7 @@ class ReflectiveBinderTest { val node = SelfRef("h", null) node.self = node assertFailsWith { - binder.bind(Url.Builder(), node, BindOptions.DEFAULT) + bind(node) } } }