// // Copyright (c) 2008, Brian Frank and Andy Frank // Licensed under the Academic Free License version 3.0 // // History: // 24 Dec 08 Brian Frank Almost Christmas! // 15 Jan 13 Nicholas Harker Added SSL Support // 21 Jan 13 Nicholas Harker Added Proxy Exclusion Support // 03 Aug 15 Matthew Giannini RFC6265 // using inet ** ** The 'WebClient' class is used to manage client side HTTP requests ** and responses. The basic lifecycle of WebClient: ** 1. configure request fields such as 'reqUri', 'reqMethod', and 'reqHeaders' ** 2. send request headers via 'writeReq' ** 3. optionally write request body via 'reqOut' ** 4. read response status and headers via 'readRes' ** 5. process response fields such as 'resCode' and 'resHeaders' ** 6. optionally read response body via 'resIn' ** ** Using the low level methods 'writeReq' and 'readRes' enables HTTP ** pipelining (multiple requests and responses on the same TCP socket ** connection). There are also a series of convenience methods which ** make common cases easier. ** ** See [pod doc]`pod-doc#webClient` and [examples]`examples::web-client`. ** class WebClient { ////////////////////////////////////////////////////////////////////////// // Constructor ////////////////////////////////////////////////////////////////////////// ** ** Construct with optional request URI. ** new make(Uri? reqUri := null) { if (reqUri != null) this.reqUri = reqUri // default headers reqHeaders["Accept-Encoding"] = "gzip" } ** The `inet::SocketConfig` to use for creating sockets SocketConfig socketConfig := SocketConfig.cur ////////////////////////////////////////////////////////////////////////// // Request ////////////////////////////////////////////////////////////////////////// ** ** The absolute URI of request. ** Uri reqUri := `` { set { if (!it.isAbs) throw ArgErr("Request URI not absolute: `$it`") if (it.scheme != "http" && it.scheme != "https") throw ArgErr("Request URI is not http or https: `$it`") &reqUri = it } } ** ** The HTTP method for the request. Defaults to "GET". ** Str reqMethod := "GET" { set { &reqMethod = it.upper } } ** ** HTTP version to use for request must be 1.0 or 1.1. ** Default is 1.1. ** Version reqVersion := ver11 ** ** The HTTP headers to use for the next request. This map uses ** case insensitive keys. The "Host" header is implicitly defined ** by 'reqUri' and must not be defined in this map. ** Str:Str reqHeaders := CaseInsensitiveMap<Str,Str>() // { caseInsensitive = true } ** ** Get the output stream used to write the request body. This ** stream is only available if the request headers included a ** "Content-Type" header. If an explicit "Content-Length" was ** specified then this is a fixed length output stream, otherwise ** the request is automatically configured to use a chunked ** transfer encoding. This stream should be closed once the ** content has been fully written. ** OutStream reqOut() { if (reqOutStream == null) throw IOErr("No output stream for request") return reqOutStream } ////////////////////////////////////////////////////////////////////////// // Response ////////////////////////////////////////////////////////////////////////// ** ** HTTP version returned by response. ** Version resVersion := ver11 ** ** HTTP status code returned by response. ** Int resCode ** ** HTTP status reason phrase returned by response. ** Str resPhrase := "" ** ** HTTP headers returned by response. ** Str:Str resHeaders := noHeaders ** ** Get a response header. If not found and checked ** is false then return true, otherwise throw Err. ** Str? resHeader(Str key, Bool checked := true) { val := resHeaders[key] if (val != null || !checked) return val throw Err("Missing HTTP header '$key'") } ** ** Input stream to read response content. The input stream ** will correctly handle end of stream when the content has been ** fully read. If the "Content-Length" header was specified the ** end of stream is based on the fixed number of bytes. If the ** "Transfer-Encoding" header defines a chunked encoding, then ** chunks are automatically handled. If the response has no ** content body, then throw IOErr. ** ** The response input stream is automatically configured with ** the correct character encoding if one is specified in the ** "Content-Type" response header. ** ** Also see convenience methods: `resStr` and `resBuf`. ** InStream resIn() { if (resInStream == null) throw IOErr("No input stream for response $resCode") return resInStream } ** ** Return the entire response back as an in-memory string. ** Convenience for 'resIn.readAllStr'. ** Str resStr() { return resIn.readAllStr } ** ** Return the entire response back as an in-memory byte buffer. ** Convenience for 'resIn.readAllBuf'. ** Buf resBuf() { return resIn.readAllBuf } ////////////////////////////////////////////////////////////////////////// // Cookies ////////////////////////////////////////////////////////////////////////// ** ** Cookies to pass for "Cookie" request header. If set to an empty ** list then the "Cookie" request header is removed. After a request ** has been completed if the "Set-Cookie" response header specified ** one or more cookies then this field is automatically updated with ** the server specified cookies. ** Cookie[] cookies := List.defVal { set { // save field &cookies = it // set reqHeaders (RFC 6265 ยง 4.2.1) if (it.isEmpty) { reqHeaders.remove("Cookie"); return } reqHeaders["Cookie"] = it.size == 1 ? it.first.toNameValStr : it.join("; ") |c| { c.toNameValStr } } } ////////////////////////////////////////////////////////////////////////// // Networking ////////////////////////////////////////////////////////////////////////// @Deprecated { msg = "Use WebClient.socketConfig to configure sockets" } once SocketOptions socketOptions() { TcpSocket().options } ** ** When set to true a 3xx response with a Location header ** will automatically update the `reqUri` field and retry the ** request using the alternate URI. Redirects are not followed ** if the request has a content body. ** Bool followRedirects := true ////////////////////////////////////////////////////////////////////////// // Proxy Support ////////////////////////////////////////////////////////////////////////// ** ** If non-null, then all requests are routed through this ** proxy address (host and port). Default is configured ** in "etc/web/config.props" with the key "proxy". Proxy ** exceptions can be configured with the "proxy.exceptions" ** config key as comma separated list of Regex globs. ** Uri? proxy := proxyDef private static Uri? proxyDef() { try return Uri.toUri(WebClient#.pod.config("proxy")) catch (Err e) e.trace return null } private Bool isProxy(Uri uri) { proxy != null && !proxyExceptions.any |re| { re.matches(uri.host.toStr) } } private once Regex[] proxyExceptions() { try return WebClient#.pod.config("proxy.exceptions")?.split(',')?.map |tok->Regex| { Regex.glob(tok) } ?: Regex[,] catch (Err e) e.trace return Regex[,] } ////////////////////////////////////////////////////////////////////////// // Authentication ////////////////////////////////////////////////////////////////////////// ** ** Authenticate request using HTTP Basic with given username ** and password. ** This authBasic(Str username, Str password) { enc := "${username}:${password}".toBuf.toBase64 reqHeaders["Authorization"] = "Basic ${enc}" return this } ////////////////////////////////////////////////////////////////////////// // Get ////////////////////////////////////////////////////////////////////////// ** ** Make a GET request and return the response content as ** an in-memory string. The web client is automatically closed. ** Throw IOErr is response is not 200. ** Str getStr() { try return getIn.readAllStr finally close } ** ** Make a GET request and return the response content as ** an in-memory byte buffer. The web client is automatically closed. ** Throw IOErr is response is not 200. ** Buf getBuf() { try return getIn.readAllBuf finally close } ** ** Make a GET request and return the input stream to the ** response or throw IOErr if response is not 200. It is the ** caller's responsibility to close this web client. ** InStream getIn() { reqMethod = "GET" writeReq readRes if (resCode != 200) throw IOErr("Bad HTTP response $resCode $resPhrase") return resIn } ////////////////////////////////////////////////////////////////////////// // Post/Patch ////////////////////////////////////////////////////////////////////////// ** ** Convenience for 'writeForm("POST", form).readRes' ** This postForm([Str:Str] form) { writeForm("POST", form).readRes } ** ** Convenience for 'writeStr("POST", content).readRes' ** This postStr(Str content) { writeStr("POST", content).readRes } ** ** Convenience for 'writeFile("POST", file).readRes' ** This postFile(File file) { writeFile("POST", file).readRes } ** ** Make a request with the given HTTP method to the URI with the given form data. ** Set the Content-Type to application/x-www-form-urlencoded. ** This method does not support the ["Expect" header]`pod-doc#expectContinue` (it ** writes all form data before reading response). Should primarily be used for POST ** and PATCH requests. ** This writeForm(Str method, [Str:Str] form) { if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header") body := Uri.encodeQuery(form) reqMethod = method reqHeaders["Content-Type"] = "application/x-www-form-urlencoded" reqHeaders["Content-Length"] = body.size.toStr // encoded form is ASCII writeReq reqOut.print(body).close return this } ** ** Make a request with the given HTTP method to the URI using UTF-8 encoding of given ** string. If Content-Type is not already set, then set it ** to "text/plain; charset=utf-8". This method does not support the ** ["Expect" header]`pod-doc#expectContinue` (it writes full string ** before reading response). Should primarily be used for "POST" and "PATCH" ** requests. ** This writeStr(Str method, Str content) { if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header") body := Buf().print(content).flip reqMethod = method ct := reqHeaders["Content-Type"] if (ct == null) reqHeaders["Content-Type"] = "text/plain; charset=utf-8" reqHeaders["Content-Length"] = body.size.toStr writeReq reqOut.writeBuf(body).close return this } ** ** Write a file using the given HTTP method to the URI. If Content-Type header is not already ** set, then it is set from the file extension's MIME type. This method does ** not support the ["Expect" header]`pod-doc#expectContinue` (it ** writes full file before reading response). Should primarily be used for "POST" and ** "PATCH" requests. ** This writeFile(Str method, File file) { if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header") reqMethod = method ct := reqHeaders["Content-Type"] if (ct == null) reqHeaders["Content-Type"] = file.mimeType?.toStr ?: "application/octet-stream" if (file.size > 0) reqHeaders["Content-Length"] = file.size.toStr writeReq file.in.pipe(reqOut, file.size) reqOut.close return this } ////////////////////////////////////////////////////////////////////////// // Service ////////////////////////////////////////////////////////////////////////// ** ** Write the request line and request headers. Once this method ** completes the request body may be written via `reqOut`, or the ** response may be immediately read via `readRes`. Throw IOErr ** if there is a network or protocol error. Return this. ** This writeReq() { // sanity checks if (!reqUri.isAbs || reqUri.scheme == null || reqUri.host == null) throw Err("reqUri is not absolute: `$reqUri`") if (reqHeaders isnot CaseInsensitiveMap) throw Err("reqHeaders must be case insensitive") if (reqHeaders.containsKey("Host")) throw Err("reqHeaders must not define 'Host'") // connect to the host:port if we aren't already connected isHttps := reqUri.scheme == "https" defPort := isHttps ? 443 : 80 usingProxy := isProxy(reqUri) isTunnel := usingProxy && isHttps if (!isConnected) { if (isTunnel) socket = openHttpsTunnel else { // make https or http socket socket = TcpSocket(socketConfig) if (isHttps) socket = socket.upgradeTls // connect to proxy or directly to request host connUri := usingProxy ? proxy : reqUri socket.connect(IpAddr(connUri.host), connUri.port ?: defPort) } } // request uri is absolute if proxy, relative otherwise reqPath := (usingProxy ? reqUri : reqUri.relToAuth).encode // host authority header host := reqUri.host if (reqUri.port != null && reqUri.port != defPort) host += ":$reqUri.port" // figure out if/how we are streaming out content body out := socket.out reqOutStream = WebUtil.makeContentOutStream(reqHeaders, out) // send request out.print(reqMethod).print(" ").print(reqPath) .print(" HTTP/").print(reqVersion).print("\r\n") out.print("Host: ").print(host).print("\r\n") WebUtil.writeHeaders(out, reqHeaders) out.print("\r\n") out.flush return this } ** Open an https tunnel through our proxy server. private TcpSocket openHttpsTunnel() { socket = TcpSocket(socketConfig) // make CONNECT request to proxy server on http port socket.connect(IpAddr(proxy.host), proxy.port ?: 80) out := socket.out out.print("CONNECT ${reqUri.host}:${reqUri.port ?: 443} HTTP/${reqVersion}").print("\r\n") .print("Host: ${reqUri.host}:${reqUri.port ?: 443}").print("\r\n") .print("\r\n") out.flush // expect a 200 response code readRes if (resCode != 200) throw IOErr("Could not open tunnel: bad HTTP response $resCode $resPhrase") // upgrade to SSL socket now return socket.upgradeTls } ** ** Read the response status line and response headers. This method ** may be called after the request has been written via `writeReq` ** and `reqOut`. Once this method completes the response status and ** headers are available. If there is a response body, it is available ** for reading via `resIn`. Throw IOErr if there is a network or ** protocol error. Return this. ** This readRes() { // read response if (!isConnected) throw IOErr("Not connected") in := socket.in res := "" try { // parse status-line res = WebUtil.readLine(in) if (res.startsWith("HTTP/1.1")) resVersion = ver11 else if (res.startsWith("HTTP/1.0")) resVersion = ver10 else throw Err("Not HTTP") resCode = res[9..11].toInt resPhrase = res[13..-1] // parse response headers setCookies := Cookie[,] resHeaders = WebUtil.doParseHeaders(in, setCookies) if (!setCookies.isEmpty) cookies = setCookies } catch (Err e) throw IOErr("Invalid HTTP response: $res", e) // check for redirect if (checkFollowRedirect) return this // if there is response content, then create wrap the raw socket // input stream with the appropiate chunked input stream resInStream = WebUtil.makeContentInStream(resHeaders, socket.in) return this } ** ** If we have a 3xx statu code with a location header, ** then check for an automate redirect. ** private Bool checkFollowRedirect() { // only redirect on 3xx status code if (resCode / 100 != 3) return false // must be explicitly configured for redirects if (!followRedirects) return false // only redirect when there is no request content if (reqOutStream != null) return false // only redirect if a location header was given loc := resHeaders["Location"] if (loc == null) return false // redirect try { ++numRedirects close newUri := Uri.decode(loc) if (!newUri.isAbs) newUri = reqUri + newUri if (reqUri == newUri && numRedirects > 20) throw Err("Cyclical redirect: $newUri") reqUri = newUri writeReq readRes return true } finally { --numRedirects } } ** ** Return if this web client is currently connected to the remote host. ** Bool isConnected() { return socket != null && socket.isConnected } ** ** Close the HTTP request and the underlying socket. Return this. ** This close() { if (socket != null) socket.close socket = null return this } ////////////////////////////////////////////////////////////////////////// // Fields ////////////////////////////////////////////////////////////////////////// private static const Version ver10 := Version("1.0") private static const Version ver11 := Version("1.1") private static const [Str:Str] noHeaders := Str:Str[:] private InStream? resInStream private OutStream? reqOutStream internal TcpSocket? socket private Int numRedirects := 0 }