1 # ezhttp/src/url: a URL taken apart far enough for an HTTP request. Scheme, host, 2 # port, and an origin-form path (absolute-path, optional query). Userinfo is 3 # stripped. The fragment is not part of the request-target. 4 # 5 # Shape follows RFC 3986 URI generic syntax: scheme ":" hier-part, with 6 # hier-part as "//" authority path-abempty ["?" query] ["#" fragment] 7 # (RFC 3986 §3, §3.1, §3.2, §3.4, §3.5). 8 import Base 9 10 # a url that came apart, or the reason it would not 11 type Loc is Data: 12 Bad{why: String} 13 Loc{scheme: String, host: String, port: U32, path: String} 14 15 # the port a scheme implies when the authority names none (RFC 3986 §3.2.3; 16 # HTTPS default 443 is the well-known TLS port, not re-specified here) 17 def dflt(scheme: String) -> U32: 18 Bool.pick(U32, String.eq(scheme, "https"), 443, 80) 19 20 # a port that parsed, or the scheme's own when the digits were not digits 21 def port.num(m: Maybe<&2, U32>, scheme: String) -> U32: 22 match m: 23 case None{}: 24 dflt(scheme) 25 case Some{n}: 26 n 27 28 # the port named after the colon, when there is anything after it 29 def port.tail(ps: List<&2, String>, scheme: String) -> U32: 30 match ps: 31 case []: 32 dflt(scheme) 33 case h <> t: 34 port.num(U32.read(h), scheme) 35 36 # the port an authority asked for, or the one its scheme implies 37 def port.of(ps: List<&2, String>, scheme: String) -> U32: 38 match ps: 39 case []: 40 dflt(scheme) 41 case h <> t: 42 port.tail(t, scheme) 43 44 # the host an authority named: everything before the colon 45 def host.of(ps: List<&2, String>) -> String: 46 match ps: 47 case []: 48 "" 49 case h <> t: 50 h 51 52 # the path a url asked for, "/" when it named none. The leading slash ended 53 # the authority, so it is put back. Absolute-path form matches RFC 3986 54 # path-absolute / path-abempty used with an authority (§3.3). 55 def path.of(ps: List<&2, String>) -> String: 56 match ps: 57 case []: 58 "/" 59 case h <> t: 60 "/" ++ String.join(t, "/") 61 62 # the last piece, or "" when the split was empty 63 def auth.last.of(m: Maybe<&2, String>) -> String: 64 match m: 65 case None{}: 66 "" 67 case Some{hostport}: 68 hostport 69 70 # the last `@`-separated piece (the hostport). Earlier pieces are userinfo. 71 def auth.last(ps: List<&2, String>) -> String: 72 auth.last.of(List.last(&2, String, ps)) 73 74 # userinfo is stripped: `user:pass@host` yields `host` (RFC 3986 §3.2.1) 75 def auth.host(authority: String) -> String: 76 auth.last(String.split(authority, '@')) 77 78 # the first piece of a `#` split 79 def frag.drop.of(ps: List<&2, String>) -> String: 80 match ps: 81 case []: 82 "" 83 case hier <> _: 84 hier 85 86 # everything before `#` (RFC 3986 §3.5). The fragment is not a request-target. 87 def frag.drop(s: String) -> String: 88 frag.drop.of(String.split(s, '#')) 89 90 # the hier-part before `?` 91 def query.hier(ps: List<&2, String>) -> String: 92 match ps: 93 case []: 94 "" 95 case hier <> _: 96 hier 97 98 # the query after the first `?`, with later `?` kept (RFC 3986 §3.4) 99 def query.text(ps: List<&2, String>) -> String: 100 match ps: 101 case []: 102 "" 103 case _ <> rest: 104 String.join(rest, "?") 105 106 # origin-form path: absolute-path, then `?` query when the query is present 107 def path.target.of(path: String, query: String, empty: Bool) -> String: 108 match empty: 109 case True{}: 110 path 111 case False{}: 112 path ++ "?" ++ query 113 114 # absolute-path ["?" query] (RFC 3986 §3.4, RFC 9112 §3.2.1) 115 def path.target(path: String, +query: String) -> String: 116 path.target.of(path, query, String.eq(query, "")) 117 118 # a url whose authority is now split on its colon 119 def parse.auth(+scheme: String, +au: List<&2, String>, path: String) -> Loc: 120 Loc{scheme, host.of(au), port.of(au, scheme), path} 121 122 # authority (userinfo stripped) and origin-form path 123 def parse.parts(+scheme: String, +ps: List<&2, String>, query: String) -> Loc: 124 parse.auth(scheme, String.split(auth.host(host.of(ps)), ':'), 125 path.target(path.of(ps), query)) 126 127 # hier-part split on `?` 128 def parse.q(+scheme: String, +ps: List<&2, String>) -> Loc: 129 parse.parts(scheme, String.split(query.hier(ps), '/'), query.text(ps)) 130 131 # fragment stripped, then query split from the hier-part 132 def parse.hier(+scheme: String, +rest: String) -> Loc: 133 parse.q(scheme, String.split(frag.drop(rest), '?')) 134 135 # what follows the scheme, once it is known whether it opens with `//` 136 def parse.body(+scheme: String, +rest: String, slashes: Bool) -> Loc: 137 match slashes: 138 case False{}: 139 Bad{scheme ++ ":" ++ rest ++ " has no //"} 140 case True{}: 141 parse.hier(scheme, String.drop(rest, 2n)) 142 143 # what follows the scheme, judged on its `//` 144 def parse.rest(+scheme: String, +rest: String) -> Loc: 145 parse.body(scheme, rest, String.starts_with(rest, "//")) 146 147 # a url split on its colons: the first piece is the scheme, and a colon in the 148 # path survives because the rest is joined back up 149 def parse.scheme(ps: List<&2, String>) -> Loc: 150 match ps: 151 case []: 152 Bad{"an empty url"} 153 case h <> t: 154 parse.rest(String.to_lower(h), String.join(t, ":")) 155 156 # a url in pieces, or the reason it is not one 157 def parse(url: String) -> Loc: 158 parse.scheme(String.split(url, ':')) 159 160 # a parsed url on one line, for a test to read 161 def show(l: Loc) -> String: 162 match l: 163 case Bad{why}: 164 "bad " ++ why 165 case Loc{scheme, host, port, path}: 166 scheme ++ " " ++ host ++ " " ++ U32.show(port) ++ " " ++ path