/*
* Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved.
* This source file is part of the Cangjie project, licensed under Apache-2.0
* with Runtime Library Exception.
*
* See https://cangjie-lang.cn/pages/LICENSE for license information.
*/
package stdx.net.http
import std.collection.*
import std.net.*
import std.io.*
import std.sync.*
import std.unicode.UnicodeStringExtension
import std.process.Process
import std.env.*
import stdx.net.tls.common.*
import std.convert.Parsable
import stdx.encoding.url.*
import stdx.log.*
/*
* Users have to build Client with ClientBuilder; Client doesn't have a public constructor
*/
public class ClientBuilder {
private var _noProxy: Bool = false
private var _http_proxy: String = ""
private var _https_proxy: String = ""
private var _connector: Connector = TcpSocketConnector
private var _logger: Logger = mutexLogger()
private var _cookieJar: ?CookieJar = CookieJarImpl(ArrayList<String>(), true)
private var _poolSize: Int64 = 10
private var _autoRedirect: Bool = true
private var _tlsConfig: ?TlsConfig = None
private var _readTimeout: Duration = Duration.second * 15
private var _writeTimeout: Duration = Duration.second * 15
private var _headerTableSize: UInt32 = 4096
private var _enablePush: Bool = true
private var _maxConcurrentStreams: UInt32 = UInt32(2 ** 31 - 1) // default no limit
private var _initialWindowSize: UInt32 = DEFAULT_WINDOW_SIZE
private var _maxFrameSize: UInt32 = MIN_FRAME_SIZE
private var _maxHeaderListSize: UInt32 = UInt32.Max
public init() {}
/*
* proxy config
* By default, read "http_proxy", "https_proxy" from system env variables.
* But if user configure proxy via this method, this proxy setting has higher priority than system proxy setting.
*
* @param addr http proxy's address.
* @return ClientBuilder whose httpProxy has been set.
*/
public func httpProxy(addr: String): ClientBuilder {
_http_proxy = addr
return this
}
/*
* @param addr https proxy's address.
* @return ClientBuilder whose httpsProxy has been set.
*/
public func httpsProxy(addr: String): ClientBuilder {
_https_proxy = addr
return this
}
/*
* clear all proxy settings
*
* @return ClientBuilder whose proxy settings are cleared.
*/
public func noProxy(): ClientBuilder {
_noProxy = true
return this
}
/*
* Note: type Connector = (String) -> StreamingSocket
* Connector, a default connector will be provided.
*
* @param c set the ClientBuilder's Connector.
* @return ClientBuilder whose Connector has been set.
*/
public func connector(c: (SocketAddress) -> StreamingSocket): ClientBuilder {
_connector = c
return this
}
/*
* the default logger will write to Console.stdout,
* the default LogLevel is INFO, if set to DEBUG, all handshake information, request, response will be logged.
* setting logger.level will take effect immediately.
* NOTE: logger should be thread-safe.
*
* @param logger set the ClientBuilder's logger.
* @return ClientBuilder whose logger has been set.
*/
public func logger(logger: Logger): ClientBuilder {
_logger = logger
return this
}
/*
* CookieJar, the default value is an empty cookieJar.
*
* @param cookieJar set the ClientBuilder's cookieJar.
* if the param is None, cookie will be forbidden.
* @return ClientBuilder whose cookieJar has been set.
*/
public func cookieJar(cookieJar: ?CookieJar): ClientBuilder {
_cookieJar = cookieJar
return this
}
/*
* Connection pool size for single host:port, if applicable, e.g. for Http/1.1 client implementation
*
* @param size set the ClientBuilder's poolSize. the size must greater than zero.
* @return ClientBuilder whose poolSize has been set.
*
* @throws HttpException, if the size less than or equal to zero.
*/
public func poolSize(size: Int64): ClientBuilder {
if (size <= 0) {
throw HttpException("The poolSize must be greater than 0.")
}
_poolSize = size
return this
}
/*
* Automatic redirection
*
* @param auto auto decide whether to enable automatic redirection.
* @return ClientBuilder whose autoRedirect has been set.
*/
public func autoRedirect(auto: Bool): ClientBuilder {
_autoRedirect = auto
return this
}
/*
* Tls layer config, by default, tlsConfig will be set to none.
*
* @param config the tls configuration.
* @return ClientBuilder whose tls has been configured.
*/
public func tlsConfig(config: TlsConfig): ClientBuilder {
_tlsConfig = config
return this
}
/*
* Read response timeout, the default value is 15s.
*
* @param timeout the read timeout configuration.
* @return ClientBuilder whose readTimeout has been configured.
*/
public func readTimeout(timeout: Duration): ClientBuilder {
_readTimeout = checkDuration(timeout)
return this
}
/*
* Write request timeout, the default value is 15s.
*
* @param timeout the write timeout configuration.
* @return ClientBuilder whose writeTimeout has been configured.
*/
public func writeTimeout(timeout: Duration): ClientBuilder {
_writeTimeout = checkDuration(timeout)
return this
}
/*
* h2 settings, these settings restrict the peer
* In h2, Max header table size for hpack encoder/decoder, the default value is 4096.
*
* @param size the header table size.
* @return ClientBuilder whose header table size has been set.
*/
public func headerTableSize(size: UInt32): ClientBuilder {
_headerTableSize = size
return this
}
/*
* In h2, server response pusher enable, the default value is true.
*
* @param enable enable decide whether to enable server response pusher.
* @return ClientBuilder whose enablePush has been configured.
*/
public func enablePush(enable: Bool): ClientBuilder {
_enablePush = enable
return this
}
/*
* In h2, server response pusher enable, the default value is UInt32(2**31 - 1).
*
* @param size the max concurrent stream size.
* @return ClientBuilder whose max concurrent stream size has been set.
*/
public func maxConcurrentStreams(size: UInt32): ClientBuilder {
_maxConcurrentStreams = size
return this
}
/*
* In h2, the initial window size, the default value is 65535.
*
* @param size the initial window size.
* @return ClientBuilder whose initial window size has been set.
*/
public func initialWindowSize(size: UInt32): ClientBuilder {
_initialWindowSize = size
return this
}
/*
* In h2, max frame size, the default value is 16384.
*
* @param size the max frame size.
* @return ClientBuilder whose max frame size has been set.
*/
public func maxFrameSize(size: UInt32): ClientBuilder {
_maxFrameSize = size
return this
}
/*
* In h2, max size of header decoded by hpack decoder, the default value is UInt32.Max.
*
* @param size the max header list size.
* @return ClientBuilder whose max header list size has been set.
*/
public func maxHeaderListSize(size: UInt32): ClientBuilder {
_maxHeaderListSize = size
return this
}
/**
* @return Client instance.
* @throws IllegalArgumentException if there is illegal config.
*/
public func build(): Client {
// check values
if (_initialWindowSize == 0 || _initialWindowSize > MAX_WINDOW) {
throw IllegalArgumentException("InitialWindowSize should between 1 and ${MAX_WINDOW}.")
}
if (_maxFrameSize < MIN_FRAME_SIZE || _maxFrameSize > MAX_FRAME_SIZE) {
throw IllegalArgumentException("MaxFrameSize should between 2^14 and 2^24-1.")
}
if (_maxConcurrentStreams < 100) {
_logger.warn("[ClientBuilder#build] Max concurrent streams num is recommended to be over 100.")
}
let client = Client()
client._logger = _logger
client._cookieJar = _cookieJar
client._poolSize = _poolSize
client._autoRedirect = _autoRedirect
client._tlsConfig = _tlsConfig
if (_tlsConfig?.supportedAlpnProtocols.contains("h2") ?? false) {
client.enableH2 = true
}
client._connector = _connector
client._readTimeout = _readTimeout
client._writeTimeout = _writeTimeout
client._headerTableSize = _headerTableSize
client._enablePush = _enablePush
client._maxConcurrentStreams = _maxConcurrentStreams
client._initialWindowSize = _initialWindowSize
client._maxFrameSize = _maxFrameSize
client._maxHeaderListSize = _maxHeaderListSize
// set proxy
client._noProxy = _noProxy
if (!_noProxy) {
client._http_proxy = if (!_http_proxy.isEmpty()) {
parseProxy(_http_proxy)
} else {
parseEnvironmentProxy(false)
}
client._https_proxy = if (!_https_proxy.isEmpty()) {
parseProxy(_https_proxy)
} else {
parseEnvironmentProxy(true)
}
}
return client
}
private func parseProxy(proxy: String): String {
if (proxy.isEmpty()) {
return proxy
}
if (!proxy.startsWith("http://")) {
throw IllegalArgumentException("Proxy must have http scheme.")
}
let url = URL.parse(proxy)
if (url.hostName.isEmpty()) {
return ""
}
return proxy
}
private func parseEnvironmentProxy(isTls: Bool): String {
let environment = if (isTls) {
getVariable("https_proxy") ?? getVariable("HTTPS_PROXY") ?? ""
} else {
getVariable("http_proxy") ?? getVariable("HTTP_PROXY") ?? ""
}
return parseProxy(environment)
}
}
public class Client { // cjlint-ignore !G.ENU.01
let isClosed: AtomicBool = AtomicBool(false)
private var client1_1: ?HttpClient1 = None
private var client2_0: ?HttpClient2 = None
var enableH2: Bool = false
var _noProxy: Bool = false
var _http_proxy: String = ""
var _https_proxy: String = ""
var _connector: Connector = TcpSocketConnector
var _logger: Logger = mutexLogger()
var _cookieJar: ?CookieJar = CookieJarImpl(ArrayList<String>(), true)
var _poolSize: Int64 = 10
var _autoRedirect: Bool = true
var _tlsConfig: ?TlsConfig = None
var _readTimeout: Duration = Duration.second * 15
var _writeTimeout: Duration = Duration.second * 15
var _headerTableSize: UInt32 = 4096
var _enablePush: Bool = true
var _maxConcurrentStreams: UInt32 = UInt32(2 ** 31 - 1)
var _initialWindowSize: UInt32 = 65535
var _maxFrameSize: UInt32 = MIN_FRAME_SIZE
var _maxHeaderListSize: UInt32 = UInt32.Max
// only used when create httpClient, seldom affects performance
private let singletonLock: Mutex = Mutex()
init() {}
/**
* proxy setting
*/
public prop httpProxy: String {
get() {
_http_proxy
}
}
public prop httpsProxy: String {
get() {
_https_proxy
}
}
/**
* Logger.
* setting logger.level will take effect immediately.
* NOTE: logger should be thread-safe.
*/
public prop logger: Logger {
get() {
_logger
}
}
/**
* Note: type Connector = (String) -> StreamingSocket
*/
public prop connector: Connector {
get() {
_connector
}
}
/**
* CookieJar.
*/
public prop cookieJar: ?CookieJar {
get() {
_cookieJar
}
}
/**
* Connection pool size
*/
public prop poolSize: Int64 {
get() {
_poolSize
}
}
/**
* Automatic redirection
*/
public prop autoRedirect: Bool {
get() {
_autoRedirect
}
}
/**
* Tls layer config.
* @return Clone of TLS configuration in client.
*/
public func getTlsConfig(): ?TlsConfig {
_tlsConfig
}
/**
* Read response timeout.
*/
public prop readTimeout: Duration {
get() {
_readTimeout
}
}
/**
* Write request timeout.
*/
public prop writeTimeout: Duration {
get() {
_writeTimeout
}
}
/**
* h2 settings, these settings restrict the peer
* In h2, Max header table size for hpack encoder/decoder.
*/
public prop headerTableSize: UInt32 {
get() {
_headerTableSize
}
}
/**
* In h2, server response pusher enable.
*/
public prop enablePush: Bool {
get() {
_enablePush
}
}
/**
* In h2, max number of concurrent streams per connection.
*/
public prop maxConcurrentStreams: UInt32 {
get() {
_maxConcurrentStreams
}
}
/**
* In h2, init window size.
*/
public prop initialWindowSize: UInt32 {
get() {
_initialWindowSize
}
}
/**
* In h2, max frame size.
*/
public prop maxFrameSize: UInt32 {
get() {
_maxFrameSize
}
}
/**
* In h2, max size of header decoded by hpack decoder.
*/
public prop maxHeaderListSize: UInt32 {
get() {
_maxHeaderListSize
}
}
/*
* close active connections and close this client
*
* @throws HttpException, If failed to close.
*/
public func close(): Unit {
if (!isClosed.swap(true)) {
client1_1?.close()
client2_0?.close()
}
}
/*
* get and invoke low level client to send request
* do redirect in this level
* do proxy in low level client
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
*/
func doRequest(req: HttpRequest): HttpResponse {
var lastReq = req
checkReq(lastReq)
setCookie(lastReq)
// a client must not generate fields in a TRACE request containing
// sensitive data that might be disclosed by the response.
// such as Cookie
// RFC 9110 9.3.8. TRACE
if (lastReq.method == "TRACE") {
removeSensitiveHeaders(lastReq.headers)
}
var response: HttpResponse = requestWithNegotiateRetry(lastReq)
resolveCookie(lastReq.url, response)
// record requests , if redirect times exceed a num eg:10 ,throw infinite loop exception
var reqsCnt = 0
// do redirect if autoRedirect on
while (autoRedirect && needRedirect(response.status)) {
httpLogDebug(logger, "[Client#doRequest] request auto redirect")
httpLogTrace(logger, "[Client#doRequest] redirect response: \n${response}")
// read in case response have body, ensure conn can be used next time
match (response.body) {
case _: HttpEmptyBody => match ((lastReq.headers.getInternal("upgrade"), response.connNode)) {
case (Some(_), Some(connNode)) => connNode.returnConn()
case _ => ()
}
case body: InputStream =>
let tmpBuf = Array<UInt8>(64, repeat: 0)
while (body.read(tmpBuf) > 0) {}
}
if (reqsCnt > 10) {
throw HttpException("Redirect loop exceed 10 times.")
}
reqsCnt++
(lastReq, response) = redirectOnce(lastReq, response)
}
if (logger.enabled(LogLevel.DEBUG)) {
httpLogDebug(logger, "[Client#doRequest] request finished, response: \n${response}")
}
return response
}
/*
* check is request legal
*/
private func checkReq(req: HttpRequest) {
// method is checked in request build
var url = req.url
if (url.scheme != "http" && url.scheme != "https") {
throw HttpException("Not HTTP protocol scheme: ${url.scheme}.")
}
if (url.hostName.isEmpty()) {
throw HttpException("No host in request URL.")
}
if (url.path.isEmpty() && req.method != "OPTIONS") { // use OPTIONS on a host the target is *
url = url.replace(path: SLASH)
}
if (!url.rawUserInfo.username().isEmpty()) {
let auth = match (req.headers.getInternal("authorization")) {
case Some(v) => v.size > 0
case None => false
}
if (!auth) {
// if % appear in userinfo, it will be decode by url, should use rawUserInfo here
let basicAuth = "Basic ${basicAuth(url.rawUserInfo.username(), url.rawUserInfo.password() ?? "")}"
req.headers.add("authorization", basicAuth)
}
// should remove userinfo after parse
url = url.replace(userInfo: "")
}
req._url = url
// for redirect, swap body to buffered body
match (req._body) {
case _: HttpRawBody => ()
case _: HttpEmptyBody => ()
case _ => req._body = HttpBufferedBody(req._body)
}
}
private func requestWithNegotiateRetry(req: HttpRequest): HttpResponse {
try {
httpLogDebug(logger, "[Client#doRequest] start send request")
let client = getClient(req)
if (logger.enabled(LogLevel.DEBUG)) {
httpLogDebug(logger, "[Client#doRequest] send request: \n${req}")
}
return client.request(req)
} catch (e: NegotiateException) {
if (req.headers.getFirst(":protocol").isSome()) {
throw HttpException("H2 upgrade failed, since ALPN negotiate failed.")
}
req._version = HTTP1_1
httpLogDebug(logger, "[Client#doRequest] h2 negotiate failed, and try h1 request again")
return getClient(req).request(req)
}
}
private func redirectOnce(lastReq: HttpRequest, response: HttpResponse): (HttpRequest, HttpResponse) {
let location = match (response.headers.getInternal("location")) {
case Some(v) =>
// Location = URI-reference
// The field value consists of a single URI-reference.
// RFC 9110 10.2.2.
if (v.size != 1) {
throw HttpException("Response's Location header can only contain a single URI-reference.")
}
v.single
case None => throw HttpException("Response missing Location header, status code: ${response.status}.")
}
let requestMethod = match (response.status) {
case 301 | 302 => match {
case lastReq.method != "HEAD" => "GET"
case _ => "HEAD"
}
case 303 => "GET"
case _ => lastReq.method
}
let newUrl = lastReq.url.resolveURL(URL.parse(location))
httpLogDebug(logger, "[Client#doRequest] redirect url is ${newUrl}")
let referer = getReferer(lastReq.url, newUrl)
let redirectReq = if (requestMethod == "GET") {
// If the request method is changed to GET, the body, "Transfer-Encoding: chunked",
// "Content-Type" and "Content-Length" must be removed.
let req = HttpRequestBuilder(lastReq).method(requestMethod).url(newUrl).body([]).build()
req.headers.del("transfer-encoding")
req.headers.del("content-type")
req.headers.del("content-length")
req
} else {
let body = match (lastReq._body) {
case body: HttpRawBody => body.rawBody
case body: HttpBufferedBody => body.bytes
case _ => Array<UInt8>()
}
HttpRequestBuilder(lastReq).method(requestMethod).url(newUrl).body(body).build()
}
// resolve header
// remove some headers which is generated by client or related to safety
// referer and cookie is set later, so removeHeader did`t remove them
removeHeaders(redirectReq.headers)
if (!referer.isEmpty()) {
redirectReq.headers.set("referer", referer)
}
setCookie(redirectReq)
if (redirectReq.method == "TRACE") {
removeSensitiveHeaders(redirectReq.headers)
}
let newResponse = requestWithNegotiateRetry(redirectReq)
resolveCookie(newUrl, newResponse)
(redirectReq, newResponse)
}
// cjlint-ignore -start !G.OTH.03
/*
* https://www.rfc-editor.org/rfc/rfc9110.html#field.referer
* A user agent MUST NOT send a Referer header field in an unsecured HTTP request if the referring resource was accessed with a secure protocol.
*/
// cjlint-ignore -end
private func getReferer(lastUrl: URL, newUrl: URL): String {
if (lastUrl.scheme == "https" && newUrl.scheme == "http") {
return ""
}
var url = lastUrl
if (!url.userInfo.username().isEmpty()) {
url = if (url.fragment.isSome()) {
url.replace(userInfo: "", fragment: "")
} else {
url.replace(userInfo: "")
}
}
return url.toString()
}
private func removeHeaders(headers: HttpHeaders): Unit {
headers.del("host")
headers.del("authorization")
headers.del("proxy-authenticate")
headers.del("proxy-authorization")
headers.del("www-authenticate")
headers.del("cookie2")
}
private func removeSensitiveHeaders(headers: HttpHeaders): Unit {
headers.del("authorization")
headers.del("proxy-authenticate")
headers.del("proxy-authorization")
headers.del("www-authenticate")
headers.del("cookie2")
headers.del("cookie")
}
private func resolveCookie(reqUrl: URL, resp: HttpResponse) {
if (let Some(cookieJar_) <- cookieJar) {
let cookies = CookieJar.parseSetCookieHeader(resp)
if (cookies.size > 0) {
httpLogDebug(logger, "[Client#resolveCookie] Store cookie from domain:${reqUrl}")
cookieJar_.storeCookies(reqUrl, cookies)
}
}
}
private func setCookie(req: HttpRequest) {
if (let Some(cookieJar_) <- cookieJar) {
if (let Some(cookieJarImpl) <- (cookieJar_ as CookieJarImpl)) {
if (let Some(cookie) <- cookieJarImpl.cookies(req.url)) {
doSetCookie(cookie, req)
}
return
}
let cookie = cookieJar_.getCookies(req.url)
doSetCookie(cookie, req)
}
}
private func doSetCookie(cookie: ArrayList<Cookie>, req: HttpRequest) {
if (cookie.size > 0) {
httpLogDebug(logger, "[Client#setCookie] Add cookie to request")
req.headers.set("cookie", CookieJar.toCookieString(cookie))
}
}
/*
* Send the request to server, block to get response.
*
* @param req the request to be sent to the server.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* or send an upgrade request
* or send a TRACE request with a non empty body.
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws ConnectionException if conn is closed by peer.
*/
public func send(req: HttpRequest): HttpResponse {
if (isClosed.load()) {
throw HttpException("This client has already closed.")
}
// websocket can be upgraded only by using the upgradeFromClient method.
if (req.headers.getFirst("upgrade").isSome()) {
throw HttpException("Request header Upgrade is only supported in method upgrade or upgradeFromClient.")
}
if (req.method == "CONNECT") {
throw HttpException("Please use connect method to send a CONNECT request.")
}
// a client must not send content in a TRACE request.
// RFC 9110 9.3.8.
if (req.method == "TRACE" && !(req.body is HttpEmptyBody)) {
throw HttpException("TRACE request can not contain content.")
}
return doRequest(req)
}
/*
* Send GET request to server, block to get response.
*
* @param url the target url.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func get(url: String): HttpResponse {
let req = HttpRequestBuilder().get().url(url).build()
return send(req)
}
/*
* Send HEAD request to server, block to get response.
*
* @param url the target url.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func head(url: String): HttpResponse {
let req = HttpRequestBuilder().head().url(url).build()
return send(req)
}
/*
* Send PUT request to server, block to get response.
*
* @param url the target url.
* @param body the body to be sent.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func put(url: String, body: String): HttpResponse {
put(url, HttpRawBody(body))
}
public func put(url: String, body: Array<UInt8>): HttpResponse {
put(url, HttpRawBody(body))
}
public func put(url: String, body: InputStream): HttpResponse {
let req = HttpRequestBuilder().put().url(url).body(body).build()
return send(req)
}
/*
* Send POST request to server, block to get response.
*
* @param url the target url.
* @param body the body to be sent.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func post(url: String, body: String): HttpResponse {
post(url, HttpRawBody(body))
}
public func post(url: String, body: Array<UInt8>): HttpResponse {
post(url, HttpRawBody(body))
}
public func post(url: String, body: InputStream): HttpResponse {
let req = HttpRequestBuilder().post().url(url).body(body).build()
return send(req)
}
/*
* Send DELETE request to server, block to get response.
* @param url the target url.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func delete(url: String): HttpResponse {
let req = HttpRequestBuilder().delete().url(url).build()
return send(req)
}
/*
* Send CONNECT request to server, block to get response.
* connect request does not support automatic redirection.
* @param url the target url.
* @param header the request header.
* @param version the request version.
* @return HttpResponse.
* @return StreamingSocket is CONNECT is success, None if failed(no 2xx response)
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func connect(url: String, header!: HttpHeaders = HttpHeaders(), version!: Protocol = HTTP1_1): (HttpResponse,
?StreamingSocket) {
if (isClosed.load()) {
throw HttpException("This client has already closed.")
}
let req = HttpRequestBuilder().connect().url(url).version(version).setHeaders(header).build()
checkReq(req)
setCookie(req)
if (req.version == HTTP2_0 && !enableH2) {
throw HttpException("HTTP/2 is not enabled.")
}
let client = getClient(req)
let (resp, conn) = client.connect(req)
resolveCookie(req.url, resp)
return (resp, conn)
}
/*
* Send OPTIONS request to server, block to get response.
* @param url the target url.
* @return HttpResponse.
*
* @throws HttpException, if request or response didn't conform to the protocol
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
* @throws UrlSyntaxException if 'url' is empty or invalid.
* @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
*/
public func options(url: String): HttpResponse {
let req = HttpRequestBuilder().options().url(url).build()
return send(req)
}
/*
* Send upgrade request.
* the upgrade request is constructed by user and the connection returned will be maintained by user.
* HTTP/1.0 and HTTP/2 do not support upgrade.
* @param req the request to be sent to the server.
* @return HttpResponse
* @return StreamingSocket if upgrade is success, None if failed(no 101 response)
*
* @throws HttpException, if request or response didn't conform to the protocol
* or request header does not contain Upgrade header,
* or send a request other than HTTP/1.1,
* or send a CONNECT request,
* or send a TRACE request with a non empty body.
* @throws SocketException or ConnectionException, if connection to server broken
* @throws TlsException, if something wrong happened in TLS.
*/
public func upgrade(req: HttpRequest): (HttpResponse, ?StreamingSocket) {
if (isClosed.load()) {
throw HttpException("This client has already closed.")
}
if (req.headers.getFirst("upgrade").isNone()) {
throw HttpException("Request header must contain Upgrade header.")
}
if (req.version == UnknownProtocol("HTTP/1.1")) {
req._version = HTTP1_1
}
if (req.version != HTTP1_1) {
throw HttpException("Only HTTP/1.1 support upgrade.")
}
// a client must not send content in a TRACE request.
// RFC 9110 9.3.8.
if (req.method == "TRACE" && !(req.body is HttpEmptyBody)) {
throw HttpException("TRACE request can not contain content.")
}
// CONNECT method is not supported by upgrade
if (req.method == "CONNECT") {
throw HttpException("CONNECT request is not supported by upgrade.")
}
let resp = doRequest(req)
let connNode = match (resp.connNode.getOrThrow() as ConnNode) {
case Some(v) => v
case None => throw HttpException("Get connection failed.")
}
// a server may ignore a received Upgrade header field if it
// wishes to continue using the current protocol on that connection.
// RFC 9110 7.8.
// treat no 101 response as normal response
if (resp.status == HttpStatusCode.STATUS_SWITCHING_PROTOCOLS) {
return (resp, connNode.h1Engine.extractFromConnInUse(connNode))
} else {
if (resp.body is HttpEmptyBody) {
connNode.h1Engine.returnConn(connNode)
}
return (resp, None)
}
}
/*
* choose protocol, only h1 & h2 supported
* proxy implement in lowlevel client
*/
private func getClient(request: HttpRequest): HttpClient {
match ((request.version, enableH2)) {
case (HTTP1_1, _) | (HTTP2_0, false) | (UnknownProtocol("HTTP/1.1"), false) =>
if (request.headers.getFirst(":protocol").isSome()) {
throw HttpException("HTTP/2 is not enabled.")
}
httpLogDebug(logger, "[Client#getClient] Using HTTP/1.1 client")
request._version = HTTP1_1
getOrCreateH1()
case (HTTP2_0, true) | (UnknownProtocol("HTTP/1.1"), true) =>
httpLogDebug(logger, "[Client#getClient] Using HTTP/2 client")
request._version = HTTP2_0
getOrCreateH2()
case (HTTP1_0, _) => throw HttpException("HTTP/1.0 request is not supported.")
case _ => throw HttpException("Protocol unknown.")
}
}
private func getOrCreateH1(): HttpClient {
if (let Some(client) <- client1_1) {
return client
}
synchronized(singletonLock) {
if (let Some(client) <- client1_1) {
return client
}
let client = HttpClient1(this)
client1_1 = client
return client
}
}
private func getOrCreateH2(): HttpClient {
match (client2_0) {
case Some(client) => client
case None => synchronized(singletonLock) {
match (client2_0) {
case Some(client) => client
case None =>
let client = HttpClient2(this)
client2_0 = client
client
}
}
}
}
}
/*
* HttpClient represent clients with different protocols, currently 1.1 & 2.0 implemented
*/
interface HttpClient {
func request(req: HttpRequest): HttpResponse
func connect(req: HttpRequest): (HttpResponse, ?StreamingSocket)
}
func normalizeHostLower(host: String): String {
var h = host.trimAscii()
// Strip optional IPv6 brackets: [::1] -> ::1
if (h.size >= 2 && h[0] == b'[' && h[h.size - 1] == b']') {
h = h[1..h.size - 1]
}
h = h.toAsciiLower()
// Strip trailing dot (FQDN): example.com. -> example.com
if (h.endsWith(".")) {
h = h[..h.size - 1]
}
return h
}
func splitNoProxyHostPort(domain: String): (String, String) {
let s = domain.trimAscii()
// [ipv6]:port
if (s.size >= 2 && s[0] == b'[') {
if (let Some(end) <- s.indexOf(b']')) {
let hostPart = s[1..end]
let rest = s[end + 1..]
return if (!rest.isEmpty() && rest[0] == b':') {
(hostPart, rest[1..])
} else {
(hostPart, "")
}
}
}
// host:port only when there is exactly one ':'
let firstColon = s.indexOf(b':')
let lastColon = s.lastIndexOf(b':')
match ((firstColon, lastColon)) {
case (Some(first), Some(last)) where first == last =>
let hostPart = s[..first]
let portPart = s[first + 1..]
return (hostPart, portPart)
case _ => (s, "")
}
}
func isIpLiteral(hostLower: String): Bool {
// IPv6 literal (basic heuristic)
if (let Some(_) <- hostLower.indexOf(b':')) {
return true
}
// IPv4 heuristic: only digits/dots AND contains at least one dot
var hasDot = false
for (r in hostLower) {
if (r == b'.') {
hasDot = true
continue
}
if (!r.isAsciiNumber()) {
return false
}
}
return hasDot
}
func hostMatchesDomain(hostLower: String, domainLower: String): Bool {
var d = domainLower
if (d.endsWith(".")) { // trailing dot (FQDN)
d = d[..d.size - 1]
}
let domain = if (d.startsWith(".")) { // remove leading dot
d[1..]
} else {
d
}
if (domain.isEmpty()) {
return false
}
if (hostLower == domain) {
return true
}
return hostLower.endsWith("." + domain)
}
func getNoProxy(): ?Array<String> {
let noProxy = getVariable("no_proxy") ?? getVariable("NO_PROXY") ?? ""
if (!noProxy.isEmpty()) {
return noProxy.split(",")
}
return None
}
func matchNoProxy(host: String, port: String): Bool {
let hostLower = normalizeHostLower(host)
let noProxy = getNoProxy()
if (noProxy.isNone()) {
return false
}
for (item in noProxy.getOrThrow()) {
let domain = item.trimAscii()
if (domain.isEmpty()) {
continue
}
if (domain == ASTERISK) {
return true
}
let (noProxyHostRaw, noProxyPortRaw) = splitNoProxyHostPort(domain)
let noProxyHost = noProxyHostRaw.trimAscii()
if (noProxyHost.isEmpty()) {
continue
}
let noProxyPort = noProxyPortRaw.trimAscii()
if (!noProxyPort.isEmpty() && noProxyPort != port) {
continue
}
let noProxyHostLower = normalizeHostLower(noProxyHost)
if (isIpLiteral(noProxyHostLower)) {
if (noProxyHostLower == hostLower) {
return true
}
continue
}
if (hostMatchesDomain(hostLower, noProxyHostLower)) {
return true
}
}
return false
}