src/redirect.bend
emerging-ezhttp@0.11.0.0 · 99 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 either an absolute http or https URL, taken as it is,
8# or an absolute path, resolved against the current URL's scheme, host and
9# port. Any other Location (a relative path, `//host/...`, empty) stops. The
10# client gives a chain at most `redirect.max()` hops.
11import Base
12import emerging-ezhttp@0.11.0.0/src/url.bend as Url
13import emerging-ezhttp@0.11.0.0/src/http.bend as Http
14
15# what the client does after a response
16type Hop is Data:
17 Stop{}
18 Go{url: String}
19
20# the most hops the client follows (httpx's default is 20)
21def redirect.max() -> Nat:
22 10n
23
24# whether a status asks the client to go elsewhere (RFC 9110 §15.4.2-9)
25def redirect.is(+status: U32) -> Bool:
26 Bool.or(U32.is_eq(status, 301), Bool.or(U32.is_eq(status, 302),
27 Bool.or(U32.is_eq(status, 303), Bool.or(U32.is_eq(status, 307),
28 U32.is_eq(status, 308)))))
29
30# whether a Location is an absolute http or https URL
31def redirect.absolute(+loc: String) -> Bool:
32 Bool.or(String.starts_with(loc, "http://"),
33 String.starts_with(loc, "https://"))
34
35# whether a Location is an absolute path: one `/`, not two
36def redirect.path(+loc: String) -> Bool:
37 Bool.and(String.starts_with(loc, "/"),
38 Bool.not(String.starts_with(loc, "//")))
39
40# the origin of a URL that came apart, with an absolute path after it
41def redirect.join(l: Url.Loc, loc: String) -> Maybe<&2, String>:
42 match l:
43 case Url.Bad{why}:
44 None{}
45 case Url.Loc{scheme, host, port, path}:
46 Some{scheme ++ "://" ++ host ++ ":" ++ U32.show(port) ++ loc}
47
48# a Location once it is known whether it is an absolute path
49def redirect.target.path(url: String, loc: String, path: Bool)
50 -> Maybe<&2, String>:
51 match path:
52 case True{}:
53 redirect.join(Url.parse(url), loc)
54 case False{}:
55 None{}
56
57# a Location once it is known whether it is an absolute URL
58def redirect.target.of(url: String, +loc: String, absolute: Bool)
59 -> Maybe<&2, String>:
60 match absolute:
61 case True{}:
62 Some{loc}
63 case False{}:
64 redirect.target.path(url, loc, redirect.path(loc))
65
66# where a Location sends a client that is at `url`, or None when it is not
67# one the client follows
68def redirect.target(url: String, +loc: String) -> Maybe<&2, String>:
69 redirect.target.of(url, loc, redirect.absolute(loc))
70
71# a resolved target as a hop
72def redirect.hop(m: Maybe<&2, String>) -> Hop:
73 match m:
74 case None{}:
75 Stop{}
76 case Some{next}:
77 Go{next}
78
79# a response once it is known whether its status redirects
80def redirect.on(url: String, +loc: String, moved: Bool) -> Hop:
81 match moved:
82 case True{}:
83 redirect.hop(redirect.target(url, loc))
84 case False{}:
85 Stop{}
86
87# the next hop from `url`, after a response with `status` and Location `loc`
88# ("" when it had none), with `left` hops still allowed
89def redirect.next(left: Nat, url: String, +status: U32, +loc: String) -> Hop:
90 match left:
91 case 0n:
92 Stop{}
93 case 1n+p:
94 redirect.on(url, loc, redirect.is(status))
95
96# the next hop after a response's status and headers
97def redirect.plan(left: Nat, url: String, +status: U32,
98 headers: List<&2, Http.Header>) -> Hop:
99 redirect.next(left, url, status, Http.headers.find(headers, "location"))