-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/#14 URI Parser및 Matcher 구현 #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> { | ||
| 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") | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Any> { | ||
| 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) | ||
| } | ||
| } | ||
|
Comment on lines
+21
to
+30
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금은 등록된 모든 라우트 패턴을 순회하고 있는데, 만약에 저희가 만드는 라이브러리가 수백개의 딥링크를 가진 앱에 적용된다면 성능에 병목이 생길 것 같아요.🤔 초기 구현으로는 사실 충분히 실용적이라 추후의 이슈로 넘겨도 좋다고 생각합니다!
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 미처 고려하지 못한 상황이네요! |
||
|
|
||
| throw WispError.UnknownPath(inputUri) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.angrypodo.wisp.runtime | ||
|
|
||
| interface WispRegistrySpec { | ||
| fun getRouteFactory(routePattern: String): RouteFactory? | ||
| fun getPatterns(): Set<String> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, String>? { | ||
| 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<String, String>() | ||
|
|
||
| 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<String, String>) { | ||
| query.split('&').forEach { pair -> | ||
| val parts = pair.split('=', limit = 2) | ||
|
|
||
| if (parts.size == 2) params[parts[0]] = parts[1] | ||
| } | ||
| } | ||
|
Comment on lines
+44
to
+50
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이번 프리코스를 하면서 체감했던 생각이 "바퀴를 재발명 하지 마라" 인데요! 그래서 저는 안드로이드 프레임워크의
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해당 방식으로 수정해보았더니 Uri 클래스에 대한 Mocking이 필요하더라구요. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.angrypodo.wisp.runtime | ||
|
|
||
| import android.net.Uri | ||
|
|
||
| interface WispUriParser { | ||
| /** | ||
| * @param uri 수신된 딥링크 Uri | ||
| * @return 백스택을 나타내는 경로 문자열 리스트 | ||
| */ | ||
| fun parse(uri: Uri): List<String> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, "대소문자가 달라도 문자가 같으면 매칭되어야 합니다.") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
클래스로 구현해주셨네요! 제가 생각할때는 object로도 구현이 가능해보이는데 어떻게 생각하시나요?
현재 구현대로면 사용자가 직접 객체를 생성하고 생명주기를 관리해야 하는 방식이네요. 테스트 용이성이나 parser의 교체가능성을 생각하면 class도 좋다고 생각해요! 저는 편의성에서 object로 구현하면 어떨까 생각이 드네요🤔🤔🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 처음에
object로 구현했었는데요, 구현하다 보니WispRegistry가 KSP로 생성되는 코드라서 런타임에 이를 유연하게 주입받으려면 생성자 주입 방식이 더 적합하다고 판단했습니다.또한, 말씀하신 대로 사용자가 커스텀 Parser를 사용하고 싶을 때,
object라면 전역 상태를 변경해야 하는 위험이 있지만,class라면 필요한 설정에 따라 인스턴스를 분리할 수 있어 확장성 면에서 이점이 더 크다고 생각했습니다.실제로 적용했을 때 말씀하신 것처럼 생명주기를 관리해야하는 부분에서 불편함이 느껴진다면
object로 변경하는 방향은 어떨까요?ㅎㅎThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋습니다! 지금 방향이 확장성 면에서는 더 좋다고 생각해요!