primitive NormalizeURI
"""
Normalize a URI per RFC 3986 sections 6.2.2 (syntax-based) and 6.2.3
(scheme-based).
Syntax-based normalization (always safe, uses only generic URI syntax):
- **Case normalization**: scheme and host are lowercased.
- **Percent-encoding normalization**: hex digits in `%XX` are uppercased;
percent-encoded unreserved characters are decoded to their literal form.
- **Dot-segment removal**: `.` and `..` segments are resolved in the path.
Scheme-based normalization (requires scheme-specific knowledge):
- **Default port removal**: ports matching the scheme's well-known default
(http→80, https→443, ftp→21) are removed.
- **Empty path normalization**: for `http` and `https`, an empty path with
an authority is set to `"/"` (per RFC 7230).
Returns `InvalidPercentEncoding` if any component contains a malformed
percent-encoded sequence, since `ParseURI` does not validate
percent-encoding within components.
"""
fun apply(uri: URI val): (URI val | InvalidPercentEncoding val) =>
"""
Normalize `uri` and return the normalized form, or an error if
percent-encoding is malformed.
"""
// -- 6.2.2: Syntax-based normalization --
// Lowercase scheme
let norm_scheme: (String | None) =
match \exhaustive\ uri.scheme
| let s: String => s.lower()
| None => None
end
// Normalize authority
let norm_authority: (URIAuthority | None) =
match \exhaustive\ uri.authority
| let a: URIAuthority =>
// Normalize userinfo percent-encoding
let norm_userinfo: (String | None) =
match \exhaustive\ a.userinfo
| let u: String =>
match \exhaustive\ _NormalizePercentEncoding(u)
| let s: String => s
| let e: InvalidPercentEncoding val => return e
end
| None => None
end
// Lowercasing must happen after decoding so that a decoded letter
// (e.g. %41 → 'A') is lowercased along with the rest of the host.
// Uses _LowerOutsidePercent rather than .lower() to preserve the
// uppercase hex digits that _NormalizePercentEncoding produces.
let norm_host: String val =
match \exhaustive\ _NormalizePercentEncoding(a.host)
| let s: String => _LowerOutsidePercent(s)
| let e: InvalidPercentEncoding val => return e
end
URIAuthority(norm_userinfo, norm_host, a.port)
| None => None
end
// Normalize path: percent-encoding first, then dot-segment removal
let pct_path =
match \exhaustive\ _NormalizePercentEncoding(uri.path)
| let s: String => s
| let e: InvalidPercentEncoding val => return e
end
let norm_path = RemoveDotSegments(pct_path)
// Normalize query percent-encoding
let norm_query: (String | None) =
match \exhaustive\ uri.query
| let q: String =>
match \exhaustive\ _NormalizePercentEncoding(q)
| let s: String => s
| let e: InvalidPercentEncoding val => return e
end
| None => None
end
// Normalize fragment percent-encoding
let norm_fragment: (String | None) =
match \exhaustive\ uri.fragment
| let f: String =>
match \exhaustive\ _NormalizePercentEncoding(f)
| let s: String => s
| let e: InvalidPercentEncoding val => return e
end
| None => None
end
// -- 6.2.3: Scheme-based normalization --
// Only applies when a scheme is present (not for relative references)
(let final_authority, let final_path) =
match \exhaustive\ norm_scheme
| let scheme: String =>
// Default port removal
let scheme_authority =
match \exhaustive\ norm_authority
| let a: URIAuthority =>
match \exhaustive\ (a.port, _SchemeDefaultPort(scheme))
| (let port: U16, let default_port: U16)
if port == default_port
=>
URIAuthority(a.userinfo, a.host, None)
else
a
end
| None => None
end
// Empty path normalization (http and https only)
let scheme_path =
if (norm_path == "") and
(scheme_authority isnt None) and
((scheme == "http") or (scheme == "https"))
then
"/"
else
norm_path
end
(scheme_authority, scheme_path)
else
(norm_authority, norm_path)
end
URI(
norm_scheme,
final_authority,
final_path,
norm_query,
norm_fragment)
primitive _LowerOutsidePercent
"""
Lowercase ASCII letters in a string, skipping characters inside `%XX`
percent-encoded triplets. This preserves the uppercase hex digits that
`_NormalizePercentEncoding` produces while lowercasing literal host
characters per RFC 3986 section 6.2.2.1.
"""
fun apply(input: String val): String val =>
var has_upper = false
var i: USize = 0
while i < input.size() do
try
let c = input(i)?
if c == '%' then
i = i + 3
elseif (c >= 'A') and (c <= 'Z') then
has_upper = true
break
else
i = i + 1
end
else
break
end
end
if not has_upper then
return input
end
let out = String(input.size())
i = 0
while i < input.size() do
try
let c = input(i)?
if c == '%' then
out.push(c)
if (i + 1) < input.size() then
out.push(input(i + 1)?)
end
if (i + 2) < input.size() then
out.push(input(i + 2)?)
end
i = i + 3
elseif (c >= 'A') and (c <= 'Z') then
out.push(c + 32)
i = i + 1
else
out.push(c)
i = i + 1
end
else
break
end
end
out.clone()
primitive _SchemeDefaultPort
"""
Return the well-known default port for a URI scheme, or `None` for
unknown schemes.
The input scheme must already be lowercased (6.2.2 case normalization
runs before 6.2.3).
"""
fun apply(scheme: String): (U16 | None) =>
if scheme == "http" then
80
elseif scheme == "https" then
443
elseif scheme == "ftp" then
21
else
None
end