src/url.bend

emerging-ezhttp@0.20.0.0 · 166 lines · 0 definitions

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).
8import Base
9
10# a url that came apart, or the reason it would not
11type 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)
17def 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
21def port.num(got: Maybe<&2, U32>, scheme: String) -> U32:
22  match got:
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
29def 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
37def 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
45def 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).
55def 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
63def auth.last.of(last: Maybe<&2, String>) -> String:
64  match last:
65    case None{}:
66      ""
67    case Some{hostport}:
68      hostport
69
70# the last `@`-separated piece (the hostport). Earlier pieces are userinfo.
71def 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)
75def auth.host(authority: String) -> String:
76  auth.last(String.split(authority, '@'))
77
78# the first piece of a `#` split
79def 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.
87def frag.drop(s: String) -> String:
88  frag.drop.of(String.split(s, '#'))
89
90# the hier-part before `?`
91def 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)
99def 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
107def 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)
115def 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
119def 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
123def 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 `?`
128def 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
132def 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 `//`
136def 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 `//`
144def 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
149def 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
157def parse(url: String) -> Loc:
158  parse.scheme(String.split(url, ':'))
159
160# a parsed url on one line, for a test to read
161def 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