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
29 changes: 0 additions & 29 deletions kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 }
26 changes: 12 additions & 14 deletions kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
}

/**
Expand All @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 =
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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?,
Expand All @@ -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)) }
Expand Down Expand Up @@ -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. */
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,41 @@ 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
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<KClass<*>, 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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading