├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── ORIGINATOR ├── README.md ├── project.clj ├── src └── ring │ └── middleware │ └── gzip.clj └── test └── ring └── middleware └── gzip_test.clj /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Clojure CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-clojure/ for more details 4 | # 5 | version: 2.1 6 | 7 | workflows: 8 | build-deploy: 9 | jobs: 10 | - build: 11 | filters: 12 | tags: 13 | only: /.*/ 14 | 15 | - deploy: 16 | requires: 17 | - build 18 | filters: 19 | tags: 20 | only: /Release-.*/ 21 | context: 22 | - CLOJARS_DEPLOY 23 | 24 | jobs: 25 | build: 26 | docker: 27 | # specify the version you desire here 28 | - image: circleci/clojure:lein-2.7.1 29 | 30 | # Specify service dependencies here if necessary 31 | # CircleCI maintains a library of pre-built images 32 | # documented at https://circleci.com/docs/2.0/circleci-images/ 33 | # - image: circleci/postgres:9.4 34 | 35 | working_directory: ~/repo 36 | 37 | environment: 38 | LEIN_ROOT: "true" 39 | # Customize the JVM maximum heap limit 40 | JVM_OPTS: -Xmx3200m 41 | 42 | steps: 43 | - checkout 44 | 45 | # Download and cache dependencies 46 | - restore_cache: 47 | keys: 48 | - v1-dependencies-{{ checksum "project.clj" }} 49 | # fallback to using the latest cache if no exact match is found 50 | - v1-dependencies- 51 | 52 | - run: lein deps 53 | 54 | - save_cache: 55 | paths: 56 | - ~/.m2 57 | key: v1-dependencies-{{ checksum "project.clj" }} 58 | 59 | # run tests! 60 | - run: lein test 61 | 62 | deploy: 63 | docker: 64 | # specify the version you desire here 65 | - image: circleci/clojure:openjdk-8-lein-2.9.1 66 | # Specify service dependencies here if necessary 67 | # CircleCI maintains a library of pre-built images 68 | # documented at https://circleci.com/docs/2.0/circleci-images/ 69 | # - image: circleci/postgres:9.4 70 | 71 | working_directory: ~/repo 72 | 73 | environment: 74 | LEIN_ROOT: "true" 75 | # Customize the JVM maximum heap limit 76 | JVM_OPTS: -Xmx3200m 77 | 78 | steps: 79 | - checkout 80 | 81 | # Download and cache dependencies 82 | - restore_cache: 83 | keys: 84 | - v1-dependencies-{{ checksum "project.clj" }} 85 | # fallback to using the latest cache if no exact match is found 86 | - v1-dependencies- 87 | 88 | # Download and cache dependencies 89 | - restore_cache: 90 | keys: 91 | - v1-dependencies-{{ checksum "project.clj" }} 92 | # fallback to using the latest cache if no exact match is found 93 | - v1-dependencies- 94 | 95 | - run: 96 | name: Install babashka 97 | command: | 98 | curl -s https://raw.githubusercontent.com/borkdude/babashka/master/install -o install.sh 99 | sudo bash install.sh 100 | rm install.sh 101 | - run: 102 | name: Install deployment-script 103 | command: | 104 | curl -s https://raw.githubusercontent.com/clj-commons/infra/main/deployment/circle-maybe-deploy.bb -o circle-maybe-deploy.bb 105 | chmod a+x circle-maybe-deploy.bb 106 | 107 | - run: lein deps 108 | 109 | - run: 110 | name: Setup GPG signing key 111 | command: | 112 | GNUPGHOME="$HOME/.gnupg" 113 | export GNUPGHOME 114 | mkdir -p "$GNUPGHOME" 115 | chmod 0700 "$GNUPGHOME" 116 | 117 | echo "$GPG_KEY" \ 118 | | base64 --decode --ignore-garbage \ 119 | | gpg --batch --allow-secret-key-import --import 120 | 121 | gpg --keyid-format LONG --list-secret-keys 122 | 123 | - save_cache: 124 | paths: 125 | - ~/.m2 126 | key: v1-dependencies-{{ checksum "project.clj" }} 127 | - run: 128 | name: Deploy 129 | command: | 130 | GPG_TTY=$(tty) 131 | export GPG_TTY 132 | echo $GPG_TTY 133 | ./circle-maybe-deploy.bb lein deploy clojars 134 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @danielcompton 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *jar 3 | lib 4 | classes 5 | .cake 6 | /target 7 | /classes 8 | /checkouts 9 | pom.xml 10 | pom.xml.asc 11 | *.jar 12 | *.class 13 | /.lein-* 14 | /.nrepl-port 15 | .hgignore 16 | .hg/ 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.4] - 2019-07-02 8 | 9 | ### Added 10 | 11 | * Async Ring middleware support. [#10](https://github.com/clj-commons/ring-gzip-middleware/pull/10) - **[@camsaul](https://github.com/camsaul)** 12 | 13 | ### Changed 14 | 15 | * Moved project maintenance to [CLJ Commons](https://clj-commons.org). 16 | 17 | ## [0.1.3] - 2013-09-25 18 | 19 | ### Changed 20 | 21 | * "fast-start" the delivery of gzip-compressed bodies, and support flushing of GZIPOutputStreams on JDK7+. [#3](https://github.com/clj-commons/ring-gzip-middleware/pull/3) - **[@cemerick](https://github.com/cemerick)** 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Michael Stephens. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /ORIGINATOR: -------------------------------------------------------------------------------- 1 | @mikejs 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ring-gzip-middleware 2 | 3 | Gzips [Ring](http://github.com/ring-clojure/ring) responses for user agents 4 | which can handle it. 5 | 6 | [![Clojars Project](https://img.shields.io/clojars/v/amalloy/ring-gzip-middleware.svg)](https://clojars.org/amalloy/ring-gzip-middleware) 7 | [![cljdoc badge](https://cljdoc.org/badge/amalloy/ring-gzip-middleware)](https://cljdoc.org/d/amalloy/ring-gzip-middleware/CURRENT) 8 | [![CircleCI](https://circleci.com/gh/clj-commons/ring-gzip-middleware.svg?style=svg)](https://circleci.com/gh/clj-commons/ring-gzip-middleware) 9 | 10 | ### Usage 11 | 12 | Apply the Ring middleware function `ring.middleware.gzip/wrap-gzip` to 13 | your Ring handler, typically at the top level (i.e. as the last bit of 14 | middleware in a `->` form). 15 | 16 | ### Installation 17 | 18 | Add `[amalloy/ring-gzip-middleware "0.1.4"]` to your Leiningen dependencies. 19 | 20 | ### Compression of seq bodies 21 | 22 | In JDK versions <=6, [`java.util.zip.GZIPOutputStream.flush()` does not actually 23 | flush data compressed so 24 | far](http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4813885), which means 25 | that every gzip response must be complete before any bytes hit the wire. While 26 | of marginal importance when compressing static files and other resources that 27 | are consumed in an all-or-nothing manner (i.e. virtually everything that is sent 28 | in a Ring response), lazy sequences are impacted negatively by this. In 29 | particular, long-polling or server-sent event responses backed by lazy 30 | sequences, when gzipped under <=JDK6, must be fully consumed before the client 31 | receives any data at all. 32 | 33 | So, _this middleware does not gzip-compress Ring seq response bodies unless the 34 | JDK in use is 7+_, in which case it takes advantage of the new `flush`-ability 35 | of `GZIPOutputStream` there. 36 | 37 | ### License 38 | 39 | Copyright (C) 2010 Michael Stephens and other contributors. 40 | 41 | Distributed under an MIT-style license (see LICENSE for details). 42 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject org.clj-commons/ring-gzip-middleware 2 | (or (System/getenv "PROJECT_VERSION") "0.1.5") 3 | :url "https://github.com/clj-commons/ring-gzip-middleware" 4 | :description "Ring gzip encoding middleware" 5 | :dependencies [[org.clojure/clojure "1.9.0"]] 6 | :license {:name "Eclipse Public License" 7 | :url "http://www.eclipse.org/legal/epl-v10.html"} 8 | :profiles {:1.12 {:dependencies [[org.clojure/clojure "1.12.0"]]} 9 | :1.11 {:dependencies [[org.clojure/clojure "1.11.4"]]} 10 | :1.10 {:dependencies [[org.clojure/clojure "1.10.3"]]} 11 | :1.9 {} 12 | :1.8 {:dependencies [[org.clojure/clojure "1.8.0"]]} 13 | :1.7 {:dependencies [[org.clojure/clojure "1.7.0"]]} 14 | :1.6 {:dependencies [[org.clojure/clojure "1.6.0"]]}} 15 | :deploy-repositories [["clojars" {:url "https://repo.clojars.org" 16 | :username :env/clojars_username 17 | :password :env/clojars_org_clj_commons_password 18 | :sign-releases true}]] 19 | :aliases {"all" ["with-profile" "1.6:1.7:1.8:1.9:1.10:1.11:1.12"]} 20 | :min-lein-version "2.0.0") 21 | -------------------------------------------------------------------------------- /src/ring/middleware/gzip.clj: -------------------------------------------------------------------------------- 1 | (ns ring.middleware.gzip 2 | (:require [clojure.java.io :as io] 3 | clojure.reflect) 4 | (:import (java.util.zip GZIPOutputStream) 5 | (java.io InputStream 6 | OutputStream 7 | Closeable 8 | File 9 | PipedInputStream 10 | PipedOutputStream))) 11 | 12 | ; only available on JDK7 13 | (def ^:private flushable-gzip? 14 | (delay (->> (clojure.reflect/reflect GZIPOutputStream) 15 | :members 16 | (some (comp '#{[java.io.OutputStream boolean]} :parameter-types))))) 17 | 18 | ; only proxying here so we can specialize io/copy (which ring uses to transfer 19 | ; InputStream bodies to the servlet response) for reading from the result of 20 | ; piped-gzipped-input-stream 21 | (defn- piped-gzipped-input-stream* 22 | [] 23 | (proxy [PipedInputStream] [])) 24 | 25 | ; exactly the same as do-copy for [InputStream OutputStream], but 26 | ; flushes the output on every chunk; this allows gzipped content to start 27 | ; flowing to clients ASAP (a reasonable change to ring IMO) 28 | (defmethod @#'io/do-copy [(class (piped-gzipped-input-stream*)) OutputStream] 29 | [^InputStream input ^OutputStream output opts] 30 | (let [buffer (make-array Byte/TYPE (or (:buffer-size opts) 1024))] 31 | (loop [] 32 | (let [size (.read input buffer)] 33 | (when (pos? size) 34 | (.write output buffer 0 size) 35 | (.flush output) 36 | (recur)))))) 37 | 38 | (defn piped-gzipped-input-stream [in] 39 | (let [pipe-in (piped-gzipped-input-stream*) 40 | pipe-out (PipedOutputStream. pipe-in)] 41 | ; separate thread to prevent blocking deadlock 42 | (future 43 | (with-open [out (if @flushable-gzip? 44 | (GZIPOutputStream. pipe-out true) 45 | (GZIPOutputStream. pipe-out))] 46 | (if (seq? in) 47 | (doseq [string in] 48 | (io/copy (str string) out) 49 | (.flush out)) 50 | (io/copy in out))) 51 | (when (instance? Closeable in) 52 | (.close ^Closeable in))) 53 | pipe-in)) 54 | 55 | (defn set-response-headers 56 | [headers] 57 | (-> headers 58 | (assoc "Content-Encoding" "gzip") 59 | (dissoc "Content-Length"))) 60 | 61 | (defn gzipped-response [resp] 62 | (-> resp 63 | (update :headers set-response-headers) 64 | (update :body piped-gzipped-input-stream))) 65 | 66 | (defn accepts-gzip? 67 | "Tests if the request indicates that the client can accept a gzipped response" 68 | [{:keys [headers]}] 69 | (let [accepts (or (get headers "accept-encoding") 70 | (get headers "Accept-Encoding") 71 | "") 72 | match (re-find #"(gzip|\*)(;q=((0|1)(.\d+)?))?" accepts)] 73 | (and match (not (contains? #{"0" "0.0" "0.00" "0.000"} (match 3)))))) 74 | 75 | (def ^:private default-status 200) 76 | 77 | (def supported-status? #{200 201 202 203 204 205}) 78 | 79 | (def min-length 200) 80 | 81 | (defn content-encoding? 82 | "Tests if the provided response has a content-encoding header" 83 | [{:keys [headers]}] 84 | (or (get headers "Content-Encoding") 85 | (get headers "content-encoding"))) 86 | 87 | (defn supported-response? 88 | [{:keys [body status] :as resp}] 89 | (and (supported-status? (or status default-status)) 90 | (not (content-encoding? resp)) 91 | (or 92 | (and (string? body) (> (count body) min-length)) 93 | (instance? InputStream body) 94 | (instance? File body) 95 | (and (seq? body) @flushable-gzip?)))) 96 | 97 | (defn gzip-response [req resp] 98 | (if (and (supported-response? resp) 99 | (accepts-gzip? req)) 100 | (gzipped-response resp) 101 | resp)) 102 | 103 | (defn wrap-gzip 104 | "Ring middleware that GZIPs response if client can handle it." 105 | [handler] 106 | (fn 107 | ([request] 108 | (gzip-response request (handler request))) 109 | ([request respond raise] 110 | (handler 111 | request 112 | (fn [response] 113 | (respond (gzip-response request response))) 114 | raise)))) 115 | -------------------------------------------------------------------------------- /test/ring/middleware/gzip_test.clj: -------------------------------------------------------------------------------- 1 | (ns ring.middleware.gzip-test 2 | (:use clojure.test 3 | ring.middleware.gzip) 4 | (:require [clojure.java.io :as io]) 5 | (:import (java.util Arrays)) 6 | (:import (java.io StringBufferInputStream ByteArrayOutputStream)) 7 | (:import (java.util.zip GZIPInputStream))) 8 | 9 | (defn- to-byte-array [inputstream] 10 | (let [buffer (ByteArrayOutputStream.)] 11 | (io/copy inputstream buffer) 12 | (.toByteArray buffer))) 13 | 14 | (defn unzip [in] 15 | (let [in (GZIPInputStream. in) 16 | bytes (to-byte-array in)] 17 | (.close in) 18 | bytes)) 19 | 20 | (defn encoding [resp] 21 | ((:headers resp) "Content-Encoding")) 22 | 23 | (def output (apply str (repeat 300 "a"))) 24 | 25 | (def app (wrap-gzip (fn [req] {:status 200 26 | :body output 27 | :headers {}}))) 28 | 29 | (defn accepting [ctype] 30 | {:headers {"accept-encoding" ctype}}) 31 | 32 | (defn set-encoding 33 | ([resp] (set-encoding resp false)) 34 | ([resp caps?] 35 | (let [content-encoding (if caps? "Content-Encoding" "content-encoding")] 36 | (assoc-in resp [:headers content-encoding] "text")))) 37 | 38 | (deftest test-basic-gzip 39 | (let [resp (app (accepting "gzip"))] 40 | (is (= 200 (:status resp))) 41 | (is (= "gzip" (encoding resp))) 42 | (is (Arrays/equals (unzip (resp :body)) (.getBytes output))))) 43 | 44 | (deftest test-basic-gzip-async 45 | (testing "middleware should work with 3-arg async handlers as well" 46 | (let [app (wrap-gzip 47 | (fn [request respond raise] 48 | (respond {:status 200 49 | :body output 50 | :headers {}}))) 51 | resp (app (accepting "gzip") identity identity)] 52 | (is (= 200 (:status resp))) 53 | (is (= "gzip" (encoding resp))) 54 | (is (Arrays/equals (unzip (resp :body)) (.getBytes output)))))) 55 | 56 | (deftest test-inputstream-gzip 57 | (let [app (wrap-gzip (fn [req] {:status 200 58 | :body (StringBufferInputStream. output) 59 | :headers {}})) 60 | resp (app (accepting "gzip"))] 61 | (is (= 200 (:status resp))) 62 | (is (= "gzip" (encoding resp))) 63 | (is (Arrays/equals (unzip (resp :body)) (.getBytes output))))) 64 | 65 | (deftest test-string-seq-gzip 66 | (let [seq-body (->> (partition-all 20 output) 67 | (map (partial apply str))) 68 | app (wrap-gzip (fn [req] {:status 200 69 | :body seq-body 70 | :headers {}})) 71 | resp (app (accepting "gzip"))] 72 | (is (= 200 (:status resp))) 73 | (if @@#'ring.middleware.gzip/flushable-gzip? 74 | (do 75 | (println "Running on JDK7+, testing gzipping of seq response bodies.") 76 | (is (= "gzip" (encoding resp))) 77 | (is (Arrays/equals (unzip (resp :body)) (.getBytes output)))) 78 | (do 79 | (println "Running on <=JDK6, testing non-gzipping of seq response bodies.") 80 | (is (nil? (encoding resp))) 81 | (is (= seq-body (resp :body))))))) 82 | 83 | (deftest test-accepts 84 | (testing "appropriate requests will be zipped" 85 | (doseq [ctype ["gzip" "*" "gzip,deflate" "gzip,deflate,sdch" 86 | "gzip, deflate" "gzip;q=1" "deflate,gzip" 87 | "deflate,gzip,sdch" "deflate,gzip;q=1" 88 | "deflate,gzip;q=1,sdch" 89 | "gzip;q=0.5"]] 90 | (is (= "gzip" (encoding (app (accepting ctype))))) 91 | (is (accepts-gzip? (accepting ctype))))) 92 | (testing "requests that ask for a zip, but not the supported type of zip are not zipped" 93 | (doseq [ctype ["" "gzip;q=0" "deflate" "deflate,sdch" 94 | "deflate,gzip;q=0" "deflate,gzip;q=0,sdch" 95 | "gzip;q=0,deflate" "*;q=0"]] 96 | (is (nil? (encoding (app (accepting ctype)))))))) 97 | 98 | (deftest test-min-length 99 | (testing "Compress string bodies greater than the min-length (200) characters long" 100 | (let [output (apply str (repeat (inc min-length) "a")) 101 | resp {:status 200 102 | :body output 103 | :headers {}} 104 | app (wrap-gzip (fn [req] resp))] 105 | (is (= "gzip" (encoding (app (accepting "gzip"))))) 106 | (is (supported-response? resp)) 107 | (testing ", but not string bodies at or below min-length" 108 | (let [resp (update resp :body subs 1) 109 | app (wrap-gzip (fn [req] resp))] 110 | (is (nil? (encoding (app (accepting "gzip"))))) 111 | (is (not (supported-response? resp)))))))) 112 | 113 | (deftest test-wrapped-encoding 114 | (testing "don't compress responses which already have a content-encoding header" 115 | (let [response {:status 200 116 | :body output 117 | :headers {"Content-Encoding" "text"}} 118 | app (wrap-gzip (fn [req] response)) 119 | resp (app (accepting "gzip"))] 120 | (is (= "text" (encoding resp))) 121 | (is (= output (:body resp)))))) 122 | 123 | (deftest test-supported 124 | (testing "responses that already have an encoding cannot be zipped" 125 | (doseq [ctype ["gzip" "*" "gzip,deflate" "gzip,deflate,sdch" 126 | "gzip, deflate" "gzip;q=1" "deflate,gzip" 127 | "deflate,gzip,sdch" "deflate,gzip;q=1" 128 | "deflate,gzip;q=1,sdch" 129 | "gzip;q=0.5" "" "gzip;q=0" "deflate" "deflate,sdch" 130 | "deflate,gzip;q=0" "deflate,gzip;q=0,sdch" 131 | "gzip;q=0,deflate" "*;q=0"]] 132 | (is (not (supported-response? (set-encoding (accepting ctype))))) 133 | (is (not (supported-response? (set-encoding (accepting ctype) true))))))) 134 | 135 | (deftest test-status 136 | (testing "don't compress non-2xx responses" 137 | (let [app (wrap-gzip (fn [req] {:status 404 138 | :body output 139 | :headers {}})) 140 | resp (app (accepting "gzip"))] 141 | (is (nil? (encoding resp))) 142 | (is (= output (:body resp)))))) 143 | 144 | (deftest test-setting-headers 145 | (testing "updating the headers of a response to indicate that they have been gziped" 146 | (is (= {"Content-Encoding" "gzip"} (set-response-headers {"Content-Length" 201}))) 147 | (is (= {"Content-Encoding" "gzip" "Age" 24} (set-response-headers {"Age" 24}))))) 148 | --------------------------------------------------------------------------------