From f41384726daae1d2507c0d0f4012b99daab88a02 Mon Sep 17 00:00:00 2001 From: Jong Date: Mon, 24 Nov 2025 14:22:49 +0900 Subject: [PATCH 1/3] add: mechanism to parse URIs, extracting and processing information for deep links --- .../wisp/runtime/DefaultWispUriParser.kt | 25 +++++++++++++++++++ .../com/angrypodo/wisp/runtime/WispError.kt | 3 +++ .../angrypodo/wisp/runtime/WispUriParser.kt | 11 ++++++++ 3 files changed, 39 insertions(+) create mode 100644 wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/DefaultWispUriParser.kt create mode 100644 wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriParser.kt diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/DefaultWispUriParser.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/DefaultWispUriParser.kt new file mode 100644 index 0000000..7a250ab --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/DefaultWispUriParser.kt @@ -0,0 +1,25 @@ +package com.angrypodo.wisp.runtime + +import android.net.Uri +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +private const val STACK = "stack" + +class DefaultWispUriParser : WispUriParser { + override fun parse(uri: Uri): List { + val encodedStack = uri.getQueryParameter(STACK) + + if (encodedStack.isNullOrBlank()) { + throw WispError.ParsingFailed(uri.toString(), "Missing 'stack' query parameter") + } + + return try { + val decodedStack = URLDecoder.decode(encodedStack, StandardCharsets.UTF_8.name()) + + decodedStack.split("|").filter { it.isNotBlank() } + } catch (e: Exception) { + throw WispError.ParsingFailed(uri.toString(), e.message ?: "Unknown decoding error") + } + } +} diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt index 34b9413..0d2554c 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt @@ -10,4 +10,7 @@ sealed class WispError(override val message: String) : Exception(message) { class InvalidParameter(path: String, paramName: String) : WispError("Parameter \"$paramName\" in path \"$path\" could not be converted.") + + class ParsingFailed(uri: String, reason: String) : + WispError("Failed to parse URI: $uri. Reason: $reason") } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriParser.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriParser.kt new file mode 100644 index 0000000..50e16b2 --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriParser.kt @@ -0,0 +1,11 @@ +package com.angrypodo.wisp.runtime + +import android.net.Uri + +interface WispUriParser { + /** + * @param uri 수신된 딥링크 Uri + * @return 백스택을 나타내는 경로 문자열 리스트 + */ + fun parse(uri: Uri): List +} From d3a06f0d62b6884d1a0b00cda824ace2eb85c639 Mon Sep 17 00:00:00 2001 From: Jong Date: Mon, 24 Nov 2025 14:48:16 +0900 Subject: [PATCH 2/3] add: URI matcher implementation with test cases --- .../angrypodo/wisp/runtime/WispUriMatcher.kt | 51 +++++++ .../com/angrypodo/wisp/WispUriMatcherTest.kt | 132 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt create mode 100644 wisp-runtime/src/test/java/com/angrypodo/wisp/WispUriMatcherTest.kt diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt new file mode 100644 index 0000000..3a4e63b --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt @@ -0,0 +1,51 @@ +package com.angrypodo.wisp.runtime + +internal object WispUriMatcher { + + /** + * 입력된 URI와 라우트 패턴을 비교하여 매칭 여부를 확인하고 파라미터를 추출합니다. + * 매칭 실패 시 null을 반환합니다. + * + * @param inputUri 비교할 실제 URI (예: "profile/123?ref=share") + * @param routePattern 라우트 템플릿 (예: "profile/{id}") + */ + fun match(inputUri: String, routePattern: String): Map? { + val path = inputUri.substringBefore('?') + val query = inputUri.substringAfter('?', missingDelimiterValue = "") + + val pathSegments = path.split('/') + val patternSegments = routePattern.split('/') + + if (pathSegments.size != patternSegments.size) return null + + val params = mutableMapOf() + + for (i in patternSegments.indices) { + val patternSegment = patternSegments[i] + val pathSegment = pathSegments[i] + + if (isPlaceholder(patternSegment)) { + val key = patternSegment.removeSurrounding("{", "}") + + params[key] = pathSegment + } else if (!patternSegment.equals(pathSegment, ignoreCase = true)) { + return null + } + } + + if (query.isNotEmpty()) parseQueryString(query, params) + + return params + } + + private fun isPlaceholder(segment: String): Boolean = + segment.startsWith("{") && segment.endsWith("}") + + private fun parseQueryString(query: String, params: MutableMap) { + query.split('&').forEach { pair -> + val parts = pair.split('=', limit = 2) + + if (parts.size == 2) params[parts[0]] = parts[1] + } + } +} diff --git a/wisp-runtime/src/test/java/com/angrypodo/wisp/WispUriMatcherTest.kt b/wisp-runtime/src/test/java/com/angrypodo/wisp/WispUriMatcherTest.kt new file mode 100644 index 0000000..7f49b1c --- /dev/null +++ b/wisp-runtime/src/test/java/com/angrypodo/wisp/WispUriMatcherTest.kt @@ -0,0 +1,132 @@ +package com.angrypodo.wisp + +import com.angrypodo.wisp.runtime.WispUriMatcher +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull + +class WispUriMatcherTest { + @Test + @DisplayName("고정 경로가 정확히 일치하면 빈 맵을 반환한다") + fun matchExactPath() { + // Given + val inputUri = "home/dashboard" + val routePattern = "home/dashboard" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result) + assertTrue(result.isEmpty(), "파라미터가 없는 고정 경로는 빈 맵을 반환해야 합니다.") + } + + @Test + @DisplayName("Path Variable이 포함된 경우 해당 값을 정확히 추출한다") + fun matchPathVariable() { + // Given + val inputUri = "profile/12345" + val routePattern = "profile/{userId}" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result) + assertEquals("12345", result["userId"]) + } + + @Test + @DisplayName("여러 개의 경로 변수를 각각 올바른 키로 추출한다") + fun matchMultiplePathVariables() { + // Given + val inputUri = "shop/category/books/item/99" + val routePattern = "shop/category/{categoryName}/item/{itemId}" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result) + assertEquals("books", result["categoryName"]) + assertEquals("99", result["itemId"]) + } + + @Test + @DisplayName("Query Parameter가 포함된 경우 함께 추출한다") + fun matchQueryParameters() { + // Given + val inputUri = "search?keyword=kotlin&sort=latest" + val routePattern = "search" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result) + assertEquals("kotlin", result["keyword"]) + assertEquals("latest", result["sort"]) + } + + @Test + @DisplayName("경로 변수와 쿼리 파라미터가 섞여 있다면 모두 추출하여 병합한다") + fun matchMixedParameters() { + // Given + val inputUri = "profile/user_123?ref=share_button&mode=dark" + val routePattern = "profile/{id}" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result) + assertEquals("user_123", result["id"]) + assertEquals("share_button", result["ref"]) + assertEquals("dark", result["mode"]) + } + + @Test + @DisplayName("경로 세그먼트의 개수가 다르면 매칭에 실패한다") + fun failWhenSegmentCountDiffers() { + // Given + val inputUri = "profile/123/edit" + val routePattern = "profile/{id}" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNull(result, "세그먼트 길이가 다르면 null을 반환해야 합니다.") + } + + @Test + @DisplayName("고정 경로 부분이 다르면 매칭에 실패한다") + fun failWhenStaticPathDiffers() { + // Given + val inputUri = "settings/profile" + val routePattern = "settings/account" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNull(result) + } + + @Test + @DisplayName("경로 매칭 시 대소문자를 구분하지 않는다") + fun matchIgnoreCase() { + // Given + val inputUri = "MyPage/Settings" + val routePattern = "mypage/settings" + + // When + val result = WispUriMatcher.match(inputUri, routePattern) + + // Then + assertNotNull(result, "대소문자가 달라도 문자가 같으면 매칭되어야 합니다.") + } +} From 197b2580c181fd4c6a2a56916d04253373ba9b6c Mon Sep 17 00:00:00 2001 From: Jong Date: Mon, 24 Nov 2025 14:52:25 +0900 Subject: [PATCH 3/3] add: basic URI routing functionality --- .../wisp/generator/WispRegistryGenerator.kt | 8 +++++ .../java/com/angrypodo/wisp/runtime/Wisp.kt | 34 +++++++++++++++++++ .../com/angrypodo/wisp/runtime/WispError.kt | 3 ++ .../wisp/runtime/WispRegistrySpec.kt | 6 ++++ 4 files changed, 51 insertions(+) create mode 100644 wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt create mode 100644 wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt index d36979e..5d57587 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt @@ -10,6 +10,7 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.MAP import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.SET import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeSpec @@ -17,6 +18,7 @@ internal object WispRegistryGenerator { private const val REGISTRY_NAME = "WispRegistry" private const val FACTORIES_PROPERTY_NAME = "factories" private const val GET_FACTORY_FUN_NAME = "getRouteFactory" + private const val GET_PATTERNS = "getPatterns" fun generate(routes: List): FileSpec { val mapType = MAP.parameterizedBy(STRING, ROUTE_FACTORY) @@ -43,6 +45,12 @@ internal object WispRegistryGenerator { .addStatement("return %N[path]", factoriesProperty) .build() + val getPatternsFun = FunSpec.builder(GET_PATTERNS) + .addModifiers(KModifier.OVERRIDE) + .returns(SET.parameterizedBy(STRING)) + .addStatement("return %N.keys", factoriesProperty) + .build() + val registryObject = TypeSpec.objectBuilder(REGISTRY_NAME) .addModifiers(KModifier.INTERNAL) .addProperty(factoriesProperty) diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt new file mode 100644 index 0000000..68f00b7 --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt @@ -0,0 +1,34 @@ +package com.angrypodo.wisp.runtime + +import android.net.Uri + +class Wisp( + private val registry: WispRegistrySpec, + private val parser: WispUriParser = DefaultWispUriParser() +) { + + fun resolveRoutes(uri: Uri): List { + val inputUris = parser.parse(uri) + + return inputUris.map { inputUri -> + createRouteObject(inputUri) + } + } + + private fun createRouteObject(inputUri: String): Any { + val allPatterns = registry.getPatterns() + + for (pattern in allPatterns) { + val params = WispUriMatcher.match(inputUri, pattern) + + if (params != null) { + val factory = registry.getRouteFactory(pattern) + ?: throw WispError.UnknownPath(pattern) + + return factory.create(params) + } + } + + throw WispError.UnknownPath(inputUri) + } +} diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt index 0d2554c..ba0d563 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt @@ -13,4 +13,7 @@ sealed class WispError(override val message: String) : Exception(message) { class ParsingFailed(uri: String, reason: String) : WispError("Failed to parse URI: $uri. Reason: $reason") + + class UnknownPath(path: String) : + WispError("The path \"$path\" is not registered with any @Wisp annotation.") } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt new file mode 100644 index 0000000..b881364 --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt @@ -0,0 +1,6 @@ +package com.angrypodo.wisp.runtime + +interface WispRegistrySpec { + fun getRouteFactory(routePattern: String): RouteFactory? + fun getPatterns(): Set +}