1 # ezhttp/src/client: fetch a URL. URL and HTTP parsing joined to the wire effect. 2 # Answers a structured Response (status + headers + body), not a hub-style 3 # "0\n…" string contract. 4 import Base 5 import ./url.bend as Url 6 import ./http.bend as Http 7 import ./body.bend as Body 8 import ./wire.bend as Wire 9 import ./wirespec.bend as Spec 10 import ./redirect.bend as Redirect 11 import ./auth.bend as Auth 12 13 # structured client result: transport/parse error, or an HTTP response 14 type Response is Data: 15 Err{why: String} 16 Response{status: U32, headers: List<&2, Http.Header>, body: String} 17 18 # a torn or successful HTTP parse as a client Response 19 def from.reply(r: Http.Reply) -> Response: 20 match r: 21 case Http.Torn{why}: 22 Err{why} 23 case Http.Reply{status, headers, body}: 24 Response{status, headers, body} 25 26 # an exchange's answer as a client Response, read by the request's method 27 # (wirespec.bend wire.heard: interim 1xx heads skipped, no body for HEAD, 28 # 1xx, 204 or 304; a failed exchange keeps its reason) 29 def http.heard(+method: String, +raw: String) -> Response: 30 from.reply(Spec.wire.heard(method, raw)) 31 32 # an exchange as wirespec.bend wrote it, with the body's tail of octets: sent 33 # and read by its own method, or the URL's reason 34 def http.send(ex: Spec.Exchange, tail: List<&2, U32>) -> IO(Response): 35 match ex: 36 case Spec.NoUrl{why}: 37 IO.pure(Response, Err{why}) 38 case Spec.Exchange{secure, host, port, +method, req}: 39 do IO: 40 +raw : String <- Wire.wire.talk(secure, host, port, method, req, tail) 41 IO.pure(Response, http.heard(method, raw)) 42 43 # a URL that came apart (or not) into an HTTP exchange with this body 44 def http.loc(method: String, l: Url.Loc, headers: List<&2, Http.Header>, 45 +body: Body.Body) -> IO(Response): 46 http.send(Spec.wire.exchange.body(method, l, headers, body), 47 Spec.wire.tail(body)) 48 49 # an exchange as wirespec.bend wrote it, bounded by a timeout, sent and read 50 # by its own method 51 def http.send.timed(ms: U32, ex: Spec.Exchange, tail: List<&2, U32>) 52 -> IO(Response): 53 match ex: 54 case Spec.NoUrl{why}: 55 IO.pure(Response, Err{why}) 56 case Spec.Exchange{secure, host, port, +method, req}: 57 do IO: 58 +raw : String <- Wire.wire.talk.timed(secure, ms, host, port, method, 59 req, tail) 60 IO.pure(Response, http.heard(method, raw)) 61 62 # a request with this method, headers and body, bounded by a timeout in 63 # milliseconds 64 def client.timed(method: String, url: String, headers: List<&2, Http.Header>, 65 +body: Body.Body, ms: U32) -> IO(Response): 66 http.send.timed(ms, Spec.wire.exchange.body(method, Url.parse(url), headers, 67 body), Spec.wire.tail(body)) 68 69 # GET bounded by a timeout in milliseconds 70 def client.get.timed(url: String, headers: List<&2, Http.Header>, ms: U32) 71 -> IO(Response): 72 client.timed("GET", url, headers, Body.Empty{}, ms) 73 74 # the next hop after a response (redirect.bend); a failed exchange stops 75 def client.hop(left: Nat, url: String, r: Response) -> Redirect.Hop: 76 match r: 77 case Err{why}: 78 Redirect.Stop{} 79 case Response{status, headers, body}: 80 Redirect.redirect.plan(left, url, status, headers) 81 82 # a response, or the GET its hop asks for 83 def client.after(hop: Redirect.Hop, r: Response, go: String -> IO(Response)) 84 -> IO(Response): 85 match hop: 86 case Redirect.Stop{}: 87 IO.pure(Response, r) 88 case Redirect.Go{next}: 89 go(next) 90 91 # GET, following redirects while `left` hops remain; `body` goes with this 92 # hop only. The answer is the last response, which is a redirect only when 93 # the hops ran out or its Location is not one redirect.bend follows 94 def client.follow(+left: Nat, +url: String, +headers: List<&2, Http.Header>, 95 body: Body.Body, +ms: U32) -> IO(Response): 96 match left: 97 case 0n: 98 client.timed("GET", url, headers, body, ms) 99 case 1n+p: 100 do IO: 101 +r : Response <- client.timed("GET", url, headers, body, ms) 102 client.after(client.hop(left, url, r), r, 103 next => client.follow(p, next, headers, Body.Empty{}, ms)) 104 105 # a request once it is known whether it follows redirects 106 def client.request.go(go: Bool, method: String, url: String, 107 headers: List<&2, Http.Header>, body: Body.Body, ms: U32) -> IO(Response): 108 match go: 109 case True{}: 110 client.follow(Redirect.redirect.max(), url, headers, body, ms) 111 case False{}: 112 client.timed(method, url, headers, body, ms) 113 114 # a request with a method, headers, body and timeout; it follows redirects 115 # when `follow` is set and the method is GET (redirect.bend 116 # redirect.follows), and answers its first response otherwise 117 def client.request.opts(+method: String, url: String, 118 headers: List<&2, Http.Header>, body: Body.Body, ms: U32, follow: Bool) 119 -> IO(Response): 120 client.request.go(Redirect.redirect.follows(method, follow), method, url, 121 headers, body, ms) 122 123 # GET with headers and a timeout, following redirects when `follow` is set 124 def client.get.opts(url: String, headers: List<&2, Http.Header>, ms: U32, 125 follow: Bool) -> IO(Response): 126 client.request.opts("GET", url, headers, Body.Empty{}, ms, follow) 127 128 # whether a response is the failure of an exchange that ran out of time 129 def client.timed_out(r: Response) -> Bool: 130 match r: 131 case Err{why}: 132 Spec.wire.timed_out(why) 133 case Response{status, headers, body}: 134 False{} 135 136 # GET 137 def client.get(url: String, headers: List<&2, Http.Header>) -> IO(Response): 138 http.loc("GET", Url.parse(url), headers, Body.Empty{}) 139 140 # HEAD (RFC 9110 §9.3.2) 141 def client.head(url: String, headers: List<&2, Http.Header>) -> IO(Response): 142 http.loc("HEAD", Url.parse(url), headers, Body.Empty{}) 143 144 # POST 145 def client.post(url: String, headers: List<&2, Http.Header>, body: Body.Body) 146 -> IO(Response): 147 http.loc("POST", Url.parse(url), headers, body) 148 149 # PUT 150 def client.put(url: String, headers: List<&2, Http.Header>, body: Body.Body) 151 -> IO(Response): 152 http.loc("PUT", Url.parse(url), headers, body) 153 154 # DELETE 155 def client.delete(url: String, headers: List<&2, Http.Header>, body: Body.Body) 156 -> IO(Response): 157 http.loc("DELETE", Url.parse(url), headers, body) 158 159 # OPTIONS 160 def client.options(url: String, headers: List<&2, Http.Header>) -> IO(Response): 161 http.loc("OPTIONS", Url.parse(url), headers, Body.Empty{}) 162 163 # Authorization: Bearer … (auth.bend) 164 def client.bearer(token: String) -> Http.Header: 165 Auth.auth.bearer(token) 166 167 # Authorization: Basic … (auth.bend; RFC 7617, RFC 4648 Base64) 168 def client.basic(user: String, password: String) -> Http.Header: 169 Auth.auth.basic(user, password)