20x Faster Rfc3986Normalizer and way less objects being created.
This commit is contained in:
+3
-19
@@ -23,13 +23,11 @@ package com.vitorpamplona.quartz.benchmark
|
||||
import androidx.benchmark.junit4.BenchmarkRule
|
||||
import androidx.benchmark.junit4.measureRepeated
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
|
||||
import io.kotlingeekdev.urireference.URIReference
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.toString
|
||||
|
||||
/**
|
||||
* Benchmark, which will execute on an Android device.
|
||||
@@ -38,28 +36,14 @@ import kotlin.toString
|
||||
* result. Modify your code to see how it affects performance.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RelayUrlNormalizerBenchmark {
|
||||
class Rfc3986UrlNormalizerBenchmark {
|
||||
@get:Rule
|
||||
val benchmarkRule = BenchmarkRule()
|
||||
|
||||
@Test
|
||||
fun normalize() {
|
||||
benchmarkRule.measureRepeated {
|
||||
"wss://relay.damus.io".normalizeRelayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeDirect() {
|
||||
benchmarkRule.measureRepeated {
|
||||
URIReference.parse("wss://nostr.mom/").normalize().toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseUriReference() {
|
||||
benchmarkRule.measureRepeated {
|
||||
URIReference.parse("wss://nostr.mom/").toString()
|
||||
Rfc3986.normalize("wss://relay.damus.io")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ torAndroid = "0.4.9.5.1"
|
||||
translate = "17.0.3"
|
||||
jetbrainsCompose = "1.10.3"
|
||||
unifiedpush = "3.0.10"
|
||||
uriReferenceKmp = "1.0"
|
||||
vico-charts-compose = "3.0.3"
|
||||
zelory = "3.0.1"
|
||||
zoomable = "2.11.1"
|
||||
@@ -124,7 +123,6 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio
|
||||
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
|
||||
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
|
||||
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
||||
uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" }
|
||||
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
|
||||
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
|
||||
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
|
||||
|
||||
@@ -118,9 +118,6 @@ kotlin {
|
||||
// SQLite KMP driver for event store
|
||||
api(libs.androidx.sqlite)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
|
||||
// RFC3986 library(normalizes URLs)
|
||||
api(libs.uri.reference.kmp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +167,6 @@ kotlin {
|
||||
dependencies {
|
||||
// Bitcoin secp256k1 bindings
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +188,6 @@ kotlin {
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +199,6 @@ kotlin {
|
||||
// Bitcoin secp256k1 bindings
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
|
||||
// SQLite bundled driver for Host tests
|
||||
implementation(libs.androidx.sqlite.bundled.jvm)
|
||||
}
|
||||
@@ -222,7 +216,6 @@ kotlin {
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,47 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import io.kotlingeekdev.urireference.URIReference
|
||||
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
|
||||
|
||||
object Rfc3986 {
|
||||
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
|
||||
fun parse(url: String) = UrlDetector(url).detect()[0]
|
||||
|
||||
fun isValidUrl(url: String): Boolean =
|
||||
runCatching {
|
||||
URIReference.parse(url)
|
||||
}.isSuccess
|
||||
fun normalize(uri: String): String = parse(uri).fullUrl
|
||||
|
||||
fun normalizeAndRemoveFragment(url: String): String =
|
||||
URIReference
|
||||
.parse(url)
|
||||
.normalize()!!
|
||||
.toStringNoFragment()
|
||||
.internIfPossible()
|
||||
fun isValidUrl(url: String): Boolean = runCatching { parse(url) }.isSuccess
|
||||
|
||||
fun host(url: String): String =
|
||||
URIReference
|
||||
.parse(url)
|
||||
.host
|
||||
?.value
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun URIReference.toStringSchemeHost(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (authority != null) sb.append("//").append(authority.toString())
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun URIReference.toStringNoFragment(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
if (scheme != null) sb.append(scheme).append(":")
|
||||
if (host != null) sb.append("//").append(host.toString())
|
||||
if (path != null) sb.append(path)
|
||||
if (query != null) sb.append("?").append(query)
|
||||
|
||||
return sb.toString()
|
||||
fun normalizeAndRemoveFragment(url: String): String = parse(url).fullUrlWithoutFragment.internIfPossible()
|
||||
|
||||
fun host(url: String): String = parse(url).host
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ class Url(
|
||||
if (index != -1) {
|
||||
_scheme = _scheme!!.substring(0, index)
|
||||
}
|
||||
_scheme = _scheme!!.lowercase()
|
||||
} else if (!originalUrl.startsWith("//")) {
|
||||
_scheme =
|
||||
DEFAULT_SCHEME
|
||||
_scheme = DEFAULT_SCHEME
|
||||
}
|
||||
}
|
||||
return _scheme ?: ""
|
||||
@@ -125,7 +125,10 @@ class Url(
|
||||
val host: String
|
||||
get() {
|
||||
if (this.rawHost == null) {
|
||||
this.rawHost = getPart(UrlPart.HOST)
|
||||
this.rawHost =
|
||||
getPart(UrlPart.HOST)?.let {
|
||||
lowercaseLiteralChars(normalizeComponent(it))
|
||||
}
|
||||
if (exists(UrlPart.PORT)) {
|
||||
this.rawHost =
|
||||
rawHost?.let {
|
||||
@@ -142,8 +145,7 @@ class Url(
|
||||
val port: Int
|
||||
get() {
|
||||
if (_port == 0) {
|
||||
val portString =
|
||||
getPart(UrlPart.PORT)
|
||||
val portString = getPart(UrlPart.PORT)
|
||||
if (!portString.isNullOrEmpty()) {
|
||||
_port = portString.toIntOrNull() ?: -1
|
||||
} else {
|
||||
@@ -156,14 +158,9 @@ class Url(
|
||||
val path: String?
|
||||
get() {
|
||||
if (this.rawPath == null) {
|
||||
this.rawPath =
|
||||
if (exists(UrlPart.PATH)) {
|
||||
getPart(
|
||||
UrlPart.PATH,
|
||||
)
|
||||
} else {
|
||||
"/"
|
||||
}
|
||||
this.rawPath = getPart(UrlPart.PATH)?.let {
|
||||
normalizeComponent(removeDotSegments(it))
|
||||
} ?: "/"
|
||||
}
|
||||
return this.rawPath
|
||||
}
|
||||
@@ -171,7 +168,10 @@ class Url(
|
||||
val query: String
|
||||
get() {
|
||||
if (_query == null) {
|
||||
_query = getPart(UrlPart.QUERY)
|
||||
_query =
|
||||
getPart(UrlPart.QUERY)?.let {
|
||||
normalizeComponent(it)
|
||||
} ?: ""
|
||||
}
|
||||
return _query ?: ""
|
||||
}
|
||||
@@ -179,7 +179,9 @@ class Url(
|
||||
val fragment: String
|
||||
get() {
|
||||
if (_fragment == null) {
|
||||
_fragment = getPart(UrlPart.FRAGMENT)
|
||||
_fragment = getPart(UrlPart.FRAGMENT)?.let {
|
||||
normalizeComponent(it)
|
||||
} ?: ""
|
||||
}
|
||||
return _fragment ?: ""
|
||||
}
|
||||
@@ -190,10 +192,10 @@ class Url(
|
||||
val usernamePasswordParts: List<String> =
|
||||
usernamePassword.substring(0, usernamePassword.length - 1).split(":")
|
||||
if (usernamePasswordParts.size == 1) {
|
||||
_username = usernamePasswordParts[0]
|
||||
_username = normalizeComponent(usernamePasswordParts[0])
|
||||
} else if (usernamePasswordParts.size == 2) {
|
||||
_username = usernamePasswordParts[0]
|
||||
_password = usernamePasswordParts[1]
|
||||
_username = normalizeComponent(usernamePasswordParts[0])
|
||||
_password = normalizeComponent(usernamePasswordParts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +244,206 @@ class Url(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dot segments from the given path as stated in
|
||||
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
|
||||
*
|
||||
* @param path
|
||||
* The path from which dot segments are to be removed.
|
||||
*
|
||||
* @return
|
||||
* The path from which dot segments are removed.
|
||||
*/
|
||||
fun removeDotSegments(path: String): String {
|
||||
// Initialize the input with the no-appended path components and the output
|
||||
// with the empty string.
|
||||
var input = path
|
||||
var output = ""
|
||||
|
||||
// While the input is not empty, loop the following steps.
|
||||
while (input.isNotEmpty()) {
|
||||
// If the input begins with a prefix of "../" or "./", then
|
||||
// remove that prefix from the input;
|
||||
if (DOT_DOT_SLASH.find(input) != null) {
|
||||
input = DOT_DOT_SLASH.replaceFirst(input, "")
|
||||
continue
|
||||
}
|
||||
|
||||
// If the input begins with a prefix of "/./" or "/.", where
|
||||
// "." is a complete path segment, then replace that prefix
|
||||
// with "/" in the input.
|
||||
if (SLASH_DOT_SLASH.find(input) != null) {
|
||||
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
|
||||
continue
|
||||
}
|
||||
|
||||
// If the input begins with a prefix of "/../" or "/..",
|
||||
// where ".." is a complete path segment, then replace that
|
||||
// prefix with "/" in the input and remove the last segment
|
||||
// and its preceding "/" (if any) from the output.
|
||||
if (SLASH_DOT_DOT_SLASH.find(input) != null) {
|
||||
input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
|
||||
output = dropLastSegment(output, true)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the input consists only of "." or "..", then remove
|
||||
// that from the input.
|
||||
if (DOT_OR_DOT_DOT.find(input) != null) {
|
||||
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
|
||||
continue
|
||||
}
|
||||
|
||||
// Move the first path segment in the input buffer to the
|
||||
// end of the output, including the initial "/" character
|
||||
// (if any) and any subsequent characters up to, but not
|
||||
// including, the next "/" character or the end of the input.
|
||||
val matchResult = MOVE_REGEX.find(input)
|
||||
if (matchResult != null) {
|
||||
input = matchResult.groups["remaining"]!!.value
|
||||
output += matchResult.groups["firstsegment"]!!.value
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the last segment (= characters after the last slash) of a path and
|
||||
* optionally the last slash. If the path doesn't contain slash, an empty string
|
||||
* is returned.
|
||||
*
|
||||
* @param path
|
||||
* The path.
|
||||
*
|
||||
* @param dropLastSlash
|
||||
* Whether or not to drop the last slash if present.
|
||||
*
|
||||
* @return The path from which the last segment is removed.
|
||||
*/
|
||||
fun dropLastSegment(
|
||||
path: String,
|
||||
dropLastSlash: Boolean,
|
||||
): String {
|
||||
// The regular expression for the target.
|
||||
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
|
||||
|
||||
// Find the target. (Any inputs matches the pattern.)
|
||||
m.find(path)
|
||||
|
||||
// Drop the target.
|
||||
return m.replaceFirst(path, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreserved characters per RFC 3986 §2.3:
|
||||
* ALPHA / DIGIT / "-" / "." / "_" / "~"
|
||||
*/
|
||||
private fun isUnreserved(c: Char): Boolean = c.isLetter() || c.isDigit() || c == '-' || c == '.' || c == '_' || c == '~'
|
||||
|
||||
/**
|
||||
* Normalize percent-encoded triplets in a single URI component string.
|
||||
*
|
||||
* For each %XX triplet:
|
||||
* - If the decoded byte is an unreserved ASCII character → decode it
|
||||
* - Otherwise → keep encoded but uppercase the hex digits
|
||||
*
|
||||
* Non-ASCII bytes (e.g. UTF-8 multi-byte sequences) are left encoded
|
||||
* since they cannot be unreserved characters.
|
||||
*/
|
||||
fun normalizeComponent(input: String): String {
|
||||
val sb = StringBuilder(input.length)
|
||||
var i = 0
|
||||
|
||||
while (i < input.length) {
|
||||
val c = input[i]
|
||||
|
||||
if (c == '%' && i + 2 < input.length) {
|
||||
val hex = input.substring(i + 1, i + 3)
|
||||
|
||||
// Validate that both characters are valid hex digits
|
||||
if (hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
|
||||
val byteValue = hex.toInt(16)
|
||||
|
||||
// Only consider single-byte ASCII values for potential decoding
|
||||
if (byteValue < 0x80) {
|
||||
val decoded = byteValue.toChar()
|
||||
if (isUnreserved(decoded)) {
|
||||
// Decode: replace %XX with the literal character
|
||||
sb.append(decoded)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Keep encoded, but uppercase the hex digits
|
||||
sb.append('%')
|
||||
sb.append(hex.uppercase())
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Regular character — pass through as-is
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase only the literal (non-percent-encoded) characters in a string.
|
||||
* Percent-encoded triplets are left untouched so their uppercased hex digits
|
||||
* are not inadvertently lowercased.
|
||||
*/
|
||||
private fun lowercaseLiteralChars(input: String): String {
|
||||
val sb = StringBuilder(input.length)
|
||||
var i = 0
|
||||
while (i < input.length) {
|
||||
if (input[i] == '%' && i + 2 < input.length) {
|
||||
sb.append(input[i])
|
||||
sb.append(input[i + 1])
|
||||
sb.append(input[i + 2])
|
||||
i += 3
|
||||
} else {
|
||||
sb.append(input[i].lowercaseChar())
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
|
||||
val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
|
||||
|
||||
// If the input begins with a prefix of "../" or "./", then
|
||||
// remove that prefix from the input;
|
||||
val DOT_DOT_SLASH = Regex("^\\.?\\./")
|
||||
|
||||
// If the input begins with a prefix of "/./" or "/.", where
|
||||
// "." is a complete path segment, then replace that prefix
|
||||
// with "/" in the input.
|
||||
val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
|
||||
|
||||
// If the input begins with a prefix of "/../" or "/..",
|
||||
// where ".." is a complete path segment, then replace that
|
||||
// prefix with "/" in the input and remove the last segment
|
||||
// and its preceding "/" (if any) from the output.
|
||||
val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
|
||||
|
||||
// If the input consists only of "." or "..", then remove
|
||||
// that from the input.
|
||||
val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
|
||||
|
||||
// Move the first path segment in the input buffer to the
|
||||
// end of the output, including the initial "/" character
|
||||
// (if any) and any subsequent characters up to, but not
|
||||
// including, the next "/" character or the end of the input.
|
||||
val MOVE_REGEX = Regex("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
|
||||
|
||||
private const val DEFAULT_SCHEME = "https"
|
||||
private val SCHEME_PORT_MAP: Map<String, Int> =
|
||||
mapOf(
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils.urldetector
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class URIReferenceNormalizerTest {
|
||||
@Test
|
||||
fun test_normalize() {
|
||||
val uriRef1 = Rfc3986.normalize("hTTp://example.com/")
|
||||
assertEquals("http://example.com/", uriRef1)
|
||||
|
||||
val uriRef2 = Rfc3986.normalize("http://example.com/")
|
||||
assertEquals("http://example.com/", uriRef2)
|
||||
|
||||
val uriRef3 = Rfc3986.normalize("http://%75ser@example.com/")
|
||||
assertEquals("http://user@example.com/", uriRef3)
|
||||
|
||||
val uriRef4 = Rfc3986.normalize("http://%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc@example.com/")
|
||||
assertEquals("http://%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC@example.com/", uriRef4)
|
||||
|
||||
val uriRef5 = Rfc3986.normalize("http://%65%78%61%6D%70%6C%65.com/")
|
||||
assertEquals("http://example.com/", uriRef5)
|
||||
|
||||
val uriRef6 = Rfc3986.normalize("http://%e4%be%8b.com/")
|
||||
assertEquals("http://%E4%BE%8B.com/", uriRef6)
|
||||
|
||||
val uriRef7 = Rfc3986.normalize("http://LOCALhost/")
|
||||
assertEquals("http://localhost/", uriRef7)
|
||||
|
||||
val uriRef8 = Rfc3986.normalize("http://example.com")
|
||||
assertEquals("http://example.com/", uriRef8)
|
||||
|
||||
val uriRef9 = Rfc3986.normalize("http://example.com/%61/%62/%63/")
|
||||
assertEquals("http://example.com/a/b/c/", uriRef9)
|
||||
|
||||
val uriRef10 = Rfc3986.normalize("http://example.com/%e3%83%91%e3%82%b9/")
|
||||
assertEquals("http://example.com/%E3%83%91%E3%82%B9/", uriRef10)
|
||||
|
||||
val uriRef11 = Rfc3986.normalize("http://example.com/a/b/c/../d/")
|
||||
assertEquals("http://example.com/a/b/d/", uriRef11)
|
||||
|
||||
val uriRef12 = Rfc3986.normalize("http://example.com:80/")
|
||||
assertEquals("http://example.com/", uriRef12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moreTests() {
|
||||
// From our conversation
|
||||
assertEquals("http://%E4%BE%8B.com/", Rfc3986.normalize("http://%e4%be%8b.com/"))
|
||||
|
||||
// Unreserved chars that should be decoded
|
||||
assertEquals("http://example.com/~user/ABC", Rfc3986.normalize("http://example.com/%7euser/%41BC"))
|
||||
|
||||
// Reserved chars that must NOT be decoded
|
||||
assertEquals("http://example.com/path%2Fsegment?key%3Dvalue", Rfc3986.normalize("http://example.com/path%2Fsegment?key%3Dvalue"))
|
||||
|
||||
// Mixed: some decodable, some not, lowercase hex
|
||||
assertEquals("http://example.com/~%2Fa%3F", Rfc3986.normalize("http://EXAMPLE.COM/%7e%2f%61%3F"))
|
||||
|
||||
// Userinfo
|
||||
assertEquals("http://user%40name@example.com/", Rfc3986.normalize("http://user%40name@example.com/"))
|
||||
|
||||
// With port and query and fragment
|
||||
assertEquals("https://example.com:8080/foo~bar?a%3D1&b%2Bc#frag%23ment", Rfc3986.normalize("https://Example.COM:8080/foo%7ebar?a%3D1&b%2Bc#frag%23ment"))
|
||||
|
||||
// IPv6
|
||||
assertEquals("http://[::1]:8080/path", Rfc3986.normalize("http://[::1]:8080/path"))
|
||||
|
||||
// Already normalized
|
||||
assertEquals("http://example.com/~user", Rfc3986.normalize("http://example.com/~user"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user