From 9b2ef258ec05ea3e5fa766e7ae8f1dc05637b4d7 Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Tue, 21 Apr 2026 16:31:01 +0200 Subject: [PATCH 1/8] Add support for constructing custom proxy routing. - Modified `->ProxySelector` to take a vector of handlers and proxy parameters in addition to the "simple" host/port config argument. - Added functions to construct handler/proxy pairs. - Added some tests for proxy selection - Updated changelog. --- CHANGELOG.md | 4 ++ src/babashka/http_client.clj | 38 ++++++++++-- src/babashka/http_client/internal.clj | 88 +++++++++++++++++++++++++++ test/babashka/http_client_test.clj | 12 +++- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d729c7..28ff510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Babashka [http-client](https://github.com/babashka/http-client): HTTP client for Clojure and babashka built on java.net.http +## Unreleased + +- [#80](https://github.com/babashka/http-client/issues/80): add idiomatic proxy configuration functionality + ## 0.4.23 (2025-06-06) - [#75](https://github.com/babashka/http-client/issues/75): override existing content type header in multipart request diff --git a/src/babashka/http_client.clj b/src/babashka/http_client.clj index 36dbb84..d0b8734 100644 --- a/src/babashka/http_client.clj +++ b/src/babashka/http_client.clj @@ -6,13 +6,43 @@ "Options used to create the (implicit) default client." i/default-client-opts) +(defn proxy-scheme + "Return a handler that will use the proxy described by `opts` for all URLs with a scheme equal to `scheme`." + [scheme opts] + (i/proxy-scheme scheme opts)) + +(defn proxy-exclude-urls + "Return a handler pair that will use a direct proxy for all URLs that have a hostname ending with any of the excluded hostnames." + [excluded-urls] + (i/proxy-exclude-urls excluded-urls)) + +(defn proxy-all + "Return a handler that will always use the provided proxy. Use `->ProxySelector` with the `:host` and `:port` arguments if you + do not combine this with other handlers." + [opts] + (i/proxy-all opts)) + +(defn proxy-host + "Return a handler that will handle a single hostname with the provided proxy." + [hostname opts] + (i/proxy-host hostname opts)) + (defn ->ProxySelector - "Constructs a `java.net.ProxySelector`. + "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` actions, or a list of predicate/proxy + pairs for more complex configuration. + Options: * `:host` - string - * `:port` - long" - [opts] - (i/->ProxySelector opts)) + * `:port` - long + Handlers: + * `pred`: A one-argument function receiving a java.net.URI argument. If it returns truthy, the corresponding proxy is returned. + * `proxy`: A map of proxy options containing `:host`, `:port` and `:type` keys. `:host` is a string, `:port` a long and `:type` can + be `:direct`, `:http` or `:socks`. Use the `proxy-scheme`, `proxy-exclude-urls`, `proxy-all` or `proxy-host` functions + to build handler pairs." + [opts-or-handlers] + (if (vector? opts-or-handlers) + (i/make-proxy-selector opts-or-handlers) + (i/->ProxySelector opts-or-handlers))) (defn ->SSLContext "Constructs a `javax.net.ssl.SSLContext`. diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index dea6807..02450b3 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -36,6 +36,12 @@ :never HttpClient$Redirect/NEVER :normal HttpClient$Redirect/NORMAL)) +(defn- ->proxy-type [type] + (case type + :direct java.net.Proxy$Type/DIRECT + :socks java.net.Proxy$Type/SOCKS + :http java.net.Proxy$Type/HTTP)) + (defn- version-keyword->version-enum [version] (case version :http1.1 HttpClient$Version/HTTP_1_1 @@ -105,6 +111,88 @@ (cond (and host port) (java.net.ProxySelector/of (java.net.InetSocketAddress. ^String host ^long port)))))) +(defn ->Proxy + [opts] + (if (instance? java.net.Proxy opts) + opts + (let [{:keys [host port type]} opts] + (cond + (= type :direct) + java.net.Proxy/NO_PROXY + (and host port type) + (java.net.Proxy. (->proxy-type type) (java.net.InetSocketAddress. ^String host ^long port)))))) + +(defn- case-insensitive-env-var [name] + (or (System/getenv (str/lower-case name)) + (System/getenv (str/upper-case name)))) + +(defn- no-proxy-value [value] + (cond + (nil? value) + #{} + (= value "*") + :all + :else + (into #{} + (map + (fn [entry] (str/trim entry)) + (str/split value #","))))) + +(defn- env->proxy-opts [value] + (when value + (let [uri (java.net.URI. value) + base-opts {:host (.getHost uri) :port (.getPort uri)}] + (condp contains? (.getScheme uri) + #{"http" "https"} (assoc base-opts :type :http) + #{"socks4" "socks4a" "socks5" "socks5a"} (assoc base-opts :type :socks))))) + +(defn make-proxy-selector [handlers] + (proxy [java.net.ProxySelector] [] + (connectFailed [_ _ _]) + (select [^URI uri] + (loop [[[pred proxy] & rest] handlers] + (cond + (nil? pred) + [java.net.Proxy/NO_PROXY] + (pred uri) + [proxy] + :else + (recur rest)))))) + +(defn proxy-scheme [scheme proxy-opts] + [(fn [^java.net.URI uri] + (= (.getScheme uri) scheme)) + (->Proxy proxy-opts)]) + +(defn proxy-exclude-urls [excluded-urls] + [(fn [^java.net.URI uri] + (some (fn [excluded-url] (str/ends-with? (.getHost uri) excluded-url)) excluded-urls)) + java.net.Proxy/NO_PROXY]) + +(defn proxy-all [proxy-opts] + [(constantly true) + (->Proxy proxy-opts)]) + +(defn proxy-host [hostname proxy-opts] + [(fn [^java.net.URI uri] + (= (.getHost uri) hostname)) + (->Proxy proxy-opts)]) + +(defn proxy-selector-from-env-vars [] + (let [http-proxy-value (env->proxy-opts (case-insensitive-env-var "http_proxy")) + https-proxy-value (env->proxy-opts (case-insensitive-env-var "https_proxy")) + no-proxy-value (no-proxy-value (case-insensitive-env-var "no_proxy"))] + (cond + ;; No proxy defined + (and (nil? http-proxy-value) (nil? https-proxy-value)) + nil + ;; Star in no_proxy variable + (= no-proxy-value :all) + nil + :else + (make-proxy-selector [(proxy-exclude-urls no-proxy-value) + (proxy-scheme "http" (or http-proxy-value https-proxy-value)) + (proxy-scheme "https" (or https-proxy-value http-proxy-value))])))) (defn ->Authenticator [v] (if (instance? Authenticator v) diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index bb58a96..10de8d1 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -511,7 +511,17 @@ (deftest proxy-selector (is (instance? java.net.ProxySelector (http/->ProxySelector {:host "https://clojure.org" - :port 1337})))) + :port 1337}))) + (let [complex-proxy-selector (http/->ProxySelector [(http/proxy-exclude-urls #{"localhost"}) + (http/proxy-scheme "http" {:host "example.org" :port 8080 :type :http})])] + (is (instance? java.net.ProxySelector complex-proxy-selector)) + (let [proxies-for-http (.select complex-proxy-selector (java.net.URI. "http://www.example.org")) + excluded-proxies (.select complex-proxy-selector (java.net.URI. "http://localhost")) + proxies-for-https (.select complex-proxy-selector (java.net.URI. "https://www.example.org"))] + (is (= (count proxies-for-http) (count proxies-for-https) (count excluded-proxies) 1)) + (is (= (.type (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) + (is (= (.type (get excluded-proxies 0)) java.net.Proxy$Type/DIRECT)) + (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT))))) (deftest cookie-handler-test (testing "nil passthrough" From 86353f86fdc86eab521138e004bbe037c035b401 Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Thu, 23 Apr 2026 10:01:43 +0200 Subject: [PATCH 2/8] Reworked handler/proxy interface into single function interface. --- src/babashka/http_client.clj | 49 ++++------- src/babashka/http_client/internal.clj | 85 ++++++++++--------- .../http_client/internal/helpers_test.clj | 2 +- test/babashka/http_client_test.clj | 23 +++-- 4 files changed, 82 insertions(+), 77 deletions(-) diff --git a/src/babashka/http_client.clj b/src/babashka/http_client.clj index d0b8734..63c96c3 100644 --- a/src/babashka/http_client.clj +++ b/src/babashka/http_client.clj @@ -6,43 +6,30 @@ "Options used to create the (implicit) default client." i/default-client-opts) -(defn proxy-scheme - "Return a handler that will use the proxy described by `opts` for all URLs with a scheme equal to `scheme`." - [scheme opts] - (i/proxy-scheme scheme opts)) - -(defn proxy-exclude-urls - "Return a handler pair that will use a direct proxy for all URLs that have a hostname ending with any of the excluded hostnames." - [excluded-urls] - (i/proxy-exclude-urls excluded-urls)) - -(defn proxy-all - "Return a handler that will always use the provided proxy. Use `->ProxySelector` with the `:host` and `:port` arguments if you - do not combine this with other handlers." - [opts] - (i/proxy-all opts)) +(defn ->ProxySelector + "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` actions, or a proxy + creation function that takes a java.net.URI and returns a proxy configuration for ->Proxy. -(defn proxy-host - "Return a handler that will handle a single hostname with the provided proxy." - [hostname opts] - (i/proxy-host hostname opts)) + Options: + * `:host` - string + * `:port` - long + Proxy function: + * `fn`: A one-argument function receiving a java.net.URI argument and returning a proxy configuration for ->Proxy or nil + (meaning don't use a proxy)." + [opts-or-fn] + (if (map? opts-or-fn) + (i/->ProxySelector opts-or-fn) + (i/fn->ProxySelector opts-or-fn))) -(defn ->ProxySelector - "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` actions, or a list of predicate/proxy - pairs for more complex configuration. +(defn ->Proxy + "Constructs a `java.net.Proxy`. Options: * `:host` - string * `:port` - long - Handlers: - * `pred`: A one-argument function receiving a java.net.URI argument. If it returns truthy, the corresponding proxy is returned. - * `proxy`: A map of proxy options containing `:host`, `:port` and `:type` keys. `:host` is a string, `:port` a long and `:type` can - be `:direct`, `:http` or `:socks`. Use the `proxy-scheme`, `proxy-exclude-urls`, `proxy-all` or `proxy-host` functions - to build handler pairs." - [opts-or-handlers] - (if (vector? opts-or-handlers) - (i/make-proxy-selector opts-or-handlers) - (i/->ProxySelector opts-or-handlers))) + * `:type` - One of `:direct` (no proxy), `:socks` (socks proxy) or `:http` (application level proxy)." + [opts] + (i/->Proxy opts)) (defn ->SSLContext "Constructs a `javax.net.ssl.SSLContext`. diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index 02450b3..0dce33a 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -138,50 +138,46 @@ (fn [entry] (str/trim entry)) (str/split value #","))))) +(defn- uri-port-with-default [^URI uri] + (if (not= (.getPort uri) -1) + (.getPort uri) + (cond + (= (.getScheme uri) "http") 80 + (= (.getScheme uri) "https") 443 + :else -1))) + (defn- env->proxy-opts [value] - (when value + (when (and value (not (str/blank? value))) (let [uri (java.net.URI. value) - base-opts {:host (.getHost uri) :port (.getPort uri)}] + base-opts {:host (.getHost uri) :port (uri-port-with-default uri)}] (condp contains? (.getScheme uri) #{"http" "https"} (assoc base-opts :type :http) #{"socks4" "socks4a" "socks5" "socks5a"} (assoc base-opts :type :socks))))) -(defn make-proxy-selector [handlers] +(defn fn->ProxySelector [proxy-fn] (proxy [java.net.ProxySelector] [] (connectFailed [_ _ _]) (select [^URI uri] - (loop [[[pred proxy] & rest] handlers] - (cond - (nil? pred) - [java.net.Proxy/NO_PROXY] - (pred uri) - [proxy] - :else - (recur rest)))))) - -(defn proxy-scheme [scheme proxy-opts] - [(fn [^java.net.URI uri] - (= (.getScheme uri) scheme)) - (->Proxy proxy-opts)]) - -(defn proxy-exclude-urls [excluded-urls] - [(fn [^java.net.URI uri] - (some (fn [excluded-url] (str/ends-with? (.getHost uri) excluded-url)) excluded-urls)) - java.net.Proxy/NO_PROXY]) - -(defn proxy-all [proxy-opts] - [(constantly true) - (->Proxy proxy-opts)]) - -(defn proxy-host [hostname proxy-opts] - [(fn [^java.net.URI uri] - (= (.getHost uri) hostname)) - (->Proxy proxy-opts)]) - -(defn proxy-selector-from-env-vars [] - (let [http-proxy-value (env->proxy-opts (case-insensitive-env-var "http_proxy")) - https-proxy-value (env->proxy-opts (case-insensitive-env-var "https_proxy")) - no-proxy-value (no-proxy-value (case-insensitive-env-var "no_proxy"))] + ;; Only allow the proxy function to return a single proxy. + ;; I don't really see the use case for multiple. + [(if-let [proxy-opts (proxy-fn uri)] + (->Proxy proxy-opts) + java.net.Proxy/NO_PROXY)]))) + +(defn- proxy-scheme [scheme proxy-opts] + (fn [^java.net.URI uri] + (when (= (.getScheme uri) scheme) + (->Proxy proxy-opts)))) + +(defn- proxy-exclude-urls [excluded-urls] + (fn [^java.net.URI uri] + (when (some (fn [excluded-url] (str/ends-with? (.getHost uri) excluded-url)) excluded-urls) + java.net.Proxy/NO_PROXY))) + +(defn proxy-selector-from-curl-vars [http-proxy-env https-proxy-env no-proxy-env] + (let [http-proxy-value (env->proxy-opts http-proxy-env) + https-proxy-value (env->proxy-opts https-proxy-env) + no-proxy-value (no-proxy-value no-proxy-env)] (cond ;; No proxy defined (and (nil? http-proxy-value) (nil? https-proxy-value)) @@ -190,9 +186,19 @@ (= no-proxy-value :all) nil :else - (make-proxy-selector [(proxy-exclude-urls no-proxy-value) - (proxy-scheme "http" (or http-proxy-value https-proxy-value)) - (proxy-scheme "https" (or https-proxy-value http-proxy-value))])))) + (fn->ProxySelector (fn [^java.net.URI uri] + (some (fn [proxy-fn] (proxy-fn uri)) + (cond-> [] + no-proxy-value (conj (proxy-exclude-urls no-proxy-value)) + https-proxy-value (conj (proxy-scheme "https" https-proxy-value)) + http-proxy-value (conj (proxy-scheme "http" http-proxy-value))))))))) + +(defn proxy-selector-from-env [] + (proxy-selector-from-curl-vars + (case-insensitive-env-var "http_proxy") + (case-insensitive-env-var "https_proxy") + (case-insensitive-env-var "no_proxy"))) + (defn ->Authenticator [v] (if (instance? Authenticator v) @@ -278,7 +284,8 @@ :user-agent (str "babashka.http-client/" iv/version)}}}) (def default-client - (delay (client default-client-opts))) + (let [default-proxy-selector (proxy-selector-from-env)] + (delay (client (merge default-client-opts (when default-proxy-selector {:proxy default-proxy-selector})))))) (defn- method-keyword->str [method] (str/upper-case (name method))) diff --git a/test/babashka/http_client/internal/helpers_test.clj b/test/babashka/http_client/internal/helpers_test.clj index ef03e82..6562c5b 100644 --- a/test/babashka/http_client/internal/helpers_test.clj +++ b/test/babashka/http_client/internal/helpers_test.clj @@ -4,5 +4,5 @@ [clojure.test :as t])) (t/deftest ->uri-tests - (let [uri (h/->uri {:scheme "https" :host "example.com" :path "/foo"})] + (let [^java.net.URI uri (h/->uri {:scheme "https" :host "example.com" :path "/foo"})] (t/is (= (.getPort uri) -1)))) diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index 10de8d1..7bc3852 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -3,6 +3,7 @@ [babashka.fs :as fs] [babashka.http-client :as http] [babashka.http-client.interceptors :as i] + [babashka.http-client.internal :as internal] [babashka.http-client.internal.version :as iv] [borkdude.deflet :refer [deflet]] [cheshire.core :as json] @@ -512,16 +513,26 @@ (is (instance? java.net.ProxySelector (http/->ProxySelector {:host "https://clojure.org" :port 1337}))) - (let [complex-proxy-selector (http/->ProxySelector [(http/proxy-exclude-urls #{"localhost"}) - (http/proxy-scheme "http" {:host "example.org" :port 8080 :type :http})])] + (let [^java.net.ProxySelector complex-proxy-selector (http/->ProxySelector (fn [^java.net.URI uri] + (when (= (.getScheme uri) "http") + {:host "http://www.example.org" + :port 128 + :type :http})))] (is (instance? java.net.ProxySelector complex-proxy-selector)) (let [proxies-for-http (.select complex-proxy-selector (java.net.URI. "http://www.example.org")) - excluded-proxies (.select complex-proxy-selector (java.net.URI. "http://localhost")) proxies-for-https (.select complex-proxy-selector (java.net.URI. "https://www.example.org"))] - (is (= (count proxies-for-http) (count proxies-for-https) (count excluded-proxies) 1)) + (is (= (count proxies-for-http) (count proxies-for-https) 1)) (is (= (.type (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) - (is (= (.type (get excluded-proxies 0)) java.net.Proxy$Type/DIRECT)) - (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT))))) + (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT)))) + (let [environment-proxy-selector (internal/proxy-selector-from-curl-vars "" "https://www.example.org" "localhost")] + (is (instance? java.net.ProxySelector environment-proxy-selector)) + (let [http-proxies (.select environment-proxy-selector (java.net.URI. "http://example.org/index.html")) + https-proxies (.select environment-proxy-selector (java.net.URI. "https://example.org/index.html")) + excluded-proxies (.select environment-proxy-selector (java.net.URI. "http://localhost:8080/do"))] + (is (= (count http-proxies) (count https-proxies) (count excluded-proxies))) + (is (= (get http-proxies 0) java.net.Proxy/NO_PROXY)) + (is (= (get https-proxies 0) (http/->Proxy {:host "example.org" :type :http :port 443}))) + (is (= (get excluded-proxies 0) java.net.Proxy/NO_PROXY))))) (deftest cookie-handler-test (testing "nil passthrough" From 6a62db36191b8911e7299c5421ab6833d7b8e6a1 Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Thu, 23 Apr 2026 14:08:42 +0200 Subject: [PATCH 3/8] PR changes. --- src/babashka/http_client.clj | 7 ++++--- test/babashka/http_client_test.clj | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/babashka/http_client.clj b/src/babashka/http_client.clj index 63c96c3..53b5380 100644 --- a/src/babashka/http_client.clj +++ b/src/babashka/http_client.clj @@ -17,9 +17,10 @@ * `fn`: A one-argument function receiving a java.net.URI argument and returning a proxy configuration for ->Proxy or nil (meaning don't use a proxy)." [opts-or-fn] - (if (map? opts-or-fn) - (i/->ProxySelector opts-or-fn) - (i/fn->ProxySelector opts-or-fn))) + (cond + (instance? java.net.ProxySelector opts-or-fn) opts-or-fn + (map? opts-or-fn) (i/->ProxySelector opts-or-fn) + :else (i/fn->ProxySelector opts-or-fn))) (defn ->Proxy "Constructs a `java.net.Proxy`. diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index 7bc3852..83ae0bd 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -513,6 +513,11 @@ (is (instance? java.net.ProxySelector (http/->ProxySelector {:host "https://clojure.org" :port 1337}))) + ;; Check passthrough behavior. + (is (instance? java.net.ProxySelector + (http/->ProxySelector + (http/->ProxySelector {:host "https://clojure.org" + :port 1337})))) (let [^java.net.ProxySelector complex-proxy-selector (http/->ProxySelector (fn [^java.net.URI uri] (when (= (.getScheme uri) "http") {:host "http://www.example.org" @@ -531,7 +536,7 @@ excluded-proxies (.select environment-proxy-selector (java.net.URI. "http://localhost:8080/do"))] (is (= (count http-proxies) (count https-proxies) (count excluded-proxies))) (is (= (get http-proxies 0) java.net.Proxy/NO_PROXY)) - (is (= (get https-proxies 0) (http/->Proxy {:host "example.org" :type :http :port 443}))) + (is (= (get https-proxies 0) (http/->Proxy {:host "www.example.org" :type :http :port 443}))) (is (= (get excluded-proxies 0) java.net.Proxy/NO_PROXY))))) (deftest cookie-handler-test From d42a5a0d01eb5ccfcb37fd786d4d7be92e03957e Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Thu, 7 May 2026 11:57:19 +0200 Subject: [PATCH 4/8] Removed usage of environment variables for default proxy. --- src/babashka/http_client/internal.clj | 67 --------------------------- test/babashka/http_client_test.clj | 11 +---- 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index 0dce33a..0022f8e 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -122,38 +122,6 @@ (and host port type) (java.net.Proxy. (->proxy-type type) (java.net.InetSocketAddress. ^String host ^long port)))))) -(defn- case-insensitive-env-var [name] - (or (System/getenv (str/lower-case name)) - (System/getenv (str/upper-case name)))) - -(defn- no-proxy-value [value] - (cond - (nil? value) - #{} - (= value "*") - :all - :else - (into #{} - (map - (fn [entry] (str/trim entry)) - (str/split value #","))))) - -(defn- uri-port-with-default [^URI uri] - (if (not= (.getPort uri) -1) - (.getPort uri) - (cond - (= (.getScheme uri) "http") 80 - (= (.getScheme uri) "https") 443 - :else -1))) - -(defn- env->proxy-opts [value] - (when (and value (not (str/blank? value))) - (let [uri (java.net.URI. value) - base-opts {:host (.getHost uri) :port (uri-port-with-default uri)}] - (condp contains? (.getScheme uri) - #{"http" "https"} (assoc base-opts :type :http) - #{"socks4" "socks4a" "socks5" "socks5a"} (assoc base-opts :type :socks))))) - (defn fn->ProxySelector [proxy-fn] (proxy [java.net.ProxySelector] [] (connectFailed [_ _ _]) @@ -164,41 +132,6 @@ (->Proxy proxy-opts) java.net.Proxy/NO_PROXY)]))) -(defn- proxy-scheme [scheme proxy-opts] - (fn [^java.net.URI uri] - (when (= (.getScheme uri) scheme) - (->Proxy proxy-opts)))) - -(defn- proxy-exclude-urls [excluded-urls] - (fn [^java.net.URI uri] - (when (some (fn [excluded-url] (str/ends-with? (.getHost uri) excluded-url)) excluded-urls) - java.net.Proxy/NO_PROXY))) - -(defn proxy-selector-from-curl-vars [http-proxy-env https-proxy-env no-proxy-env] - (let [http-proxy-value (env->proxy-opts http-proxy-env) - https-proxy-value (env->proxy-opts https-proxy-env) - no-proxy-value (no-proxy-value no-proxy-env)] - (cond - ;; No proxy defined - (and (nil? http-proxy-value) (nil? https-proxy-value)) - nil - ;; Star in no_proxy variable - (= no-proxy-value :all) - nil - :else - (fn->ProxySelector (fn [^java.net.URI uri] - (some (fn [proxy-fn] (proxy-fn uri)) - (cond-> [] - no-proxy-value (conj (proxy-exclude-urls no-proxy-value)) - https-proxy-value (conj (proxy-scheme "https" https-proxy-value)) - http-proxy-value (conj (proxy-scheme "http" http-proxy-value))))))))) - -(defn proxy-selector-from-env [] - (proxy-selector-from-curl-vars - (case-insensitive-env-var "http_proxy") - (case-insensitive-env-var "https_proxy") - (case-insensitive-env-var "no_proxy"))) - (defn ->Authenticator [v] (if (instance? Authenticator v) diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index 83ae0bd..037f5d3 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -528,16 +528,7 @@ proxies-for-https (.select complex-proxy-selector (java.net.URI. "https://www.example.org"))] (is (= (count proxies-for-http) (count proxies-for-https) 1)) (is (= (.type (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) - (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT)))) - (let [environment-proxy-selector (internal/proxy-selector-from-curl-vars "" "https://www.example.org" "localhost")] - (is (instance? java.net.ProxySelector environment-proxy-selector)) - (let [http-proxies (.select environment-proxy-selector (java.net.URI. "http://example.org/index.html")) - https-proxies (.select environment-proxy-selector (java.net.URI. "https://example.org/index.html")) - excluded-proxies (.select environment-proxy-selector (java.net.URI. "http://localhost:8080/do"))] - (is (= (count http-proxies) (count https-proxies) (count excluded-proxies))) - (is (= (get http-proxies 0) java.net.Proxy/NO_PROXY)) - (is (= (get https-proxies 0) (http/->Proxy {:host "www.example.org" :type :http :port 443}))) - (is (= (get excluded-proxies 0) java.net.Proxy/NO_PROXY))))) + (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT))))) (deftest cookie-handler-test (testing "nil passthrough" From d702787a7d9eac3782e46b25398a6fb94f318e49 Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Tue, 12 May 2026 08:00:54 +0200 Subject: [PATCH 5/8] Removed leftover default proxy functionality. --- src/babashka/http_client/internal.clj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index 0022f8e..889921b 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -217,8 +217,7 @@ :user-agent (str "babashka.http-client/" iv/version)}}}) (def default-client - (let [default-proxy-selector (proxy-selector-from-env)] - (delay (client (merge default-client-opts (when default-proxy-selector {:proxy default-proxy-selector})))))) + (delay (client default-client-opts))) (defn- method-keyword->str [method] (str/upper-case (name method))) From 985c8e7ebc0904e7bc35b2614e74327c01b53b30 Mon Sep 17 00:00:00 2001 From: Jan Seeger Date: Mon, 15 Jun 2026 14:09:50 +0200 Subject: [PATCH 6/8] Implement PR fixes. - Removed `->Proxy` function from public interface, dynamic proxy functions now return a map of `:host`, `:port` and `:type`. - Support `:type` for static proxy configuration. Allows specifying a static socks proxy now. - Moved proxy selector dispatch on argument type into internal package. --- src/babashka/http_client.clj | 25 ++++---------- src/babashka/http_client/internal.clj | 47 +++++++++++++++------------ test/babashka/http_client_test.clj | 10 +++++- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/babashka/http_client.clj b/src/babashka/http_client.clj index 53b5380..f6ae9be 100644 --- a/src/babashka/http_client.clj +++ b/src/babashka/http_client.clj @@ -7,30 +7,17 @@ i/default-client-opts) (defn ->ProxySelector - "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` actions, or a proxy - creation function that takes a java.net.URI and returns a proxy configuration for ->Proxy. + "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` and `type` options or a proxy + creation function that takes a java.net.URI and returns a map of these parameters for creating a proxy dynamically. Options: * `:host` - string * `:port` - long - Proxy function: - * `fn`: A one-argument function receiving a java.net.URI argument and returning a proxy configuration for ->Proxy or nil - (meaning don't use a proxy)." + * `:type` - `:direct` (don't use a proxy, host and port will be ignored), `:socks` (socks proxy) or `:http` (HTTP proxy, default value)." [opts-or-fn] - (cond - (instance? java.net.ProxySelector opts-or-fn) opts-or-fn - (map? opts-or-fn) (i/->ProxySelector opts-or-fn) - :else (i/fn->ProxySelector opts-or-fn))) - -(defn ->Proxy - "Constructs a `java.net.Proxy`. - - Options: - * `:host` - string - * `:port` - long - * `:type` - One of `:direct` (no proxy), `:socks` (socks proxy) or `:http` (application level proxy)." - [opts] - (i/->Proxy opts)) + (if (instance? java.net.ProxySelector opts-or-fn) + opts-or-fn + (i/->ProxySelector opts-or-fn))) (defn ->SSLContext "Constructs a `javax.net.ssl.SSLContext`. diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index 889921b..b4cf7f1 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -103,34 +103,41 @@ trust-managers (SecureRandom.)))))) -(defn ->ProxySelector - [opts] - (if (instance? java.net.ProxySelector opts) - opts - (let [{:keys [host port]} opts] - (cond (and host port) - (java.net.ProxySelector/of (java.net.InetSocketAddress. ^String host ^long port)))))) - (defn ->Proxy [opts] (if (instance? java.net.Proxy opts) opts - (let [{:keys [host port type]} opts] + (let [{:keys [host port type] :or {type :http}} opts] (cond (= type :direct) java.net.Proxy/NO_PROXY (and host port type) - (java.net.Proxy. (->proxy-type type) (java.net.InetSocketAddress. ^String host ^long port)))))) - -(defn fn->ProxySelector [proxy-fn] - (proxy [java.net.ProxySelector] [] - (connectFailed [_ _ _]) - (select [^URI uri] - ;; Only allow the proxy function to return a single proxy. - ;; I don't really see the use case for multiple. - [(if-let [proxy-opts (proxy-fn uri)] - (->Proxy proxy-opts) - java.net.Proxy/NO_PROXY)]))) + (java.net.Proxy. (->proxy-type type) (java.net.InetSocketAddress. ^String host ^long port)) + :else + (throw (ex-info "Don't know how to create proxy from options." {:opts opts})))))) + +(defn ->ProxySelector + [opts-or-fn] + (cond + (instance? java.net.ProxySelector opts-or-fn) + opts-or-fn + (fn? opts-or-fn) + ;; Create a dynamic proxy selector. + (proxy [java.net.ProxySelector] [] + (connectFailed [_ _ _]) + (select [^URI uri] + ;; Only allow the proxy function to return a single proxy. + ;; Returning multiple is currently not supported. + [(if-let [proxy-values (opts-or-fn uri)] + (->Proxy proxy-values) + java.net.Proxy/NO_PROXY)])) + :else + ;; Return a static proxy configuration that always returns the same proxy. + (let [static-proxy (->Proxy opts-or-fn)] + (proxy [java.net.ProxySelector] [] + (connectFailed [_ _ _]) + (select [^URI _uri] + [static-proxy]))))) (defn ->Authenticator [v] diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index 037f5d3..b7bd49c 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -528,7 +528,15 @@ proxies-for-https (.select complex-proxy-selector (java.net.URI. "https://www.example.org"))] (is (= (count proxies-for-http) (count proxies-for-https) 1)) (is (= (.type (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) - (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT))))) + (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT)))) + (let [^java.net.ProxySelector static-proxy-selector (http/->ProxySelector + {:host "127.0.0.1" + :port 8081 + :type :socks})] + (let [selected-proxies (.select static-proxy-selector (java.net.URI. "http://www.example.org"))] + (is (= (count selected-proxies) 1)) + (is (= (.type (selected-proxies 0)) java.net.Proxy$Type/SOCKS)) + (is (= (.address (selected-proxies 0)) (java.net.InetSocketAddress. "127.0.0.1" 8081)))))) (deftest cookie-handler-test (testing "nil passthrough" From e7586475728913818690213eeadd5c2ab85aab02 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Sun, 26 Jul 2026 13:31:45 +0200 Subject: [PATCH 7/8] Proxy selector: drop :socks, java.net.http supports HTTP proxies only --- CHANGELOG.md | 2 +- src/babashka/http_client.clj | 8 ++-- src/babashka/http_client/internal.clj | 22 ++++------ test/babashka/http_client_test.clj | 60 ++++++++++++++++----------- 4 files changed, 51 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ff510..4c014f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Babashka [http-client](https://github.com/babashka/http-client): HTTP client for ## Unreleased -- [#80](https://github.com/babashka/http-client/issues/80): add idiomatic proxy configuration functionality +- [#80](https://github.com/babashka/http-client/issues/80): accept a function of the request URI in `:proxy` to select a proxy per request ([@jeeger](https://github.com/jeeger)) ## 0.4.23 (2025-06-06) diff --git a/src/babashka/http_client.clj b/src/babashka/http_client.clj index f6ae9be..27c0a94 100644 --- a/src/babashka/http_client.clj +++ b/src/babashka/http_client.clj @@ -7,13 +7,15 @@ i/default-client-opts) (defn ->ProxySelector - "Constructs a `java.net.ProxySelector`. Can either take a map of `:host` and `:port` and `type` options or a proxy - creation function that takes a java.net.URI and returns a map of these parameters for creating a proxy dynamically. + "Constructs a `java.net.ProxySelector` from a map of options, or from a + function of a `java.net.URI` returning such a map to select a proxy per + request. The function may return `nil` to connect directly. Options: * `:host` - string * `:port` - long - * `:type` - `:direct` (don't use a proxy, host and port will be ignored), `:socks` (socks proxy) or `:http` (HTTP proxy, default value)." + * `:type` - `:http` (default) or `:direct`, which ignores `:host` and `:port`. + `java.net.http` connects through HTTP proxies only." [opts-or-fn] (if (instance? java.net.ProxySelector opts-or-fn) opts-or-fn diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index b4cf7f1..bb6c03e 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -36,12 +36,6 @@ :never HttpClient$Redirect/NEVER :normal HttpClient$Redirect/NORMAL)) -(defn- ->proxy-type [type] - (case type - :direct java.net.Proxy$Type/DIRECT - :socks java.net.Proxy$Type/SOCKS - :http java.net.Proxy$Type/HTTP)) - (defn- version-keyword->version-enum [version] (case version :http1.1 HttpClient$Version/HTTP_1_1 @@ -108,13 +102,15 @@ (if (instance? java.net.Proxy opts) opts (let [{:keys [host port type] :or {type :http}} opts] - (cond - (= type :direct) - java.net.Proxy/NO_PROXY - (and host port type) - (java.net.Proxy. (->proxy-type type) (java.net.InetSocketAddress. ^String host ^long port)) - :else - (throw (ex-info "Don't know how to create proxy from options." {:opts opts})))))) + (case type + :direct java.net.Proxy/NO_PROXY + :http (if (and host port) + (java.net.Proxy. java.net.Proxy$Type/HTTP + (java.net.InetSocketAddress. ^String host ^long port)) + (throw (ex-info "Proxy needs both :host and :port." {:opts opts}))) + (throw (ex-info (str "Unsupported proxy type: " (pr-str type) + ". java.net.http connects through HTTP proxies only.") + {:opts opts})))))) (defn ->ProxySelector [opts-or-fn] diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index b7bd49c..b72efcc 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -511,32 +511,44 @@ (deftest proxy-selector (is (instance? java.net.ProxySelector - (http/->ProxySelector {:host "https://clojure.org" + (http/->ProxySelector {:host "clojure.org" :port 1337}))) - ;; Check passthrough behavior. - (is (instance? java.net.ProxySelector - (http/->ProxySelector - (http/->ProxySelector {:host "https://clojure.org" - :port 1337})))) - (let [^java.net.ProxySelector complex-proxy-selector (http/->ProxySelector (fn [^java.net.URI uri] - (when (= (.getScheme uri) "http") - {:host "http://www.example.org" - :port 128 - :type :http})))] - (is (instance? java.net.ProxySelector complex-proxy-selector)) - (let [proxies-for-http (.select complex-proxy-selector (java.net.URI. "http://www.example.org")) - proxies-for-https (.select complex-proxy-selector (java.net.URI. "https://www.example.org"))] + (testing "a ProxySelector is passed through" + (is (instance? java.net.ProxySelector + (http/->ProxySelector + (http/->ProxySelector {:host "clojure.org" + :port 1337}))))) + (testing "a function selects a proxy per request" + (let [^java.net.ProxySelector selector + (http/->ProxySelector (fn [^java.net.URI uri] + (when (= (.getScheme uri) "http") + {:host "www.example.org" + :port 128}))) + proxies-for-http (.select selector (java.net.URI. "http://www.example.org")) + proxies-for-https (.select selector (java.net.URI. "https://www.example.org"))] (is (= (count proxies-for-http) (count proxies-for-https) 1)) - (is (= (.type (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) - (is (= (.type (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT)))) - (let [^java.net.ProxySelector static-proxy-selector (http/->ProxySelector - {:host "127.0.0.1" - :port 8081 - :type :socks})] - (let [selected-proxies (.select static-proxy-selector (java.net.URI. "http://www.example.org"))] - (is (= (count selected-proxies) 1)) - (is (= (.type (selected-proxies 0)) java.net.Proxy$Type/SOCKS)) - (is (= (.address (selected-proxies 0)) (java.net.InetSocketAddress. "127.0.0.1" 8081)))))) + (is (= (.type ^java.net.Proxy (get proxies-for-http 0)) java.net.Proxy$Type/HTTP)) + (is (= (.address ^java.net.Proxy (get proxies-for-http 0)) + (java.net.InetSocketAddress. "www.example.org" 128))) + (testing "returning nil connects directly" + (is (= (.type ^java.net.Proxy (get proxies-for-https 0)) java.net.Proxy$Type/DIRECT))))) + (testing "a map always selects the same proxy" + (let [^java.net.ProxySelector selector (http/->ProxySelector {:host "127.0.0.1" + :port 8081}) + selected (.select selector (java.net.URI. "http://www.example.org"))] + (is (= (count selected) 1)) + (is (= (.type ^java.net.Proxy (selected 0)) java.net.Proxy$Type/HTTP)) + (is (= (.address ^java.net.Proxy (selected 0)) (java.net.InetSocketAddress. "127.0.0.1" 8081))))) + (testing ":direct ignores host and port" + (let [^java.net.ProxySelector selector (http/->ProxySelector {:type :direct})] + (is (= (.type ^java.net.Proxy ((.select selector (java.net.URI. "http://www.example.org")) 0)) + java.net.Proxy$Type/DIRECT)))) + (testing "java.net.http supports HTTP proxies only" + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Unsupported proxy type" + (http/->ProxySelector {:host "127.0.0.1" :port 8081 :type :socks})))) + (testing "a proxy needs host and port" + (is (thrown-with-msg? clojure.lang.ExceptionInfo #":host and :port" + (http/->ProxySelector {:host "127.0.0.1"}))))) (deftest cookie-handler-test (testing "nil passthrough" From 7bd5ae867ab95ffeda9bb0e82e23bc9c1470f4c0 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Sun, 26 Jul 2026 13:42:14 +0200 Subject: [PATCH 8/8] Keep ->Proxy private, it is an implementation detail of ->ProxySelector --- src/babashka/http_client/internal.clj | 2 +- test/babashka/http_client_test.clj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/babashka/http_client/internal.clj b/src/babashka/http_client/internal.clj index bb6c03e..b7aa123 100644 --- a/src/babashka/http_client/internal.clj +++ b/src/babashka/http_client/internal.clj @@ -97,7 +97,7 @@ trust-managers (SecureRandom.)))))) -(defn ->Proxy +(defn- ->Proxy [opts] (if (instance? java.net.Proxy opts) opts diff --git a/test/babashka/http_client_test.clj b/test/babashka/http_client_test.clj index b72efcc..bee92a9 100644 --- a/test/babashka/http_client_test.clj +++ b/test/babashka/http_client_test.clj @@ -3,7 +3,6 @@ [babashka.fs :as fs] [babashka.http-client :as http] [babashka.http-client.interceptors :as i] - [babashka.http-client.internal :as internal] [babashka.http-client.internal.version :as iv] [borkdude.deflet :refer [deflet]] [cheshire.core :as json]