├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── dev ├── bench.clj └── demo │ └── main.clj ├── links.md ├── media └── chart_1.svg ├── project.clj ├── src ├── clj │ └── ring │ │ └── adapter │ │ └── jdk.clj └── java │ └── ring │ └── adapter │ └── jdk │ ├── Config.java │ ├── Const.java │ ├── Err.java │ ├── Handler.java │ ├── Header.java │ ├── IO.java │ ├── KW.java │ ├── Main.java │ ├── Response.java │ └── Server.java ├── test └── ring │ └── adapter │ └── jdk_test.clj └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | /.prepl-port 12 | .hgignore 13 | .hg/ 14 | .idea/ 15 | *.iml 16 | 17 | node_modules/ 18 | package-lock.json 19 | package.json 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 0.1.5-SNAPSHOT 3 | 4 | - ? 5 | - ? 6 | - ? 7 | 8 | ## 0.1.4-SNAPSHOT 9 | 10 | - ? 11 | - ? 12 | - ? 13 | 14 | ## 0.1.3-SNAPSHOT 15 | 16 | - ? 17 | - ? 18 | - ? 19 | 20 | ## 0.1.2 21 | 22 | - add flush calls @kumarshantanu 23 | - better deps 24 | 25 | ## 0.1.1 26 | 27 | - fix scheme request field 28 | - fix uri with a query-string case 29 | - add toc 30 | - add http(s) readme section 31 | - add tests 32 | - add todos 33 | 34 | ## 0.1.0 35 | 36 | - initial release 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | repl: 3 | lein with-profile +dev,+test repl 4 | 5 | .PHONY: test 6 | test: 7 | lein test 8 | 9 | release: 10 | lein release 11 | 12 | toc-install: 13 | npm install --save markdown-toc 14 | 15 | toc-build: 16 | node_modules/.bin/markdown-toc -i README.md 17 | 18 | install: 19 | lein install 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ring JDK Adapter 2 | 3 | Ring JDK Adapter is a small wrapper on top of a built-in HTTP server available 4 | in Java. It's like Jetty but has no dependencies. It's almost as fast as Jetty, 5 | too (see benchmars below). 6 | 7 | ## Table of Contents 8 | 9 | 10 | 11 | - [Why](#why) 12 | - [Availability](#availability) 13 | - [Installation](#installation) 14 | - [Quick Demo](#quick-demo) 15 | - [Parameters](#parameters) 16 | - [Body Type](#body-type) 17 | - [Middleware](#middleware) 18 | - [Exception Handling](#exception-handling) 19 | - [HTTPs & SSL](#https--ssl) 20 | - [Benchmarks](#benchmarks) 21 | 22 | 23 | 24 | ## Why 25 | 26 | Sometimes you want a local HTTP server in Clojure, e.g. for testing or mocking 27 | purposes. There is a number of adapters for Ring but all of them rely on third 28 | party servers like Jetty, Undertow, etc. Running them means to fetch plenty of 29 | dependencies. This is tolerable to some extent, yet sometimes you really want 30 | something quick and simple. 31 | 32 | Since version 9 or 11 (I don't remember for sure), Java ships its own HTTP 33 | server. The package name is `com.sun.net.httpserver` and the module name is 34 | `jdk.httpserver`. The library provides an adapter to serve Ring handlers. It's 35 | completely free from any dependencies. 36 | 37 | Ring JDK Adapter is a great choice for local HTTP stubs or mock services that 38 | mimic HTTP services. Despite some people think it's for development purposes 39 | only, the server is pretty fast! One can use it even in production. 40 | 41 | ## Availability 42 | 43 | It's worth mentioning that some Java installations may miss the `jdk.httpserver` 44 | module. Please ensure the JVM you're using in production supports it first. Here 45 | is the list of the JVMs I've checked manually: 46 | 47 | - OpenJDK 48 | - GraalVM 49 | 50 | Check out the following links: 51 | 52 | - [StackOverflow: Is package com.sun.net.httpserver standard?](https://stackoverflow.com/questions/58764710/is-package-com-sun-net-httpserver-standard) 53 | - [Java® Platform, Standard Edition & Java Development Kit Version 21 API Specification](https://docs.oracle.com/en/java/javase/21/docs/api/index.html) 54 | 55 | ## Installation 56 | 57 | ~~~clojure 58 | ;; lein 59 | [com.github.igrishaev/ring-jdk-adapter "0.1.2"] 60 | 61 | ;; deps 62 | com.github.igrishaev/ring-jdk-adapter {:mvn/version "0.1.2"} 63 | ~~~ 64 | 65 | Requires Java version at least 16, Clojure at least 1.8.0. 66 | 67 | ## Quick Demo 68 | 69 | Import the namespace, declare a Ring handler as usual: 70 | 71 | ~~~clojure 72 | (ns demo 73 | (:require 74 | [ring.adapter.jdk :as jdk])) 75 | 76 | (defn handler [request] 77 | {:status 200 78 | :headers {"Content-Type" "text/plain"} 79 | :body "Hello world!"}) 80 | ~~~ 81 | 82 | Pass it into the `server` function and check the http://127.0.0.1:8082 page in 83 | your browser: 84 | 85 | ~~~clojure 86 | (def server 87 | (jdk/server handler {:port 8082})) 88 | ~~~ 89 | 90 | The `server` function returns an instance of the `Server` class. To stop it, 91 | pass the result into the `jdk/stop` or `jdk/close` functions: 92 | 93 | ~~~clojure 94 | (jdk/stop server) 95 | ~~~ 96 | 97 | Since the `Server` class implements `AutoCloseable` interface, it's compatible 98 | with the `with-open` macro: 99 | 100 | ~~~clojure 101 | (with-open [server (jdk/server handler opt?)] 102 | ...) 103 | ~~~ 104 | 105 | The server gets closed once you've exited the macro. Here is a similar 106 | `with-server` macro which acts the same: 107 | 108 | ~~~clojure 109 | (jdk/with-server [handler opt?] 110 | ...) 111 | ~~~ 112 | 113 | ## Parameters 114 | 115 | The `server` function and the `with-server` macro accept the second optional map 116 | of the parameters: 117 | 118 | | Name | Default | Description | 119 | |-------------------|-----------|-------------------------------------------------------------------------------| 120 | | `:host` | 127.0.0.1 | Host name to listen | 121 | | `:port` | 8080 | Port to listen | 122 | | `:stop-delay-sec` | 0 | How many seconds to wait when stopping the server | 123 | | `:root-path` | / | A path to mount the handler | 124 | | `:threads` | 0 | Amount of CPU threads. When > thn 0, a new `FixedThreadPool` executor is used | 125 | | `:executor` | null | A custom instance of `Executor`. Might be a virtual executor as well | 126 | | `:socket-backlog` | 0 | A numeric value passed into the `HttpServer.create` method | 127 | 128 | Example: 129 | 130 | ~~~clojure 131 | (def server 132 | (jdk/server handler 133 | {:host "0.0.0.0" ;; listen all addresses 134 | :port 8800 ;; a custom port 135 | :threads 8 ;; use custom fixed thread executor 136 | :root-path "/my/app"})) 137 | ~~~ 138 | 139 | When run, the handler above is be available by the address 140 | http://127.0.0.1:8800/my/app in the browser. 141 | 142 | ## Body Type 143 | 144 | JDK adapter supports the following response `:body` types: 145 | 146 | - `java.lang.String` 147 | - `java.io.InputStream` 148 | - `java.io.File` 149 | - `java.lang.Iterable` (see below) 150 | - `null` (nothing gets sent) 151 | 152 | When the body is `Iterable` (might be a lazy seq as well), every item is sent as 153 | a string in UTF-8 encoding. Null values are skipped. 154 | 155 | ## Middleware 156 | 157 | To gain all the power of Ring (parsed parameters, JSON, sessions, etc), wrap 158 | your handler with the standard middleware: 159 | 160 | ~~~clojure 161 | (ns demo 162 | (:require 163 | [ring.middleware.params :refer [wrap-params]] 164 | [ring.middleware.keyword-params :refer [wrap-keyword-params]] 165 | [ring.middleware.multipart-params :refer [wrap-multipart-params]])) 166 | 167 | (let [handler (-> handler 168 | wrap-keyword-params 169 | wrap-params 170 | wrap-multipart-params)] 171 | (jdk/server handler {:port 8082})) 172 | ~~~ 173 | 174 | The wrapped handler will receive a `request` map with parsed `:query-params`, 175 | `:form-params`, and `:params` fields. These middleware come from the `ring-core` 176 | library which you need to add into your dependencies. The same applies to 177 | handling JSON and the `ring-json` library. 178 | 179 | ## Exception Handling 180 | 181 | If something gets wrong while handling a request, you'll get a plain text page 182 | with a short message and a stack trace: 183 | 184 | ~~~clojure 185 | (defn handler [request] 186 | (/ 0 0) ;; ! 187 | {:status 200 188 | :headers {"Content-Type" "text/plain"} 189 | :body "hello"}) 190 | ~~~ 191 | 192 | This is what you'll get in the browser: 193 | 194 | ~~~text 195 | failed to execute ring handler 196 | java.lang.ArithmeticException: Divide by zero 197 | at clojure.lang.Numbers.divide(Numbers.java:190) 198 | at clojure.lang.Numbers.divide(Numbers.java:3911) 199 | at bench$handler.invokeStatic(form-init14855917186251843338.clj:8) 200 | at bench$handler.invoke(form-init14855917186251843338.clj:7) 201 | at ring.adapter.jdk.Handler.handle(Handler.java:112) 202 | at jdk.httpserver/com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:98) 203 | at jdk.httpserver/sun.net.httpserver.AuthFilter.doFilter(AuthFilter.java:82) 204 | at jdk.httpserver/com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:101) 205 | at jdk.httpserver/sun.net.httpserver.ServerImpl$Exchange$LinkHandler.handle(ServerImpl.java:873) 206 | at jdk.httpserver/com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:98) 207 | at jdk.httpserver/sun.net.httpserver.ServerImpl$Exchange.run(ServerImpl.java:849) 208 | at jdk.httpserver/sun.net.httpserver.ServerImpl$DefaultExecutor.execute(ServerImpl.java:204) 209 | at jdk.httpserver/sun.net.httpserver.ServerImpl$Dispatcher.handle(ServerImpl.java:567) 210 | at jdk.httpserver/sun.net.httpserver.ServerImpl$Dispatcher.run(ServerImpl.java:532) 211 | at java.base/java.lang.Thread.run(Thread.java:1575) 212 | ~~~ 213 | 214 | To prevent this data from being leaked to the client, use your own 215 | `wrap-exception` middleware, something like this: 216 | 217 | ~~~clojure 218 | (defn wrap-exception [handler] 219 | (fn [request] 220 | (try 221 | (handler request) 222 | (catch Exception e 223 | (log/errorf e ...) 224 | {:status 500 225 | :headers {...} 226 | :body "No cigar! Roll again!"})))) 227 | ~~~ 228 | 229 | ## HTTPs & SSL 230 | 231 | At the moment, the adapter supports HTTP only. There is a pending TODO to make 232 | it also work with HTTPs and a custom SSL context. 233 | 234 | ## Benchmarks 235 | 236 | As mentioned above, the JDK server although though is for dev purposes only, is 237 | not so bad! The chart below proves it's almost as fast as Jetty. There are five 238 | attempts of `ab -l -n 1000 -c 50 ...` made against both Jetty and JDK servers 239 | (1000 requests in total, 50 parallel). The levels of RPS are pretty equal: about 240 | 12-13K requests per second. 241 | 242 | Measured on Macbook M3 Pro 32Gb, default settings, the same REPL. 243 | 244 | 245 | 246 | Ivan Grishaev, 2024 247 | -------------------------------------------------------------------------------- /dev/bench.clj: -------------------------------------------------------------------------------- 1 | (ns bench 2 | (:import 3 | java.util.concurrent.Executors) 4 | (:require 5 | [ring.adapter.jdk :as jdk])) 6 | 7 | (defn handler [request] 8 | {:status 200 9 | :headers {"Content-Type" "text/plain"} 10 | :body "hello"}) 11 | 12 | (comment 13 | 14 | (require 15 | '[ring.adapter.jetty :as jetty]) 16 | 17 | (import 18 | 'org.eclipse.jetty.util.thread.QueuedThreadPool) 19 | 20 | (def -exe 21 | (Executors/newVirtualThreadPerTaskExecutor)) 22 | 23 | (.close -exe) 24 | 25 | ;; default 26 | (def -server-jetty 27 | (jetty/run-jetty handler 28 | {:port 8081 29 | :join? false})) 30 | 31 | ;; executor 32 | (def -server-jetty 33 | (let [pool (doto (new QueuedThreadPool) 34 | (.setVirtualThreadsExecutor 35 | (Executors/newVirtualThreadPerTaskExecutor)))] 36 | (jetty/run-jetty handler 37 | {:port 8081 38 | :join? false 39 | :thread-pool pool}))) 40 | 41 | (.stop -server-jetty) 42 | 43 | ;; default 44 | (def -server-jdk 45 | (jdk/server handler {:port 8082})) 46 | 47 | ;; executor 48 | (def -server-jdk 49 | (jdk/server handler {:port 8082 50 | :executor -exe})) 51 | 52 | (.close -server-jdk) 53 | 54 | ) 55 | 56 | ;; mb m3 pro 32g 57 | ;; ab -l -n 1000 -c 50 http://127.0.0.1:8081/ (jetty) 58 | ;; ab -l -n 1000 -c 50 http://127.0.0.1:8082/ (jdk) 59 | 60 | ;; jetty (default) 61 | ;; Requests per second: 13505.30 [#/sec] (mean) 62 | 63 | ;; jdk (default) 64 | ;; Requests per second: 12615.75 [#/sec] (mean) 65 | 66 | 67 | ;; jetty (virtual threads) 68 | ;; Requests per second: 12797.71 [#/sec] (mean) 69 | 70 | ;; jdk (virtual threads) 71 | ;; Requests per second: 13640.15 [#/sec] (mean) 72 | -------------------------------------------------------------------------------- /dev/demo/main.clj: -------------------------------------------------------------------------------- 1 | (ns demo.main 2 | (:gen-class) 3 | (:require 4 | [ring.adapter.jdk :as jdk])) 5 | 6 | (defn handler [request] 7 | {:status 200 8 | :headers {"Content-Type" "text/plain"} 9 | :body "hello"}) 10 | 11 | (defn -main [& _] 12 | (jdk/server handler {:port 8082})) 13 | -------------------------------------------------------------------------------- /links.md: -------------------------------------------------------------------------------- 1 | 2 | # Ring spec 3 | https://github.com/ring-clojure/ring/blob/master/SPEC.md 4 | -------------------------------------------------------------------------------- /media/chart_1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (def MIN_JAVA_VERSION "16") 2 | (def RING_VERSION "1.13.0") 3 | (def CLJ_VERSION "1.8.0") 4 | 5 | (defproject com.github.igrishaev/ring-jdk-adapter "0.1.3-SNAPSHOT" 6 | 7 | :description 8 | "Zero-deps Ring server on top of jdk.httpserver" 9 | 10 | :url 11 | "https://github.com/igrishaev/ring-jdk-adapter" 12 | 13 | :pom-addition 14 | [:properties 15 | ["maven.compiler.source" ~MIN_JAVA_VERSION] 16 | ["maven.compiler.target" ~MIN_JAVA_VERSION]] 17 | 18 | :license 19 | {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" 20 | :url "https://www.eclipse.org/legal/epl-2.0/"} 21 | 22 | :deploy-repositories 23 | {"releases" 24 | {:url "https://repo.clojars.org" 25 | :creds :gpg} 26 | "snapshots" 27 | {:url "https://repo.clojars.org" 28 | :creds :gpg}} 29 | 30 | :release-tasks 31 | [["vcs" "assert-committed"] 32 | ["change" "version" "leiningen.release/bump-version" "release"] 33 | ["vcs" "commit"] 34 | ["vcs" "tag" "--no-sign"] 35 | ["with-profile" "uberjar" "install"] 36 | ["with-profile" "uberjar" "deploy"] 37 | ["change" "version" "leiningen.release/bump-version"] 38 | ["vcs" "commit"] 39 | ["vcs" "push"]] 40 | 41 | :source-paths ["src/clj"] 42 | :java-source-paths ["src/java"] 43 | :javac-options ["-Xlint:unchecked" 44 | "-Xlint:preview" 45 | "--release" ~MIN_JAVA_VERSION] 46 | 47 | :dependencies 48 | [[org.clojure/clojure :scope "provided"]] 49 | 50 | :managed-dependencies 51 | [[org.clojure/clojure ~CLJ_VERSION] 52 | [ring/ring-core ~RING_VERSION] 53 | [ring/ring-jetty-adapter ~RING_VERSION] 54 | [clj-http "3.13.0"] 55 | [commons-io/commons-io "2.19.0"]] 56 | 57 | ;; this is to test with custom JVMs locally 58 | ;; :java-cmd 59 | ;; "/Users/.../Contents/Home/bin/java" 60 | 61 | :profiles 62 | {:dev 63 | {:source-paths ["dev"] 64 | :dependencies [[ring/ring-core] 65 | [ring/ring-jetty-adapter] 66 | [clj-http] 67 | [commons-io/commons-io]] 68 | :global-vars 69 | {*warn-on-reflection* true 70 | *assert* true}} 71 | :test 72 | {:source-paths ["test"]} 73 | 74 | :demo 75 | {:dependencies [[org.clojure/clojure]] 76 | :source-paths ["dev"] 77 | :main ^:skip-aot demo.main 78 | :aot :all 79 | :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) 80 | -------------------------------------------------------------------------------- /src/clj/ring/adapter/jdk.clj: -------------------------------------------------------------------------------- 1 | (ns ring.adapter.jdk 2 | (:import 3 | (ring.adapter.jdk Server 4 | Config))) 5 | 6 | (set! *warn-on-reflection* true) 7 | 8 | (defn ->Config 9 | " 10 | Build a Config object out from a Clojure map. 11 | " 12 | ^Config [opt] 13 | (if (empty? opt) 14 | Config/DEFAULT 15 | (let [{:keys [host 16 | port 17 | stop-delay-sec 18 | root-path 19 | threads 20 | executor 21 | socket-backlog]} 22 | opt] 23 | 24 | (cond-> (Config/builder) 25 | 26 | host 27 | (.host host) 28 | 29 | port 30 | (.port port) 31 | 32 | stop-delay-sec 33 | (.stop_delay_sec stop-delay-sec) 34 | 35 | root-path 36 | (.root_path root-path) 37 | 38 | threads 39 | (.threads threads) 40 | 41 | executor 42 | (.executor executor) 43 | 44 | socket-backlog 45 | (.socket_backlog socket-backlog) 46 | 47 | :finally 48 | (.build))))) 49 | 50 | (defn server 51 | " 52 | Given a Clojure Ring handler (arity-1 function) 53 | and, optionally a Clojure map of options, run 54 | an HTTP server in a separate thread. Return an 55 | instance of the `ring.adapter.jdk.Server` class. 56 | Needs to be closed afterwards; can be used with 57 | the `with-open` macro. 58 | 59 | Optional parameters: 60 | - `:host` is the host name to bind (default is 127.0.0.1). 61 | - `:port` is the port number to listen (default is 8080). 62 | - `:threads` is the number of threads to use. When > 0, 63 | then a custom instance of FixedThreadPool is used. 64 | - `:executor` is a custom Executor object, if needed. 65 | - `:root-path` is a string, a global path prefix for 66 | the handier. 67 | - `:stop-delay-sec` is a number of seconds to wait when 68 | closing the server; 69 | - `:socket-backlog` is a number, an option for socket 70 | when binding a server. 71 | " 72 | 73 | (^Server [handler] 74 | (Server/start handler Config/DEFAULT)) 75 | (^Server [handler opt] 76 | (Server/start handler (->Config opt)))) 77 | 78 | (defn close 79 | " 80 | Close the server (the same as stop). 81 | " 82 | [^Server server] 83 | (.close server)) 84 | 85 | (defn stop 86 | " 87 | Close the server (the same as close). 88 | " 89 | [^Server server] 90 | (.close server)) 91 | 92 | (defmacro with-server 93 | " 94 | A wrapper on top of the `with-open` macro. Takes a handler 95 | (mandatory) and an optional map of parameters. Runs the server 96 | while the body is being executed. Closes the server instance 97 | on exit. 98 | " 99 | [[handler opt] & body] 100 | `(with-open [server# (Server/start ~handler (->Config ~opt))] 101 | ~@body)) 102 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Config.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import java.util.concurrent.Executor; 4 | 5 | public record Config( 6 | String host, 7 | int port, 8 | int stop_delay_sec, 9 | String root_path, 10 | int threads, 11 | Executor executor, 12 | int socket_backlog 13 | ) { 14 | 15 | @SuppressWarnings("unused") 16 | public static Config DEFAULT = builder().build(); 17 | 18 | public static Builder builder() { 19 | return new Builder(); 20 | } 21 | 22 | public static class Builder { 23 | 24 | private String host = Const.host; 25 | private int port = Const.port; 26 | private int stop_delay_sec = Const.stop_delay_sec; 27 | private String root_path = Const.root_path; 28 | private int threads = Const.threads; 29 | private Executor executor = Const.executor; 30 | private int socket_backlog = Const.socket_backlog; 31 | 32 | @SuppressWarnings("unused") 33 | public Builder host(final String host) { 34 | this.host = host; 35 | return this; 36 | } 37 | 38 | @SuppressWarnings("unused") 39 | public Builder port(final int port) { 40 | this.port = port; 41 | return this; 42 | } 43 | 44 | @SuppressWarnings("unused") 45 | public Builder stop_delay_sec(final int stop_delay_sec) { 46 | this.stop_delay_sec = stop_delay_sec; 47 | return this; 48 | } 49 | 50 | @SuppressWarnings("unused") 51 | public Builder root_path(final String root_path) { 52 | this.root_path = root_path; 53 | return this; 54 | } 55 | 56 | @SuppressWarnings("unused") 57 | public Builder threads(final int threads) { 58 | this.threads = threads; 59 | return this; 60 | } 61 | 62 | @SuppressWarnings("unused") 63 | public Builder executor(final Executor executor) { 64 | this.executor = executor; 65 | return this; 66 | } 67 | 68 | @SuppressWarnings("unused") 69 | public Builder socket_backlog(final int socket_backlog) { 70 | this.socket_backlog = socket_backlog; 71 | return this; 72 | } 73 | 74 | public Config build() { 75 | return new Config( 76 | host, 77 | port, 78 | stop_delay_sec, 79 | root_path, 80 | threads, 81 | executor, 82 | socket_backlog 83 | ); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Const.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import java.util.concurrent.Executor; 4 | 5 | public class Const { 6 | public static String host = "127.0.0.1"; 7 | public static int port = 8080; 8 | public static int stop_delay_sec = 0; 9 | public static String root_path = "/"; 10 | public static int threads = 0; 11 | public static Executor executor = null; 12 | public static int socket_backlog = 0; 13 | } 14 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Err.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | public class Err { 4 | 5 | private Err(){} 6 | 7 | public static RuntimeException error(final Throwable e, final String template, final Object... args) { 8 | return new RuntimeException(String.format(template, args), e); 9 | } 10 | 11 | public static RuntimeException error(final String template, final Object... args) { 12 | return new RuntimeException(String.format(template, args)); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Handler.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import clojure.lang.*; 4 | import com.sun.net.httpserver.Headers; 5 | import com.sun.net.httpserver.HttpExchange; 6 | import com.sun.net.httpserver.HttpHandler; 7 | 8 | import java.io.*; 9 | import java.net.InetSocketAddress; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.net.URI; 13 | 14 | public class Handler implements HttpHandler { 15 | 16 | private final IFn ringHandler; 17 | 18 | public Handler(final IFn ringHandler) { 19 | this.ringHandler = ringHandler; 20 | } 21 | 22 | private static IPersistentMap toClojureHeaders(final Headers headers) { 23 | IPersistentMap result = PersistentHashMap.EMPTY; 24 | String header; 25 | for (Map.Entry> me: headers.entrySet()) { 26 | // ring relies on lower-cased headers 27 | header = me.getKey().toLowerCase(); 28 | if (me.getValue().size() == 1) { 29 | result = result.assoc(header, me.getValue().get(0)); 30 | } else { 31 | result = result.assoc(header, PersistentVector.create(me.getValue())); 32 | } 33 | } 34 | return result; 35 | } 36 | 37 | private static Keyword toClojureMethod(final String method) { 38 | return switch (method) { 39 | case "GET" -> KW.get; 40 | case "POST" -> KW.post; 41 | case "PUT" -> KW.put; 42 | case "PATCH" -> KW.patch; 43 | case "DELETE" -> KW.delete; 44 | case "OPTIONS" -> KW.options; 45 | default -> Keyword.intern(method.toLowerCase()); 46 | }; 47 | } 48 | 49 | private Map toRequest(final HttpExchange httpExchange) { 50 | final String protocol = httpExchange.getProtocol(); 51 | final String method = httpExchange.getRequestMethod(); 52 | final URI uri = httpExchange.getRequestURI(); 53 | final String queryString = uri.getQuery(); 54 | final InputStream body = httpExchange.getRequestBody(); 55 | final InetSocketAddress remoteAddress = httpExchange.getRemoteAddress(); 56 | final Headers headers = httpExchange.getRequestHeaders(); 57 | final int serverPort = httpExchange.getHttpContext().getServer().getAddress().getPort(); 58 | final String serverName = httpExchange.getHttpContext().getServer().getAddress().getHostName(); 59 | final String uriString = uri.getPath(); 60 | 61 | return PersistentHashMap.create( 62 | KW.body, body, 63 | KW.uri, uriString, 64 | KW.protocol, protocol, 65 | KW.remote_addr, remoteAddress.toString(), 66 | KW.request_method, toClojureMethod(method), 67 | KW.query_string, queryString, 68 | KW.headers, toClojureHeaders(headers), 69 | KW.server_name, serverName, 70 | KW.server_port, serverPort, 71 | // TODO: implement HTTPs/SSL 72 | KW.scheme, KW.http 73 | ); 74 | } 75 | 76 | private void sendStatus(final HttpExchange exchange, final int httpStatus, final long contentLength) { 77 | try { 78 | exchange.sendResponseHeaders(httpStatus, contentLength); 79 | } catch (IOException e) { 80 | throw Err.error(e, 81 | "cannot send response headers, status: %s, response length: %s", 82 | httpStatus, contentLength 83 | ); 84 | } 85 | } 86 | 87 | private void sendResponse(final Response response, final HttpExchange exchange) { 88 | final Headers headers = exchange.getResponseHeaders(); 89 | for (Header h: response.headers()) { 90 | headers.add(h.k(), h.v()); 91 | } 92 | sendStatus(exchange, response.status(), response.contentLength()); 93 | final OutputStream out = exchange.getResponseBody(); 94 | final InputStream bodyStream = response.bodyStream(); 95 | if (bodyStream != null) { 96 | IO.transfer(bodyStream, out); 97 | } 98 | final Iterable bodyIter = response.bodyIter(); 99 | if (bodyIter != null) { 100 | for (Object x: bodyIter) { 101 | if (x != null) { 102 | IO.transfer(x.toString(), out); 103 | } 104 | } 105 | } 106 | IO.close(out); 107 | } 108 | 109 | @Override 110 | public void handle(HttpExchange exchange) { 111 | final Map request = toRequest(exchange); 112 | Object ringResponse; 113 | Response javaResponse; 114 | try { 115 | ringResponse = ringHandler.invoke(request); 116 | javaResponse = Response.fromRingResponse(ringResponse); 117 | } catch (Exception e) { 118 | javaResponse = Response.get500response(e, "failed to execute ring handler"); 119 | } 120 | sendResponse(javaResponse, exchange); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Header.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | public record Header(String k, String v) {} 4 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/IO.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import java.io.*; 4 | import java.nio.charset.Charset; 5 | import java.nio.charset.StandardCharsets; 6 | 7 | public class IO { 8 | 9 | public static void transfer(final InputStream in, final OutputStream out) { 10 | try { 11 | in.transferTo(out); 12 | out.flush(); 13 | } catch (IOException e) { 14 | throw Err.error("could not transfer ab input stream into the output stream"); 15 | } 16 | } 17 | 18 | public static void transfer(final String s, final OutputStream out) { 19 | final byte[] buf = s.getBytes(StandardCharsets.UTF_8); 20 | try { 21 | out.write(buf); 22 | out.flush(); 23 | } catch (IOException e) { 24 | throw Err.error("could not transfer a string into the output stream"); 25 | } 26 | } 27 | 28 | public static InputStream toInputStream(final File file) { 29 | try { 30 | return new FileInputStream(file); 31 | } catch (FileNotFoundException e) { 32 | throw Err.error(e, "file not found: %s", file); 33 | } 34 | } 35 | 36 | @SuppressWarnings("unused") 37 | public static void write(final OutputStream out, final String s) { 38 | write(out, s, StandardCharsets.UTF_8); 39 | } 40 | 41 | public static void write(final OutputStream out, final String s, final Charset charset) { 42 | try { 43 | out.write(s.getBytes(charset)); 44 | } catch (IOException e) { 45 | throw Err.error(e, "could not write string to the output stream"); 46 | } 47 | } 48 | 49 | public static void close(final OutputStream out) { 50 | try { 51 | out.close(); 52 | } catch (IOException e) { 53 | throw Err.error(e, "cannot close the output stream"); 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/KW.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import clojure.lang.Keyword; 4 | 5 | public class KW { 6 | public static final Keyword body = Keyword.intern("body"); 7 | public static final Keyword uri = Keyword.intern("uri"); 8 | public static final Keyword protocol = Keyword.intern("protocol"); 9 | public static final Keyword remote_addr = Keyword.intern("remote-addr"); 10 | public static final Keyword request_method = Keyword.intern("request-method"); 11 | public static final Keyword scheme = Keyword.intern("scheme"); 12 | public static final Keyword headers = Keyword.intern("headers"); 13 | public static final Keyword server_name = Keyword.intern("server-name"); 14 | public static final Keyword server_port = Keyword.intern("server-port"); 15 | public static final Keyword status = Keyword.intern("status"); 16 | public static final Keyword query_string = Keyword.intern("query-string"); 17 | public static final Keyword get = Keyword.intern("get"); 18 | public static final Keyword post = Keyword.intern("post"); 19 | public static final Keyword put = Keyword.intern("put"); 20 | public static final Keyword delete = Keyword.intern("delete"); 21 | public static final Keyword patch = Keyword.intern("patch"); 22 | public static final Keyword options = Keyword.intern("options"); 23 | public static final Keyword http = Keyword.intern("http"); 24 | @SuppressWarnings("unused") 25 | public static final Keyword https = Keyword.intern("https"); 26 | @SuppressWarnings("unused") 27 | public static final Keyword ws = Keyword.intern("ws"); 28 | @SuppressWarnings("unused") 29 | public static final Keyword wss = Keyword.intern("wss"); 30 | 31 | public static String KWtoString(final Keyword kw) { 32 | return kw.toString().substring(1); 33 | } 34 | } 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Main.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import clojure.lang.AFn; 4 | import clojure.lang.PersistentHashMap; 5 | 6 | public class Main { 7 | 8 | public static void main(String... args) { 9 | final Server s = Server.start(new AFn() { 10 | @Override 11 | public Object invoke(Object request) { 12 | int a = 0 / 1; 13 | return PersistentHashMap.create( 14 | KW.status, 200, 15 | KW.headers, PersistentHashMap.create("content-type", "text/plain"), 16 | KW.body, 42 17 | ); 18 | } 19 | }); 20 | System.out.println("test"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Response.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import java.io.*; 4 | import java.nio.charset.StandardCharsets; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.Map; 8 | 9 | import clojure.lang.Keyword; 10 | import clojure.lang.RT; 11 | 12 | public record Response ( 13 | int status, 14 | List
headers, 15 | InputStream bodyStream, 16 | Iterable bodyIter, 17 | long contentLength 18 | ) { 19 | 20 | public static Response get500response(final Throwable e, final String message) { 21 | final List
headers = List.of(new Header("Content-Type", "text/plain")); 22 | final StringWriter sw = new StringWriter(); 23 | e.printStackTrace(new PrintWriter(sw)); 24 | final String payload = message + "\n" + sw; 25 | final byte[] buf = payload.getBytes(StandardCharsets.UTF_8); 26 | return new Response( 27 | 500, 28 | headers, 29 | new ByteArrayInputStream(buf), 30 | null, 31 | buf.length 32 | ); 33 | } 34 | 35 | public static int getStatus(final Map ringResponse) { 36 | final Object x = ringResponse.get(KW.status); 37 | if (x instanceof Number n) { 38 | return RT.intCast(n); 39 | } else { 40 | throw Err.error("ring status is not integer: %s", x); 41 | } 42 | } 43 | 44 | public static String toHeaderKey(final Object x) { 45 | if (x instanceof String s) { 46 | return s; 47 | } else if (x instanceof Keyword kw) { 48 | return KW.KWtoString(kw); 49 | } else { 50 | throw Err.error("unsupported header key: %s", x); 51 | } 52 | } 53 | 54 | public static List
getHeaders(final Map ringResponse) { 55 | final Object x = ringResponse.get(KW.headers); 56 | 57 | if (x == null) { 58 | return List.of(); 59 | } 60 | 61 | if (x instanceof Map m) { 62 | String k; 63 | Object v; 64 | final List
result = new ArrayList<>(m.size()); 65 | for (Map.Entry me: m.entrySet()) { 66 | k = toHeaderKey(me.getKey()); 67 | v = me.getValue(); 68 | if (v instanceof String vs) { 69 | result.add(new Header(k, vs)); 70 | } else if (v instanceof Iterable iterable) { 71 | for (Object vi : iterable) { 72 | if (vi instanceof String vis) { 73 | result.add(new Header(k, vis)); 74 | } else if (vi != null) { 75 | result.add(new Header(k, vi.toString())); 76 | } 77 | } 78 | } else if (v != null) { 79 | result.add(new Header(k, v.toString())); 80 | } 81 | } 82 | return result; 83 | } 84 | throw Err.error("wrong ring headers: %s", x); 85 | } 86 | 87 | public static Response fromRingResponse(final Object x) { 88 | if (x instanceof Map ringResponse) { 89 | final int status = getStatus(ringResponse); 90 | final List
headers = getHeaders(ringResponse); 91 | 92 | final Object bodyObj = ringResponse.get(KW.body); 93 | InputStream bodyStream = null; 94 | Iterable bodyIter = null; 95 | long contentLength = 0; 96 | 97 | if (bodyObj instanceof InputStream in) { 98 | bodyStream = in; 99 | } else if (bodyObj instanceof File file) { 100 | bodyStream = IO.toInputStream(file); 101 | contentLength = file.length(); 102 | } else if (bodyObj instanceof String s) { 103 | final byte[] buf = s.getBytes(StandardCharsets.UTF_8); 104 | bodyStream = new ByteArrayInputStream(buf); 105 | contentLength = buf.length; 106 | } else if (bodyObj == null) { 107 | bodyStream = InputStream.nullInputStream(); 108 | } else if (bodyObj instanceof Iterable i) { 109 | bodyIter = i; 110 | } else { 111 | throw Err.error("unsupported ring body: %s", bodyObj); 112 | } 113 | 114 | return new Response(status, headers, bodyStream, bodyIter, contentLength); 115 | 116 | } else { 117 | throw Err.error("unsupported ring response: %s", x); 118 | } 119 | 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/java/ring/adapter/jdk/Server.java: -------------------------------------------------------------------------------- 1 | package ring.adapter.jdk; 2 | 3 | import clojure.lang.IFn; 4 | import com.sun.net.httpserver.HttpServer; 5 | 6 | import java.io.IOException; 7 | import java.net.InetSocketAddress; 8 | import java.util.concurrent.Executor; 9 | import java.util.concurrent.Executors; 10 | 11 | public class Server implements AutoCloseable { 12 | 13 | private final Config config; 14 | private final IFn ringHandler; 15 | private HttpServer httpServer; 16 | 17 | private Server(final IFn ringHandler, final Config config) { 18 | this.ringHandler = ringHandler; 19 | this.config = config; 20 | } 21 | 22 | @Override 23 | public String toString() { 24 | return String.format("", 25 | config.host(), 26 | config.port(), 27 | ringHandler.toString() 28 | ); 29 | } 30 | 31 | @SuppressWarnings("unused") 32 | public static Server start(final IFn clojureHandler) { 33 | return start(clojureHandler, Config.DEFAULT); 34 | } 35 | 36 | @SuppressWarnings({"unused", "resource"}) 37 | public static Server start(final IFn ringHandler, final Config config) { 38 | return new Server(ringHandler, config).init(); 39 | } 40 | 41 | private Server init() { 42 | final InetSocketAddress address = new InetSocketAddress( 43 | config.host(), config.port() 44 | ); 45 | try { 46 | httpServer = HttpServer.create(address, config.socket_backlog()); 47 | } catch (IOException e) { 48 | throw Err.error(e, "failed to create HTTP server, addr: %s", address); 49 | } 50 | final Handler javaHandler = new Handler(ringHandler); 51 | httpServer.createContext(config.root_path(), javaHandler); 52 | final int threads = config.threads(); 53 | Executor executor; 54 | if (threads > 0) { 55 | executor = Executors.newFixedThreadPool(threads); 56 | } else { 57 | executor = config.executor(); 58 | } 59 | httpServer.setExecutor(executor); 60 | httpServer.start(); 61 | return this; 62 | } 63 | 64 | @Override 65 | public void close() { 66 | httpServer.stop(config.stop_delay_sec()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/ring/adapter/jdk_test.clj: -------------------------------------------------------------------------------- 1 | (ns ring.adapter.jdk-test 2 | (:import 3 | (java.io File IOException)) 4 | (:require 5 | [clj-http.client :as client] 6 | [clojure.java.io :as io] 7 | [clojure.string :as str] 8 | [clojure.test :refer [deftest is]] 9 | [ring.adapter.jdk :as jdk] 10 | [ring.middleware.keyword-params :refer [wrap-keyword-params]] 11 | [ring.middleware.multipart-params :refer [wrap-multipart-params]] 12 | [ring.middleware.params :refer [wrap-params]] 13 | [ring.util.request :as request])) 14 | 15 | 16 | (def PORT 8081) 17 | 18 | (def URL 19 | (format "http://127.0.0.1:%s" PORT)) 20 | 21 | 22 | (defn simplify [response] 23 | (-> response 24 | (update :headers dissoc "Date") 25 | (select-keys [:status 26 | :protocol-version 27 | :headers 28 | :length 29 | :headers 30 | :body]))) 31 | 32 | 33 | (def RESP_HELLO 34 | {:status 200 35 | :headers {"Content-Type" "text/plain"} 36 | :body "hello"}) 37 | 38 | 39 | (defn get-temp-file 40 | " 41 | Return an temporal file, an instance of java.io.File class. 42 | " 43 | (^File [] 44 | (get-temp-file "tmp" ".tmp")) 45 | (^File [prefix suffix] 46 | (File/createTempFile prefix suffix))) 47 | 48 | 49 | (defn handler-ok [request] 50 | RESP_HELLO) 51 | 52 | 53 | (defn handler-capture [atom!] 54 | (fn [request] 55 | (reset! atom! request) 56 | RESP_HELLO)) 57 | 58 | 59 | (deftest test-server-ok 60 | (jdk/with-server [handler-ok {:port PORT}] 61 | (let [response 62 | (client/get URL)] 63 | (is (= {:status 200 64 | :protocol-version {:name "HTTP" 65 | :major 1 66 | :minor 1} 67 | :length 5 68 | :headers 69 | {"Content-type" "text/plain", 70 | "Content-length" "5"} 71 | :body "hello"} 72 | (simplify response)))))) 73 | 74 | 75 | (deftest test-capture-request 76 | (let [capture! (atom nil)] 77 | (jdk/with-server [(handler-capture capture!) 78 | {:port PORT}] 79 | (let [_ (client/get (str URL "/hello?foo=1&bar=2")) 80 | request @capture!] 81 | (is (= {:protocol "HTTP/1.1", 82 | :headers 83 | {"accept-encoding" "gzip, deflate", 84 | "connection" "close", 85 | "host" "127.0.0.1:8081"}, 86 | :server-port 8081, 87 | :server-name "localhost" 88 | :query-string "foo=1&bar=2" 89 | :uri "/hello", 90 | :scheme :http 91 | :request-method :get} 92 | (-> request 93 | (update :headers dissoc "user-agent") 94 | (dissoc :body 95 | :remote-addr)))))))) 96 | 97 | 98 | (deftest test-server-query-string-presents 99 | (let [capture! (atom nil)] 100 | (jdk/with-server [(handler-capture capture!) 101 | {:port PORT}] 102 | (let [response 103 | (client/get URL {:query-params {:foo 1 :bar 2 :baz [3 4 5]}}) 104 | request @capture!] 105 | (is (= 200 (:status response))) 106 | (is (= "foo=1&bar=2&baz=3&baz=4&baz=5" 107 | (:query-string request))))))) 108 | 109 | 110 | (deftest test-server-to-string 111 | (with-open [server (jdk/server handler-ok {:port PORT})] 112 | (is (str/includes? 113 | (str server) 114 | " "hello abc" 121 | (.getBytes) 122 | (io/input-stream)) 123 | :headers {"content-type" "text/plain"}}) 124 | {:port PORT}] 125 | (let [response 126 | (client/get URL)] 127 | (is (= 200 (:status response))) 128 | (is (= "hello abc" (:body response)))))) 129 | 130 | 131 | (deftest test-server-return-file 132 | (let [file (get-temp-file) 133 | _ (spit file "some string")] 134 | (jdk/with-server [(constantly 135 | {:status 200 136 | :body file 137 | :headers {"content-type" "text/plain"}}) 138 | {:port PORT}] 139 | (let [response 140 | (client/get URL)] 141 | (is (= 200 (:status response))) 142 | (is (= "some string" (:body response))))))) 143 | 144 | 145 | (deftest test-server-return-missing-file 146 | (let [file (io/file "some-file.test")] 147 | (jdk/with-server [(constantly 148 | {:status 200 149 | :body file 150 | :headers {"content-type" "text/plain"}}) 151 | {:port PORT}] 152 | (let [{:keys [status body]} 153 | (client/get URL {:throw-exceptions false})] 154 | (is (= 500 status)) 155 | (is (str/includes? body "java.lang.RuntimeException: file not found: some-file.test")))))) 156 | 157 | 158 | (deftest test-server-return-iterable 159 | (let [items (for [x ["aaa" "bbb" "ccc" 1 :foo {:test 3} nil [1 2 3]]] 160 | x)] 161 | (jdk/with-server [(constantly 162 | {:status 200 163 | :body items 164 | :headers {"content-type" "text/plain"}}) 165 | {:port PORT}] 166 | (let [response 167 | (client/get URL)] 168 | (is (= 200 (:status response))) 169 | (is (= "aaabbbccc1:foo{:test 3}[1 2 3]" 170 | (:body response))))))) 171 | 172 | 173 | (deftest test-server-header-multi-return 174 | (jdk/with-server [(constantly 175 | {:status 200 176 | :body "test" 177 | :headers {"content-type" "text/plain" 178 | "X-TEST" ["foo" "bar" "baz"]}}) 179 | {:port PORT}] 180 | (let [{:keys [status headers]} 181 | (client/get URL)] 182 | (is (= 200 status)) 183 | (is (= ["foo" "bar" "baz"] 184 | (get headers "X-TEST")))))) 185 | 186 | 187 | (deftest test-server-header-multi-pass 188 | (let [request! (atom nil)] 189 | (jdk/with-server [(handler-capture request!) 190 | {:port PORT}] 191 | (let [{:keys [status]} 192 | (client/get URL {:headers {:X-TEST ["foo" "bar" "baz"]}}) 193 | 194 | request 195 | @request!] 196 | 197 | (is (= 200 status)) 198 | (is (= ["foo" "bar" "baz"] 199 | (get-in request [:headers "X-test"]))))))) 200 | 201 | 202 | (deftest test-server-header-multi-pass 203 | (jdk/with-server [(fn [_] 204 | (/ 0 0)) 205 | {:port PORT}] 206 | (let [{:keys [status headers body]} 207 | (client/get URL {:throw-exceptions false})] 208 | 209 | (is (= 500 status)) 210 | (is (= "text/plain" (get headers "Content-Type"))) 211 | 212 | (is (str/includes? body "failed to execute ring handler")) 213 | (is (str/includes? body "java.lang.ArithmeticException: Divide by zero")) 214 | (is (str/includes? body "\tat clojure.lang.Numbers.divide"))))) 215 | 216 | 217 | (deftest test-server-status>500 218 | (jdk/with-server [(constantly 219 | {:status 999 220 | :body "test" 221 | :headers {"content-type" "text/plain"}}) 222 | {:port PORT}] 223 | (let [{:keys [status]} 224 | (client/get URL {:throw-exceptions false})] 225 | (is (= 999 status))))) 226 | 227 | 228 | (deftest test-server-status-only 229 | (jdk/with-server [(constantly 230 | {:status 200}) 231 | {:port PORT}] 232 | (let [{:keys [status headers body]} 233 | (client/get URL {:throw-exceptions false})] 234 | (is (= 200 status)) 235 | (is (= {"Transfer-encoding" "chunked"} 236 | (dissoc headers "Date"))) 237 | (is (= "" body))))) 238 | 239 | 240 | (deftest test-server-status-nil 241 | (jdk/with-server [(constantly {:status nil}) 242 | {:port PORT}] 243 | (let [{:keys [status body]} 244 | (client/get URL {:throw-exceptions false})] 245 | (is (= 500 status)) 246 | (is (str/includes? body "java.lang.RuntimeException: ring status is not integer: null"))))) 247 | 248 | 249 | (deftest test-server-ring-response-nil 250 | (jdk/with-server [(constantly nil) 251 | {:port PORT}] 252 | (let [{:keys [status body]} 253 | (client/get URL {:throw-exceptions false})] 254 | (is (= 500 status)) 255 | (is (str/includes? body "java.lang.RuntimeException: unsupported ring response: null"))))) 256 | 257 | 258 | (deftest test-server-body-nil 259 | (jdk/with-server [(constantly {:status 200 260 | :body nil}) 261 | {:port PORT}] 262 | (let [{:keys [status body]} 263 | (client/get URL {:throw-exceptions false})] 264 | (is (= 200 status)) 265 | (is (= "" body))))) 266 | 267 | 268 | (deftest test-server-threads-ok 269 | (jdk/with-server [(constantly {:status 200}) 270 | {:port PORT 271 | :threads 8}] 272 | (let [{:keys [status body]} 273 | (client/get URL {:throw-exceptions false})] 274 | (is (= 200 status)) 275 | (is (= "" body))))) 276 | 277 | 278 | (deftest test-server-root-path 279 | (let [request! (atom nil)] 280 | (jdk/with-server [(handler-capture request!) 281 | {:port PORT 282 | :root-path "/some/prefix"}] 283 | 284 | (let [{:keys [status body]} 285 | (client/get URL {:throw-exceptions false})] 286 | (is (= nil @request!)) 287 | (is (= 404 status)) 288 | (is (= "

404 Not Found

No context found for request" body))) 289 | 290 | (let [{:keys [status body]} 291 | (client/get (str URL "/some/prefix/hello") {:throw-exceptions false})] 292 | (is (= "/some/prefix/hello" 293 | (:uri @request!))) 294 | (is (= 200 status)) 295 | (is (= "hello" body)))))) 296 | 297 | (deftest test-server-headers-nil 298 | (jdk/with-server [(constantly {:status 200 299 | :headers nil}) 300 | {:port PORT}] 301 | (let [{:keys [status body]} 302 | (client/get URL {:throw-exceptions false})] 303 | (is (= 200 status)) 304 | (is (= "" body))))) 305 | 306 | 307 | (deftest test-server-params-middleware 308 | (let [request! (atom nil) 309 | handler (-> request! 310 | handler-capture 311 | wrap-keyword-params 312 | wrap-params 313 | wrap-multipart-params)] 314 | 315 | ;; check GET 316 | (jdk/with-server [handler {:port PORT}] 317 | (let [{:keys [status headers body]} 318 | (client/get URL {:query-params {:q1 "ABC" :q2 "XYZ"} 319 | :throw-exceptions false}) 320 | 321 | request 322 | @request!] 323 | 324 | (is (= 200 status)) 325 | 326 | (is (= {"q1" "ABC" "q2" "XYZ"} 327 | (:query-params request))) 328 | 329 | (is (= {:q1 "ABC" :q2 "XYZ"} 330 | (:params request)))) 331 | 332 | ;; check POST 333 | (let [{:keys [status headers body]} 334 | (client/post URL {:form-params {:f1 "AAA" :f2 "BBB"} 335 | :throw-exceptions false}) 336 | 337 | request 338 | @request!] 339 | 340 | (is (= 200 status)) 341 | 342 | (is (= {} 343 | (:query-params request))) 344 | 345 | (is (= {"f1" "AAA" "f2" "BBB"} 346 | (:form-params request))) 347 | 348 | (is (= {:f1 "AAA" :f2 "BBB"} 349 | (:params request))))))) 350 | 351 | 352 | (deftest test-server-header-value-non-string 353 | (jdk/with-server [(constantly 354 | {:status 200 355 | :headers 356 | {"X-Metric-A" 42 357 | "X-Metric-B" nil 358 | "X-Metric-C" true 359 | "X-Metric-D" ["A" "B" 123 nil :hello]}}) 360 | {:port PORT}] 361 | (let [{:keys [status headers body]} 362 | (client/get URL {:throw-exceptions false})] 363 | (is (= 200 status)) 364 | (is (= {"X-metric-d" ["A" "B" "123" ":hello"], 365 | "X-metric-a" "42", 366 | "X-metric-c" "true"} 367 | (dissoc headers "Date" "Transfer-encoding")))))) 368 | 369 | 370 | (deftest test-server-header-key-keyword 371 | (jdk/with-server [(constantly 372 | {:status 200 373 | :headers {:hello/foo 42}}) 374 | {:port PORT}] 375 | (let [{:keys [status headers body]} 376 | (client/get URL {:throw-exceptions false})] 377 | (is (= 200 status)) 378 | (is (= "42" (get headers "Hello/foo")))))) 379 | 380 | 381 | (deftest test-server-header-key-weird 382 | (jdk/with-server [(constantly 383 | {:status 200 384 | :headers {42 42}}) 385 | {:port PORT}] 386 | (let [{:keys [status headers body]} 387 | (client/get URL {:throw-exceptions false})] 388 | (is (= 500 status)) 389 | (is (str/includes? body "unsupported header key: 42"))))) 390 | 391 | 392 | (deftest test-server-ring-util-functions 393 | (let [request! (atom nil)] 394 | (jdk/with-server [(handler-capture request!) 395 | {:port PORT}] 396 | (let [{:keys [status]} 397 | (client/get (str URL "/foo/bar/baz?test=1") 398 | {:throw-exceptions false 399 | :headers {"content-type" "Text/Plain" 400 | "content-Length" "42"} 401 | }) 402 | 403 | request 404 | @request!] 405 | 406 | (is (= 200 status)) 407 | 408 | (is (= :http 409 | (:scheme request))) 410 | 411 | (is (= "/foo/bar/baz" 412 | (:uri request))) 413 | 414 | (is (= "http://127.0.0.1:8081/foo/bar/baz?test=1" 415 | (request/request-url request))) 416 | 417 | (is (= "Text/Plain" 418 | (request/content-type request))) 419 | 420 | (is (= 42 421 | (request/content-length request))) 422 | 423 | (try 424 | (slurp (:body request)) 425 | (is false) 426 | (catch IOException e 427 | (is (= "Stream is closed" (.getMessage e))))) 428 | 429 | (try 430 | (request/body-string request) 431 | (is false) 432 | (catch IOException e 433 | (is (= "Stream is closed" (.getMessage e))))))))) 434 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | 2 | - https server support 3 | - ssl test 4 | --------------------------------------------------------------------------------