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