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