Skip to content

Commit b34b700

Browse files
committed
feat(core): Implement RFC 6570 URI template matching and expansion utilities
Add `UriTemplate`, `UriTemplateMatcher`, and `MatchResult` classes to support [RFC-6570](https://www.rfc-editor.org/rfc/rfc6570.txt) URI template matching and expansion. Implemented unit tests for conformance with all expansion levels and path operators.
1 parent 921b1fd commit b34b700

8 files changed

Lines changed: 1637 additions & 0 deletions

File tree

kotlin-sdk-core/api/kotlin-sdk-core.api

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4731,3 +4731,42 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl
47314731
public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject;
47324732
}
47334733

4734+
public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult {
4735+
public fun <init> (Ljava/util/Map;I)V
4736+
public final fun component1 ()Ljava/util/Map;
4737+
public final fun component2 ()I
4738+
public final fun copy (Ljava/util/Map;I)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4739+
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;Ljava/util/Map;IILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4740+
public fun equals (Ljava/lang/Object;)Z
4741+
public final fun get (Ljava/lang/String;)Ljava/lang/String;
4742+
public final fun getScore ()I
4743+
public final fun getVariables ()Ljava/util/Map;
4744+
public fun hashCode ()I
4745+
public fun toString ()Ljava/lang/String;
4746+
}
4747+
4748+
public final class io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate {
4749+
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/UriTemplate$Companion;
4750+
public fun <init> (Ljava/lang/String;)V
4751+
public fun equals (Ljava/lang/Object;)Z
4752+
public static final fun expand (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String;
4753+
public final fun expand (Ljava/util/Map;)Ljava/lang/String;
4754+
public final fun getLiteralLength ()I
4755+
public final fun getTemplate ()Ljava/lang/String;
4756+
public fun hashCode ()I
4757+
public final fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4758+
public final fun matcher ()Lio/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher;
4759+
public static final fun matches (Ljava/lang/String;Ljava/lang/String;)Z
4760+
public fun toString ()Ljava/lang/String;
4761+
}
4762+
4763+
public final class io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate$Companion {
4764+
public final fun expand (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String;
4765+
public final fun matches (Ljava/lang/String;Ljava/lang/String;)Z
4766+
}
4767+
4768+
public final class io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher {
4769+
public final fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4770+
public final fun matches (Ljava/lang/String;)Z
4771+
}
4772+
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package io.modelcontextprotocol.kotlin.sdk.utils
2+
3+
import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Modifier
4+
import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.OpInfo
5+
import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Part
6+
import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.VarSpec
7+
import kotlin.jvm.JvmInline
8+
import kotlin.jvm.JvmStatic
9+
10+
/**
11+
* Result of matching a URI against a [UriTemplate].
12+
*
13+
* @property variables Variable values extracted from the URI, keyed by variable name.
14+
* All variables from every expression are captured, including multi-variable expressions
15+
* like `{x,y}` or `{?x,y}`. Values are the raw (pct-encoded) substrings from the URI.
16+
* @property score Specificity score — total literal character count of the matched template.
17+
* Higher scores indicate more specific templates. Use this to resolve ambiguity when
18+
* multiple templates match the same URI.
19+
*
20+
* Example: selecting the most specific match
21+
* ```
22+
* val templates = listOf(
23+
* UriTemplate("users/{id}"), // score = 6 ("users/")
24+
* UriTemplate("users/profile"), // score = 13 ("users/profile")
25+
* )
26+
* val best = templates
27+
* .mapNotNull { t -> t.match(uri)?.let { t to it } }
28+
* .maxByOrNull { (_, result) -> result.score }
29+
* ```
30+
*/
31+
public data class MatchResult(val variables: Map<String, String>, val score: Int) {
32+
/** Returns the value of variable [name], or `null` if it was not captured. */
33+
public operator fun get(name: String): String? = variables[name]
34+
}
35+
36+
/**
37+
* RFC 6570 URI Template implementation.
38+
*
39+
* Supports all four levels of URI template expansion:
40+
* - **Level 1** – Simple string expansion: `{var}`
41+
* - **Level 2** – Reserved (`{+var}`) and fragment (`{#var}`) expansion
42+
* - **Level 3** – Label (`{.var}`), path (`{/var}`), path-parameter (`{;var}`),
43+
* query (`{?var}`), and query-continuation (`{&var}`) expansion with multiple variables
44+
* - **Level 4** – Prefix modifiers (`{var:3}`) and explode modifiers (`{var*}`)
45+
*
46+
* The template string is parsed eagerly on construction into a list of [Part]s.
47+
*
48+
* Variable values supplied to [expand] may be:
49+
* - `null` or absent key → undefined (skipped in expansion)
50+
* - [String] → simple string value
51+
* - [List] of [String] → list value (an empty list is treated as undefined)
52+
* - [Map] of [String] to [String] → associative array (an empty map is treated as undefined)
53+
*
54+
* @param template The URI template string.
55+
* @see [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570)
56+
*/
57+
@Suppress("TooManyFunctions")
58+
public class UriTemplate(public val template: String) {
59+
60+
//region Typed variable value (expansion-only) ─────────────────────────────
61+
62+
private sealed interface VarValue {
63+
@JvmInline
64+
value class Str(val value: String) : VarValue
65+
data class Lst(val values: List<String>) : VarValue
66+
data class Assoc(val pairs: List<Pair<String, String>>) : VarValue
67+
}
68+
69+
//endregion
70+
//region Parsed model ───────────────────────────────────────────────────────
71+
72+
private val parts: List<Part> = UriTemplateParser.parse(template)
73+
74+
/**
75+
* The total number of encoded literal characters in this template.
76+
*
77+
* This value equals [MatchResult.score] when this template successfully matches a URI,
78+
* making it useful for pre-ranking templates before matching.
79+
*/
80+
public val literalLength: Int = parts.sumOf { if (it is Part.Literal) it.text.length else 0 }
81+
82+
//endregion
83+
//region Public API ─────────────────────────────────────────────────────────
84+
85+
/**
86+
* Expands the URI template with the given [variables].
87+
*
88+
* @return The expanded URI string.
89+
*/
90+
public fun expand(variables: Map<String, Any?>): String = buildString {
91+
for (part in parts) {
92+
when (part) {
93+
is Part.Literal -> append(part.text)
94+
is Part.Expression -> append(expandExpression(part, variables))
95+
}
96+
}
97+
}
98+
99+
/**
100+
* Returns a compiled [UriTemplateMatcher] for this template (cached lazily).
101+
* Use [match] for one-off checks; use [matcher] when matching against many URIs.
102+
*/
103+
public fun matcher(): UriTemplateMatcher = _matcher
104+
105+
private val _matcher: UriTemplateMatcher by lazy {
106+
UriTemplateMatcher.build(parts, literalLength)
107+
}
108+
109+
/**
110+
* Matches [uri] against this template and returns extracted variables plus a
111+
* specificity [MatchResult.score], or `null` if the URI does not match.
112+
*
113+
* Example:
114+
* ```
115+
* UriTemplate("https://api.example.com/users/{id}/posts/{postId}")
116+
* .match("https://api.example.com/users/alice/posts/99")
117+
* // MatchResult(variables = {"id": "alice", "postId": "99"}, score = 36)
118+
* ```
119+
*/
120+
public fun match(uri: String): MatchResult? = _matcher.match(uri)
121+
122+
/**
123+
* Compares this instance of [UriTemplate] with another object for equality.
124+
*
125+
* @param other The object to compare with this instance. It may be `null` or of any type.
126+
* @return `true` if the given object is a [UriTemplate] and its `template` property is equal
127+
* to that of this instance, otherwise `false`.
128+
*/
129+
override fun equals(other: Any?): Boolean = other is UriTemplate && template == other.template
130+
131+
/**
132+
* Returns a hash code value for this instance.
133+
*
134+
* The hash code is based on the `template` property.
135+
*/
136+
override fun hashCode(): Int = template.hashCode()
137+
138+
/**
139+
* Returns a string representation of this instance.
140+
*
141+
* The string representation is in the format `UriTemplate(template)`.
142+
*/
143+
override fun toString(): String = "UriTemplate($template)"
144+
145+
public companion object {
146+
/**
147+
* Expands [template] with [variables] in a single call.
148+
*
149+
* Equivalent to `UriTemplate(template).expand(variables)`. The template is parsed on
150+
* every call without caching. Prefer constructing a [UriTemplate] instance when the
151+
* same template is expanded repeatedly.
152+
*
153+
* Particularly useful from Java, where it is available as a static method.
154+
*/
155+
@JvmStatic
156+
public fun expand(template: String, variables: Map<String, Any?>): String =
157+
UriTemplate(template).expand(variables)
158+
159+
/**
160+
* Returns `true` if [uri] matches [template], without retaining a [UriTemplateMatcher] instance.
161+
* Equivalent to `UriTemplate(template).matcher().matches(uri)`.
162+
*/
163+
@JvmStatic
164+
public fun matches(template: String, uri: String): Boolean = UriTemplate(template).matcher().matches(uri)
165+
}
166+
167+
//endregion
168+
//region Expression expansion ───────────────────────────────────────────────
169+
170+
private fun expandExpression(part: Part.Expression, variables: Map<String, Any?>): String {
171+
if (part.varSpecs.isEmpty()) return "{}"
172+
val op = part.op
173+
return buildString {
174+
var firstItem = true
175+
for (varSpec in part.varSpecs) {
176+
val value = resolveValue(variables[varSpec.name]) ?: continue
177+
for (item in itemsForVar(varSpec, value, op)) {
178+
append(if (firstItem) op.first else op.sep)
179+
firstItem = false
180+
append(item)
181+
}
182+
}
183+
}
184+
}
185+
186+
private fun itemsForVar(spec: VarSpec, value: VarValue, op: OpInfo): List<String> = when (value) {
187+
is VarValue.Str -> listOf(expandStr(spec, value.value, op))
188+
is VarValue.Lst -> expandList(spec, value.values, op)
189+
is VarValue.Assoc -> expandAssoc(spec, value.pairs, op)
190+
}
191+
192+
//endregion
193+
//region String expansion ───────────────────────────────────────────────────
194+
195+
private fun expandStr(spec: VarSpec, raw: String, op: OpInfo): String = buildString {
196+
if (op.named) {
197+
append(UriTemplateParser.pctEncodeUnreserved(spec.name))
198+
if (raw.isEmpty()) {
199+
append(op.ifemp)
200+
return@buildString
201+
}
202+
append('=')
203+
}
204+
val encoded = when (val mod = spec.modifier) {
205+
is Modifier.Prefix -> UriTemplateParser.pctEncode(
206+
UriTemplateParser.truncateCodePoints(raw, mod.length),
207+
op.allowReserved,
208+
)
209+
210+
else -> UriTemplateParser.pctEncode(raw, op.allowReserved)
211+
}
212+
append(encoded)
213+
}
214+
215+
//endregion
216+
//region List expansion ─────────────────────────────────────────────────────
217+
218+
private fun expandList(spec: VarSpec, values: List<String>, op: OpInfo): List<String> = when (spec.modifier) {
219+
Modifier.Explode -> values.map { v ->
220+
if (op.named) {
221+
val name = UriTemplateParser.pctEncodeUnreserved(spec.name)
222+
if (v.isEmpty()) {
223+
"$name${op.ifemp}"
224+
} else {
225+
"$name=${UriTemplateParser.pctEncode(v, op.allowReserved)}"
226+
}
227+
} else {
228+
UriTemplateParser.pctEncode(v, op.allowReserved)
229+
}
230+
}
231+
232+
else -> listOf(
233+
buildString {
234+
if (op.named) {
235+
append(UriTemplateParser.pctEncodeUnreserved(spec.name))
236+
append('=')
237+
}
238+
append(values.joinToString(",") { UriTemplateParser.pctEncode(it, op.allowReserved) })
239+
},
240+
)
241+
}
242+
243+
//endregion
244+
//region Associative-array expansion ────────────────────────────────────────
245+
246+
private fun expandAssoc(spec: VarSpec, pairs: List<Pair<String, String>>, op: OpInfo): List<String> =
247+
when (spec.modifier) {
248+
Modifier.Explode -> pairs.map { (k, v) ->
249+
val encK = UriTemplateParser.pctEncode(k, op.allowReserved)
250+
if (v.isEmpty()) {
251+
"$encK${op.ifemp}"
252+
} else {
253+
"$encK=${UriTemplateParser.pctEncode(v, op.allowReserved)}"
254+
}
255+
}
256+
257+
else -> listOf(
258+
buildString {
259+
if (op.named) {
260+
append(UriTemplateParser.pctEncodeUnreserved(spec.name))
261+
append('=')
262+
}
263+
append(
264+
pairs.joinToString(",") { (k, v) ->
265+
"${UriTemplateParser.pctEncode(k, op.allowReserved)},${
266+
UriTemplateParser.pctEncode(
267+
v,
268+
op.allowReserved,
269+
)
270+
}"
271+
},
272+
)
273+
},
274+
)
275+
}
276+
277+
//endregion
278+
//region Value resolution ───────────────────────────────────────────────────
279+
280+
private fun resolveValue(raw: Any?): VarValue? = when (raw) {
281+
null -> null
282+
283+
is String -> VarValue.Str(raw)
284+
285+
is List<*> -> {
286+
val strs = raw.filterNotNull().map { it.toString() }
287+
if (strs.isEmpty()) null else VarValue.Lst(strs)
288+
}
289+
290+
is Map<*, *> -> {
291+
val pairs = raw.entries
292+
.mapNotNull { (k, v) -> if (k != null && v != null) k.toString() to v.toString() else null }
293+
if (pairs.isEmpty()) null else VarValue.Assoc(pairs)
294+
}
295+
296+
else -> VarValue.Str(raw.toString())
297+
}
298+
//endregion
299+
}

0 commit comments

Comments
 (0)