url
stdlib/extra/url.hk: URLs taken apart and put back. A Url value, a parser with positioned errors, a renderer that round-trips, percent-encoding and -decoding for the pieces, and query strings as a name -> values map.
Every program that builds a query string needs the encoding, and a set that is almost right produces URLs that almost work: a stray & or = left unencoded splits one parameter into two with no word about it, and the reader of the resulting request has no way to tell that from what the caller meant. Every program that reads one needs the inverse, and the same care: + is a space in a form body and a plus in a path, and a %XX that is not two hex digits is an error to report and no byte to guess at.
parse follows RFC 3986 §3's generic syntax: scheme, then an authority (// userinfo @ host : port) when the // is present, then path, ? query, # fragment. Every part comes back verbatim: no case folding, no dot-segment removal, no percent-normalising. It decodes nothing, because which decoding applies is a property of the part (a form query is not a path), and the caller therefore applies decode_component / decode_form to the piece it reads. What it refuses is what no URL may carry: a missing or malformed scheme, a byte that is whitespace or a control character or one of "<>\^\{|}, an unclosed [IPv6 literal], a port that is not a number in 0..65535`. Each error names the byte offset it was found at.
There are two encodings and no single one: they look interchangeable and are not, and picking the wrong one fails with no word about it.
encodecomponent RFC 3986 §2.3. A space is %20. What a path segment, a header value, and a signed request (OAuth, AWS SigV4) all specify. encodeform application/x-www-form-urlencoded, the WHATWG URL serializer. A space is +. What an HTML form body and most query strings carry.
The two safe sets differ as well: the form encoding leaves * alone and encodes ~, where RFC 3986 does the reverse. That is what browsers, curl and java.net.URLEncoder emit, and output here is byte-identical to theirs.
Both are total. Every input has an encoding, and there is no error type. Both are defined over the UTF-8 bytes of the text, which is what makes a non-ASCII component encode the same way everywhere.
Decoding mirrors the pair: decode_component reads %XX only and decode_form reads + as a space as well, and both refuse a malformed escape or a result that is not UTF-8, with the offset.
Pure and whole-input: every entry point that builds is @encapsulated, so the builder each writes into is allocated and finished inside the call and nothing mutable escapes. That leaves them callable from meta and from ordinary pure code, on both tiers. http.parse_url is the narrower, http-flavoured reading of the same syntax; this is the general one.
Url
struct Url
scheme: string
userinfo: Option<string>
host: Option<string>
port: Option<u16>
path: string
query: Option<string>
fragment: Option<string>
end
A URL taken apart. host is None when the URL has no authority at all (mailto:a@b.example) and Some("") when it has an empty one (file:///etc/hosts), which lets a render put the // back where it was. An IPv6 literal host retains its brackets ([::1]). path is whatever followed the authority, "" included; query and fragment are what followed their ? / #, without the marker, and absent when the marker was. Every part is verbatim: decode a piece with decode_component (or decode_form for a form-encoded query) when you read it.
impl Display<Url>
to_string
def to_string(self) -> string
The URL spelled back out - render.
parse("http://h:8080/p?q#f").map(|u| u.to_string()).unwrap_or("") => "http://h:8080/p?q#f"
impl Eq<Url>
eq?
def eq?(self, other: Self) -> bool
Equal part by part.
parse("http://h/p").unwrap_or(_empty_url()).eq?(parse("http://h/p").unwrap_or(_empty_url())) => true
UrlError
type UrlError
BadScheme(int)
BadCharacter(int)
BadHost(int)
BadPort(int)
BadEscape(int)
BadUtf8(int)
end
Why a URL could not be parsed, or a piece of one decoded, with the byte offset at which the problem was found (the json.JsonError idiom).
impl Display<UrlError>
to_string
def to_string(self) -> string
Renders as <what> at offset <at>.
BadPort(9).to_string() => "invalid port at offset 9"
impl Eq<UrlError>
eq?
def eq?(self, other: Self) -> bool
Equal when the kind and the offset match.
BadPort(9).eq?(BadPort(9)) => true
BadPort(9).eq?(BadHost(9)) => false
emptyurl
def _empty_url() -> Url
The placeholder a doctest falls back to where a Result<Url, _> has to be unwrapped; never produced by parse, whose scheme is never empty.
parse
def parse(text: string) -> Result<Url, UrlError>
Take text apart (RFC 3986 §3). Every part comes back verbatim, Url naming what each one is, and an error names the byte it was found at.
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.scheme).unwrap_or("") => "https"
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.userinfo.unwrap_or("-")).unwrap_or("") => "u"
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.host.unwrap_or("-")).unwrap_or("") => "h"
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.port.unwrap_or(0u16)).unwrap_or(1u16) => 8080u16
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.path).unwrap_or("") => "/a/b"
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.query.unwrap_or("-")).unwrap_or("") => "q=1"
parse("https://u@h:8080/a/b?q=1#top").map(|u| u.fragment.unwrap_or("-")).unwrap_or("") => "top"
parse("mailto:a@b.example").map(|u| u.host.unwrap_or("(none)")).unwrap_or("") => "(none)"
parse("file:///etc/hosts").map(|u| u.host.unwrap_or("(none)")).unwrap_or("x") => ""
parse("//no-scheme/p") => Err(BadScheme(0))
parse("http://h:99999/") => Err(BadPort(9))
parse("http://h/a b") => Err(BadCharacter(10))
parseafter_scheme
def _parse_after_scheme(text: string, scheme: string, from: int) -> Result<Url, UrlError>
The parts after scheme:, starting at byte from.
_Authority
struct _Authority
userinfo: Option<string>
host: string
port: Option<u16>
end
The three parts of an authority.
parseauthority
def _parse_authority(text: string, from: int, stop: int) -> Result<_Authority, UrlError>
text[from, stop) as [userinfo @] host [: port]: the userinfo is what precedes the last @ (a password may bear an encoded one, and the spec says the last), a bracketed host runs to its ], and otherwise the port is what follows the last :.
parseport
def _parse_port(text: string, from: int, stop: int) -> Result<Option<u16>, UrlError>
text[from, stop) as a port: empty is no port (RFC 3986 allows host:), otherwise digits that fit a u16.
schemeend
def _scheme_end(raw: bytes) -> Result<int, UrlError>
Where the scheme's : is, once letter (letter|digit|+|-|.)* runs up to it; the error names the offset of the first byte that broke the rule.
schemescan
def _scheme_scan(raw: bytes, i: int) -> Result<int, UrlError>
_alpha?
def _alpha?(b: u8) -> bool
firstbad_character
def _first_bad_character(raw: bytes) -> Option<int>
The first byte no URL may carry unencoded: anything at or below a space, DEL, and the RFC 3986 §2 excluded set "<>\^\{|} (a [/] is structure, %` an escape, and a byte past ASCII is left to the caller's decoding).
_excluded?
def _excluded?(b: u8) -> bool
findfrom
def _find_from(text: string, needle: string, from: int) -> Option<int>
The first needle at or after byte from.
findbefore
def _find_before(text: string, needle: string, from: int, stop: int) -> Option<int>
The first needle in [from, stop).
rfindbetween
def _rfind_between(text: string, needle: string, from: int, stop: int) -> Option<int>
The last needle in [from, stop).
rfindstep
def _rfind_step(text: string, needle: string, from: int, stop: int, last: Option<int>) -> Option<int>
render
def render(u: Url) -> string
Spell u back out: scheme: then, when host is present, // with the userinfo, host and port the way they were; then the path, ?query, #fragment. parse(render(u)) gives back u for any u parse produced.
render(Url(scheme="http", userinfo=None, host=Some("h"), port=Some(80u16), path="/p", query=Some("a=1"), fragment=None)) => "http://h:80/p?a=1"
render(Url(scheme="mailto", userinfo=None, host=None, port=None, path="a@b", query=None, fragment=None)) => "mailto:a@b"
decode_component
def decode_component(text: string) -> Result<string, UrlError>
Undo encode_component: every %XX becomes the byte, everything else is left alone (a + is a plus), and the bytes must read back as UTF-8.
decode_component("hello%20world") => Ok("hello world")
decode_component("a%2Bb") => Ok("a+b")
decode_component("%C3%A4i") => Ok("äi")
decode_component("100%") => Err(BadEscape(3))
decode_component("%ZZ") => Err(BadEscape(0))
decode_form
def decode_form(text: string) -> Result<string, UrlError>
Undo encode_form: as decode_component, and a + is a space.
decode_form("hello+world") => Ok("hello world")
decode_form("a%2Bb") => Ok("a+b")
_decode
def _decode(text: string, plus_is_space: bool) -> Result<string, UrlError>
escapebyte
def _escape_byte(raw: bytes, i: int) -> Option<u8>
The byte a %XX at i spells, or None when the two hex digits are not there.
hexvalue
def _hex_value(b: u8) -> Option<u8>
parse_query
def parse_query(query: string) -> Result<Map<string, List<string>>, UrlError>
A query string (a=1&b=2&a=3) as a name -> values map, in the http.Headers arrangement: a repeated name has every value, in order; a pair with no = has the value ""; an empty segment (a&&b) is skipped. Names and values are form-decoded (+ is a space). An error's offset is into the query as given.
parse_query("a=1&b=2&a=3").map(|m| m.get("a").unwrap_or([])).unwrap_or([]) => ["1", "3"]
parse_query("q=hello+world").map(|m| m.get("q").unwrap_or([])).unwrap_or([]) => ["hello world"]
parse_query("flag&x=").map(|m| m.get("flag").unwrap_or([])).unwrap_or([]) => [""]
parse_query("a=%ZZ") => Err(BadEscape(2))
querysegments
def _query_segments(query: string, from: int, acc: Result<Map<string, List<string>>, UrlError>) -> Result<Map<string, List<string>>, UrlError>
querypair
def _query_pair(query: string, from: int, stop: int, m: Map<string, List<string>>) -> Result<Map<string, List<string>>, UrlError>
_shift
def _shift(e: UrlError, by: int) -> UrlError
An error found inside a slice, re-offset into the whole.
render_query
def render_query(pairs: List<Pair<string, string>>) -> string
Name/value pairs as a query string, each side form-encoded, joined by & in the order given: the inverse of parse_query up to that order.
render_query([Pair(first="q", second="hello world"), Pair(first="page", second="2")]) => "q=hello+world&page=2"
render_query([]) => ""
unreservedextra
_unreserved_extra: string = "-._~"
The characters each set leaves alone beyond the alphanumerics.
formextra
_form_extra: string = "*-._"
_printable
_printable: string = "*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
ASCII 0x2A through 0x7E, indexed by byte - 42. Every character either set leaves verbatim sits in that span, and one table answers both.
hexdigits
_hex_digits: string = "0123456789ABCDEF"
Uppercase, per RFC 3986 §2.1: a decoder must accept either case, but two encoders that disagree produce different bytes for the same input, and a request signature is computed over those bytes.
encode_component
def encode_component(text: string) -> string
Percent-encode text for one component of a URL: a path segment, a query parameter name or value, or anything else that must not be read as structure. RFC 3986's unreserved set (A-Z a-z 0-9 - . _ ~) survives and every other byte becomes %XX, a space included.
encode_component("hello world") => "hello%20world"
encode_component("a+b&c=d") => "a%2Bb%26c%3Dd"
encode_component("-._~") => "-._~"
encode_component("äi") => "%C3%A4i"
encode_form
def encode_form(text: string) -> string
Percent-encode text for an application/x-www-form-urlencoded body or query string, where a space is + and never %20. A-Z a-z 0-9 * - . _ survive; everything else, ~ included, becomes %XX.
A + in the input is itself encoded, which leaves the two spellings of a space distinguishable to whatever reads the result back.
encode_form("hello world") => "hello+world"
encode_form("a+b&c=d") => "a%2Bb%26c%3Dd"
encode_form("*-._") => "*-._"
encode_form("~") => "%7E"
_encode
def _encode(text: string, extra: string, space: string) -> string
One pass over the UTF-8 bytes: a byte in the safe set is copied, a space becomes space, and anything else becomes %XX. A multi-byte scalar is never special-cased: each of its bytes falls through to the percent form, which is what RFC 3986 §2.5 specifies.
_safe?
def _safe?(b: u8, extra: string) -> bool
Whether b is kept verbatim. The range test comes first because _char is only defined over it, and because it rules out every byte of a multi-byte scalar in one comparison.
_alphanumeric?
def _alphanumeric?(b: u8) -> bool
_char
def _char(b: u8) -> string
The ASCII character for b, which callers ask for only after _safe?'s range test has held.
_hex
def _hex(nibble: u8) -> string