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 bend-kit-bytes@0.3.2.0/bytes.bend as Bytes 6 import ./url.bend as Url 7 import ./http.bend as Http 8 import ./body.bend as Body 9 import ./wire.bend as Wire 10 import ./wirespec.bend as Spec 11 import ./redirect.bend as Redirect 12 import ./auth.bend as Auth 13 import ./qs.bend as Qs 14 import ./packed.bend as Packed 15 16 # structured client result: transport/parse error, or an HTTP response: its 17 # status, its header fields and reason phrase as text (decoded from the octets 18 # received as UTF-8, SPEC.md HTTP-BODY-4, HTTP-CLI-11), and its body's octets 19 # as they arrived, packed (`client.text` decodes them when asked) 20 type Response is Type: # noqa: L001 the wire effect keeps src/client.bend out of law files (HTTP-TRUST-5) 21 Err{why: String} 22 Response{status: U32, headers: List<&2, Http.Header>, reason: String, 23 bytes: Bytes.Bytes} 24 25 # a body's octets decoded as UTF-8 (SPEC.md HTTP-BODY-4) 26 def client.text(b: Bytes.Bytes) -> String: 27 Qs.octets.text(Bytes.to_string(b)) 28 29 # a response read off its octets as a client Response, its head's octet text 30 # decoded 31 def from.got(g: Packed.Got) -> Response: 32 match g: 33 case Packed.Torn{why}: 34 Err{why} 35 case Packed.Got{status, headers, reason, body}: 36 Response{status, Qs.octets.headers(headers), Qs.octets.text(reason), body} 37 38 # an exchange's answer as a client Response, read by the request's method 39 # (packed.bend packed.reply: interim 1xx heads skipped, no body for HEAD, 1xx, 40 # 204 or 304); a failed exchange keeps its reason (wirespec.bend wire.failed) 41 def http.answer(+method: String, a: Wire.Answer) -> Response: 42 match a: 43 case Wire.Failed{answer}: 44 Err{Spec.wire.failed(answer)} 45 case Wire.Received{octets}: 46 from.got(Packed.packed.reply(method, octets)) 47 48 # an exchange as wirespec.bend wrote it, with the body's octets after it: 49 # sent and read by its own method, or the URL's reason 50 def http.send(ex: Spec.Exchange, tail: Bytes.Bytes) -> IO(Response): 51 match ex: 52 case Spec.NoUrl{why}: 53 IO.pure(Response, Err{why}) 54 case Spec.Exchange{secure, host, port, +method, req}: 55 do IO: 56 a : Wire.Answer <- Wire.wire.talk(secure, host, port, method, req, tail) 57 IO.pure(Response, http.answer(method, a)) 58 59 # an exchange for a body of this shape, sent with the body's octets 60 def http.loc.shaped(method: String, l: Url.Loc, 61 headers: List<&2, Http.Header>, r: Body.Shape & Bytes.Bytes) 62 -> IO(Response): 63 (shape, tail) = r 64 http.send(Spec.wire.exchange.body(method, l, headers, shape), tail) 65 66 # a URL that came apart (or not) into an HTTP exchange with this body 67 def http.loc(method: String, l: Url.Loc, headers: List<&2, Http.Header>, 68 body: Body.Body) -> IO(Response): 69 http.loc.shaped(method, l, headers, Body.body.shape(body)) 70 71 # an exchange as wirespec.bend wrote it, bounded by a timeout, sent and read 72 # by its own method 73 def http.send.timed(ms: U32, ex: Spec.Exchange, tail: Bytes.Bytes) 74 -> IO(Response): 75 match ex: 76 case Spec.NoUrl{why}: 77 IO.pure(Response, Err{why}) 78 case Spec.Exchange{secure, host, port, +method, req}: 79 do IO: 80 a : Wire.Answer <- Wire.wire.talk.timed(secure, ms, host, port, method, 81 req, tail) 82 IO.pure(Response, http.answer(method, a)) 83 84 # a timed exchange for a body of this shape 85 def client.timed.shaped(method: String, url: String, 86 headers: List<&2, Http.Header>, ms: U32, r: Body.Shape & Bytes.Bytes) 87 -> IO(Response): 88 (shape, tail) = r 89 http.send.timed(ms, Spec.wire.exchange.body(method, Url.parse(url), headers, 90 shape), tail) 91 92 # a request with this method, headers and body, bounded by a timeout in 93 # milliseconds 94 def client.timed(method: String, url: String, headers: List<&2, Http.Header>, 95 body: Body.Body, ms: U32) -> IO(Response): 96 client.timed.shaped(method, url, headers, ms, Body.body.shape(body)) 97 98 # GET bounded by a timeout in milliseconds 99 def client.get.timed(url: String, headers: List<&2, Http.Header>, ms: U32) 100 -> IO(Response): 101 client.timed("GET", url, headers, Body.Empty{}, ms) 102 103 # the next hop after a response (redirect.bend), and the response; a failed 104 # exchange stops 105 def client.hop(left: Nat, url: String, r: Response) -> Redirect.Hop & Response: 106 match r: 107 case Err{why}: 108 (Redirect.Stop{}, Err{why}) 109 case Response{+status, +headers, reason, bytes}: 110 (Redirect.redirect.plan(left, url, status, headers), 111 Response{status, headers, reason, bytes}) 112 113 # a response, or the GET its hop asks for 114 def client.after(p: Redirect.Hop & Response, go: String -> IO(Response)) 115 -> IO(Response): 116 (hop, r) = p 117 match hop: 118 case Redirect.Stop{}: 119 IO.pure(Response, r) 120 case Redirect.Go{next}: 121 go(next) 122 123 # GET, following redirects while `left` hops remain; `body` goes with this 124 # hop only. The answer is the last response, which is a redirect only when 125 # the hops ran out or its Location is not one redirect.bend follows 126 def client.follow(+left: Nat, +url: String, +headers: List<&2, Http.Header>, 127 body: Body.Body, +ms: U32) -> IO(Response): 128 match left: 129 case 0n: 130 client.timed("GET", url, headers, body, ms) 131 case 1n+p: 132 do IO: 133 r : Response <- client.timed("GET", url, headers, body, ms) 134 client.after(client.hop(left, url, r), 135 next => client.follow(p, next, headers, Body.Empty{}, ms)) 136 137 # a request once it is known whether it follows redirects 138 def client.request.go(go: Bool, method: String, url: String, 139 headers: List<&2, Http.Header>, body: Body.Body, ms: U32) -> IO(Response): 140 match go: 141 case True{}: 142 client.follow(Redirect.redirect.max(), url, headers, body, ms) 143 case False{}: 144 client.timed(method, url, headers, body, ms) 145 146 # a request with a method, headers, body and timeout; it follows redirects 147 # when `follow` is set and the method is GET (redirect.bend 148 # redirect.follows), and answers its first response otherwise 149 def client.request.opts(+method: String, url: String, 150 headers: List<&2, Http.Header>, body: Body.Body, ms: U32, follow: Bool) 151 -> IO(Response): 152 client.request.go(Redirect.redirect.follows(method, follow), method, url, 153 headers, body, ms) 154 155 # GET with headers and a timeout, following redirects when `follow` is set 156 def client.get.opts(url: String, headers: List<&2, Http.Header>, ms: U32, 157 follow: Bool) -> IO(Response): 158 client.request.opts("GET", url, headers, Body.Empty{}, ms, follow) 159 160 # whether a response is the failure of an exchange that ran out of time 161 def client.timed_out(r: Response) -> Bool: 162 match r: 163 case Err{why}: 164 Spec.wire.timed_out(why) 165 case Response{status, headers, reason, bytes}: 166 False{} 167 168 # GET 169 def client.get(url: String, headers: List<&2, Http.Header>) -> IO(Response): 170 http.loc("GET", Url.parse(url), headers, Body.Empty{}) 171 172 # HEAD (RFC 9110 §9.3.2) 173 def client.head(url: String, headers: List<&2, Http.Header>) -> IO(Response): 174 http.loc("HEAD", Url.parse(url), headers, Body.Empty{}) 175 176 # POST 177 def client.post(url: String, headers: List<&2, Http.Header>, body: Body.Body) 178 -> IO(Response): 179 http.loc("POST", Url.parse(url), headers, body) 180 181 # PUT 182 def client.put(url: String, headers: List<&2, Http.Header>, body: Body.Body) 183 -> IO(Response): 184 http.loc("PUT", Url.parse(url), headers, body) 185 186 # DELETE 187 def client.delete(url: String, headers: List<&2, Http.Header>, body: Body.Body) 188 -> IO(Response): 189 http.loc("DELETE", Url.parse(url), headers, body) 190 191 # OPTIONS 192 def client.options(url: String, headers: List<&2, Http.Header>) -> IO(Response): 193 http.loc("OPTIONS", Url.parse(url), headers, Body.Empty{}) 194 195 # Authorization: Bearer … (auth.bend) 196 def client.bearer(token: String) -> Http.Header: 197 Auth.auth.bearer(token) 198 199 # Authorization: Basic … (auth.bend; RFC 7617, RFC 4648 Base64) 200 def client.basic(user: String, password: String) -> Http.Header: 201 Auth.auth.basic(user, password)