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