src/redirect.bend
emerging-ezhttp@0.12.0.0 · 298 lines · 0 definitions
1# ezhttp/src/redirect: where a GET goes after a response, pure. The client's
2# follow loop (client.bend) sends the next GET where this module says and
3# decides nothing itself. SPEC.md HTTP-REDIR lists what is guaranteed.
4#
5# Policy: the client follows redirects for GET only, so every hop is a GET.
6# 301, 302, 303, 307 and 308 with a Location are followed; any other status
7# stops. A Location is resolved against the current URL as RFC 3986 §5.2
8# resolves a reference: an absolute http or https URL is taken as it is, a
9# network-path (`//host/...`) takes the current scheme, an absolute path the
10# current origin, a query the current path, and a relative path is merged
11# with the current path's directory; dot segments are removed. An empty
12# Location, a fragment, or another scheme stops. The client gives a chain at
13# most `redirect.max()` hops.
14import Base
15import emerging-ezhttp@0.12.0.0/src/url.bend as Url
16import emerging-ezhttp@0.12.0.0/src/http.bend as Http
17
18# what the client does after a response
19type Hop is Data:
20 Stop{}
21 Go{url: String}
22
23# the most hops the client follows (httpx's default is 20)
24def redirect.max() -> Nat:
25 10n
26
27# whether a status asks the client to go elsewhere (RFC 9110 §15.4.2-9)
28def redirect.is(+status: U32) -> Bool:
29 Bool.or(U32.is_eq(status, 301), Bool.or(U32.is_eq(status, 302),
30 Bool.or(U32.is_eq(status, 303), Bool.or(U32.is_eq(status, 307),
31 U32.is_eq(status, 308)))))
32
33# whether a Location is an absolute http or https URL
34def redirect.absolute(+loc: String) -> Bool:
35 Bool.or(String.starts_with(loc, "http://"),
36 String.starts_with(loc, "https://"))
37
38# --- dot segments (RFC 3986 §5.2.4) ---
39
40# the segments kept so far, last first, with the last one dropped
41def redirect.pop(out: List<&2, String>) -> List<&2, String>:
42 match out:
43 case []:
44 []
45 case _ <> t:
46 t
47
48# a path that ends in `.` or `..` keeps its trailing `/`: an empty last segment
49def redirect.dots.end(last: Bool, out: List<&2, String>) -> List<&2, String>:
50 match last:
51 case True{}:
52 "" <> out
53 case False{}:
54 out
55
56# a segment that is not `.`: `..` drops the last kept, any other is kept
57def redirect.dots.up(up: Bool, last: Bool, s: String, out: List<&2, String>,
58 rest: List<&2, String> -> List<&2, String>) -> List<&2, String>:
59 match up:
60 case True{}:
61 rest(redirect.dots.end(last, redirect.pop(out)))
62 case False{}:
63 rest(s <> out)
64
65# one segment: `.` is dropped, `..` drops the last kept, any other is kept
66def redirect.dots.one(dot: Bool, up: Bool, last: Bool, s: String,
67 out: List<&2, String>, rest: List<&2, String> -> List<&2, String>)
68 -> List<&2, String>:
69 match dot:
70 case True{}:
71 rest(redirect.dots.end(last, out))
72 case False{}:
73 redirect.dots.up(up, last, s, out, rest)
74
75# a path's segments with dot segments removed, `out` holding those kept so
76# far, last first
77def redirect.dots(ss: List<&2, String>, out: List<&2, String>)
78 -> List<&2, String>:
79 match ss:
80 case []:
81 List.reverse(&2, String, out)
82 case +s <> +t:
83 redirect.dots.one(String.eq(s, "."), String.eq(s, ".."),
84 List.is_empty(&2, String, t), s, out, o => redirect.dots(t, o))
85
86# the segments after a path's leading `/`, dot segments removed, as a path
87def redirect.clean(ss: List<&2, String>) -> String:
88 "/" ++ String.join(redirect.dots(ss, []), "/")
89
90# --- merging (RFC 3986 §5.2.3) ---
91
92# every segment but the last, after `s`
93def redirect.init.of(t: List<&2, String>, s: String) -> List<&2, String>:
94 match t:
95 case []:
96 []
97 case u <> r:
98 s <> redirect.init.of(r, u)
99
100# every segment but the last
101def redirect.init(ss: List<&2, String>) -> List<&2, String>:
102 match ss:
103 case []:
104 []
105 case s <> t:
106 redirect.init.of(t, s)
107
108# --- references ---
109
110# a Location's path and its query (with its `?`, "" when it has none)
111type Ref is Data:
112 Ref{path: String, query: String}
113
114# a Location divided at its first `?`
115def redirect.split.of(c: Http.Cut, loc: String) -> Ref:
116 match c:
117 case Http.NoCut{}:
118 Ref{loc, ""}
119 case Http.Cut{before, after}:
120 Ref{before, "?" ++ after}
121
122# a Location's path and query, its fragment dropped
123def redirect.split(+loc: String) -> Ref:
124 +bare = Url.frag.drop(loc)
125 redirect.split.of(Http.divide(bare, "?"), bare)
126
127# an absolute-path reference's path, dot segments removed, then its query
128def redirect.abs.of(r: Ref) -> String:
129 match r:
130 case Ref{path, query}:
131 redirect.clean(List.tail(&2, String, String.split(path, '/'))) ++ query
132
133# a relative-path reference merged with the current path, dot segments
134# removed, then its query
135def redirect.rel.of(base: String, r: Ref) -> String:
136 match r:
137 case Ref{path, query}:
138 redirect.clean(List.tail(&2, String,
139 List.append(&2, String, redirect.init(String.split(base, '/')),
140 String.split(path, '/')))) ++ query
141
142# a path without its query
143def redirect.base(path: String) -> String:
144 Url.query.hier(String.split(path, '?'))
145
146# --- which sort of reference a Location is ---
147
148# what sort of reference a Location that is not an absolute URL is
149type Sort is Data:
150 Net{}
151 Abs{}
152 Stay{}
153 Query{}
154 Rel{}
155
156# a character that ends a reference's first segment
157def redirect.schemed.end(+c: Char) -> Bool:
158 Bool.or(Char.is_eq(c, '/'), Bool.or(Char.is_eq(c, '?'), Char.is_eq(c, '#')))
159
160# a character that does not end the first segment: a `:` names a scheme
161def redirect.schemed.colon(colon: Bool, rest: Unit -> Bool) -> Bool:
162 match colon:
163 case True{}:
164 True{}
165 case False{}:
166 rest(Unit{})
167
168# one character of the scheme search
169def redirect.schemed.at(stop: Bool, colon: Bool, rest: Unit -> Bool) -> Bool:
170 match stop:
171 case True{}:
172 False{}
173 case False{}:
174 redirect.schemed.colon(colon, rest)
175
176# whether a reference names a scheme: a `:` before its first `/`, `?` or `#`
177def redirect.schemed(s: String) -> Bool:
178 match s:
179 case SNil{}:
180 False{}
181 case SCon{+c, t}:
182 redirect.schemed.at(redirect.schemed.end(c), Char.is_eq(c, ':'),
183 _u => redirect.schemed(t))
184
185# a Location the client does not follow: empty, a fragment, another scheme
186def redirect.stays(+loc: String) -> Bool:
187 Bool.or(String.is_empty(loc), Bool.or(String.starts_with(loc, "#"),
188 redirect.schemed(loc)))
189
190# a relative reference: a query, or a relative path
191def redirect.sort.rel(query: Bool) -> Sort:
192 match query:
193 case True{}:
194 Query{}
195 case False{}:
196 Rel{}
197
198# a reference that is not a path from the root: one the client stays on, or
199# a relative one
200def redirect.sort.stay(stay: Bool, query: Bool) -> Sort:
201 match stay:
202 case True{}:
203 Stay{}
204 case False{}:
205 redirect.sort.rel(query)
206
207# a reference that is not a network path: an absolute path, or another
208def redirect.sort.abs(abs: Bool, stay: Bool, query: Bool) -> Sort:
209 match abs:
210 case True{}:
211 Abs{}
212 case False{}:
213 redirect.sort.stay(stay, query)
214
215# the sort, from what the Location starts with
216def redirect.sort.of(net: Bool, abs: Bool, stay: Bool, query: Bool) -> Sort:
217 match net:
218 case True{}:
219 Net{}
220 case False{}:
221 redirect.sort.abs(abs, stay, query)
222
223# what sort of reference a Location is
224def redirect.sort(+loc: String) -> Sort:
225 redirect.sort.of(String.starts_with(loc, "//"), String.starts_with(loc, "/"),
226 redirect.stays(loc), String.starts_with(loc, "?"))
227
228# --- the target ---
229
230# a Location of a known sort against the current URL's parts
231def redirect.against(scheme: String, host: String, port: U32, path: String,
232 +loc: String, k: Sort) -> Maybe<&2, String>:
233 match k:
234 case Net{}:
235 Some{scheme ++ ":" ++ loc}
236 case Abs{}:
237 Some{scheme ++ "://" ++ host ++ ":" ++ U32.show(port)
238 ++ redirect.abs.of(redirect.split(loc))}
239 case Stay{}:
240 None{}
241 case Query{}:
242 Some{scheme ++ "://" ++ host ++ ":" ++ U32.show(port)
243 ++ redirect.base(path) ++ loc}
244 case Rel{}:
245 Some{scheme ++ "://" ++ host ++ ":" ++ U32.show(port)
246 ++ redirect.rel.of(redirect.base(path), redirect.split(loc))}
247
248# a Location against the current URL, once it is parsed
249def redirect.at(l: Url.Loc, +loc: String) -> Maybe<&2, String>:
250 match l:
251 case Url.Bad{why}:
252 None{}
253 case Url.Loc{scheme, host, port, path}:
254 redirect.against(scheme, host, port, path, loc, redirect.sort(loc))
255
256# a Location once it is known whether it is an absolute URL
257def redirect.target.of(url: String, +loc: String, absolute: Bool)
258 -> Maybe<&2, String>:
259 match absolute:
260 case True{}:
261 Some{loc}
262 case False{}:
263 redirect.at(Url.parse(url), loc)
264
265# where a Location sends a client that is at `url`, or None when it is not
266# one the client follows
267def redirect.target(url: String, +loc: String) -> Maybe<&2, String>:
268 redirect.target.of(url, loc, redirect.absolute(loc))
269
270# a resolved target as a hop
271def redirect.hop(m: Maybe<&2, String>) -> Hop:
272 match m:
273 case None{}:
274 Stop{}
275 case Some{next}:
276 Go{next}
277
278# a response once it is known whether its status redirects
279def redirect.on(url: String, +loc: String, moved: Bool) -> Hop:
280 match moved:
281 case True{}:
282 redirect.hop(redirect.target(url, loc))
283 case False{}:
284 Stop{}
285
286# the next hop from `url`, after a response with `status` and Location `loc`
287# ("" when it had none), with `left` hops still allowed
288def redirect.next(left: Nat, url: String, +status: U32, +loc: String) -> Hop:
289 match left:
290 case 0n:
291 Stop{}
292 case 1n+p:
293 redirect.on(url, loc, redirect.is(status))
294
295# the next hop after a response's status and headers
296def redirect.plan(left: Nat, url: String, +status: U32,
297 headers: List<&2, Http.Header>) -> Hop:
298 redirect.next(left, url, status, Http.headers.find(headers, "location"))