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