├── .clj-kondo └── borkdude │ └── deflet │ ├── borkdude │ └── deflet.clj_kondo │ └── config.edn ├── .dir-locals.el ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── API.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bb.edn ├── build.clj ├── deps.edn ├── dev └── user.clj ├── icon.png ├── script ├── changelog.clj └── test_clj.clj ├── src └── babashka │ ├── http_client.clj │ └── http_client │ ├── interceptors.clj │ ├── internal.clj │ ├── internal │ ├── helpers.clj │ ├── multipart.clj │ ├── version.clj │ └── websocket.clj │ └── websocket.clj └── test ├── babashka ├── http_client │ ├── internal │ │ └── helpers_test.clj │ └── websocket_test.clj └── http_client_test.clj └── keystore.p12 /.clj-kondo/borkdude/deflet/borkdude/deflet.clj_kondo: -------------------------------------------------------------------------------- 1 | (ns borkdude.deflet 2 | (:require [clj-kondo.hooks-api :as hooks-api])) 3 | 4 | (defn deflet* [children] 5 | (let [f (first children) 6 | r (next children)] 7 | (if (and (hooks-api/list-node? f) 8 | (#{'def 'defp} (hooks-api/sexpr (first (:children f))))) 9 | (let [def-children (:children f)] 10 | (with-meta (hooks-api/list-node 11 | [(hooks-api/coerce 'clojure.core/let) 12 | (hooks-api/vector-node [(second def-children) 13 | (nth def-children 2)]) 14 | (deflet* r)]) 15 | (meta f))) 16 | (if-not r (or f (hooks-api/coerce nil)) 17 | (with-meta 18 | (hooks-api/list-node (list (hooks-api/coerce 'do) 19 | f 20 | (deflet* r))) 21 | (meta f)))))) 22 | 23 | (defn deflet [{:keys [node]}] 24 | (let [children (:children node) 25 | new-node (deflet* children)] 26 | {:node new-node})) 27 | -------------------------------------------------------------------------------- /.clj-kondo/borkdude/deflet/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {borkdude.deflet/defp clojure.core/def} 2 | :hooks {:analyze-call {borkdude.deflet/deflet borkdude.deflet/deflet 3 | borkdude.deflet/defletp borkdude.deflet/deflet}}} 4 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((clojure-mode 2 | (cider-clojure-cli-aliases . ":test:repl"))) 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Please answer the following questions and leave the below in as part of your PR. 2 | 3 | - [ ] I have read the [developer documentation](https://github.com/babashka/babashka/blob/master/doc/dev.md). 4 | 5 | - [ ] This PR corresponds to an [issue with a clear problem statement](https://github.com/babashka/babashka/blob/master/doc/dev.md#start-with-an-issue-before-writing-code). 6 | 7 | - [ ] This PR contains a [test](https://github.com/babashka/babashka/blob/master/doc/dev.md#tests) to prevent against future regressions 8 | 9 | - [ ] I have updated the [CHANGELOG.md](https://github.com/babashka/babashka/blob/master/CHANGELOG.md) file with a description of the addressed issue. 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | test: 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | # It is important to install java before installing clojure tools which needs java 20 | # exclusions: babashka, clj-kondo and cljstyle 21 | - name: Prepare java 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: 'zulu' 25 | java-version: '11' 26 | 27 | - name: Install clojure tools 28 | uses: DeLaGuardo/setup-clojure@12.5 29 | with: 30 | # Install just one or all simultaneously 31 | # The value must indicate a particular version of the tool, or use 'latest' 32 | # to always provision the latest version 33 | bb: latest 34 | 35 | # Optional step: 36 | - name: Cache clojure dependencies 37 | uses: actions/cache@v4 38 | with: 39 | path: | 40 | ~/.m2/repository 41 | ~/.gitlibs 42 | ~/.deps.clj 43 | # List all files containing dependencies: 44 | key: cljdeps-${{ hashFiles('deps.edn') }} 45 | # key: cljdeps-${{ hashFiles('deps.edn', 'bb.edn') }} 46 | # key: cljdeps-${{ hashFiles('project.clj') }} 47 | # key: cljdeps-${{ hashFiles('build.boot') }} 48 | restore-keys: cljdeps- 49 | 50 | - name: Run clj tests 51 | run: bb test:clj :clj-all 52 | 53 | - name: Run bb tests 54 | run: bb test:bb 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .cpcache 3 | target 4 | .nrepl-port 5 | src/scratch.clj 6 | scratch.clj 7 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | - [`babashka.http-client`](#babashka.http-client) 3 | - [`->Authenticator`](#babashka.http-client/->Authenticator) - Constructs a java.net.Authenticator. 4 | - [`->CookieHandler`](#babashka.http-client/->CookieHandler) - Constructs a java.net.CookieHandler using java.net.CookieManager. 5 | - [`->Executor`](#babashka.http-client/->Executor) - Constructs a java.util.concurrent.Executor. 6 | - [`->ProxySelector`](#babashka.http-client/->ProxySelector) - Constructs a java.net.ProxySelector. 7 | - [`->SSLContext`](#babashka.http-client/->SSLContext) - Constructs a javax.net.ssl.SSLContext. 8 | - [`->SSLParameters`](#babashka.http-client/->SSLParameters) - Constructs a javax.net.ssl.SSLParameters. 9 | - [`client`](#babashka.http-client/client) - Construct a custom client. 10 | - [`default-client-opts`](#babashka.http-client/default-client-opts) - Options used to create the (implicit) default client. 11 | - [`delete`](#babashka.http-client/delete) - Convenience wrapper for request with method :delete. 12 | - [`get`](#babashka.http-client/get) - Convenience wrapper for request with method :get. 13 | - [`head`](#babashka.http-client/head) - Convenience wrapper for request with method :head. 14 | - [`patch`](#babashka.http-client/patch) - Convenience wrapper for request with method :patch. 15 | - [`post`](#babashka.http-client/post) - Convenience wrapper for request with method :post. 16 | - [`put`](#babashka.http-client/put) - Convenience wrapper for request with method :put. 17 | - [`request`](#babashka.http-client/request) - Perform request. 18 | - [`babashka.http-client.interceptors`](#babashka.http-client.interceptors) 19 | - [`accept-header`](#babashka.http-client.interceptors/accept-header) - Request: adds :accept header. 20 | - [`basic-auth`](#babashka.http-client.interceptors/basic-auth) - Request: adds :authorization header based on :basic-auth (a map of :user and :pass) in request. 21 | - [`construct-uri`](#babashka.http-client.interceptors/construct-uri) - Request: construct uri from map. 22 | - [`decode-body`](#babashka.http-client.interceptors/decode-body) - Response: based on the value of :as in request, decodes as :string, :stream or :bytes. 23 | - [`decompress-body`](#babashka.http-client.interceptors/decompress-body) - Response: decompresses body based on "content-encoding" header. 24 | - [`default-interceptors`](#babashka.http-client.interceptors/default-interceptors) - Default interceptor chain. 25 | - [`form-params`](#babashka.http-client.interceptors/form-params) - Request: encodes :form-params map and adds :body. 26 | - [`multipart`](#babashka.http-client.interceptors/multipart) - Adds appropriate body and header if making a multipart request. 27 | - [`oauth-token`](#babashka.http-client.interceptors/oauth-token) - Request: adds :authorization header based on :oauth-token (a string token) in request. 28 | - [`query-params`](#babashka.http-client.interceptors/query-params) - Request: encodes :query-params map and appends to :uri. 29 | - [`throw-on-exceptional-status-code`](#babashka.http-client.interceptors/throw-on-exceptional-status-code) - Response: throw on exceptional status codes. 30 | - [`unexceptional-statuses`](#babashka.http-client.interceptors/unexceptional-statuses) 31 | - [`uri-with-query`](#babashka.http-client.interceptors/uri-with-query) - We can't use the URI constructor because it encodes all arguments for us. 32 | - [`babashka.http-client.websocket`](#babashka.http-client.websocket) - Code is very much based on hato's websocket code. 33 | - [`abort!`](#babashka.http-client.websocket/abort!) - Closes this WebSocket's input and output abruptly. 34 | - [`close!`](#babashka.http-client.websocket/close!) - Initiates an orderly closure of this WebSocket's output by sending a Close message with the given status code and the reason. 35 | - [`ping!`](#babashka.http-client.websocket/ping!) - Sends a Ping message with bytes from the given buffer. 36 | - [`pong!`](#babashka.http-client.websocket/pong!) - Sends a Pong message with bytes from the given buffer. 37 | - [`send!`](#babashka.http-client.websocket/send!) - Sends a message to the WebSocket. 38 | - [`websocket`](#babashka.http-client.websocket/websocket) - Builds java.net.http.Websocket client. 39 | 40 | ----- 41 | # babashka.http-client 42 | 43 | 44 | 45 | 46 | 47 | 48 | ## `->Authenticator` 49 | ``` clojure 50 | 51 | (->Authenticator opts) 52 | ``` 53 | 54 | Constructs a `java.net.Authenticator`. 55 | 56 | Options: 57 | 58 | * `:user` - the username 59 | * `:pass` - the password 60 |

Source

61 | 62 | ## `->CookieHandler` 63 | ``` clojure 64 | 65 | (->CookieHandler opts) 66 | ``` 67 | 68 | Constructs a `java.net.CookieHandler` using `java.net.CookieManager`. 69 | 70 | Options: 71 | 72 | * `:store` - an optional `java.net.CookieStore` implementation 73 | * `:policy` - a `java.net.CookiePolicy` or one of `:accept-all`, `:accept-none`, `:original-server` 74 |

Source

75 | 76 | ## `->Executor` 77 | ``` clojure 78 | 79 | (->Executor opts) 80 | ``` 81 | 82 | Constructs a `java.util.concurrent.Executor`. 83 | 84 | Options: 85 | 86 | * `:threads` - constructs a `ThreadPoolExecutor` with the specified number of threads 87 |

Source

88 | 89 | ## `->ProxySelector` 90 | ``` clojure 91 | 92 | (->ProxySelector opts) 93 | ``` 94 | 95 | Constructs a `java.net.ProxySelector`. 96 | Options: 97 | * `:host` - string 98 | * `:port` - long 99 |

Source

100 | 101 | ## `->SSLContext` 102 | ``` clojure 103 | 104 | (->SSLContext opts) 105 | ``` 106 | 107 | Constructs a `javax.net.ssl.SSLContext`. 108 | 109 | Options: 110 | 111 | * `:key-store` - a file, URI or URL or anything else that is compatible with `io/input-stream`, e.g. (io/resource somepath.p12) 112 | * `:key-store-pass` - the password for the keystore 113 | * `:key-store-type` - the type of keystore to create [note: not the type of the file] (default: pkcs12) 114 | * `:trust-store` - a file, URI or URL or anything else that is compatible with `io/input-stream`, e.g. (io/resource somepath.p12) 115 | * `:trust-store-pass` - the password for the trust store 116 | * `:trust-store-type` - the type of trust store to create [note: not the type of the file] (default: pkcs12) 117 | * `:insecure` - if `true`, an insecure trust manager accepting all server certificates will be configured. 118 | 119 | Note that `:keystore` and `:truststore` can be set using the 120 | `javax.net.ssl.keyStore` and `javax.net.ssl.trustStore` System 121 | properties globally. 122 |

Source

123 | 124 | ## `->SSLParameters` 125 | ``` clojure 126 | 127 | (->SSLParameters opts) 128 | ``` 129 | 130 | Constructs a `javax.net.ssl.SSLParameters`. 131 | 132 | Options: 133 | 134 | * `:ciphers` - a list of cipher suite names 135 | * `:protocols` - a list of protocol names 136 |

Source

137 | 138 | ## `client` 139 | ``` clojure 140 | 141 | (client opts) 142 | ``` 143 | 144 | Construct a custom client. To get the same behavior as the (implicit) default client, pass [`default-client-opts`](#babashka.http-client/default-client-opts). 145 | 146 | Options: 147 | * `:follow-redirects` - `:never`, `:always` or `:normal` 148 | * `:connect-timeout` - connection timeout in milliseconds. 149 | * `:request` - default request options which will be used in requests made with this client. 150 | * `:executor` - a `java.util.concurrent.Executor` or a map of options, see docstring of [`->Executor`](#babashka.http-client/->Executor) 151 | * `:ssl-context` - a `javax.net.ssl.SSLContext` or a map of options, see docstring of [`->SSLContext`](#babashka.http-client/->SSLContext). 152 | * `:ssl-parameters` - a `javax.net.ssl.SSLParameters' or a map of options, see docstring of `->SSLParameters`. 153 | * `:proxy` - a `java.net.ProxySelector` or a map of options, see docstring of [`->ProxySelector`](#babashka.http-client/->ProxySelector). 154 | * `:authenticator` - a `java.net.Authenticator` or a map of options, see docstring of [`->Authenticator`](#babashka.http-client/->Authenticator). 155 | * `:cookie-handler` - a `java.net.CookieHandler` or a map of options, see docstring of [`->CookieHandler`](#babashka.http-client/->CookieHandler). 156 | * `:version` - the HTTP version: `:http1.1` or `:http2` (default: `:http2`). 157 | * `:priority` - priority for HTTP2 requests, integer between 1-256 inclusive. 158 | 159 | Returns map with: 160 | 161 | * `:client` - a `java.net.http.HttpClient`. 162 | 163 | The map can be passed to [`request`](#babashka.http-client/request) via the `:client` key. 164 | 165 |

Source

166 | 167 | ## `default-client-opts` 168 | 169 | 170 | 171 | 172 | Options used to create the (implicit) default client. 173 |

Source

174 | 175 | ## `delete` 176 | ``` clojure 177 | 178 | (delete uri) 179 | (delete uri opts) 180 | ``` 181 | 182 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:delete` 183 |

Source

184 | 185 | ## `get` 186 | ``` clojure 187 | 188 | (get uri) 189 | (get uri opts) 190 | ``` 191 | 192 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:get` 193 |

Source

194 | 195 | ## `head` 196 | ``` clojure 197 | 198 | (head uri) 199 | (head uri opts) 200 | ``` 201 | 202 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:head` 203 |

Source

204 | 205 | ## `patch` 206 | ``` clojure 207 | 208 | (patch url) 209 | (patch url opts) 210 | ``` 211 | 212 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:patch` 213 |

Source

214 | 215 | ## `post` 216 | ``` clojure 217 | 218 | (post uri) 219 | (post uri opts) 220 | ``` 221 | 222 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:post` 223 |

Source

224 | 225 | ## `put` 226 | ``` clojure 227 | 228 | (put url) 229 | (put url opts) 230 | ``` 231 | 232 | Convenience wrapper for [`request`](#babashka.http-client/request) with method `:put` 233 |

Source

234 | 235 | ## `request` 236 | ``` clojure 237 | 238 | (request opts) 239 | ``` 240 | 241 | Perform request. Returns map with at least `:body`, `:status` 242 | 243 | Options: 244 | 245 | * `:uri` - the uri to request (required). 246 | May be a string or map of `:scheme` (required), `:host` (required), `:port`, `:path` and `:query` 247 | * `:headers` - a map of headers 248 | * `:method` - the request method: `:get`, `:post`, `:head`, `:delete`, `:patch` or `:put` 249 | * `:interceptors` - custom interceptor chain 250 | * `:client` - a client as produced by [`client`](#babashka.http-client/client) or a clojure function. If not provided a default client will be used. 251 | When providing :client with a a clojure function, it will be called with the Clojure representation of 252 | the request which can be useful for testing. 253 | * `:query-params` - a map of query params. The values can be a list to send multiple params with the same key. 254 | * `:form-params` - a map of form params to send in the request body. 255 | * `:body` - a file, inputstream or string to send as the request body. 256 | * `:basic-auth` - a sequence of `user` `password` or map with `:user` `:pass` used for basic auth. 257 | * `:oauth-token` - a string token used for bearer auth. 258 | * `:async` - perform request asynchronously. The response will be a `CompletableFuture` of the response map. 259 | * `:async-then` - a function that is called on the async result if successful 260 | * `:async-catch` - a function that is called on the async result if exceptional 261 | * `:timeout` - request timeout in milliseconds 262 | * `:throw` - throw on exceptional status codes, all other than `#{200 201 202 203 204 205 206 207 300 301 302 303 304 307}` 263 | * `:version` - the HTTP version: `:http1.1` or `:http2` (default: `:http2`). 264 |

Source

265 | 266 | ----- 267 | # babashka.http-client.interceptors 268 | 269 | 270 | 271 | 272 | 273 | 274 | ## `accept-header` 275 | 276 | 277 | 278 | 279 | Request: adds `:accept` header. Only supported value is `:json`. 280 |

Source

281 | 282 | ## `basic-auth` 283 | 284 | 285 | 286 | 287 | Request: adds `:authorization` header based on `:basic-auth` (a map 288 | of `:user` and `:pass`) in request. 289 |

Source

290 | 291 | ## `construct-uri` 292 | 293 | 294 | 295 | 296 | Request: construct uri from map 297 |

Source

298 | 299 | ## `decode-body` 300 | 301 | 302 | 303 | 304 | Response: based on the value of `:as` in request, decodes as `:string`, `:stream` or `:bytes`. Defaults to `:string`. 305 |

Source

306 | 307 | ## `decompress-body` 308 | 309 | 310 | 311 | 312 | Response: decompresses body based on "content-encoding" header. Valid values: `gzip` and `deflate`. 313 |

Source

314 | 315 | ## `default-interceptors` 316 | 317 | 318 | 319 | 320 | Default interceptor chain. Interceptors are called in order for request and in reverse order for response. 321 |

Source

322 | 323 | ## `form-params` 324 | 325 | 326 | 327 | 328 | Request: encodes `:form-params` map and adds `:body`. 329 |

Source

330 | 331 | ## `multipart` 332 | 333 | 334 | 335 | 336 | Adds appropriate body and header if making a multipart request. 337 |

Source

338 | 339 | ## `oauth-token` 340 | 341 | 342 | 343 | 344 | Request: adds `:authorization` header based on `:oauth-token` (a string token) 345 | in request. 346 |

Source

347 | 348 | ## `query-params` 349 | 350 | 351 | 352 | 353 | Request: encodes `:query-params` map and appends to `:uri`. 354 |

Source

355 | 356 | ## `throw-on-exceptional-status-code` 357 | 358 | 359 | 360 | 361 | Response: throw on exceptional status codes 362 |

Source

363 | 364 | ## `unexceptional-statuses` 365 | 366 | 367 | 368 |

Source

369 | 370 | ## `uri-with-query` 371 | ``` clojure 372 | 373 | (uri-with-query uri new-query) 374 | ``` 375 | 376 | We can't use the URI constructor because it encodes all arguments for us. 377 | See https://stackoverflow.com/a/77971448/6264 378 |

Source

379 | 380 | ----- 381 | # babashka.http-client.websocket 382 | 383 | 384 | Code is very much based on hato's websocket code. Credits to @gnarroway! 385 | 386 | 387 | 388 | 389 | ## `abort!` 390 | ``` clojure 391 | 392 | (abort! ws) 393 | ``` 394 | 395 | Closes this WebSocket's input and output abruptly. 396 |

Source

397 | 398 | ## `close!` 399 | ``` clojure 400 | 401 | (close! ws) 402 | (close! ws status-code reason) 403 | ``` 404 | 405 | Initiates an orderly closure of this WebSocket's output by sending a 406 | Close message with the given status code and the reason. 407 |

Source

408 | 409 | ## `ping!` 410 | ``` clojure 411 | 412 | (ping! ws data) 413 | ``` 414 | 415 | Sends a Ping message with bytes from the given buffer. 416 |

Source

417 | 418 | ## `pong!` 419 | ``` clojure 420 | 421 | (pong! ws data) 422 | ``` 423 | 424 | Sends a Pong message with bytes from the given buffer. 425 |

Source

426 | 427 | ## `send!` 428 | ``` clojure 429 | 430 | (send! ws data) 431 | (send! ws data opts) 432 | ``` 433 | 434 | Sends a message to the WebSocket. 435 | `data` can be a CharSequence (e.g. string), byte array or ByteBuffer 436 | 437 | Options: 438 | * `:last`: this is the last message, defaults to `true` 439 |

Source

440 | 441 | ## `websocket` 442 | ``` clojure 443 | 444 | (websocket {:keys [client], :as opts}) 445 | ``` 446 | 447 | Builds `java.net.http.Websocket` client. 448 | * `:uri` - the uri to request (required). 449 | May be a string or map of `:scheme` (required), `:host` (required), `:port`, `:path` and `:query` 450 | * `:headers` - a map of headers for the initial handshake` 451 | * `:client` - a client as produced by `client`. If not provided a default client will be used. 452 | * `:connect-timeout` Sets a timeout for establishing a WebSocket connection (in millis). 453 | * `:subprotocols` - sets a request for the given subprotocols. 454 | * `:async` - return `CompleteableFuture` of websocket 455 | 456 | Callbacks options: 457 | * `:on-open` - `[ws]`, called when a `WebSocket` has been connected. 458 | * `:on-message` - `[ws data last]` A textual/binary data has been received. 459 | * `:on-ping` - `[ws data]` A Ping message has been received. 460 | * `:on-pong` - `[ws data]` A Pong message has been received. 461 | * `:on-close` - `[ws status reason]` Receives a Close message indicating the WebSocket's input has been closed. 462 | * `:on-error` - `[ws err]` An error has occurred. 463 |

Source

464 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Babashka [http-client](https://github.com/babashka/http-client): HTTP client for Clojure and babashka built on java.net.http 4 | 5 | ## 0.4.22 (2024-11-11) 6 | 7 | - [#73](https://github.com/babashka/http-client/issues/71): Allow implicit ports when specifying the URL as a map ([@lvh](https://github.com/lvh)) 8 | - [#71](https://github.com/babashka/http-client/issues/71): Link back to sources in release artifact 9 | ([@lread](https://github.com/lread)) 10 | 11 | ## 0.4.21 (2024-09-10) 12 | 13 | - [#68](https://github.com/babashka/http-client/issues/68) Fix accidental URI path decoding in uri-with-query ([@hxtmdev](https://github.com/hxtmdev)) 14 | 15 | ## 0.4.20 (2024-08-13) 16 | 17 | - [#60](https://github.com/babashka/http-client/issues/60): Minimum Clojure version is now 1.10 instead of 1.11 18 | ([@lread](https://github.com/lread)) 19 | 20 | ## 0.4.19 (2024-04-24) 21 | 22 | - [#55](https://github.com/babashka/http-client/issues/55): allow `:body` be `java.net.http.HttpRequest$BodyPublisher` 23 | 24 | ## 0.4.18 (2024-04-18) 25 | 26 | - Support a Clojure function as `:client` option, mostly useful for testing 27 | 28 | ## 0.4.17 (2024-04-12) 29 | 30 | - [#49](https://github.com/babashka/http-client/issues/49): add `::oauth-token` interceptor 31 | - [#52](https://github.com/babashka/http-client/issues/52): document `:throw` option 32 | 33 | ## 0.4.16 (2024-02-10) 34 | 35 | - [#45](https://github.com/babashka/http-client/issues/45): query param values are double encoded 36 | 37 | ## 0.4.15 (2023-09-04) 38 | 39 | - [#43](https://github.com/babashka/http-client/issues/43): when using a string key for `Accept` header, the value is overridden by the default 40 | 41 | ## 0.4.14 (2023-08-17) 42 | 43 | - [#41](https://github.com/babashka/http-client/issues/41): add `:uri` to response map 44 | 45 | ## 0.4.13 (2023-08-08) 46 | 47 | - [#38](https://github.com/babashka/http-client/issues/38): Fix double wrapping of futures on exceptions during async requests ([@axvr](https://github.com/axvr)) 48 | 49 | ## 0.4.12 50 | 51 | - Add `babashka.http-client.websocket` API (mostly based on hato, thanks [@gnarroway](https://github.com/gnarroway)). See [API docs](https://github.com/babashka/http-client/blob/main/API.md#babashka.http-client.websocket). 52 | - The `:ssl-context {:insecure true}` option was made more accepting, see babashka issue [#1587](https://github.com/babashka/babashka/issues/1587) 53 | - [#32](https://github.com/babashka/http-client/issues/32): Documentation updates for missing parameters and functions ([@casselc](https://github.com/casselc)) 54 | - [#34](https://github.com/babashka/http-client/issues/34): add construction helpers for `:cookie-handler`, `:ssl-parameters`, and `:executor` ([@casselc](https://github.com/casselc)) 55 | 56 | ## 0.3.11 57 | 58 | - Fix [#28](https://github.com/babashka/http-client/issues/28): add `:authenticator` option 59 | 60 | ## 0.2.9 61 | 62 | - Accept `java.net.URI` as uri directly in `request`, `get`, etc. 63 | - [#22](https://github.com/babashka/http-client/issues/22): Support options for `:ssl-context`, similar to hato 64 | - [#23](https://github.com/babashka/http-client/issues/23): ease construction of `ProxySelector` via `:proxy` key 65 | 66 | ## 0.1.8 67 | 68 | - Fix binary file uploads 69 | 70 | ## 0.1.7 71 | 72 | - Add `:async-then` and `:async-catch` callbacks that go together with `:async` 73 | - Change `:follow-redirects` option from `:always` to the safer `:normal` 74 | 75 | ## 0.1.6 76 | 77 | - Merge client `:request` options earlier to pick up on `:interceptors` settings 78 | 79 | ## 0.1.5 80 | 81 | - Add `http/put` convenience function 82 | 83 | ## 0.1.4 84 | 85 | - Implement `:multipart` uploads, largely based on [hato](https://github.com/gnarroway/hato)'s implementation 86 | - [#13](https://github.com/babashka/http-client/issues/13): Add a default user-agent header: `babashka.http-client/` ([@lispyclouds](https://github.com/lispyclouds)) 87 | 88 | ## 0.0.3 89 | 90 | - [#12](https://github.com/babashka/http-client/issues/12): Do not uncompress (empty) body of `:head` request 91 | 92 | ## 0.0.2 93 | 94 | - Introduce `:request` option in `client` function for passing default request options via client. 95 | - Expose `default-client-opts`, a map which can be used to get same behavior as (implicit) default client 96 | - Accept `gzip` and `deflate` as encoding in default client 97 | - Set accept header to `*/*` in default client 98 | 99 | ## 0.0.1 100 | 101 | Initial version 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-client 2 | 3 | [![Clojars Project](https://img.shields.io/clojars/v/org.babashka/http-client.svg)](https://clojars.org/org.babashka/http-client) 4 | [![bb built-in](https://raw.githubusercontent.com/babashka/babashka/master/logo/built-in-badge.svg)](https://book.babashka.org#badges) 5 | 6 | An HTTP client for Clojure and Babashka built on `java.net.http`. 7 | 8 | ## API 9 | 10 | See [API.md](API.md). 11 | 12 | > NOTE: The `babashka.http-client` library is built-in as of babashka version 1.1.171. 13 | 14 | > TIP: We test and support `babashka.http-client` on Clojure v1.10 and above. 15 | 16 | ## Installation 17 | 18 | Use as a dependency in `deps.edn` or `bb.edn`: 19 | 20 | ``` clojure 21 | org.babashka/http-client {:mvn/version "0.4.22"} 22 | ``` 23 | 24 | ## Rationale 25 | 26 | Babashka has several built-in options for making HTTP requests, including: 27 | 28 | - [babashka.curl](https://github.com/babashka/babashka.curl) 29 | - [http-kit](https://github.com/http-kit/http-kit) 30 | - [java.net.http](https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/package-summary.html) 31 | 32 | In addition, it allows to use several libraries to be used as a dependency: 33 | 34 | - [java-http-clj](https://github.com/schmee/java-http-clj) 35 | - [hato](https://github.com/gnarroway/hato) 36 | - [clj-http-lite](https://github.com/clj-commons/clj-http-lite) 37 | 38 | The built-in clients come with their own trade-offs. E.g. babashka.curl shells 39 | out to `curl` which on Windows requires your local `curl` to be 40 | updated. Http-kit buffers the entire response in memory. Using `java.net.http` 41 | directly can be a bit verbose. 42 | 43 | Babashka's http-client aims to be a good default for most scripting use cases 44 | and is built on top of `java.net.http` and can be used as a dependency-free JVM 45 | library as well. The API is mostly compatible with babashka.curl so it can be 46 | used as a drop-in replacement. The other built-in solutions will not be removed 47 | any time soon. 48 | 49 | ## Usage 50 | 51 | The APIs in this library are mostly compatible with 52 | [babashka.curl](https://github.com/babashka/babashka.curl), which is in turn 53 | inspired by libraries like [clj-http](https://github.com/dakrone/clj-http). 54 | 55 | ``` clojure 56 | (require '[babashka.http-client :as http]) 57 | (require '[clojure.java.io :as io]) ;; optional 58 | (require '[cheshire.core :as json]) ;; optional 59 | ``` 60 | 61 | ### GET 62 | 63 | Simple `GET` request: 64 | 65 | ``` clojure 66 | (http/get "https://httpstat.us/200") 67 | ;;=> {:status 200, :body "200 OK", :headers { ... }} 68 | ``` 69 | 70 | ### Headers 71 | 72 | Passing headers: 73 | 74 | ``` clojure 75 | (def resp (http/get "https://httpstat.us/200" {:headers {"Accept" "application/json"}})) 76 | (json/parse-string (:body resp)) ;;=> {"code" 200, "description" "OK"} 77 | ``` 78 | 79 | Headers may be provided as keywords as well: 80 | 81 | ``` clojure 82 | {:headers {:content-type "application/json"}} 83 | ``` 84 | 85 | ### Query parameters 86 | 87 | Query parameters: 88 | 89 | ``` clojure 90 | (-> 91 | (http/get "https://postman-echo.com/get" {:query-params {"q" "clojure"}}) 92 | :body 93 | (json/parse-string true) 94 | :args) 95 | ;;=> {:q "clojure"} 96 | ``` 97 | 98 | To send multiple params to the same key: 99 | ```clojure 100 | ;; https://postman-echo.com/get?q=clojure&q=curl 101 | 102 | (-> (http/get "https://postman-echo.com/get" {:query-params {:q ["clojure" "curl"]}}) 103 | :body (json/parse-string true) :args) 104 | ;;=> {:q ["clojure" "curl"]} 105 | ``` 106 | 107 | ### POST 108 | 109 | A `POST` request with a `:body`: 110 | 111 | ``` clojure 112 | (def resp (http/post "https://postman-echo.com/post" {:body "From Clojure"})) 113 | (json/parse-string (:body resp)) ;;=> {"args" {}, "data" "From Clojure", ...} 114 | ``` 115 | 116 | A `POST` request with a JSON `:body`: 117 | 118 | ``` clojure 119 | (def resp (http/post "https://postman-echo.com/post" 120 | {:headers {:content-type "application/json"} 121 | :body (json/encode {:a 1 :b "2"})})) 122 | (:data (json/parse-string (:body resp) true)) ;;=> {:a 1, :b "2"} 123 | ``` 124 | 125 | Posting a file as a `POST` body: 126 | 127 | ``` clojure 128 | (:status (http/post "https://postman-echo.com/post" {:body (io/file "README.md")})) 129 | ;; => 200 130 | ``` 131 | 132 | Posting a stream as a `POST` body: 133 | 134 | ``` clojure 135 | (:status (http/post "https://postman-echo.com/post" {:body (io/input-stream "README.md")})) 136 | ;; => 200 137 | ``` 138 | 139 | Posting form params: 140 | 141 | ``` clojure 142 | (:status (http/post "https://postman-echo.com/post" {:form-params {"name" "Michiel"}})) 143 | ;; => 200 144 | ``` 145 | 146 | ### Basic auth 147 | 148 | Basic auth: 149 | 150 | ``` clojure 151 | (:body (http/get "https://postman-echo.com/basic-auth" {:basic-auth ["postman" "password"]})) 152 | ;; => "{\"authenticated\":true}" 153 | ``` 154 | ### Oauth token 155 | 156 | Oauth token: 157 | 158 | ``` clojure 159 | (:body (http/get "https://httpbin.org/bearer" {:oauth-token "qwertyuiop"})) 160 | ;; => "{\n \"authenticated\": true, \n \"token\": \"qwertyuiop\"\n}\n" 161 | ``` 162 | 163 | ### Streaming 164 | 165 | With `:as :stream`: 166 | 167 | ``` clojure 168 | (:body (http/get "https://github.com/babashka/babashka/raw/master/logo/icon.png" 169 | {:as :stream})) 170 | ``` 171 | 172 | will return the raw input stream. 173 | 174 | ### Download binary 175 | 176 | Download a binary file: 177 | 178 | ``` clojure 179 | (io/copy 180 | (:body (http/get "https://github.com/babashka/babashka/raw/master/logo/icon.png" 181 | {:as :stream})) 182 | (io/file "icon.png")) 183 | (.length (io/file "icon.png")) 184 | ;;=> 7748 185 | ``` 186 | 187 | To obtain an in-memory byte array you can use `:as :bytes`. 188 | 189 | ### URI construction 190 | 191 | Using the verbose `:uri` API for fine grained (and safer) URI construction: 192 | 193 | ``` clojure 194 | (-> (http/request {:uri {:scheme "https" 195 | :host "httpbin.org" 196 | :port 443 197 | :path "/get" 198 | :query "q=test"}}) 199 | :body 200 | (json/parse-string true)) 201 | ;;=> 202 | {:args {:q "test"}, 203 | :headers 204 | {:Accept "*/*", 205 | :Host "httpbin.org", 206 | :User-Agent "Java-http-client/11.0.17" 207 | :X-Amzn-Trace-Id 208 | "Root=1-5e63989e-7bd5b1dba75e951a84d61b6a"}, 209 | :origin "46.114.35.45", 210 | :url "https://httpbin.org/get?q=test"} 211 | ``` 212 | 213 | ### Custom client 214 | 215 | The default client in babashka.http-client is constructed conceptually as follows: 216 | 217 | ``` clojure 218 | (def client (http/client http/default-client-opts)) 219 | ``` 220 | 221 | To pass more options in addition to the default options, you can use `http/default-client-opts` and associate more options: 222 | 223 | ``` clojure 224 | (def client (http/client (assoc-in http/default-client-opts [:ssl-context :insecure] true))) 225 | ``` 226 | 227 | Then use the custom client with HTTP requests: 228 | 229 | ``` clojure 230 | (http/get "https://clojure.org" {:client client}) 231 | ``` 232 | 233 | ### Redirects 234 | 235 | The default client is configured to always follow redirects. To opt out of this behaviour, construct a custom client: 236 | 237 | ```clojure 238 | (:status (http/get "https://httpstat.us/302" {:client (http/client {:follow-redirects :never})})) 239 | ;; => 302 240 | (:status (http/get "https://httpstat.us/302" {:client (http/client {:follow-redirects :always})})) 241 | ;; => 200 242 | ``` 243 | 244 | ### Exceptions 245 | 246 | An `ExceptionInfo` will be thrown for all HTTP response status codes other than `#{200 201 202 203 204 205 206 207 300 301 302 303 304 307}`. 247 | 248 | ```clojure 249 | user=> (http/get "https://httpstat.us/404") 250 | Execution error (ExceptionInfo) at babashka.http-client.interceptors/fn (interceptors.clj:194). 251 | Exceptional status code: 404 252 | ``` 253 | 254 | To opt out of an exception being thrown, set `:throw` to false. 255 | 256 | ```clojure 257 | (:status (http/get "https://httpstat.us/404" {:throw false})) 258 | ;;=> 404 259 | ``` 260 | 261 | ### Multipart 262 | 263 | To perform a multipart request, supply `:multipart` with a sequence of maps with the following options: 264 | 265 | - `:name`: The name of the param 266 | - `:part-name`: Override for `:name` 267 | - `:content`: The part's data. May be string or something that can be fed into `clojure.java.io/input-stream` 268 | - `:file-name`: The part's file name. If the `:content` is a file, the name of the file will be used, unless `:file-name` is set. 269 | - `:content-type`: The part's content type. By default, if `:content` is a string it will be `text/plain; charset=UTF-8`; if `:content` is a file it will attempt to guess the best content type or fallback to `application/octet-stream`. 270 | 271 | An example request: 272 | 273 | ``` clojure 274 | (http/post "https://postman-echo.com/post" 275 | {:multipart [{:name "title" :content "My Title"} 276 | {:name "Content/type" :content "image/jpeg"} 277 | {:name "file" :content (io/file "foo.jpg") :file-name "foobar.jpg"}]}) 278 | ``` 279 | 280 | ### Compression 281 | 282 | To accept gzipped or zipped responses, use: 283 | 284 | ``` clojure 285 | (http/get "https://api.stackexchange.com/2.2/sites" 286 | {:headers {"Accept-Encoding" ["gzip" "deflate"]}}) 287 | ``` 288 | 289 | The above server only serves compressed responses, so if you remove the header, the request will fail. 290 | Accepting compressed responses may become the default in a later version of this library. 291 | 292 | ### Interceptors 293 | 294 | Babashka http-client interceptors are similar to Pedestal interceptors. They are maps of `:name` (a string), `:request` (a function), `:response` (a function). 295 | An example is shown in this test: 296 | 297 | ``` clojure 298 | (deftest interceptor-test 299 | (let [json-interceptor 300 | {:name ::json 301 | :description 302 | "A request with `:as :json` will automatically get the 303 | \"application/json\" accept header and the response is decoded as JSON." 304 | :request (fn [request] 305 | (if (= :json (:as request)) 306 | (-> (assoc-in request [:headers :accept] "application/json") 307 | ;; Read body as :string 308 | ;; Mark request as amenable to json decoding 309 | (assoc :as :string ::json true)) 310 | request)) 311 | :response (fn [response] 312 | (if (get-in response [:request ::json]) 313 | (update response :body #(json/parse-string % true)) 314 | response))} 315 | ;; Add json interceptor add beginning of chain 316 | ;; It will be the first to see the request and the last to see the response 317 | interceptors (cons json-interceptor interceptors/default-interceptors) 318 | ] 319 | (testing "interceptors on request" 320 | (let [resp (http/get "https://httpstat.us/200" 321 | {:interceptors interceptors 322 | :as :json})] 323 | (is (= 200 (-> resp :body 324 | ;; response as JSON 325 | :code))))))) 326 | ``` 327 | 328 | A `:request` function is executed when the request is built and the `:response` 329 | function is executed on the response. Default interceptors are in 330 | `babashka.http-client.interceptors/default-interceptors`. Interceptors can be 331 | configured on the level of requests by passing a modified `:interceptors` 332 | chain. 333 | 334 | #### Changing an existing interceptor 335 | 336 | In this example we change the `throw-on-exceptional-status-code` interceptor to not throw on a `404` status code: 337 | 338 | ``` clojure 339 | (require '[babashka.http-client :as http] 340 | '[babashka.http-client.interceptors :as i]) 341 | 342 | (def unexceptional-statuses 343 | (conj #{200 201 202 203 204 205 206 207 300 301 302 303 304 307} 344 | ;; we also don't throw on 404 345 | 404)) 346 | 347 | (def my-throw-on-exceptional-status-code 348 | "Response: throw on exceptional status codes" 349 | {:name ::throw-on-exceptional-status-code 350 | :response (fn [resp] 351 | (if-let [status (:status resp)] 352 | (if (or (false? (some-> resp :request :throw)) 353 | (contains? unexceptional-statuses status)) 354 | resp 355 | (throw (ex-info (str "Exceptional status code: " status) resp))) 356 | resp))}) 357 | 358 | (def my-interceptors 359 | (mapv (fn [i] 360 | (if (= ::i/throw-on-exceptional-status-code 361 | (:name i)) 362 | my-throw-on-exceptional-status-code 363 | i)) 364 | i/default-interceptors)) 365 | 366 | (def my-response 367 | (http/get "https://postman-echo.com/get/404" {:interceptors my-interceptors})) 368 | 369 | (prn (:status my-response)) ;; 404 370 | ``` 371 | 372 | #### Testing interceptors 373 | 374 | For testing interceptors it can be useful to use the `:client` option in combination with a 375 | Clojure function. When passing a function, the request won't be converted to a 376 | `java.net.http.Request` but just passed as a ring request to the function. The 377 | function is expected to return a ring response: 378 | 379 | ``` clojure 380 | (http/get "https://clojure.org" {:client (fn [req] {:body 200})}) 381 | ``` 382 | 383 | ### Async 384 | 385 | To execute request asynchronously, use `:async true`. The response will be a 386 | `CompletableFuture` with the response map. 387 | 388 | ``` clojure 389 | (-> (http/get "https://clojure.org" {:async true}) deref :status) 390 | ;;=> 200 391 | ``` 392 | 393 | ### Timeouts 394 | 395 | Two different timeouts can be set: 396 | 397 | - The connection timeout, `:connect-timeout`, in `http/client` 398 | - The request `:timeout` in `http/request` 399 | 400 | Alternatively you can use `:async` + `deref` with a timeout + default value: 401 | 402 | ``` 403 | (let [resp (http/get "https://httpstat.us/200?sleep=5000" {:async true})] (deref resp 1000 ::too-late)) 404 | ;;=> :user/too-late 405 | ``` 406 | 407 | ## Logging and debug 408 | 409 | If you need to debug HTTP requests you need to add a JVM system property with some debug options: 410 | `"-Djdk.httpclient.HttpClient.log=errors,requests,headers,frames[:control:data:window:all..],content,ssl,trace,channel"` 411 | 412 | One way to handle that with tools-deps is to add an alias with `:jvm-opts` option. 413 | 414 | Here is a code snippet for `deps.edn` 415 | ```clojure 416 | { 417 | ;; REDACTED 418 | :aliases { 419 | :debug 420 | {:jvm-opts 421 | [;; enable logging for java.net.http 422 | "-Djdk.httpclient.HttpClient.log=errors,requests,headers,frames[:control:data:window:all..],content,ssl,trace,channel"]} 423 | }} 424 | ``` 425 | 426 | ## Test 427 | 428 | ``` clojure 429 | $ bb test:clj 430 | $ bb test:bb 431 | ``` 432 | 433 | ## Credits 434 | 435 | This library has borrowed liberally from [java-http-clj](https://github.com/schmee/java-http-clj) and [hato](https://github.com/gnarroway/hato), both available under the MIT license. 436 | 437 | ## License 438 | 439 | Copyright © 2022 - 2023 Michiel Borkent 440 | 441 | Distributed under the MIT License. See LICENSE. 442 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:paths ["script"] 2 | :tasks 3 | {test:clj {:doc "Run jvm tests, optionally specify clj-version (ex. :clj-1.10 :clj-1.11(default) or :clj-all)" 4 | :task test-clj/-main} 5 | 6 | test:bb {:extra-paths ["src" "test"] 7 | :extra-deps {io.github.cognitect-labs/test-runner 8 | {:git/tag "v0.5.0" :git/sha "b3fd0d2"} 9 | io.github.borkdude/deflet {:mvn/version "0.1.0"}} 10 | :task cognitect.test-runner/-main} 11 | 12 | quickdoc {:doc "Invoke quickdoc" 13 | :extra-deps {io.github.borkdude/quickdoc 14 | {:git/sha "e4f08eb5b1882cf0bffcbb7370699c0a63c9fd72"}} 15 | :task (exec 'quickdoc.api/quickdoc) 16 | :exec-args {:git/branch "main" 17 | :github/repo "https://github.com/babashka/http-client" 18 | :source-paths ["src/babashka/http_client.clj" 19 | "src/babashka/http_client/interceptors.clj" 20 | "src/babashka/http_client/websocket.clj"]}} 21 | refresh-version {:requires ([babashka.fs :as fs] 22 | [clojure.string :as str]) 23 | :task (let [version (-> (slurp "deps.edn") 24 | clojure.edn/read-string 25 | :aliases :neil :project :version)] 26 | (fs/update-file "src/babashka/http_client/internal/version.clj" 27 | (fn [contents] 28 | (str/replace contents (re-pattern "#_:version(.*)") 29 | (fn [[_ match]] 30 | (str "#_:version " (pr-str version)))))))} 31 | 32 | publish {:depends [refresh-version] 33 | :task (clojure "-T:build deploy")}}} 34 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b] 3 | [clojure.edn :as edn])) 4 | 5 | (def project (-> (edn/read-string (slurp "deps.edn")) 6 | :aliases :neil :project)) 7 | (def lib (or (:name project) 'my/lib1)) 8 | 9 | ;; use neil project set version 1.2.0 to update the version in deps.edn 10 | 11 | (def version (or (:version project) 12 | "0.0.1")) 13 | (def class-dir "target/classes") 14 | (def basis (b/create-basis {:project "deps.edn"})) 15 | (def uber-file (format "target/%s-%s-standalone.jar" (name lib) version)) 16 | (def jar-file (format "target/%s-%s.jar" (name lib) version)) 17 | 18 | (defn clean [_] 19 | (b/delete {:path "target"})) 20 | 21 | (defn jar [_] 22 | (b/write-pom {:class-dir class-dir 23 | :lib lib 24 | :version version 25 | :basis basis 26 | :src-dirs ["src"] 27 | :scm {:url "https://github.com/babashka/http-client" 28 | :connection "scm:git:git@github.com:babashka/http-client.git" 29 | :developerConnection "scm:git:git@github.com:babashka/http-client.git" 30 | :tag (format "v%s" version)} 31 | :pom-data 32 | [[:description "HTTP client for Clojure and Babashka built on java.net.http"] 33 | [:url "https://github.com/babashka/http-client"] 34 | [:licenses 35 | [:license 36 | [:name "MIT License"] 37 | [:url "https://opensource.org/license/mit/"]]]]}) 38 | (b/copy-dir {:src-dirs ["src" "resources"] 39 | :target-dir class-dir}) 40 | (b/jar {:class-dir class-dir 41 | :jar-file jar-file})) 42 | 43 | (defn install [_] 44 | (jar {}) 45 | (b/install {:basis basis 46 | :lib lib 47 | :version version 48 | :jar-file jar-file 49 | :class-dir class-dir})) 50 | 51 | (defn uber [_] 52 | (clean nil) 53 | (b/copy-dir {:src-dirs ["src" "resources"] 54 | :target-dir class-dir}) 55 | (b/compile-clj {:basis basis 56 | :src-dirs ["src"] 57 | :class-dir class-dir}) 58 | (b/uber {:class-dir class-dir 59 | :uber-file uber-file 60 | :basis basis})) 61 | 62 | (defn deploy [opts] 63 | (jar opts) 64 | ((requiring-resolve 'deps-deploy.deps-deploy/deploy) 65 | (merge {:installer :remote 66 | :artifact jar-file 67 | :pom-file (b/pom-path {:lib lib :class-dir class-dir})} 68 | opts)) 69 | opts) 70 | 71 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {} 2 | :aliases 3 | {:neil {:project {:name org.babashka/http-client 4 | :version "0.4.22"}} 5 | :clj-1.10 {:extra-deps {org.clojure/clojure {:mvn/version "1.10.3"}}} 6 | :clj-1.11 {:extra-deps {org.clojure/clojure {:mvn/version "1.11.4"}}} 7 | :clj-1.12 {:extra-deps {org.clojure/clojure {:mvn/version "1.12.0-rc1"}}} 8 | 9 | :repl {:extra-deps {cheshire/cheshire {:mvn/version "5.11.0"} 10 | io.github.borkdude/deflet {:mvn/version "0.1.0"} 11 | babashka/fs {:mvn/version "0.2.16"}} 12 | :extra-paths ["dev"]} 13 | :test ;; added by neil 14 | {:extra-paths ["dev" "test"] 15 | :extra-deps {cheshire/cheshire {:mvn/version "5.11.0"} 16 | io.github.cognitect-labs/test-runner 17 | {:git/tag "v0.5.0" :git/sha "b3fd0d2"} 18 | http-kit/http-kit {:mvn/version "2.6.0"} 19 | babashka/fs {:mvn/version "0.2.16"} 20 | io.github.borkdude/deflet {:mvn/version "0.1.0"}} 21 | :main-opts ["-m" "cognitect.test-runner"] 22 | :exec-fn cognitect.test-runner.api/test} 23 | :build ;; added by neil 24 | {:deps {io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"} 25 | slipset/deps-deploy {:mvn/version "0.2.0"}} 26 | :ns-default build} 27 | 28 | :dev {:extra-deps {hato/hato {:mvn/version "0.9.0"}}}}} 29 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (println "Toggling warn on reflection to true...") 2 | (alter-var-root #'*warn-on-reflection* (constantly true)) 3 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babashka/http-client/5eb11afa0611242fe3f4489b7ebb82caa0c2e2b3/icon.png -------------------------------------------------------------------------------- /script/changelog.clj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns changelog 4 | (:require [clojure.string :as str])) 5 | 6 | (let [changelog (slurp "CHANGELOG.md") 7 | replaced (str/replace changelog 8 | #" #(\d+)" 9 | (fn [[_ issue after]] 10 | (format " [#%s](https://github.com/babashka/http-client/issues/%s)%s" 11 | issue issue (str after)))) 12 | replaced (str/replace replaced 13 | #"@([a-zA-Z0-9-_]+)([, \.)])" 14 | (fn [[_ name after]] 15 | (format "[@%s](https://github.com/%s)%s" 16 | name name after)))] 17 | (spit "CHANGELOG.md" replaced)) 18 | -------------------------------------------------------------------------------- /script/test_clj.clj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns test-clj 4 | (:require 5 | [babashka.tasks :as tasks] 6 | [clojure.edn :as edn] 7 | [clojure.string :as str])) 8 | 9 | (defn -main[& args] 10 | (let [farg (first args) 11 | ;; allow for missing leading colon 12 | farg (if (and farg (str/starts-with? farg "clj-")) 13 | (str ":" farg) 14 | farg) 15 | clj-version-aliases (->> "deps.edn" 16 | slurp 17 | edn/read-string 18 | :aliases 19 | keys 20 | (map str) 21 | (filter (fn [a] (-> a name (str/starts-with? ":clj-")))) 22 | sort 23 | (into [])) 24 | [aliases args] (cond 25 | (nil? farg) [[":clj-1.11"] []] 26 | 27 | (= ":clj-all" farg) [clj-version-aliases (rest args)] 28 | 29 | (and (str/starts-with? farg ":clj-") 30 | (not (some #{farg} clj-version-aliases))) 31 | (throw (ex-info (format "%s not recognized, valid clj- args are: %s or \":clj-all\"" farg clj-version-aliases) {})) 32 | 33 | (some #{farg} clj-version-aliases) [[farg] (rest args)] 34 | 35 | :else [[":clj-1.11"] args])] 36 | (doseq [alias aliases] 37 | (println (format "-[Running jvm tests for %s]-" alias)) 38 | (apply tasks/clojure (str "-M:test" alias) args)))) 39 | 40 | (when (= *file* (System/getProperty "babashka.file")) 41 | (apply -main *command-line-args*)) 42 | -------------------------------------------------------------------------------- /src/babashka/http_client.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client 2 | (:refer-clojure :exclude [send get]) 3 | (:require [babashka.http-client.internal :as i])) 4 | 5 | (def default-client-opts 6 | "Options used to create the (implicit) default client." 7 | i/default-client-opts) 8 | 9 | (defn ->ProxySelector 10 | "Constructs a `java.net.ProxySelector`. 11 | Options: 12 | * `:host` - string 13 | * `:port` - long" 14 | [opts] 15 | (i/->ProxySelector opts)) 16 | 17 | (defn ->SSLContext 18 | "Constructs a `javax.net.ssl.SSLContext`. 19 | 20 | Options: 21 | 22 | * `:key-store` - a file, URI or URL or anything else that is compatible with `io/input-stream`, e.g. (io/resource somepath.p12) 23 | * `:key-store-pass` - the password for the keystore 24 | * `:key-store-type` - the type of keystore to create [note: not the type of the file] (default: pkcs12) 25 | * `:trust-store` - a file, URI or URL or anything else that is compatible with `io/input-stream`, e.g. (io/resource somepath.p12) 26 | * `:trust-store-pass` - the password for the trust store 27 | * `:trust-store-type` - the type of trust store to create [note: not the type of the file] (default: pkcs12) 28 | * `:insecure` - if `true`, an insecure trust manager accepting all server certificates will be configured. 29 | 30 | Note that `:keystore` and `:truststore` can be set using the 31 | `javax.net.ssl.keyStore` and `javax.net.ssl.trustStore` System 32 | properties globally." 33 | [opts] 34 | (i/->SSLContext opts)) 35 | 36 | (defn ->Authenticator 37 | "Constructs a `java.net.Authenticator`. 38 | 39 | Options: 40 | 41 | * `:user` - the username 42 | * `:pass` - the password" 43 | [opts] 44 | (i/->Authenticator opts)) 45 | 46 | (defn ->CookieHandler 47 | "Constructs a `java.net.CookieHandler` using `java.net.CookieManager`. 48 | 49 | Options: 50 | 51 | * `:store` - an optional `java.net.CookieStore` implementation 52 | * `:policy` - a `java.net.CookiePolicy` or one of `:accept-all`, `:accept-none`, `:original-server`" 53 | [opts] 54 | (i/->CookieHandler opts)) 55 | 56 | (defn ->SSLParameters 57 | "Constructs a `javax.net.ssl.SSLParameters`. 58 | 59 | Options: 60 | 61 | * `:ciphers` - a list of cipher suite names 62 | * `:protocols` - a list of protocol names" 63 | [opts] 64 | (i/->SSLParameters opts)) 65 | 66 | (defn ->Executor 67 | "Constructs a `java.util.concurrent.Executor`. 68 | 69 | Options: 70 | 71 | * `:threads` - constructs a `ThreadPoolExecutor` with the specified number of threads" 72 | [opts] 73 | (i/->Executor opts)) 74 | 75 | (defn client 76 | "Construct a custom client. To get the same behavior as the (implicit) default client, pass `default-client-opts`. 77 | 78 | Options: 79 | * `:follow-redirects` - `:never`, `:always` or `:normal` 80 | * `:connect-timeout` - connection timeout in milliseconds. 81 | * `:request` - default request options which will be used in requests made with this client. 82 | * `:executor` - a `java.util.concurrent.Executor` or a map of options, see docstring of `->Executor` 83 | * `:ssl-context` - a `javax.net.ssl.SSLContext` or a map of options, see docstring of `->SSLContext`. 84 | * `:ssl-parameters` - a `javax.net.ssl.SSLParameters' or a map of options, see docstring of `->SSLParameters`. 85 | * `:proxy` - a `java.net.ProxySelector` or a map of options, see docstring of `->ProxySelector`. 86 | * `:authenticator` - a `java.net.Authenticator` or a map of options, see docstring of `->Authenticator`. 87 | * `:cookie-handler` - a `java.net.CookieHandler` or a map of options, see docstring of `->CookieHandler`. 88 | * `:version` - the HTTP version: `:http1.1` or `:http2`. 89 | * `:priority` - priority for HTTP2 requests, integer between 1-256 inclusive. 90 | 91 | Returns map with: 92 | 93 | * `:client` - a `java.net.http.HttpClient`. 94 | 95 | The map can be passed to `request` via the `:client` key. 96 | " 97 | ([opts] 98 | (i/client opts))) 99 | 100 | (defn request 101 | "Perform request. Returns map with at least `:body`, `:status` 102 | 103 | Options: 104 | 105 | * `:uri` - the uri to request (required). 106 | May be a string or map of `:scheme` (required), `:host` (required), `:port`, `:path` and `:query` 107 | * `:headers` - a map of headers 108 | * `:method` - the request method: `:get`, `:post`, `:head`, `:delete`, `:patch` or `:put` 109 | * `:interceptors` - custom interceptor chain 110 | * `:client` - a client as produced by `client` or a clojure function. If not provided a default client will be used. 111 | When providing :client with a a clojure function, it will be called with the Clojure representation of 112 | the request which can be useful for testing. 113 | * `:query-params` - a map of query params. The values can be a list to send multiple params with the same key. 114 | * `:form-params` - a map of form params to send in the request body. 115 | * `:body` - a file, inputstream or string to send as the request body. 116 | * `:basic-auth` - a sequence of `user` `password` or map with `:user` `:pass` used for basic auth. 117 | * `:oauth-token` - a string token used for bearer auth. 118 | * `:async` - perform request asynchronously. The response will be a `CompletableFuture` of the response map. 119 | * `:async-then` - a function that is called on the async result if successful 120 | * `:async-catch` - a function that is called on the async result if exceptional 121 | * `:timeout` - request timeout in milliseconds 122 | * `:throw` - throw on exceptional status codes, all other than `#{200 201 202 203 204 205 206 207 300 301 302 303 304 307}` 123 | * `:version` - the HTTP version: `:http1.1` or `:http2`." 124 | [opts] 125 | (i/request opts)) 126 | 127 | (defn get 128 | "Convenience wrapper for `request` with method `:get`" 129 | ([uri] (get uri nil)) 130 | ([uri opts] 131 | (let [opts (assoc opts :uri uri :method :get)] 132 | (request opts)))) 133 | 134 | (defn delete 135 | "Convenience wrapper for `request` with method `:delete`" 136 | ([uri] (delete uri nil)) 137 | ([uri opts] 138 | (let [opts (assoc opts :uri uri :method :delete)] 139 | (request opts)))) 140 | 141 | (defn head 142 | "Convenience wrapper for `request` with method `:head`" 143 | ([uri] (head uri nil)) 144 | ([uri opts] 145 | (let [opts (assoc opts :uri uri :method :head)] 146 | (request opts)))) 147 | 148 | (defn post 149 | "Convenience wrapper for `request` with method `:post`" 150 | ([uri] (post uri nil)) 151 | ([uri opts] 152 | (let [opts (assoc opts :uri uri :method :post)] 153 | (request opts)))) 154 | 155 | (defn patch 156 | "Convenience wrapper for `request` with method `:patch`" 157 | ([url] (patch url nil)) 158 | ([url opts] 159 | (let [opts (assoc opts 160 | :uri url 161 | :method :patch)] 162 | (request opts)))) 163 | 164 | (defn put 165 | "Convenience wrapper for `request` with method `:put`" 166 | ([url] (put url nil)) 167 | ([url opts] 168 | (let [opts (assoc opts 169 | :uri url 170 | :method :put)] 171 | (request opts)))) 172 | -------------------------------------------------------------------------------- /src/babashka/http_client/interceptors.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.interceptors 2 | (:refer-clojure :exclude [send get]) 3 | (:require 4 | [babashka.http-client.internal.multipart :as multipart] 5 | [babashka.http-client.internal.helpers :as aux] 6 | [clojure.java.io :as io] 7 | [clojure.string :as str]) 8 | (:import 9 | [java.net URLEncoder] 10 | [java.util Base64] 11 | [java.util.zip 12 | GZIPInputStream 13 | Inflater 14 | InflaterInputStream 15 | ZipException])) 16 | 17 | (set! *warn-on-reflection* true) 18 | 19 | (defn- coerce-key 20 | "Coerces a key to str" 21 | [k] 22 | (if (keyword? k) 23 | (-> k str (subs 1)) 24 | (str k))) 25 | 26 | (defn- url-encode 27 | "Returns an UTF-8 URL encoded version of the given string." 28 | [^String unencoded] 29 | (URLEncoder/encode unencoded "UTF-8")) 30 | 31 | (defn- map->form-params [form-params-map] 32 | (loop [params* (transient []) 33 | kvs (seq form-params-map)] 34 | (if kvs 35 | (let [[k v] (first kvs) 36 | v (url-encode (str v)) 37 | param (str (url-encode (coerce-key k)) "=" v)] 38 | (recur (conj! params* param) (next kvs))) 39 | (str/join "&" (persistent! params*))))) 40 | 41 | (defn- basic-auth-value [x] 42 | (let [[user pass] (if (sequential? x) x [(clojure.core/get x :user) (clojure.core/get x :pass)]) 43 | basic-auth (str user ":" pass)] 44 | (str "Basic " (.encodeToString (Base64/getEncoder) (.getBytes basic-auth "UTF-8"))))) 45 | 46 | (def basic-auth 47 | "Request: adds `:authorization` header based on `:basic-auth` (a map 48 | of `:user` and `:pass`) in request." 49 | {:name ::basic-auth 50 | :request (fn [opts] 51 | (if-let [basic-auth (:basic-auth opts)] 52 | (let [headers (:headers opts) 53 | auth (basic-auth-value basic-auth) 54 | headers (assoc headers :authorization auth) 55 | opts (assoc opts :headers headers)] 56 | opts) 57 | opts))}) 58 | 59 | (def oauth-token 60 | "Request: adds `:authorization` header based on `:oauth-token` (a string token) 61 | in request." 62 | {:name ::oauth-token 63 | :request (fn [opts] 64 | (if-let [oauth-token (:oauth-token opts)] 65 | (let [headers (:headers opts) 66 | auth (str "Bearer " oauth-token) 67 | headers (assoc headers :authorization auth) 68 | opts (assoc opts :headers headers)] 69 | opts) 70 | opts))}) 71 | 72 | (def accept-header 73 | "Request: adds `:accept` header. Only supported value is `:json`." 74 | {:name ::accept-header 75 | :request 76 | (fn [opts] 77 | (if-let [accept (:accept opts)] 78 | (let [headers (:headers opts) 79 | accept-header (case accept 80 | :json "application/json") 81 | headers (assoc headers :accept accept-header) 82 | opts (assoc opts :headers headers)] 83 | opts) 84 | opts))}) 85 | 86 | (defn- map->query-params [query-params-map] 87 | (loop [params* (transient []) 88 | kvs (seq query-params-map)] 89 | (if kvs 90 | (let [[k v] (first kvs)] 91 | (if (and (coll? v) 92 | (seqable? v)) 93 | (recur params* (concat 94 | (map (fn [v] 95 | [k v]) v) 96 | (rest kvs))) 97 | (recur (conj! params* (str (url-encode (coerce-key k)) 98 | "=" 99 | (url-encode (str v)))) (next kvs)))) 100 | (str/join "&" (persistent! params*))))) 101 | 102 | (defn uri-with-query 103 | "We can't use the URI constructor because it encodes all arguments for us. 104 | See https://stackoverflow.com/a/77971448/6264" 105 | [^java.net.URI uri new-query] 106 | (let [old-query (.getQuery uri) 107 | new-query (if old-query (str old-query "&" new-query) 108 | new-query)] 109 | (java.net.URI. 110 | (str (.getScheme uri) "://" 111 | (.getAuthority uri) 112 | (.getRawPath uri) 113 | (when-let [nq new-query] 114 | (str "?" nq)) 115 | (when-let [f (.getFragment uri)] 116 | (str "#" f)))))) 117 | 118 | (def query-params 119 | "Request: encodes `:query-params` map and appends to `:uri`." 120 | {:name ::query-params 121 | :request (fn [opts] 122 | (if-let [qp (:query-params opts)] 123 | (let [^java.net.URI uri (:uri opts) 124 | new-query (map->query-params qp) 125 | new-uri (uri-with-query uri new-query)] 126 | (assoc opts :uri new-uri)) 127 | opts))}) 128 | 129 | (comment 130 | (def uri (java.net.URI. "https://borkdude:foobar@foobar.net:80/single%2felement?q=1#/dude")) 131 | (.getScheme uri) ;;=> https 132 | (.getSchemeSpecificPart uri) ;;=> //foobar.net/?q=1 133 | (.getUserInfo uri) ;;=> nil 134 | (.getAuthority uri) ;;=> "foobar.net" 135 | (.getRawPath uri) ;;=> "/single%2felement" 136 | (.getQuery uri) ;;=> q=1 137 | (.getFragment uri) ;;=> nil 138 | (uri-with-query uri "f=dude%26hello")) 139 | 140 | (def form-params 141 | "Request: encodes `:form-params` map and adds `:body`." 142 | {:name ::form-params 143 | :request (fn [opts] 144 | (if-let [fp (:form-params opts)] 145 | (let [opts (assoc opts :body (map->form-params fp)) 146 | ct (get-in opts [:headers :content-type])] 147 | (if ct 148 | opts 149 | (assoc-in opts [:headers :content-type] "application/x-www-form-urlencoded"))) 150 | opts))}) 151 | 152 | (defmulti ^:private do-decompress-body 153 | (fn [resp] 154 | (when-let [encoding (get-in resp [:headers "content-encoding"])] 155 | (str/lower-case encoding)))) 156 | 157 | (defn- gunzip 158 | "Returns a gunzip'd version of the given byte array or input stream." 159 | [b] 160 | (when b 161 | (when (instance? java.io.InputStream b) 162 | (GZIPInputStream. b)))) 163 | 164 | (defmethod do-decompress-body "gzip" 165 | [resp] 166 | (update resp :body gunzip)) 167 | 168 | (defn- inflate 169 | "Returns a zlib inflate'd version of the given byte array or InputStream. Taken from hato." 170 | [b] 171 | (when b 172 | ;; This weirdness is because HTTP servers lie about what kind of deflation 173 | ;; they're using, so we try one way, then if that doesn't work, reset and 174 | ;; try the other way 175 | (let [stream (java.io.BufferedInputStream. b) 176 | _ (.mark stream 512) 177 | iis (InflaterInputStream. stream) 178 | readable? (try (.read iis) true 179 | (catch ZipException _ false)) 180 | _ (.reset stream) 181 | iis' (if readable? 182 | (InflaterInputStream. stream) 183 | (InflaterInputStream. stream (Inflater. true)))] 184 | 185 | iis'))) 186 | 187 | (defmethod do-decompress-body "deflate" 188 | [resp] 189 | (update resp :body inflate)) 190 | 191 | (defmethod do-decompress-body :default [resp] 192 | resp) 193 | 194 | (def decompress-body 195 | "Response: decompresses body based on \"content-encoding\" header. Valid values: `gzip` and `deflate`." 196 | {:name ::decompress 197 | :response (fn [resp] 198 | (if (or (false? (:decompress-body (:request resp))) 199 | (= :head (-> resp :request :method))) 200 | resp 201 | (do-decompress-body resp)))}) 202 | 203 | (defn- stream-bytes [is] 204 | (let [baos (java.io.ByteArrayOutputStream.)] 205 | (io/copy is baos) 206 | (.toByteArray baos))) 207 | 208 | (def decode-body 209 | "Response: based on the value of `:as` in request, decodes as `:string`, `:stream` or `:bytes`. Defaults to `:string`." 210 | {:name ::decode-body 211 | :response (fn [resp] 212 | (let [as (or (-> resp :request :as) :string) 213 | body (:body resp) 214 | body (if (not (string? body)) 215 | (case as 216 | :string (slurp body) 217 | :stream body 218 | :bytes (stream-bytes body)) 219 | body)] 220 | (assoc resp :body body)))}) 221 | 222 | (def construct-uri 223 | "Request: construct uri from map" 224 | {:name ::construct-uri 225 | :request (fn [req] 226 | (let [uri (:uri req) 227 | uri (aux/->uri uri)] 228 | (assoc req :uri uri)))}) 229 | 230 | (def unexceptional-statuses 231 | #{200 201 202 203 204 205 206 207 300 301 302 303 304 307}) 232 | 233 | (def throw-on-exceptional-status-code 234 | "Response: throw on exceptional status codes" 235 | {:name ::throw-on-exceptional-status-code 236 | :response (fn [resp] 237 | (if-let [status (:status resp)] 238 | (if (or (false? (some-> resp :request :throw)) 239 | (contains? unexceptional-statuses status)) 240 | resp 241 | (throw (ex-info (str "Exceptional status code: " status) resp))) 242 | resp))}) 243 | 244 | (def multipart 245 | "Adds appropriate body and header if making a multipart request." 246 | {:name ::multipart 247 | :request (fn [{:keys [multipart] :as req}] 248 | (if multipart 249 | (let [b (multipart/boundary)] 250 | (-> req 251 | (dissoc :multipart) 252 | (assoc :body (multipart/body multipart b)) 253 | (update :headers assoc "content-type" (str "multipart/form-data; boundary=" b)))) 254 | req))}) 255 | 256 | (def default-interceptors 257 | "Default interceptor chain. Interceptors are called in order for request and in reverse order for response." 258 | [throw-on-exceptional-status-code 259 | construct-uri 260 | accept-header 261 | basic-auth 262 | oauth-token 263 | query-params 264 | form-params 265 | multipart 266 | decode-body 267 | decompress-body]) 268 | 269 | #_(defn insert-interceptors-before [chain before & interceptors] 270 | (let [[pre _ post] (partition-by #(= before %) chain)] 271 | (reduce into [] [pre (conj (vec interceptors) before) post]))) 272 | 273 | #_(defn insert-interceptors-after [chain after & interceptors] 274 | (let [[pre _ post] (partition-by #(= after %) chain)] 275 | (reduce into [] [pre (list* after interceptors) post]))) 276 | -------------------------------------------------------------------------------- /src/babashka/http_client/internal.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal 2 | {:no-doc true} 3 | (:refer-clojure :exclude [send get]) 4 | (:require 5 | [babashka.http-client.interceptors :as interceptors] 6 | [babashka.http-client.internal.helpers :as aux] 7 | [babashka.http-client.internal.version :as iv] 8 | [clojure.java.io :as io] 9 | [clojure.string :as str]) 10 | (:import 11 | [java.net URI URLEncoder Authenticator PasswordAuthentication] 12 | [java.net.http 13 | HttpClient 14 | HttpClient$Builder 15 | HttpClient$Redirect 16 | HttpClient$Version 17 | HttpRequest 18 | HttpRequest$BodyPublisher 19 | HttpRequest$BodyPublishers 20 | HttpRequest$Builder 21 | HttpResponse 22 | HttpResponse$BodyHandlers] 23 | [java.security KeyStore SecureRandom] 24 | [java.security.cert X509Certificate] 25 | [java.time Duration] 26 | [java.util.concurrent CompletableFuture] 27 | [java.util.function Function Supplier] 28 | [java.util.function Supplier] 29 | [javax.net.ssl KeyManagerFactory TrustManagerFactory SSLContext TrustManager])) 30 | 31 | (set! *warn-on-reflection* true) 32 | 33 | (defn- ->follow-redirect [redirect] 34 | (case redirect 35 | :always HttpClient$Redirect/ALWAYS 36 | :never HttpClient$Redirect/NEVER 37 | :normal HttpClient$Redirect/NORMAL)) 38 | 39 | (defn- version-keyword->version-enum [version] 40 | (case version 41 | :http1.1 HttpClient$Version/HTTP_1_1 42 | :http2 HttpClient$Version/HTTP_2)) 43 | 44 | (defn ->timeout [t] 45 | (if (integer? t) 46 | (Duration/ofMillis t) 47 | t)) 48 | 49 | (defn- load-keystore 50 | ^KeyStore [store store-type store-pass] 51 | (when store 52 | (with-open [kss (io/input-stream store)] 53 | (doto (KeyStore/getInstance store-type) 54 | (.load kss (char-array store-pass)))))) 55 | 56 | (def has-extended? (resolve 'javax.net.ssl.X509ExtendedTrustManager)) 57 | 58 | (defmacro if-has-extended [then else] 59 | (if has-extended? then else)) 60 | 61 | (def insecure-tm 62 | (delay 63 | (if-has-extended 64 | (proxy [javax.net.ssl.X509ExtendedTrustManager] [] 65 | (checkClientTrusted 66 | ([_ _]) 67 | ([_ _ _])) 68 | (checkServerTrusted 69 | ([_ _]) 70 | ([_ _ _])) 71 | (getAcceptedIssuers [] (into-array X509Certificate []))) 72 | (reify javax.net.ssl.X509TrustManager 73 | (checkClientTrusted [_ _ _]) 74 | (checkServerTrusted [_ _ _]) 75 | (getAcceptedIssuers [_] (into-array X509Certificate [])))))) 76 | 77 | (defn ->SSLContext 78 | [v] 79 | (if (instance? SSLContext v) 80 | v 81 | (let [{:keys [key-store key-store-type key-store-pass trust-store trust-store-type trust-store-pass insecure]} v 82 | ;; compatibility with hato 83 | key-store-type (or key-store-type (:keystore-type v) "pkcs12") 84 | trust-store-type (or trust-store-type "pkcs12") 85 | key-managers (when-let [ks (load-keystore key-store key-store-type key-store-pass)] 86 | (.getKeyManagers (doto (KeyManagerFactory/getInstance (KeyManagerFactory/getDefaultAlgorithm)) 87 | (.init ks (char-array key-store-pass))))) 88 | 89 | trust-managers (if insecure 90 | (into-array TrustManager [@insecure-tm]) 91 | (when-let [ts (load-keystore trust-store trust-store-type trust-store-pass)] 92 | (.getTrustManagers (doto (TrustManagerFactory/getInstance (TrustManagerFactory/getDefaultAlgorithm)) 93 | (.init ts)))))] 94 | 95 | (doto (SSLContext/getInstance "TLS") 96 | (.init key-managers 97 | trust-managers 98 | (SecureRandom.)))))) 99 | 100 | (defn ->ProxySelector 101 | [opts] 102 | (if (instance? java.net.ProxySelector opts) 103 | opts 104 | (let [{:keys [host port]} opts] 105 | (cond (and host port) 106 | (java.net.ProxySelector/of (java.net.InetSocketAddress. ^String host ^long port)))))) 107 | 108 | (defn ->Authenticator 109 | [v] 110 | (if (instance? Authenticator v) 111 | v 112 | (let [{:keys [user pass]} v] 113 | (when (and user pass) 114 | (proxy [Authenticator] [] 115 | (getPasswordAuthentication [] 116 | (PasswordAuthentication. user (char-array pass)))))))) 117 | 118 | (defn ->CookieHandler 119 | [v] 120 | (when v 121 | (if (instance? java.net.CookieHandler v) 122 | v 123 | (let [{:keys [store policy]} v 124 | policy (if (instance? java.net.CookiePolicy policy) 125 | policy 126 | (case policy 127 | :accept-all java.net.CookiePolicy/ACCEPT_ALL 128 | :accept-none java.net.CookiePolicy/ACCEPT_NONE 129 | :original-server java.net.CookiePolicy/ACCEPT_ORIGINAL_SERVER 130 | java.net.CookiePolicy/ACCEPT_NONE))] 131 | (java.net.CookieManager. store policy))))) 132 | 133 | (defn ->SSLParameters 134 | [v] 135 | (when v 136 | (if (instance? javax.net.ssl.SSLParameters v) 137 | v 138 | (let [{:keys [ciphers protocols]} v 139 | params (javax.net.ssl.SSLParameters.)] 140 | (when (seq ciphers) 141 | (.setCipherSuites params (into-array String ciphers))) 142 | (when (seq protocols) 143 | (.setProtocols params (into-array String protocols))) 144 | params)))) 145 | 146 | (defn ->Executor 147 | [v] 148 | (when v 149 | (if (instance? java.util.concurrent.Executor v) 150 | v 151 | (when (pos-int? (:threads v)) 152 | (java.util.concurrent.Executors/newFixedThreadPool (:threads v)))))) 153 | 154 | (defn client-builder 155 | (^HttpClient$Builder [] 156 | (client-builder {})) 157 | (^HttpClient$Builder [opts] 158 | (let [{:keys [connect-timeout 159 | cookie-handler 160 | executor 161 | follow-redirects 162 | priority 163 | proxy 164 | authenticator 165 | ssl-context 166 | ssl-parameters 167 | version]} opts] 168 | (cond-> (HttpClient/newBuilder) 169 | connect-timeout (.connectTimeout (->timeout connect-timeout)) 170 | cookie-handler (.cookieHandler (->CookieHandler cookie-handler)) 171 | executor (.executor (->Executor executor)) 172 | follow-redirects (.followRedirects (->follow-redirect follow-redirects)) 173 | priority (.priority priority) 174 | authenticator (.authenticator (->Authenticator authenticator)) 175 | proxy (.proxy (->ProxySelector proxy)) 176 | ssl-context (.sslContext (->SSLContext ssl-context)) 177 | ssl-parameters (.sslParameters (->SSLParameters ssl-parameters)) 178 | version (.version (version-keyword->version-enum version)))))) 179 | 180 | (defn client 181 | ([opts] 182 | {:client (.build (client-builder opts)) 183 | :request (:request opts) 184 | :type :babashka.http-client/client})) 185 | 186 | (def default-client-opts 187 | {:follow-redirects :normal 188 | :request {:headers {:accept "*/*" 189 | :accept-encoding ["gzip" "deflate"] 190 | :user-agent (str "babashka.http-client/" iv/version)}}}) 191 | 192 | (def default-client 193 | (delay (client default-client-opts))) 194 | 195 | (defn- method-keyword->str [method] 196 | (str/upper-case (name method))) 197 | 198 | (defn- input-stream-supplier [s] 199 | (reify Supplier 200 | (get [_this] s))) 201 | 202 | (defn- ->body-publisher [body] 203 | (cond 204 | (nil? body) 205 | (HttpRequest$BodyPublishers/noBody) 206 | 207 | (string? body) 208 | (HttpRequest$BodyPublishers/ofString body) 209 | 210 | (instance? java.io.InputStream body) 211 | (HttpRequest$BodyPublishers/ofInputStream (input-stream-supplier body)) 212 | 213 | (bytes? body) 214 | (HttpRequest$BodyPublishers/ofByteArray body) 215 | 216 | (instance? java.io.File body) 217 | (let [^java.nio.file.Path path (.toPath (io/file body))] 218 | (HttpRequest$BodyPublishers/ofFile path)) 219 | 220 | (instance? java.nio.file.Path body) 221 | (let [^java.nio.file.Path path body] 222 | (HttpRequest$BodyPublishers/ofFile path)) 223 | 224 | (instance? HttpRequest$BodyPublisher body) 225 | body 226 | 227 | :else 228 | (throw (ex-info (str "Don't know how to convert " (type body) "to body") 229 | {:body body})))) 230 | 231 | (defn- url-encode 232 | "Returns an UTF-8 URL encoded version of the given string." 233 | [^String unencoded] 234 | (URLEncoder/encode unencoded "UTF-8")) 235 | 236 | (defn map->form-params [form-params-map] 237 | (loop [params* (transient []) 238 | kvs (seq form-params-map)] 239 | (if kvs 240 | (let [[k v] (first kvs) 241 | v (url-encode (str v)) 242 | param (str (url-encode (aux/coerce-key k)) "=" v)] 243 | (recur (conj! params* param) (next kvs))) 244 | (str/join "&" (persistent! params*))))) 245 | 246 | (defn ->request-builder ^HttpRequest$Builder [opts] 247 | (let [{:keys [expect-continue 248 | headers 249 | method 250 | timeout 251 | uri 252 | version 253 | body]} opts] 254 | (cond-> (HttpRequest/newBuilder) 255 | (some? expect-continue) (.expectContinue expect-continue) 256 | 257 | (seq headers) (.headers (into-array String (aux/coerce-headers headers))) 258 | method (.method (method-keyword->str method) (->body-publisher body)) 259 | timeout (.timeout (->timeout timeout)) 260 | uri (.uri ^URI uri) 261 | version (.version (version-keyword->version-enum version))))) 262 | 263 | (defn- apply-interceptors [init interceptors k] 264 | (reduce (fn [acc i] 265 | (if-let [f (clojure.core/get i k)] 266 | (f acc) 267 | acc)) 268 | init interceptors)) 269 | 270 | (defn ring->HttpRequest 271 | (^HttpRequest [req-map] 272 | (.build (->request-builder req-map)))) 273 | 274 | (defn- version-enum->version-keyword [^HttpClient$Version version] 275 | (case (.name version) 276 | "HTTP_1_1" :http1.1 277 | "HTTP_2" :http2)) 278 | 279 | (defn response->map [^HttpResponse resp] 280 | {:status (.statusCode resp) 281 | :body (.body resp) 282 | :version (-> resp .version version-enum->version-keyword) 283 | :headers (into {} 284 | (map (fn [[k v]] [k (if (= 1 (count v)) 285 | (first v) 286 | (vec v))])) 287 | (.map (.headers resp))) 288 | :uri (.uri resp)}) 289 | 290 | (defn then [x f] 291 | (if (instance? CompletableFuture x) 292 | (.thenApply ^CompletableFuture x 293 | ^Function (reify Function 294 | (apply [_ args] 295 | (f args)))) 296 | (f x))) 297 | 298 | (defn merge-opts [x y] 299 | (if (and (map? x) (map? y)) 300 | (merge x y) 301 | y)) 302 | 303 | (defn request 304 | [{:keys [client raw] :as req}] 305 | (let [client (or client @default-client) 306 | request-defaults (:request client) 307 | client* (or (:client client) client) 308 | ^HttpClient client client* 309 | ring-client (when (ifn? client*) 310 | client*) 311 | req (merge-with merge-opts request-defaults req) 312 | req (update req :headers aux/prefer-string-keys) 313 | request-interceptors (or (:interceptors req) 314 | interceptors/default-interceptors) 315 | req (apply-interceptors req request-interceptors :request) 316 | req' (when-not ring-client (ring->HttpRequest req)) 317 | async (:async req) 318 | resp (if ring-client 319 | (ring-client req) 320 | (if async 321 | (.sendAsync client req' (HttpResponse$BodyHandlers/ofInputStream)) 322 | (.send client req' (HttpResponse$BodyHandlers/ofInputStream))))] 323 | (if raw resp 324 | (let [resp (if ring-client resp (then resp response->map)) 325 | resp (then resp (fn [resp] 326 | (assoc resp :request req))) 327 | resp (reduce (fn [resp interceptor] 328 | (if-let [f (:response interceptor)] 329 | (then resp f) 330 | resp)) 331 | resp (reverse (or (:interceptors req) 332 | interceptors/default-interceptors)))] 333 | (if async 334 | (let [then-fn (:async-then req) 335 | catch-fn (:async-catch req)] 336 | (cond-> ^CompletableFuture resp 337 | then-fn (.thenApply 338 | (reify Function 339 | (apply [_ resp] 340 | (then-fn resp)))) 341 | catch-fn (.exceptionally 342 | (reify Function 343 | (apply [_ e] 344 | (let [^Throwable e e 345 | cause (ex-cause e)] 346 | (catch-fn {:ex e 347 | :ex-cause cause 348 | :ex-data (ex-data (or cause e)) 349 | :ex-message (ex-message (or cause e)) 350 | :request req}))))))) 351 | resp))))) 352 | -------------------------------------------------------------------------------- /src/babashka/http_client/internal/helpers.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal.helpers 2 | {:no-doc true} 3 | (:require [clojure.string :as str])) 4 | 5 | (defn ->uri [uri] 6 | (cond (string? uri) (java.net.URI/create uri) 7 | (map? uri) 8 | (java.net.URI. ^String (:scheme uri) 9 | ^String (:user uri) 10 | ^String (:host uri) 11 | ^Integer (:port uri -1) 12 | ^String (:path uri) 13 | ^String (:query uri) 14 | ^String (:fragment uri)) 15 | :else uri)) 16 | 17 | (defn coerce-key 18 | "Coerces a key to str" 19 | [k] 20 | (if (keyword? k) 21 | (-> k str (subs 1)) 22 | (str k))) 23 | 24 | (defn capitalize-header [hdr] 25 | (str/join "-" (map str/capitalize (str/split hdr #"-")))) 26 | 27 | (defn prefer-string-keys 28 | "Dissoc-es keyword header if equivalent string header is available already." 29 | [header-map] 30 | (reduce (fn [m k] 31 | (if (keyword? k) 32 | (let [s (coerce-key k)] 33 | (if (or (clojure.core/get header-map (capitalize-header s)) 34 | (clojure.core/get header-map s)) 35 | (dissoc m k) 36 | m)) 37 | m)) 38 | header-map 39 | (keys header-map))) 40 | 41 | (defn coerce-headers 42 | [headers] 43 | (mapcat 44 | (fn [[k v]] 45 | (if (sequential? v) 46 | (interleave (repeat (coerce-key k)) v) 47 | [(coerce-key k) v])) 48 | headers)) 49 | 50 | ;;;; 51 | 52 | (comment 53 | (prefer-string-keys {:accept 1 "Accept" 2}) 54 | ) 55 | -------------------------------------------------------------------------------- /src/babashka/http_client/internal/multipart.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal.multipart 2 | "Multipart implementation largely inspired by hato. Credits to @gnarroway!" 3 | {:no-doc true} 4 | (:refer-clojure :exclude [get]) 5 | (:require [clojure.java.io :as io]) 6 | (:import [java.io InputStream File] 7 | [java.nio.file Files])) 8 | 9 | (set! *warn-on-reflection* true) 10 | 11 | ;;; Helpers 12 | 13 | (defn- content-disposition 14 | [{:keys [part-name name content file-name]}] 15 | (str "Content-Disposition: form-data; " 16 | (format "name=\"%s\"" (or part-name name)) 17 | (when-let [fname (or file-name 18 | (when (instance? File content) 19 | (.getName ^File content)))] 20 | (format "; filename=\"%s\"" fname)))) 21 | 22 | (defn- content-type 23 | [{:keys [content content-type]}] 24 | (str "Content-Type: " 25 | (cond 26 | content-type content-type 27 | (string? content) "text/plain; charset=UTF-8" 28 | (instance? File content) (or (Files/probeContentType (.toPath ^File content)) 29 | "application/octet-stream") 30 | :else "application/octet-stream"))) 31 | 32 | (defn- content-transfer-encoding 33 | [{:keys [content]}] 34 | (if (string? content) 35 | "Content-Transfer-Encoding: 8bit" 36 | "Content-Transfer-Encoding: binary")) 37 | 38 | (def crlf "\r\n") 39 | 40 | (defn boundary 41 | "Creates a boundary string compliant with RFC2046 42 | 43 | See https://www.ietf.org/rfc/rfc2046.txt" 44 | [] 45 | (str "babashka_http_client_Boundary" (java.util.UUID/randomUUID))) 46 | 47 | (defn concat-streams [^InputStream is1 ^InputStream is2 & more] 48 | (let [is (new java.io.SequenceInputStream is1 is2)] 49 | (if more 50 | (recur is (first more) (next more)) 51 | is))) 52 | 53 | (defn ->input-stream [x] 54 | (if (string? x) 55 | (java.io.ByteArrayInputStream. (.getBytes ^String x)) 56 | (io/input-stream x))) 57 | 58 | (defn body 59 | "Returns an InputStream from the multipart input." 60 | [ms b] 61 | (let [streams 62 | (mapcat (fn [m] 63 | (map ->input-stream 64 | [(str "--" b) 65 | crlf 66 | (content-disposition m) 67 | crlf 68 | (content-type m) 69 | crlf 70 | (content-transfer-encoding m) 71 | crlf 72 | crlf 73 | (:content m) 74 | crlf])) 75 | ms) 76 | concat-stream (apply concat-streams 77 | (concat streams 78 | [(->input-stream (str "--" b "--")) 79 | (->input-stream crlf)]))] 80 | concat-stream)) 81 | 82 | (comment 83 | (def b (boundary)) 84 | (def ms [{:name "title" :content "My Awesome Picture"} 85 | {:name "Content/type" :content "image/jpeg"} 86 | {:name "foo.txt" :part-name "eggplant" :content "Eggplants"} 87 | {:name "file" :content (io/file ".nrepl-port")}]) 88 | (with-open [xin (io/input-stream (body ms b)) 89 | xout (java.io.ByteArrayOutputStream.)] 90 | (io/copy xin xout) 91 | (String. (.toByteArray xout)))) 92 | -------------------------------------------------------------------------------- /src/babashka/http_client/internal/version.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal.version 2 | {:no-doc true}) 3 | 4 | (def version 5 | #_:version "0.4.22" 6 | ) 7 | -------------------------------------------------------------------------------- /src/babashka/http_client/internal/websocket.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal.websocket 2 | "Code is very much based on hato's websocket code. Credits to @gnarroway!" 3 | {:no-doc true} 4 | (:require 5 | [babashka.http-client.internal.helpers :as aux]) 6 | (:import 7 | [java.net URI] 8 | [java.net.http 9 | HttpClient 10 | WebSocket 11 | WebSocket$Builder 12 | WebSocket$Listener] 13 | [java.nio ByteBuffer] 14 | [java.time Duration] 15 | [java.util.concurrent CompletableFuture] 16 | [java.util.function Function])) 17 | 18 | (set! *warn-on-reflection* true) 19 | 20 | (defn request->WebSocketListener 21 | [{:keys [on-open 22 | on-message 23 | on-ping 24 | on-pong 25 | on-close 26 | on-error]}] 27 | ; The .requests below is from the implementation of the default listener 28 | (reify WebSocket$Listener 29 | (onOpen [_ ws] 30 | (.request ws 1) 31 | (when on-open 32 | (on-open ws))) 33 | (onText [_ ws data last?] 34 | (.request ws 1) 35 | (when on-message 36 | (.thenApply (CompletableFuture/completedFuture nil) 37 | (reify Function 38 | (apply [_ _] (on-message ws data last?)))))) 39 | (onBinary [_ ws data last?] 40 | (.request ws 1) 41 | (when on-message 42 | (.thenApply (CompletableFuture/completedFuture nil) 43 | (reify Function 44 | (apply [_ _] (on-message ws data last?)))))) 45 | (onPing [_ ws data] 46 | (.request ws 1) 47 | (when on-ping 48 | (.thenApply (CompletableFuture/completedFuture nil) 49 | (reify Function 50 | (apply [_ _] (on-ping ws data)))))) 51 | (onPong [_ ws data] 52 | (.request ws 1) 53 | (when on-pong 54 | (.thenApply (CompletableFuture/completedFuture nil) 55 | (reify Function 56 | (apply [_ _] (on-pong ws data)))))) 57 | (onClose [_ ws status reason] 58 | (when on-close 59 | (.thenApply (CompletableFuture/completedFuture nil) 60 | (reify Function 61 | (apply [_ _] (on-close ws status reason)))))) 62 | (onError [_ ws err] 63 | (when on-error 64 | (on-error ws err))))) 65 | 66 | (defn- with-headers 67 | ^WebSocket$Builder [builder headers] 68 | (reduce (fn [^WebSocket$Builder builder [k v]] 69 | (.header builder (aux/coerce-key k) v)) 70 | builder 71 | headers)) 72 | 73 | (defn websocket 74 | [{:keys [uri 75 | client 76 | headers 77 | connect-timeout 78 | subprotocols 79 | async] 80 | :as opts}] 81 | (let [^HttpClient http-client client 82 | ^WebSocket$Listener listener (request->WebSocketListener opts)] 83 | (cond-> (.newWebSocketBuilder http-client) 84 | connect-timeout (.connectTimeout (Duration/ofMillis connect-timeout)) 85 | (seq subprotocols) (.subprotocols (first subprotocols) (into-array String (rest subprotocols))) 86 | headers (with-headers headers) 87 | true (.buildAsync (aux/->uri uri) listener) 88 | (not async) deref))) 89 | 90 | (defn ->buffer ^java.nio.ByteBuffer [x] 91 | (cond (bytes? x) 92 | (java.nio.ByteBuffer/wrap ^bytes x) 93 | (string? x) (recur (.getBytes ^String x)) 94 | :else x)) 95 | 96 | (defn send! 97 | ([^WebSocket ws data] 98 | (send! ws data nil)) 99 | ([^WebSocket ws data {:keys [last] :or {last true}}] 100 | (cond (instance? CharSequence data) 101 | (.sendText ws ^CharSequence data last) 102 | :else 103 | (.sendBinary ws ^java.nio.ByteBuffer (->buffer data) last)))) 104 | 105 | (defn ping! 106 | [^WebSocket ws data] 107 | (.sendPing ws (->buffer data))) 108 | 109 | (defn pong! 110 | [^WebSocket ws ^ByteBuffer data] 111 | (.sendPong ws (->buffer data))) 112 | 113 | (defn close! 114 | ([^WebSocket ws] 115 | (close! ws WebSocket/NORMAL_CLOSURE "")) 116 | ([^WebSocket ws status-code ^String reason] 117 | (.sendClose ws status-code reason))) 118 | 119 | (defn abort! 120 | [^WebSocket ws] 121 | (.abort ws)) 122 | -------------------------------------------------------------------------------- /src/babashka/http_client/websocket.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.websocket 2 | "Code is very much based on hato's websocket code. Credits to @gnarroway!" 3 | (:require 4 | [babashka.http-client.internal :as i] 5 | [babashka.http-client.internal.websocket :as w]) 6 | (:import [java.util.concurrent CompletableFuture])) 7 | 8 | (set! *warn-on-reflection* true) 9 | 10 | (defn websocket 11 | "Builds `java.net.http.Websocket` client. 12 | * `:uri` - the uri to request (required). 13 | May be a string or map of `:scheme` (required), `:host` (required), `:port`, `:path` and `:query` 14 | * `:headers` - a map of headers for the initial handshake` 15 | * `:client` - a client as produced by `client`. If not provided a default client will be used. 16 | * `:connect-timeout` Sets a timeout for establishing a WebSocket connection (in millis). 17 | * `:subprotocols` - sets a request for the given subprotocols. 18 | * `:async` - return `CompleteableFuture` of websocket 19 | 20 | Callbacks options: 21 | * `:on-open` - `[ws]`, called when a `WebSocket` has been connected. 22 | * `:on-message` - `[ws data last]` A textual/binary data has been received. 23 | * `:on-ping` - `[ws data]` A Ping message has been received. 24 | * `:on-pong` - `[ws data]` A Pong message has been received. 25 | * `:on-close` - `[ws status reason]` Receives a Close message indicating the WebSocket's input has been closed. 26 | * `:on-error` - `[ws err]` An error has occurred." 27 | [{:keys [client] 28 | :as opts}] 29 | (let [client (or client (:client @i/default-client))] 30 | (w/websocket (assoc opts :client client)))) 31 | 32 | (defn send! 33 | "Sends a message to the WebSocket. 34 | `data` can be a CharSequence (e.g. string), byte array or ByteBuffer 35 | 36 | Options: 37 | * `:last`: this is the last message, defaults to `true`" 38 | ([ws data] 39 | (send! ws data nil)) 40 | ([ws data opts] 41 | (w/send! ws data opts))) 42 | 43 | (defn ping! 44 | "Sends a Ping message with bytes from the given buffer." 45 | ^CompletableFuture [ws data] 46 | (w/ping! ws data)) 47 | 48 | (defn pong! 49 | "Sends a Pong message with bytes from the given buffer." 50 | ^CompletableFuture [ws data] 51 | (w/pong! ws data)) 52 | 53 | (defn close! 54 | "Initiates an orderly closure of this WebSocket's output by sending a 55 | Close message with the given status code and the reason." 56 | (^CompletableFuture [ws] 57 | (w/close! ws)) 58 | (^CompletableFuture [ws status-code reason] 59 | (w/close! ws status-code reason))) 60 | 61 | (defn abort! 62 | "Closes this WebSocket's input and output abruptly." 63 | ^CompletableFuture [ws] 64 | (w/abort! ws)) 65 | -------------------------------------------------------------------------------- /test/babashka/http_client/internal/helpers_test.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.internal.helpers-test 2 | (:require 3 | [babashka.http-client.internal.helpers :as h] 4 | [clojure.test :as t])) 5 | 6 | (t/deftest ->uri-tests 7 | (let [uri (h/->uri {:scheme "https" :host "example.com" :path "/foo"})] 8 | (t/is (= (.getPort uri) -1)))) 9 | -------------------------------------------------------------------------------- /test/babashka/http_client/websocket_test.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client.websocket-test 2 | (:require [babashka.http-client.websocket :as ws] :reload 3 | [org.httpkit.server :as srv] 4 | [clojure.test :as t :refer [deftest is]])) 5 | 6 | (defn my-chatroom-handler 7 | [{:keys [on-receive on-close on-open on-ping]}] 8 | (fn [ring-req] 9 | (if-not (:websocket? ring-req) 10 | {:status 200 :body "Welcome to the chatroom! JS client connecting..."} 11 | (srv/as-channel ring-req 12 | {:on-receive on-receive 13 | :on-close on-close 14 | :on-open on-open 15 | :on-ping on-ping})))) 16 | 17 | (def port (atom 1446)) 18 | 19 | (deftest websocket-test 20 | (let [port (swap! port inc) 21 | pings (atom []) 22 | received (atom []) 23 | pongs (atom []) 24 | actions (atom 0) 25 | srv (srv/run-server (my-chatroom-handler {:on-ping (fn [_ch data] 26 | (swap! pings conj data) 27 | (swap! actions inc)) 28 | :on-receive (fn [_ch data] 29 | (swap! received conj data) 30 | (swap! actions inc))}) 31 | {:port port 32 | :legacy-return-value? false}) 33 | ws (ws/websocket {:uri (str "ws://localhost:" port) 34 | :headers {:foo "bar"} 35 | :on-pong (fn [_ch data] 36 | (swap! pongs conj data) 37 | (swap! actions inc))})] 38 | (ws/send! ws "hello") 39 | (ws/ping! ws "yolo") 40 | (while (not= @actions 3)) 41 | (is (= ["hello"] @received)) 42 | (is (= 1 (count @pongs))) 43 | (srv/server-stop! srv))) 44 | -------------------------------------------------------------------------------- /test/babashka/http_client_test.clj: -------------------------------------------------------------------------------- 1 | (ns babashka.http-client-test 2 | (:require 3 | [babashka.fs :as fs] 4 | [babashka.http-client :as http] 5 | [babashka.http-client.interceptors :as i] 6 | [babashka.http-client.internal.version :as iv] 7 | [borkdude.deflet :refer [deflet]] 8 | [cheshire.core :as json] 9 | [clojure.java.io :as io] 10 | [clojure.string :as str] 11 | [clojure.test :refer [deftest is testing]] 12 | [org.httpkit.server :as server]) 13 | (:import 14 | [clojure.lang ExceptionInfo] 15 | [java.net.http HttpRequest$BodyPublishers] 16 | [javax.net.ssl SSLContext])) 17 | 18 | (set! *warn-on-reflection* false) ;; only in this test namespace 19 | 20 | (def !server (atom nil)) 21 | 22 | (defn run-server [] 23 | (let [server 24 | (server/run-server 25 | (fn [{:keys [uri body] :as req}] 26 | (let [status (Long/valueOf (subs uri 1)) 27 | json? (some-> req :headers (get "accept") (str/includes? "application/json"))] 28 | (case status 29 | 200 (let [body (if json? 30 | (json/generate-string {:code 200}) 31 | (if body body 32 | "200 OK"))] 33 | {:status 200 34 | :body body}) 35 | 404 {:status 404 36 | :body "404 Not Found"} 37 | 302 {:status 302 38 | :headers {"location" "/200"}} 39 | {:status status 40 | :body (str status)}))) 41 | {:port 12233 42 | :legacy-return-value? false})] 43 | (reset! !server server))) 44 | 45 | (defn stop-server [] 46 | (server/server-stop! @!server)) 47 | 48 | (defn my-test-fixture [f] 49 | (println "Spinning up server") 50 | (run-server) 51 | (f) 52 | (println "Tearing down server") 53 | (stop-server)) 54 | 55 | (clojure.test/use-fixtures :once my-test-fixture) 56 | 57 | ;; reload client so we're not testing the built-in namespace in bb 58 | 59 | (require '[babashka.http-client.interceptors :as interceptors] :reload 60 | '[babashka.http-client :as http] :reload) 61 | 62 | (defmethod clojure.test/report :begin-test-var [m] 63 | (println "===" (-> m :var meta :name)) 64 | (println)) 65 | 66 | (deftest get-test 67 | (is (str/includes? (:body (http/get "http://localhost:12233/200")) 68 | "200")) 69 | (is (= 200 70 | (-> (http/get "http://localhost:12233/200" 71 | {:headers {"Accept" "application/json"}}) 72 | :body 73 | (json/parse-string true) 74 | :code))) 75 | (testing "query params" 76 | (is (= {:foo1 "bar1", :foo2 "bar2", :foo3 "bar3", :not-string "42", :namespaced/key "foo"} 77 | (-> (http/get "https://postman-echo.com/get" {:query-params {"foo1" "bar1" "foo2" "bar2" :foo3 "bar3" :not-string 42 :namespaced/key "foo"}}) 78 | :body 79 | (json/parse-string true) 80 | :args))) 81 | (is (= {:foo1 ["bar1" "bar2"]} 82 | (-> (http/get "https://postman-echo.com/get" {:query-params {"foo1" ["bar1" "bar2"]}}) 83 | :body 84 | (json/parse-string true) 85 | :args)))) 86 | (testing "can pass uri" 87 | (is (= 200 88 | (-> (http/get (java.net.URI. "http://localhost:12233/200") 89 | {:headers {"Accept" "application/json"}}) 90 | :body 91 | (json/parse-string true) 92 | :code))))) 93 | 94 | (deftest delete-test 95 | (is (= 200 (:status (http/delete "https://postman-echo.com/delete"))))) 96 | 97 | (deftest head-test 98 | (is (= 200 (:status (http/head "https://postman-echo.com/head")))) 99 | ;; github apparently sets encoding despite HEAD request, which returns empty 100 | ;; body and then causes GZIP error 101 | (is (= 200 (:status (http/head "https://github.com/babashka/http-client"))))) 102 | 103 | (deftest post-test 104 | (is (subs (:body (http/post "https://postman-echo.com/post")) 105 | 0 10)) 106 | (is (str/includes? 107 | (:body (http/post "https://postman-echo.com/post" 108 | {:body "From Clojure"})) 109 | "From Clojure")) 110 | (testing "text file body" 111 | (is (str/includes? 112 | (:body (http/post "https://postman-echo.com/post" 113 | {:body (io/file "README.md")})) 114 | "babashka"))) 115 | (testing "binary file body" 116 | (let [file-bytes (fs/read-all-bytes (io/file "icon.png")) 117 | body1 (:body (http/post "http://localhost:12233/200" 118 | {:body (io/file "icon.png") 119 | :as :bytes})) 120 | body2 (:body (http/post "http://localhost:12233/200" 121 | {:body (fs/path "icon.png") 122 | :as :bytes}))] 123 | (is (java.util.Arrays/equals file-bytes body1)) 124 | (is (java.util.Arrays/equals file-bytes body2)))) 125 | (testing "JSON body" 126 | (let [response (http/post "https://postman-echo.com/post" 127 | {:headers {"Content-Type" "application/json"} 128 | :body (json/generate-string {:a "foo"})}) 129 | body (:body response) 130 | body (json/parse-string body true) 131 | json (:json body)] 132 | (is (= {:a "foo"} json)))) 133 | (testing "stream body" 134 | (is (str/includes? 135 | (:body (http/post "https://postman-echo.com/post" 136 | {:body (io/input-stream "README.md")})) 137 | "babashka"))) 138 | (testing "HttpRequest$BodyPublisher body" 139 | (let [body (:body (http/post "https://postman-echo.com/post" 140 | {:body (HttpRequest$BodyPublishers/fromPublisher 141 | (HttpRequest$BodyPublishers/ofInputStream 142 | (reify java.util.function.Supplier 143 | (get [_this] (io/input-stream "README.md")))) 144 | (.length (io/file "README.md")))})) 145 | {:keys [data headers]} (json/parse-string body true)] 146 | (is (= (str (fs/size "README.md")) (:content-length headers))) 147 | (is (= (slurp "README.md") data)))) 148 | (testing "form-params" 149 | (let [body (:body (http/post "https://postman-echo.com/post" 150 | {:form-params {"name" "Michiel Borkent" 151 | :location "NL" 152 | :this-isnt-a-string 42}})) 153 | body (json/parse-string body true) 154 | headers (:headers body) 155 | content-type (:content-type headers)] 156 | (is (= "application/x-www-form-urlencoded" content-type)))) 157 | ;; TODO: 158 | #_(testing "multipart" 159 | (testing "posting file" 160 | (let [tmp-file (java.io.File/createTempFile "foo" "bar") 161 | _ (spit tmp-file "Michiel Borkent") 162 | _ (.deleteOnExit tmp-file) 163 | body (:body (client/post "https://postman-echo.com/post" 164 | {:multipart [{:name "file" 165 | :content (io/file tmp-file)} 166 | {:name "filename" :content (.getPath tmp-file)} 167 | ["file2" (io/file tmp-file)]]})) 168 | body (json/parse-string body true) 169 | headers (:headers body) 170 | content-type (:content-type headers)] 171 | (is (str/starts-with? content-type "multipart/form-data")) 172 | (is (:files body)) 173 | (is (str/includes? (-> body :form :filename) "foo")) 174 | (prn body))))) 175 | 176 | (deftest patch-test 177 | (is (str/includes? 178 | (:body (http/patch "https://postman-echo.com/patch" 179 | {:body "hello"})) 180 | "hello"))) 181 | 182 | (deftest put-test 183 | (is (str/includes? 184 | (:body (http/put "https://postman-echo.com/put" 185 | {:body "hello"})) 186 | "hello"))) 187 | 188 | (deftest basic-auth-test 189 | (is (re-find #"authenticated.*true" 190 | (:body 191 | (http/get "https://postman-echo.com/basic-auth" 192 | {:basic-auth ["postman" "password"]}))))) 193 | 194 | (deftest oauth-token-test 195 | (let [token "qwertyuiop" 196 | response (http/get "https://httpbin.org/bearer" {:oauth-token token}) 197 | resp-body (-> response :body (json/parse-string true))] 198 | (is (= 200 (:status response))) 199 | (is (:authenticated resp-body)) 200 | (is (= token (:token resp-body))))) 201 | 202 | (deftest get-response-object-test 203 | (let [response (http/get "http://localhost:12233/200")] 204 | (is (map? response)) 205 | (is (= 200 (:status response))) 206 | (is (= "200 OK" (:body response))) 207 | (is (string? (get-in response [:headers "server"])))) 208 | 209 | (testing "response object as stream" 210 | (let [response (http/get "http://localhost:12233/200" {:as :stream})] 211 | (is (map? response)) 212 | (is (= 200 (:status response))) 213 | (is (instance? java.io.InputStream (:body response))) 214 | (is (= "200 OK" (slurp (:body response)))) 215 | (is (instance? java.net.URI (:uri response))))) 216 | 217 | (testing "response object with following redirect" 218 | (let [response (http/get (str "https://httpbingo.org/redirect/" 2)) 219 | uri (:uri response)] 220 | (is (= "https://httpbingo.org/get" (str uri))) 221 | (is (map? response)) 222 | (is (= 200 (:status response))))) 223 | 224 | (testing "response object without fully following redirects" 225 | ;; (System/getProperty "jdk.httpclient.redirects.retrylimit" "0") 226 | (let [response (http/get "https://httpbin.org/redirect-to?url=https://www.httpbin.org" 227 | {:client (http/client {:follow-redirects :never})})] 228 | (is (map? response)) 229 | (is (= 302 (:status response))) 230 | (is (= "" (:body response))) 231 | (is (= "https://www.httpbin.org" (get-in response [:headers "location"]))) 232 | (is (empty? (:redirects response)))))) 233 | 234 | (deftest accept-header-test 235 | (is (= 200 236 | (-> (http/get "http://localhost:12233/200" 237 | {:accept :json}) 238 | :body 239 | (json/parse-string true) 240 | :code)))) 241 | 242 | (deftest url-encode-query-params-test 243 | (is (= {"my query param?" "hello there" 244 | "q" "foo & bar"} 245 | (-> (http/get "https://postman-echo.com/get" {:query-params {"my query param?" "hello there" 246 | :q "foo & bar"}}) 247 | :body 248 | (json/parse-string) 249 | (get "args"))))) 250 | 251 | (deftest request-uri-test 252 | (is (= 200 (:status (http/head "http://localhost:12233/200")))) 253 | (is (= 200 (:status (http/head {:scheme "http" 254 | :host "localhost" 255 | :port 12233 256 | :path "/200"})))) 257 | (is (= 200 (:status (http/head (java.net.URI. "http://localhost:12233/200")))))) 258 | 259 | ;; (deftest download-binary-file-as-stream-test 260 | ;; (testing "download image" 261 | ;; (let [tmp-file (java.io.File/createTempFile "icon" ".png")] 262 | ;; (.deleteOnExit tmp-file) 263 | ;; (io/copy (:body (client/get "https://github.com/babashka/babashka/raw/master/logo/icon.png" {:as :stream})) 264 | ;; tmp-file) 265 | ;; (is (= (.length (io/file "test" "icon.png")) 266 | ;; (.length tmp-file))))) 267 | ;; (testing "download image with response headers" 268 | ;; (let [tmp-file (java.io.File/createTempFile "icon" ".png")] 269 | ;; (.deleteOnExit tmp-file) 270 | ;; (let [resp (client/get "https://github.com/babashka/babashka/raw/master/logo/icon.png" {:as :stream})] 271 | ;; (is (= 200 (:status resp))) 272 | ;; (io/copy (:body resp) tmp-file)) 273 | ;; (is (= (.length (io/file "test" "icon.png")) 274 | ;; (.length tmp-file))))) 275 | ;; (testing "direct bytes response" 276 | ;; (let [tmp-file (java.io.File/createTempFile "icon" ".png")] 277 | ;; (.deleteOnExit tmp-file) 278 | ;; (let [resp (client/get "https://github.com/babashka/babashka/raw/master/logo/icon.png" {:as :bytes})] 279 | ;; (is (= 200 (:status resp))) 280 | ;; (is (= (Class/forName "[B") (class (:body resp)))) 281 | ;; (io/copy (:body resp) tmp-file) 282 | ;; (is (= (count (:body resp)) 283 | ;; (.length (io/file "test" "icon.png")) 284 | ;; (.length tmp-file))))))) 285 | 286 | (deftest stream-test 287 | ;; This test aims to test what is tested manually as follows: 288 | ;; - from https://github.com/enkot/SSE-Fake-Server: npm install sse-fake-server 289 | ;; - start with: PORT=1668 node fakeserver.js 290 | ;; - ./bb '(let [resp (client/get "http://localhost:1668/stream" {:as :stream}) body (:body resp) proc (:process resp)] (prn (take 1 (line-seq (io/reader body)))) (.destroy proc))' 291 | ;; ("data: Stream Hello!") 292 | (let [server (java.net.ServerSocket. 1668) 293 | port (.getLocalPort server)] 294 | (future (try (with-open 295 | [socket (.accept server) 296 | out (io/writer (.getOutputStream socket))] 297 | (binding [*out* out] 298 | (println "HTTP/1.1 200 OK") 299 | (println "Content-Type: text/event-stream") 300 | (println "Connection: keep-alive") 301 | (println) 302 | (try (loop [] 303 | (println "data: Stream Hello!") 304 | (Thread/sleep 20) 305 | (recur)) 306 | (catch Exception _ nil)))) 307 | (catch Exception e 308 | (prn e)))) 309 | (let [resp (http/get (str "http://localhost:" port) 310 | {:as :stream}) 311 | status (:status resp) 312 | headers (:headers resp) 313 | body (:body resp)] 314 | (is (= 200 status)) 315 | (is (= "text/event-stream" (get headers "content-type"))) 316 | (is (= (repeat 2 "data: Stream Hello!") (take 2 (line-seq (io/reader body))))) 317 | (is (= (repeat 10 "data: Stream Hello!") (take 10 (line-seq (io/reader body)))))))) 318 | 319 | (deftest exceptional-status-test 320 | (testing "should throw" 321 | (let [ex (is (thrown? ExceptionInfo (http/get "http://localhost:12233/404"))) 322 | response (ex-data ex)] 323 | (is (= 404 (:status response))))) 324 | (testing "should throw when streaming based on status code" 325 | (let [ex (is (thrown? ExceptionInfo (http/get "http://localhost:12233/404" {:throw true 326 | :as :stream}))) 327 | response (ex-data ex)] 328 | (is (= 404 (:status response))) 329 | (is (= "404 Not Found" (slurp (:body response)))))) 330 | (testing "should not throw" 331 | (let [response (http/get "http://localhost:12233/404" {:throw false})] 332 | (is (= 404 (:status response)))))) 333 | 334 | (deftest compressed-test 335 | (let [resp (http/get "https://api.stackexchange.com/2.2/sites" 336 | {:headers {"Accept-Encoding" ["gzip" "deflate"]}})] 337 | (is (-> resp :body (json/parse-string true) :items)))) 338 | 339 | (deftest default-client-test 340 | (let [resp (http/get "https://postman-echo.com/get") 341 | headers (-> resp :body (json/parse-string true) :headers)] 342 | (is (= "*/*" (:accept headers))) 343 | (is (= "gzip, deflate" (:accept-encoding headers))) 344 | (is (= (str "babashka.http-client/" iv/version) (:user-agent headers))))) 345 | 346 | (deftest client-request-opts-test 347 | (let [client (http/client {:request {:headers {"x-my-header" "yolo"}}}) 348 | resp (http/get "https://postman-echo.com/get" 349 | {:client client}) 350 | header (-> resp :body (json/parse-string true) :headers :x-my-header)] 351 | (is (= "yolo" header)) 352 | (let [resp (http/get "https://postman-echo.com/get" 353 | {:client client :headers {"x-my-header" "dude"}}) 354 | header (-> resp :body (json/parse-string true) :headers :x-my-header)] 355 | (is (= "dude" header))))) 356 | 357 | (deftest header-with-keyword-key-test 358 | (is (= 200 359 | (-> (http/get "http://localhost:12233/200" 360 | {:headers {:accept "application/json"}}) 361 | :body 362 | (json/parse-string true) 363 | :code))) 364 | (is (= 200 365 | (-> (http/get "http://localhost:12233/200" 366 | {:headers {"Accept" "application/json"}}) 367 | :body 368 | (json/parse-string true) 369 | :code))) 370 | (is (= 200 371 | (-> (http/get "http://localhost:12233/200" 372 | {:headers {"accept" "application/json"}}) 373 | :body 374 | (json/parse-string true) 375 | :code))) 376 | (is (= 200 377 | (-> (http/get "http://localhost:12233/200" 378 | {:accept :json}) 379 | :body 380 | (json/parse-string true) 381 | :code)))) 382 | 383 | (deftest follow-redirects-test 384 | (testing "default behaviour of following redirects automatically" 385 | (is (= 200 (:status (http/get "http://localhost:12233/302"))))) 386 | 387 | (testing "follow redirects set to false" 388 | (is (= 302 (:status (http/get "http://localhost:12233/302" {:client (http/client {:follow-redirects false})})))))) 389 | 390 | (deftest interceptor-test 391 | (let [json-interceptor 392 | {:name ::json 393 | :description 394 | "A request with `:as :json` will automatically get the 395 | \"application/json\" accept header and the response is decoded as JSON." 396 | :request (fn [request] 397 | (if (= :json (:as request)) 398 | (-> (assoc-in request [:headers :accept] "application/json") 399 | ;; Read body as :string 400 | ;; Mark request as amenable to json decoding 401 | (assoc :as :string ::json true)) 402 | request)) 403 | :response (fn [response] 404 | (if (get-in response [:request ::json]) 405 | (update response :body #(json/parse-string % true)) 406 | response))} 407 | ;; Add json interceptor add beginning of chain 408 | ;; It will be the first to see the request and the last to see the response 409 | interceptors (cons json-interceptor interceptors/default-interceptors)] 410 | (testing "interceptors on request" 411 | (let [resp (http/get "http://localhost:12233/200" 412 | {:interceptors interceptors 413 | :as :json})] 414 | (is (= 200 (-> resp :body 415 | ;; response as JSON 416 | :code))))) 417 | (testing "interceptors on client" 418 | (let [client (http/client (assoc-in http/default-client-opts 419 | [:request :interceptors] interceptors)) 420 | resp (http/get "http://localhost:12233/200" 421 | {:client client 422 | :as :json})] 423 | (is (= 200 (-> resp :body 424 | ;; response as JSON 425 | :code))))))) 426 | 427 | (deftest multipart-test 428 | (let [uuid (.toString (java.util.UUID/randomUUID)) 429 | _ (spit (doto (io/file ".test-data") 430 | (.deleteOnExit)) uuid) 431 | resp (http/post "https://postman-echo.com/post" 432 | {:multipart [{:name "title" :content "My Awesome Picture"} 433 | {:name "Content/type" :content "image/jpeg"} 434 | {:name "foo.txt" :part-name "eggplant" :content "Eggplants"} 435 | {:name "file" :content (io/file ".test-data") :file-name "dude"}]}) 436 | resp-body (:body resp) 437 | resp-body (json/parse-string resp-body true) 438 | headers (:headers resp-body)] 439 | (is (str/starts-with? (:content-type headers) "multipart/form-data; boundary=babashka_http_client_Boundary")) 440 | (is (some? (:dude (:files resp-body)))) 441 | (is (= "My Awesome Picture" (-> resp-body :form :title))))) 442 | 443 | (deftest async-test 444 | (deflet 445 | (def async-resp (http/get "http://localhost:12233/200" {:async true})) 446 | (is (instance? java.util.concurrent.CompletableFuture async-resp)) 447 | (is (= 200 (:status @async-resp))) 448 | (def async-resp (http/get "http://localhost:12233/200" {:async true 449 | :async-then (fn [resp] 450 | (:status resp))})) 451 | (is (= 200 @async-resp)) 452 | (def async-resp (http/get "http://localhost:12233/422" {:async true})) 453 | (def _ex (is (thrown-with-msg? java.util.concurrent.ExecutionException 454 | #"^clojure.lang.ExceptionInfo: Exceptional status code: 422 " 455 | @async-resp))) 456 | (def async-resp (http/get "http://localhost:12233/404" {:async true 457 | :async-then (fn [resp] 458 | (:status resp)) 459 | :async-catch (fn [e] 460 | (:ex-data e))})) 461 | (is (= 404 (:status @async-resp))))) 462 | 463 | (deftest ssl-context-test 464 | ;; keystore was generated with: 465 | ;; keytool -keystore keystore.p12 -genkey -alias client -keyalg RSA 466 | ;; name: Michiel Borkent 467 | (is (not= (SSLContext/getDefault) 468 | (.sslContext (:client (http/client {:ssl-context {:key-store "test/keystore.p12" 469 | :key-store-pass "bbrocks" 470 | :trust-store "test/keystore.p12" 471 | :trust-store-pass "bbrocks"}})))))) 472 | 473 | (deftest proxy-selector 474 | (is (instance? java.net.ProxySelector 475 | (http/->ProxySelector {:host "https://clojure.org" 476 | :port 1337})))) 477 | 478 | (deftest cookie-handler-test 479 | (testing "nil passthrough" 480 | (is (nil? (http/->CookieHandler nil)))) 481 | (testing "CookiePolicy passthrough" 482 | (is (instance? java.net.CookieHandler (http/->CookieHandler {:policy java.net.CookiePolicy/ACCEPT_ORIGINAL_SERVER})))) 483 | (testing "CookieHandler passthrough" 484 | (is (instance? java.net.CookieHandler (http/->CookieHandler (http/->CookieHandler {:policy :accept-all}))))) 485 | (let [test-uri (java.net.URI. "http://test.test") 486 | test-headers {"Set-Cookie" ["Test=Value; Domain=.test.test" "Test2=Value2; Domain=.not.test"]}] 487 | (testing ":original-server keyword policy" 488 | (let [ch (http/->CookieHandler {:policy :original-server})] 489 | (is (instance? java.net.CookieHandler ch)) 490 | (.put ch test-uri test-headers) 491 | (is (= 1 (count (.. ch getCookieStore getCookies)))))) 492 | (testing ":accept-all keyword policy" 493 | (let [ch (http/->CookieHandler {:policy :accept-all})] 494 | (is (instance? java.net.CookieHandler ch)) 495 | (.put ch test-uri test-headers) 496 | (is (= 2 (count (.. ch getCookieStore getCookies)))))) 497 | (testing ":accept-none keyword policy" 498 | (let [ch (http/->CookieHandler {:policy :accept-none})] 499 | (is (instance? java.net.CookieHandler ch)) 500 | (.put ch test-uri test-headers) 501 | (is (zero? (count (.. ch getCookieStore getCookies)))))) 502 | (testing "default should :accept-none" 503 | (let [ch (http/->CookieHandler {})] 504 | (is (instance? java.net.CookieHandler ch)) 505 | (.put ch test-uri test-headers) 506 | (is (zero? (count (.. ch getCookieStore getCookies)))))))) 507 | 508 | (deftest ssl-parameters-test 509 | (is (nil? (http/->SSLParameters nil))) 510 | (let [params (http/->SSLParameters {:ciphers [] 511 | :protocols []})] 512 | (is (and (instance? javax.net.ssl.SSLParameters params) 513 | (nil? (.getCipherSuites params)) 514 | (nil? (.getProtocols params))))) 515 | (let [params (http/->SSLParameters {:ciphers ["SSL_NULL_WITH_NULL_NULL"]})] 516 | (is (and (instance? javax.net.ssl.SSLParameters params) 517 | (= "SSL_NULL_WITH_NULL_NULL" (first (.getCipherSuites params)))))) 518 | (let [params (http/->SSLParameters {:protocols ["TLSv1"]})] 519 | (is (and (instance? javax.net.ssl.SSLParameters params) 520 | (= "TLSv1" (first (.getProtocols params)))))) 521 | (let [params-from-opts (http/->SSLParameters {:ciphers ["SSL_NULL_WITH_NULL_NULL"] 522 | :protocols ["TLSv1"]}) 523 | params-from-params (http/->SSLParameters params-from-opts)] 524 | (is (and (instance? javax.net.ssl.SSLParameters params-from-params) 525 | (= "SSL_NULL_WITH_NULL_NULL" (first (.getCipherSuites params-from-params))) 526 | (= "TLSv1" (first (.getProtocols params-from-params))))))) 527 | 528 | (deftest executor-test 529 | (testing "nil passthrough" 530 | (is nil? (http/->Executor nil))) 531 | (testing "Executor passthrough" 532 | (let [ex (java.util.concurrent.Executors/newSingleThreadExecutor)] 533 | (is (= ex (http/->Executor ex))))) 534 | (testing "Missing or invalid opts yield nil" 535 | (is nil? (http/->Executor {})) 536 | (is nil? (http/->Executor {:threads -1}))) 537 | (is (instance? java.util.concurrent.ThreadPoolExecutor (http/->Executor {:threads 2})))) 538 | 539 | (deftest uri-with-query-params-test 540 | (when (resolve `i/uri-with-query) 541 | (is (= 542 | "https://borkdude:foobar@foobar.net:80/single%2felement?q=%26moo#/dude" 543 | (str (#'i/uri-with-query (java.net.URI. "https://borkdude:foobar@foobar.net:80/single%2felement#/dude") 544 | "q=%26moo")))) 545 | (is (= 546 | "https://borkdude:foobar@foobar.net:80/single%2felement?q=1&q=%26moo#/dude" 547 | (str (#'i/uri-with-query (java.net.URI. "https://borkdude:foobar@foobar.net:80/single%2felement?q=1#/dude") 548 | "q=%26moo")))))) 549 | 550 | (deftest ring-client-test 551 | (testing "inputstring body" 552 | (doseq [resp [(http/get "https://clojure.org" 553 | {:client (fn [_req] 554 | {:body "Hello" 555 | :clojure true})}) 556 | (http/get "https://clojure.org" 557 | {:client (fn [req] 558 | {:body (java.io.ByteArrayInputStream. (.getBytes "Hello")) 559 | :clojure (= "https://clojure.org" (str (:uri req)))})}) 560 | (http/get "https://clojure.org" 561 | {:client (fn [_req] 562 | {:body (java.io.StringReader. "Hello") 563 | :clojure true})})]] 564 | (is (:clojure resp)) 565 | (is (= "Hello" (:body resp)))))) 566 | 567 | (comment 568 | (run-server) 569 | (stop-server)) 570 | -------------------------------------------------------------------------------- /test/keystore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babashka/http-client/5eb11afa0611242fe3f4489b7ebb82caa0c2e2b3/test/keystore.p12 --------------------------------------------------------------------------------