|
| 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