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