├── dev-resources ├── json10b.json ├── json100b.json ├── json1k.json └── json10k.json ├── doc ├── images │ ├── perf-json.png │ ├── perf-transit.png │ ├── muuntaja-small.png │ ├── perf-json-relative.png │ ├── perf-json-relative2.png │ ├── interceptors-perf-json.png │ ├── perf-transit-relative.png │ └── interceptors-perf-json-relative.png ├── cljdoc.edn ├── Differences-to-existing-formatters.md ├── Creating-new-formats.md ├── Performance.md ├── With-Ring.md └── Configuration.md ├── .gitignore ├── scripts ├── lein-modules ├── set-version └── build-docs.sh ├── modules ├── muuntaja │ ├── src │ │ └── muuntaja │ │ │ ├── format │ │ │ ├── core.clj │ │ │ ├── edn.clj │ │ │ ├── transit.clj │ │ │ └── json.clj │ │ │ ├── util.clj │ │ │ ├── protocols.clj │ │ │ ├── parse.clj │ │ │ ├── interceptor.clj │ │ │ └── middleware.clj │ └── project.clj ├── muuntaja-yaml │ ├── project.clj │ └── src │ │ └── muuntaja │ │ └── format │ │ └── yaml.clj ├── muuntaja-cheshire │ ├── project.clj │ └── src │ │ └── muuntaja │ │ └── format │ │ └── cheshire.clj ├── muuntaja-msgpack │ ├── project.clj │ └── src │ │ └── muuntaja │ │ └── format │ │ └── msgpack.clj ├── muuntaja-charred │ ├── project.clj │ └── src │ │ └── muuntaja │ │ └── format │ │ └── charred.clj └── muuntaja-form │ ├── project.clj │ └── src │ └── muuntaja │ └── format │ └── form.clj ├── test └── muuntaja │ ├── protocols_test.clj │ ├── test_utils.clj │ ├── ring_middleware │ ├── format_test.clj │ ├── format_params_test.clj │ └── format_response_test.clj │ ├── parse_test.clj │ ├── formats_perf_test.clj │ ├── interceptor_test.clj │ ├── ring_json │ └── json_test.clj │ ├── middleware_test.clj │ └── core_test.clj ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── CONTRIBUTING.md ├── project.clj ├── README.md └── LICENSE /dev-resources/json10b.json: -------------------------------------------------------------------------------- 1 | {"imu":42} -------------------------------------------------------------------------------- /doc/images/perf-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/perf-json.png -------------------------------------------------------------------------------- /doc/images/perf-transit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/perf-transit.png -------------------------------------------------------------------------------- /doc/images/muuntaja-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/muuntaja-small.png -------------------------------------------------------------------------------- /doc/images/perf-json-relative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/perf-json-relative.png -------------------------------------------------------------------------------- /doc/images/perf-json-relative2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/perf-json-relative2.png -------------------------------------------------------------------------------- /dev-resources/json100b.json: -------------------------------------------------------------------------------- 1 | {"number":100,"boolean":true,"list":[{"kikka":"kukka"}],"nested":{"map":"this is value","secret":1}} -------------------------------------------------------------------------------- /doc/images/interceptors-perf-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/interceptors-perf-json.png -------------------------------------------------------------------------------- /doc/images/perf-transit-relative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/perf-transit-relative.png -------------------------------------------------------------------------------- /doc/images/interceptors-perf-json-relative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metosin/muuntaja/HEAD/doc/images/interceptors-perf-json-relative.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml* 2 | *jar 3 | /lib/ 4 | /classes/ 5 | target 6 | .lein-failures 7 | .lein-deps-sum 8 | .lein-repl-history 9 | .nrepl-port 10 | \#*\# 11 | -------------------------------------------------------------------------------- /scripts/lein-modules: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Modules 6 | for ext in \ 7 | muuntaja \ 8 | muuntaja-form \ 9 | muuntaja-cheshire \ 10 | muuntaja-charred \ 11 | muuntaja-msgpack \ 12 | muuntaja-yaml; do 13 | cd modules/$ext; lein "$@"; cd ../..; 14 | done 15 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/format/core.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.core) 2 | 3 | (defprotocol Decode 4 | (decode [this data charset])) 5 | 6 | (defprotocol EncodeToBytes 7 | (encode-to-bytes [this data charset])) 8 | 9 | (defprotocol EncodeToOutputStream 10 | (encode-to-output-stream [this data charset])) 11 | 12 | (defrecord Format [name encoder decoder return matches]) 13 | -------------------------------------------------------------------------------- /scripts/set-version: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | ext="sedbak$$" 4 | 5 | find . -name project.clj -exec sed -i.$ext "s/\[\(\(fi\.\)\?metosin\/muuntaja.*\) \".*\"\]/[\1 \"$1\"\]/g" '{}' \; 6 | find . -name project.clj -exec sed -i.$ext "s/defproject \(\(fi\.\)\?metosin\/muuntaja.*\) \".*\"/defproject \1 \"$1\"/g" '{}' \; 7 | sed -i.$ext "s/\[\(\(fi\.\)\?metosin\/muuntaja.*\) \".*\"\]/[\1 \"$1\"\]/g" **/*.md 8 | find . -name "*.$ext" -exec rm '{}' \; 9 | -------------------------------------------------------------------------------- /doc/cljdoc.edn: -------------------------------------------------------------------------------- 1 | {:cljdoc.doc/tree [["Readme" {:file "README.md"}] 2 | ["Configuration" {:file "doc/Configuration.md"}] 3 | ["Creating new formats" {:file "doc/Creating-new-formats.md"}] 4 | ["Differences to existing formatters" {:file "doc/Differences-to-existing-formatters.md"}] 5 | ["Performance" {:file "doc/Performance.md"}] 6 | ["With Ring" {:file "doc/With-Ring.md"}]]} 7 | -------------------------------------------------------------------------------- /test/muuntaja/protocols_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.protocols-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.protocols :as protocols]) 4 | (:import (java.io ByteArrayOutputStream))) 5 | 6 | (deftest StreamableResponse-test 7 | (let [sr (protocols/->StreamableResponse #(.write % (.getBytes "kikka")))] 8 | (is (= "kikka" (slurp sr))) 9 | (is (= "kikka" (slurp (protocols/into-input-stream sr)))) 10 | (is (= "kikka" (str (sr (ByteArrayOutputStream. 4096))))))) 11 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/util.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.util 2 | (:import (java.io ByteArrayInputStream))) 3 | 4 | (defn byte-stream [^bytes bytes] 5 | (ByteArrayInputStream. bytes)) 6 | 7 | (defn throw! [formats format message] 8 | (throw 9 | (ex-info 10 | (str message " " (pr-str format)) 11 | {:formats (-> formats :formats keys) 12 | :format format}))) 13 | 14 | (defn some-value [pred c] 15 | (let [f (fn [x] (if (pred x) x))] 16 | (some f c))) 17 | 18 | (defn assoc-assoc [m k1 k2 v] 19 | (assoc m k1 (assoc (k1 m) k2 v))) 20 | 21 | (defmacro when-ns [ns & body] 22 | `(try 23 | (eval '(do (require ~ns) ~@body)) 24 | (catch Exception ~'_))) 25 | -------------------------------------------------------------------------------- /modules/muuntaja-yaml/project.clj: -------------------------------------------------------------------------------- 1 | (defproject metosin/muuntaja-yaml "0.6.11" 2 | :description "YAML format for Muuntaja" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/muuntaja] 15 | [clj-commons/clj-yaml]]) 16 | -------------------------------------------------------------------------------- /modules/muuntaja-cheshire/project.clj: -------------------------------------------------------------------------------- 1 | (defproject metosin/muuntaja-cheshire "0.6.11" 2 | :description "Cheshire/JSON format for Muuntaja" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/muuntaja] 15 | [cheshire]]) 16 | -------------------------------------------------------------------------------- /modules/muuntaja-msgpack/project.clj: -------------------------------------------------------------------------------- 1 | (defproject metosin/muuntaja-msgpack "0.6.11" 2 | :description "Messagepack format for Muuntaja" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/muuntaja] 15 | [clojure-msgpack]]) 16 | -------------------------------------------------------------------------------- /modules/muuntaja-charred/project.clj: -------------------------------------------------------------------------------- 1 | (defproject fi.metosin/muuntaja-charred "0.6.11" 2 | :description "Charred/JSON format for Muuntaja" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/muuntaja] 15 | [com.cnuernber/charred]]) 16 | -------------------------------------------------------------------------------- /modules/muuntaja-form/project.clj: -------------------------------------------------------------------------------- 1 | (defproject metosin/muuntaja-form "0.6.11" 2 | :description "application/x-www-form-urlencoded format for Muuntaja" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/muuntaja] 15 | [ring/ring-codec]]) 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build-clj: 11 | strategy: 12 | matrix: 13 | jdk: [8, 11, 17, 21] 14 | 15 | name: Clojure (Java ${{ matrix.jdk }}) 16 | 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Setup Java ${{ matrix.jdk }} 22 | uses: actions/setup-java@v3.12.0 23 | with: 24 | distribution: zulu 25 | java-version: ${{ matrix.jdk }} 26 | - name: Setup Clojure 27 | uses: DeLaGuardo/setup-clojure@master 28 | with: 29 | lein: 2.9.4 30 | - name: Run tests 31 | run: lein test 32 | -------------------------------------------------------------------------------- /modules/muuntaja/project.clj: -------------------------------------------------------------------------------- 1 | (defproject metosin/muuntaja "0.6.11" 2 | :description "Clojure library for format encoding, decoding and content-negotiation" 3 | :url "https://github.com/metosin/muuntaja" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :scm {:name "git" 7 | :url "https://github.com/metosin/muuntaja" 8 | :dir "../.."} 9 | :plugins [[lein-parent "0.3.2"]] 10 | :parent-project {:path "../../project.clj" 11 | :inherit [:deploy-repositories 12 | :managed-dependencies 13 | :profiles [:dev]]} 14 | :dependencies [[metosin/jsonista] 15 | [com.cognitect/transit-clj]]) 16 | -------------------------------------------------------------------------------- /test/muuntaja/test_utils.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.test_utils 2 | (:import [java.io ByteArrayInputStream])) 3 | 4 | (set! *warn-on-reflection* true) 5 | 6 | (defn request-stream [request] 7 | (let [b (.getBytes ^String (:body request))] 8 | (fn [] 9 | (assoc request :body (ByteArrayInputStream. b))))) 10 | 11 | (defn context-stream [request] 12 | (let [b (.getBytes ^String (:body request)) 13 | ctx {:request request}] 14 | (fn [] 15 | (assoc-in ctx [:request :body] (ByteArrayInputStream. b))))) 16 | 17 | (defn title [s] 18 | (println 19 | (str "\n\u001B[35m" 20 | (apply str (repeat (+ 6 (count s)) "#")) 21 | "\n## " s " ##\n" 22 | (apply str (repeat (+ 6 (count s)) "#")) 23 | "\u001B[0m\n"))) 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published # reacts to releases and prereleases, but not their drafts 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: "Setup Java 8" 14 | uses: actions/setup-java@v3.12.0 15 | with: 16 | distribution: zulu 17 | java-version: 8 # build releases with java 8 for maximum compatibility 18 | - name: Setup Clojure 19 | uses: DeLaGuardo/setup-clojure@master 20 | with: 21 | lein: 2.9.4 22 | - name: Deploy to Clojars 23 | run: ./scripts/lein-modules do jar, deploy 24 | env: 25 | CLOJARS_USER: metosinci 26 | CLOJARS_DEPLOY_TOKEN: "${{ secrets.CLOJARS_DEPLOY_TOKEN }}" 27 | -------------------------------------------------------------------------------- /dev-resources/json1k.json: -------------------------------------------------------------------------------- 1 | {"results":[{"gender":"male","name":{"title":"mr","first":"morris","last":"lambert"},"location":{"street":"7239 hillcrest rd","city":"nowra","state":"australian capital territory","postcode":7541},"email":"morris.lambert@example.com","login":{"username":"smallbird414","password":"carole","salt":"yO9OBSsk","md5":"658323a603522238fb32a86b82eafd55","sha1":"289f6e9a8ccd42b539e0c43283e788aeb8cd0f6e","sha256":"57bca99b2b4e78aa2171eda4db3f35e7631ca3b30f157bdc7ea089a855c66668"},"dob":"1950-07-13 09:18:34","registered":"2012-04-07 00:05:32","phone":"08-2274-7839","cell":"0452-558-702","id":{"name":"TFN","value":"740213762"},"picture":{"large":"https://randomuser.me/api/portraits/men/95.jpg","medium":"https://randomuser.me/api/portraits/med/men/95.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/men/95.jpg"},"nat":"AU"}],"info":{"seed":"fb0c2b3c7cedc7af","results":1,"page":1,"version":"1.1"}} 2 | -------------------------------------------------------------------------------- /scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | rev=$(git rev-parse HEAD) 6 | remoteurl=$(git ls-remote --get-url origin) 7 | 8 | git fetch 9 | if [[ -z $(git branch -r --list origin/gh-pages) ]]; then 10 | # If repo doesn't have gh-pages branch, create it 11 | ( 12 | mkdir doc 13 | cd doc 14 | git init 15 | git remote add origin "${remoteurl}" 16 | git checkout -b gh-pages 17 | git commit --allow-empty -m "Init" 18 | git push -u origin gh-pages 19 | ) 20 | elif [[ ! -d doc ]]; then 21 | # Clone existing gh-pages branch if not cloned 22 | git clone --branch gh-pages "${remoteurl}" doc 23 | else 24 | # Reset existing clone to remote state 25 | ( 26 | cd doc 27 | git fetch 28 | git reset --hard origin/gh-pages 29 | ) 30 | fi 31 | 32 | mkdir -p doc 33 | lein codox 34 | cd doc 35 | git add --all 36 | git commit -m "Build docs from ${rev}." 37 | git push origin gh-pages 38 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/format/edn.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.edn 2 | (:refer-clojure :exclude [format]) 3 | (:require [clojure.edn :as edn] 4 | [muuntaja.format.core :as core]) 5 | (:import (java.io InputStreamReader PushbackReader InputStream OutputStream))) 6 | 7 | (defn decoder [options] 8 | (let [options (merge {:readers *data-readers*} options)] 9 | (reify 10 | core/Decode 11 | (decode [_ data charset] 12 | (edn/read options (PushbackReader. (InputStreamReader. ^InputStream data ^String charset))))))) 13 | 14 | (defn encoder [_] 15 | (reify 16 | core/EncodeToBytes 17 | (encode-to-bytes [_ data charset] 18 | (.getBytes 19 | (let [w (new java.io.StringWriter)] 20 | (print-method data w) 21 | (.toString w)) 22 | ^String charset)) 23 | core/EncodeToOutputStream 24 | (encode-to-output-stream [_ data charset] 25 | (fn [^OutputStream output-stream] 26 | (.write output-stream (.getBytes 27 | (pr-str data) 28 | ^String charset)))))) 29 | 30 | (def format 31 | (core/map->Format 32 | {:name "application/edn" 33 | :decoder [decoder] 34 | :encoder [encoder]})) 35 | -------------------------------------------------------------------------------- /modules/muuntaja-yaml/src/muuntaja/format/yaml.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.yaml 2 | "[Clj-yaml API docs](https://cljdoc.org/d/clj-commons/clj-yaml/CURRENT/api/clj-yaml.core)" 3 | (:refer-clojure :exclude [format]) 4 | (:require [clj-yaml.core :as yaml] 5 | [muuntaja.format.core :as core]) 6 | (:import (java.io OutputStream OutputStreamWriter InputStream) 7 | (org.yaml.snakeyaml Yaml))) 8 | 9 | (defn decoder [{:keys [unsafe mark keywords] :or {keywords true}}] 10 | (reify 11 | core/Decode 12 | (decode [_ data _] 13 | ;; Call SnakeYAML .load directly because clj-yaml only provides String version 14 | (yaml/decode (.load (yaml/make-yaml :unsafe unsafe :mark mark) ^InputStream data) keywords)))) 15 | 16 | (defn encoder [options] 17 | (let [options-args (mapcat identity options)] 18 | (reify 19 | core/EncodeToBytes 20 | (encode-to-bytes [_ data _] 21 | (.getBytes 22 | ^String (apply yaml/generate-string data options-args))) 23 | core/EncodeToOutputStream 24 | (encode-to-output-stream [_ data _] 25 | (fn [^OutputStream output-stream] 26 | (.dump ^Yaml (apply yaml/make-yaml options-args) (yaml/encode data) (OutputStreamWriter. output-stream)) 27 | (.flush output-stream)))))) 28 | 29 | (def format 30 | (core/map->Format 31 | {:name "application/x-yaml" 32 | :decoder [decoder {:keywords true}] 33 | :encoder [encoder]})) 34 | -------------------------------------------------------------------------------- /modules/muuntaja-msgpack/src/muuntaja/format/msgpack.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.msgpack 2 | "Uses [clojure-msgpack](https://github.com/edma2/clojure-msgpack)" 3 | (:refer-clojure :exclude [format]) 4 | (:require [clojure.walk :as walk] 5 | [msgpack.core :as msgpack] 6 | [msgpack.clojure-extensions] 7 | [muuntaja.format.core :as core]) 8 | (:import (java.io ByteArrayOutputStream DataInputStream DataOutputStream OutputStream))) 9 | 10 | (defn decoder [{:keys [keywords?] :as options}] 11 | (let [transform (if keywords? walk/keywordize-keys identity)] 12 | (reify 13 | core/Decode 14 | (decode [_ data _] 15 | (transform (msgpack/unpack-stream (DataInputStream. data) options)))))) 16 | 17 | (defn encoder [options] 18 | (reify 19 | core/EncodeToBytes 20 | (encode-to-bytes [_ data _] 21 | (with-open [out-stream (ByteArrayOutputStream.)] 22 | (let [data-out (DataOutputStream. out-stream)] 23 | (msgpack/pack-stream (walk/stringify-keys data) data-out) options) 24 | (.toByteArray out-stream))) 25 | core/EncodeToOutputStream 26 | (encode-to-output-stream [_ data _] 27 | (fn [^OutputStream output-stream] 28 | (let [data-out (DataOutputStream. output-stream)] 29 | (msgpack/pack-stream (walk/stringify-keys data) data-out) options))))) 30 | 31 | (def format 32 | (core/map->Format 33 | {:name "application/msgpack" 34 | :decoder [decoder {:keywords? true}] 35 | :encoder [encoder]})) 36 | -------------------------------------------------------------------------------- /modules/muuntaja-cheshire/src/muuntaja/format/cheshire.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.cheshire 2 | (:refer-clojure :exclude [format]) 3 | (:require [cheshire.core :as cheshire] 4 | [cheshire.parse :as parse] 5 | [muuntaja.format.core :as core]) 6 | (:import (java.io InputStreamReader InputStream OutputStreamWriter OutputStream))) 7 | 8 | (defn decoder [{:keys [key-fn array-coerce-fn bigdecimals?]}] 9 | (if-not bigdecimals? 10 | (reify 11 | core/Decode 12 | (decode [_ data charset] 13 | (cheshire/parse-stream (InputStreamReader. ^InputStream data ^String charset) key-fn array-coerce-fn))) 14 | (reify 15 | core/Decode 16 | (decode [_ data charset] 17 | (binding [parse/*use-bigdecimals?* bigdecimals?] 18 | (cheshire/parse-stream (InputStreamReader. ^InputStream data ^String charset) key-fn array-coerce-fn)))))) 19 | 20 | (defn encoder [options] 21 | (reify 22 | core/EncodeToBytes 23 | (encode-to-bytes [_ data charset] 24 | (.getBytes (cheshire/generate-string data options) ^String charset)) 25 | core/EncodeToOutputStream 26 | (encode-to-output-stream [_ data charset] 27 | (fn [^OutputStream output-stream] 28 | (cheshire/generate-stream 29 | data (OutputStreamWriter. output-stream ^String charset) options) 30 | (.flush output-stream))))) 31 | 32 | (def format 33 | (core/map->Format 34 | {:name "application/json" 35 | :decoder [decoder {:key-fn true}] 36 | :encoder [encoder]})) 37 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/format/transit.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.transit 2 | "Check [Transit API](https://cognitect.github.io/transit-clj/#cognitect.transit/) 3 | for available options. 4 | 5 | :decoder-opts are passed to reader function and 6 | :encoder-opts are passed to writer function." 7 | (:require [cognitect.transit :as transit] 8 | [muuntaja.format.core :as core]) 9 | (:import (java.io ByteArrayOutputStream OutputStream))) 10 | 11 | (defn decoder 12 | [type options] 13 | (reify 14 | core/Decode 15 | (decode [_ data _] 16 | (let [reader (transit/reader data type options)] 17 | (transit/read reader))))) 18 | 19 | (defn encoder [type {:keys [verbose] :as options}] 20 | (let [full-type (if (and (= type :json) verbose) :json-verbose type)] 21 | (reify 22 | core/EncodeToBytes 23 | (encode-to-bytes [_ data _] 24 | (let [baos (ByteArrayOutputStream.) 25 | writer (transit/writer baos full-type options)] 26 | (transit/write writer data) 27 | (.toByteArray baos))) 28 | core/EncodeToOutputStream 29 | (encode-to-output-stream [_ data _] 30 | (fn [^OutputStream output-stream] 31 | (transit/write 32 | (transit/writer output-stream full-type options) data) 33 | (.flush output-stream)))))) 34 | 35 | (def json-format 36 | (core/map->Format 37 | {:name "application/transit+json" 38 | :decoder [(partial decoder :json)] 39 | :encoder [(partial encoder :json)]})) 40 | 41 | (def msgpack-format 42 | (core/map->Format 43 | {:name "application/transit+msgpack" 44 | :decoder [(partial decoder :msgpack)] 45 | :encoder [(partial encoder :msgpack)]})) 46 | -------------------------------------------------------------------------------- /modules/muuntaja-form/src/muuntaja/format/form.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.form 2 | (:refer-clojure :exclude [format]) 3 | (:require [muuntaja.format.core :as core] 4 | [ring.util.codec :as codec]) 5 | (:import (java.io OutputStream))) 6 | 7 | (defn- map-keys [f coll] 8 | (->> (map (fn [[k v]] [(f k) v]) coll) (into {}))) 9 | 10 | (defn decoder 11 | "Create a decoder which converts a ‘application/x-www-form-urlencoded’ 12 | representation into clojure data." 13 | [{:keys [decode-key-fn] :as options}] 14 | (reify 15 | core/Decode 16 | (decode [_ data charset] 17 | (let [input (slurp data :encoding charset) 18 | output (codec/form-decode input charset)] 19 | (if decode-key-fn 20 | (map-keys decode-key-fn output) 21 | output))))) 22 | 23 | (defn encoder 24 | "Create an encoder which converts clojure data into an 25 | ‘application/x-www-form-urlencoded’ representation." 26 | [_] 27 | (reify 28 | core/EncodeToBytes 29 | (encode-to-bytes [_ data charset] 30 | (let [encoded (codec/form-encode data charset)] 31 | (.getBytes ^String encoded ^String charset))) 32 | 33 | core/EncodeToOutputStream 34 | (encode-to-output-stream [_ data charset] 35 | (fn [^OutputStream output-stream] 36 | (let [encoded (codec/form-encode data charset) 37 | bytes (.getBytes ^String encoded ^String charset)] 38 | (.write output-stream bytes)))))) 39 | 40 | (def format 41 | "Formatter handling ‘application/x-www-form-urlencoded’ representations 42 | with the `ring.util.codec` library." 43 | (core/map->Format 44 | {:name "application/x-www-form-urlencoded" 45 | :decoder [decoder {:decode-key-fn keyword}] 46 | :encoder [encoder]})) 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Contributions are welcome. 4 | 5 | Please file bug reports and feature requests to https://github.com/metosin/muuntaja/issues. 6 | 7 | ## Making changes 8 | 9 | * Fork the repository on Github 10 | * Create a topic branch from where you want to base your work (usually the master branch) 11 | * Check the formatting rules from existing code (no trailing whitepace, mostly default indentation) 12 | * Ensure any new code is well-tested, and if possible, any issue fixed is covered by one or more new tests 13 | * Verify that all tests pass using `lein all test` 14 | * Push your code to your fork of the repository 15 | * Make a Pull Request 16 | 17 | Installing jars and changing of version numbers can be done with the following scripts: 18 | 19 | ```sh 20 | ./scripts/set-version 1.0.0 21 | ./scripts/lein-modules install 22 | ``` 23 | 24 | ## Running locally 25 | 26 | You can run the a local nREPL with the following: 27 | 28 | ```sh 29 | lein with-profile default,dev repl 30 | ``` 31 | 32 | Note: make sure you install modules first if you've made any changes you want reflected. See above. 33 | 34 | Tests can be ran standalone via `lein test`. 35 | 36 | 37 | ## Commit messages 38 | 39 | 1. Separate subject from body with a blank line 40 | 2. Limit the subject line to 50 characters 41 | 3. Capitalize the subject line 42 | 4. Do not end the subject line with a period 43 | 5. Use the imperative mood in the subject line 44 | - "Add x", "Fix y", "Support z", "Remove x" 45 | 6. Wrap the body at 72 characters 46 | 7. Use the body to explain what and why vs. how 47 | 48 | For comprehensive explanation read this [post by Chris Beams](http://chris.beams.io/posts/git-commit/#seven-rules). 49 | 50 | ## Releases 51 | 52 | Releases are built & deployed automatically by Github Actions. Just create a release via the Github UI. 53 | -------------------------------------------------------------------------------- /modules/muuntaja-charred/src/muuntaja/format/charred.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.charred 2 | (:refer-clojure :exclude [format]) 3 | (:require 4 | [charred.api :as charred] 5 | [muuntaja.format.core :as core]) 6 | (:import 7 | [charred JSONReader JSONWriter] 8 | [java.io InputStream InputStreamReader OutputStream OutputStreamWriter] 9 | [org.apache.commons.io.output ByteArrayOutputStream])) 10 | 11 | (defn decoder [options] 12 | (let [json-reader-fn (charred/json-reader-fn options)] 13 | (reify 14 | core/Decode 15 | (decode [_ data charset] 16 | (let [[^JSONReader json-rdr finalize-fn] (json-reader-fn) 17 | input (InputStreamReader. ^InputStream data ^String charset)] 18 | (with-open [rdr (charred/reader->char-reader input options)] 19 | (.beginParse json-rdr rdr) 20 | (finalize-fn (.readObject json-rdr)))))))) 21 | 22 | (defn encoder [options] 23 | (let [json-writer-fn (charred/json-writer-fn options)] 24 | (reify 25 | core/EncodeToBytes 26 | (encode-to-bytes [_ data charset] 27 | (let [output-stream (ByteArrayOutputStream.) 28 | output (OutputStreamWriter. output-stream ^String charset)] 29 | (with-open [^JSONWriter writer (json-writer-fn output)] 30 | (.writeObject writer data)) 31 | (.toByteArray output-stream))) 32 | 33 | core/EncodeToOutputStream 34 | (encode-to-output-stream [_ data charset] 35 | (fn [^OutputStream output-stream] 36 | (let [output (OutputStreamWriter. output-stream ^String charset)] 37 | (with-open [^JSONWriter writer (json-writer-fn output)] 38 | (.writeObject writer data)) 39 | (.flush output-stream))))))) 40 | 41 | (def format 42 | (core/map->Format 43 | {:name "application/json" 44 | :decoder [decoder {:key-fn keyword 45 | :async? false}] 46 | :encoder [encoder {:escape-unicode false}]})) 47 | -------------------------------------------------------------------------------- /doc/Differences-to-existing-formatters.md: -------------------------------------------------------------------------------- 1 | # Differences to existing formatters 2 | 3 | Both `ring-json` and `ring-middleware-format` tests have been ported to muuntaja to 4 | verify behavior and demonstrate differences. 5 | 6 | ## Middleware 7 | 8 | ### Common 9 | 10 | * By default, Keywords are used in map keys (good for `clojure.spec` & `Schema`) 11 | * By default, requires exact string match on content-type 12 | * regex-matches can be enabled manually via options 13 | * No in-built exception handling 14 | * Exceptions have `:type` of `:muuntaja/*`, catch them elsewhere 15 | * Optionally use `muuntaja.middleware/wrap-exception` to catch 'em 16 | * Does not merge `:body-params` into `:params` 17 | * Because merging persistent HashMaps is slow. 18 | * Optionally add `muuntaja.middleware/wrap-params` to your mw-stack before `muuntaja.middleware/wrap-format` 19 | 20 | ### Ring-json & ring-transit 21 | 22 | * Supports multiple formats in a single middleware 23 | * Returns Stream responses instead of Strings, `slurp` the stream to get the String 24 | * Does not populate the `:json-params`/`:transit-params` 25 | * If you need these, write your own middleware for this. 26 | 27 | ### Ring-middleware-format 28 | 29 | * Does not recreate a `:body` stream after consuming the body 30 | * Multiple `wrap-format` (or `wrap-request`) middleware can be used in the same mw stack, only first one is effective, rest are no-op 31 | * By default, encodes only collections (or responses with `Content-Type` header set) 32 | * this can be changed by setting the`[:http :encode-response-body?]` option to `(constantly true)` 33 | * Does not set the `Content-Length` header (which is done by the ring-adapters) 34 | * `:yaml-in-html` / `text/html` is not supported, roll you own formats if you need these 35 | * `:yaml` and `:msgpack` are not set on by default 36 | 37 | ## Pedestal Interceptors 38 | 39 | **TODO** 40 | 41 | * Decoded body is set always to `:body-params` 42 | * Does not populate the `:json-params`/`:transit-params`, if you need these, write an extra interceptor for this. 43 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/format/json.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.format.json 2 | "Check [jsonista.core/object-mapper](https://cljdoc.org/d/metosin/jsonista/CURRENT/api/jsonista.core#object-mapper) 3 | for available options." 4 | (:refer-clojure :exclude [format]) 5 | (:require [jsonista.core :as j] 6 | [muuntaja.format.core :as core]) 7 | (:import (java.io InputStream 8 | InputStreamReader 9 | OutputStreamWriter 10 | OutputStream) 11 | (com.fasterxml.jackson.databind ObjectMapper))) 12 | 13 | (defn object-mapper! [{:keys [mapper] :as options}] 14 | (cond 15 | (instance? ObjectMapper mapper) 16 | mapper 17 | 18 | (or (contains? options :key-fn) (contains? options :bigdecimals?)) 19 | (throw (AssertionError. 20 | (str 21 | "In Muuntaja 0.6.0+ the default JSON formatter has changed\n" 22 | "from Cheshire to Jsonita. Changed options:\n\n" 23 | " :key-fn => :encode-key-fn & :decode-key-fn\n" 24 | " :bigdecimals? => :bigdecimals\n" 25 | options "\n"))) 26 | 27 | :else 28 | (j/object-mapper (dissoc options :mapper)))) 29 | 30 | (defn decoder [options] 31 | (let [mapper (object-mapper! options)] 32 | (reify 33 | core/Decode 34 | (decode [_ data charset] 35 | (if (.equals "utf-8" ^String charset) 36 | (j/read-value data mapper) 37 | (j/read-value (InputStreamReader. ^InputStream data ^String charset) mapper)))))) 38 | 39 | (defn encoder [options] 40 | (let [mapper (object-mapper! options)] 41 | (reify 42 | core/EncodeToBytes 43 | (encode-to-bytes [_ data charset] 44 | (if (.equals "utf-8" ^String charset) 45 | (j/write-value-as-bytes data mapper) 46 | (.getBytes ^String (j/write-value-as-string data mapper) ^String charset))) 47 | core/EncodeToOutputStream 48 | (encode-to-output-stream [_ data charset] 49 | (fn [^OutputStream output-stream] 50 | (if (.equals "utf-8" ^String charset) 51 | (j/write-value output-stream data mapper) 52 | (j/write-value (OutputStreamWriter. output-stream ^String charset) data mapper))))))) 53 | 54 | (def format 55 | (core/map->Format 56 | {:name "application/json" 57 | :decoder [decoder {:decode-key-fn true}] 58 | :encoder [encoder]})) 59 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/protocols.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.protocols 2 | (:require [clojure.java.io :as io] 3 | [muuntaja.util :as util]) 4 | (:import (clojure.lang IFn AFn) 5 | (java.io ByteArrayOutputStream ByteArrayInputStream InputStreamReader BufferedReader InputStream Writer OutputStream FileInputStream File))) 6 | 7 | (deftype StreamableResponse [f] 8 | IFn 9 | (invoke [_ output-stream] 10 | (f output-stream) 11 | output-stream) 12 | (applyTo [this args] 13 | (AFn/applyToHelper this args))) 14 | 15 | (util/when-ns 16 | 'ring.core.protocols 17 | (extend-protocol ring.core.protocols/StreamableResponseBody 18 | (Class/forName "[B") 19 | (write-body-to-stream [body _ ^OutputStream output-stream] 20 | (with-open [out output-stream] 21 | (.write out ^bytes body))) 22 | 23 | StreamableResponse 24 | (write-body-to-stream [this _ ^OutputStream output-stream] 25 | (with-open [out output-stream] 26 | ((.f this) ^OutputStream out))))) 27 | 28 | (extend StreamableResponse 29 | io/IOFactory 30 | (assoc io/default-streams-impl 31 | :make-input-stream (fn [^StreamableResponse this _] 32 | (with-open [out (ByteArrayOutputStream. 4096)] 33 | ((.f this) out) 34 | (ByteArrayInputStream. 35 | (.toByteArray out)))) 36 | :make-reader (fn [^StreamableResponse this _] 37 | (with-open [out (ByteArrayOutputStream. 4096)] 38 | ((.f this) out) 39 | (BufferedReader. 40 | (InputStreamReader. 41 | (ByteArrayInputStream. 42 | (.toByteArray out)))))))) 43 | 44 | (defmethod print-method StreamableResponse 45 | [_ ^Writer w] 46 | (.write w (str "<>"))) 47 | 48 | (defprotocol IntoInputStream 49 | (into-input-stream ^java.io.InputStream [this])) 50 | 51 | (extend-protocol IntoInputStream 52 | (Class/forName "[B") 53 | (into-input-stream [this] (ByteArrayInputStream. this)) 54 | 55 | File 56 | (into-input-stream [this] (FileInputStream. this)) 57 | 58 | InputStream 59 | (into-input-stream [this] this) 60 | 61 | StreamableResponse 62 | (into-input-stream [this] 63 | (io/make-input-stream this nil)) 64 | 65 | String 66 | (into-input-stream [this] 67 | (ByteArrayInputStream. (.getBytes this "utf-8"))) 68 | 69 | nil 70 | (into-input-stream [_] 71 | (ByteArrayInputStream. (byte-array 0)))) 72 | -------------------------------------------------------------------------------- /doc/Creating-new-formats.md: -------------------------------------------------------------------------------- 1 | # Creating New Formats 2 | 3 | Formats are presented as Clojure maps, registered into options under `:formats` with format name as a key. 4 | Format maps can the following optional keys: 5 | 6 | | key | description 7 | | -------------------|--------------- 8 | | `:decoder` | a function (or a function generator) to parse an InputStreams into Clojure data structure. If the key is missing or value is `nil`, no decoding will be done. 9 | | `:encoder` | a function (or a function generator) to encode Clojure data structures into an `InputStream` or to `muuntaja.protocols/Stremable`. If the key is missing or value is `nil`, no encoding will be done. 10 | | `:opts` | extra options maps for both the decoder and encoder function generator. 11 | | `:decoder-opts` | extra options maps for the decoder function generator. 12 | | `:encoder-opts` | extra options maps for the encoder function generator. 13 | | `:matches` | a regexp for additional matching of the content-type in request negotiation. Added for legacy support, e.g. `#"^application/(.+\+)?json$"`. Results of the regexp are memoized against the given content-type for near constant-time performance. 14 | 15 | ## Function generators 16 | 17 | To allow easier customization of the formats on the client side, function generators can be used instead of plain functions. Function generators have a [Duct](https://github.com/duct-framework/duct)/[Reagent](https://github.com/reagent-project/reagent)-style vector presentations with the generator function & optionally the default opts for it. 18 | 19 | ```clj 20 | {:decoder [json-format/make-json-decoder]} 21 | ;; => (json-format/make-json-decoder {}) 22 | 23 | {:decoder [formats/make-json-decoder {:key-fn true}]} 24 | ;; => (json-format/make-json-decoder {:key-fn true}) 25 | ``` 26 | 27 | Clients can override format options with providing `:decoder-opts` or `:encoder-opts`. These get merged over the default opts. 28 | 29 | ```clj 30 | {:decoder [formats/make-json-decoder {:key-fn true}] 31 | :decoder-opts {:bigdecimals? true} 32 | ;; => (json-format/make-json-decoder {:key-fn true, :bigdecimals? true}) 33 | ``` 34 | 35 | ## Example of a new format 36 | 37 | ```clj 38 | (require '[muuntaja.format.json :as json-format]) 39 | (require '[clojure.string :as str]) 40 | (require '[muuntaja.core :as muuntaja]) 41 | 42 | (def streaming-upper-case-json-format 43 | {:decoder [json-format/make-json-decoder {:keywords? false, :key-fn str/upper-case}] 44 | :encoder [json-format/make-streaming-json-encoder]}) 45 | 46 | (def m 47 | (muuntaja/create 48 | (assoc-in 49 | muuntaja/default-options 50 | [:formats "application/upper-json"] 51 | streaming-upper-case-json-format))) 52 | 53 | (->> {:kikka 42} 54 | (muuntaja/encode m "application/json") 55 | (muuntaja/decode m "application/upper-json")) 56 | ; {"KIKKA" 42} 57 | ``` 58 | -------------------------------------------------------------------------------- /doc/Performance.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | ## Background 4 | 5 | Muuntaja has been built with performance in mind, while still doing mostly everything in Clojure: 6 | 7 | * single middleware/interceptor for all formats (instead of stacked middleware) 8 | * avoid run-time regexps 9 | * avoid dynamic bindings 10 | * avoid Clojure (map) destructuring 11 | * (Java-backed) memoized content negotiation 12 | * Protocols over Multimethods 13 | * Records over Maps 14 | * use field access instead of lookups 15 | * keep all core functions small enough to enable JVM Inlining 16 | * unroll generic functions (like `get-in`) 17 | * use streaming when possible 18 | 19 | The codebase contains the performance test, done using [Criterium](https://github.com/hugoduncan/criterium) under the `perf` Leiningen profile with a 2013 Mackbook pro. 20 | 21 | **NOTE:** Tests are not scientific proof and may contain errors. If you have idea how to test things better, please poke us. 22 | 23 | ## Middleware 24 | 25 | Muuntaja is tested against the current common ring-based formatters. It's fastest in [all tests](https://github.com/metosin/muuntaja/blob/master/test/muuntaja/core_perf_test.clj). 26 | 27 | ### `[ring/ring-json "0.4.0"]` & `[ring-transit "0.1.6"]` 28 | 29 | * ok performance by default, but only provide a single format - Stacking these separate middleware makes the pipeline slower. 30 | 31 | ### `[ring-middleware-format "0.7.0"]` 32 | 33 | * has really bad defaults: 34 | * with 1K JSON, Muuntaja is 10-30x faster (depending on the JSON encoder used) 35 | * with 100k JSON, Muuntaja is still 2-4x faster 36 | * with tuned r-m-f options 37 | * with <1K messages, Muuntaja is still much faster 38 | * similar perf on large messages 39 | 40 | ### JSON 41 | ![perf-json-relative](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/perf-json-relative.png) 42 | ![perf-json-relative2](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/perf-json-relative2.png) 43 | ![perf-json](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/perf-json.png) 44 | 45 | ## Transit 46 | ![perf-transit](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/perf-transit.png) 47 | ![perf-transit-relative](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/perf-transit-relative.png) 48 | 49 | ## Interceptors 50 | 51 | Pedestal: 52 | * `io.pedestal.http.content-negotiation/negotiate-content` for content negotiation 53 | * `io.pedestal.http.body-params/body-params` for decoding the request body 54 | * `io.pedestal.http/json-body`, `io.pedestal.http/transit-json-body` etc. to encode responses 55 | 56 | Muuntaja: 57 | * `muuntaja.interceptor/format-negotiate` for content negotiation 58 | * `muuntaja.interceptor/format-request` for decoding request body 59 | * `muuntaja.interceptor/format-response` to encode responses 60 | * `muuntaja.interceptor/format` all on one step 61 | 62 | ![interceptor-perf-json-relative2](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/interceptors-perf-json-relative.png) 63 | ![interceptor-perf-json-relative](https://raw.githubusercontent.com/metosin/muuntaja/master/doc/images/interceptors-perf-json.png) 64 | -------------------------------------------------------------------------------- /test/muuntaja/ring_middleware/format_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.ring-middleware.format-test 2 | (:require [clojure.test :refer :all] 3 | [cheshire.core :as json] 4 | [muuntaja.core :as m] 5 | [muuntaja.middleware :as middleware] 6 | [muuntaja.format.yaml :as yaml-format] 7 | [clj-yaml.core :as yaml]) 8 | (:import [java.io ByteArrayInputStream])) 9 | 10 | (defn stream [s] 11 | (ByteArrayInputStream. (.getBytes s "UTF-8"))) 12 | 13 | (def api-echo 14 | (-> (fn [req] 15 | {:status 200 16 | :params (:params req) 17 | :body (:body-params req)}) 18 | (middleware/wrap-params) 19 | (middleware/wrap-format))) 20 | 21 | (def api-echo-json 22 | (-> (fn [req] 23 | {:status 200 24 | :params (:params req) 25 | :body (:body-params req)}) 26 | (middleware/wrap-params) 27 | (middleware/wrap-format 28 | (-> m/default-options 29 | (m/select-formats ["application/json"]))))) 30 | 31 | (def api-echo-yaml 32 | (-> (fn [req] 33 | {:status 200 34 | :params (:params req) 35 | :body (:body-params req)}) 36 | (middleware/wrap-params) 37 | (middleware/wrap-format 38 | (-> m/default-options 39 | (m/install yaml-format/format) 40 | (m/select-formats ["application/x-yaml"]))))) 41 | 42 | (deftest test-api-round-trip 43 | (let [ok-accept "application/edn" 44 | msg {:test :ok} 45 | r-trip (api-echo {:headers {"accept" ok-accept 46 | "content-type" ok-accept} 47 | :body (stream (pr-str msg))})] 48 | (is (= (get-in r-trip [:headers "Content-Type"]) 49 | "application/edn; charset=utf-8")) 50 | (is (= (read-string (slurp (:body r-trip))) msg)) 51 | (is (= (:params r-trip) msg)) 52 | (is (.contains (get-in (api-echo {:headers {"accept" "foo/bar" 53 | "content-type" ok-accept} 54 | :body (stream (pr-str msg))}) 55 | [:headers "Content-Type"]) 56 | "application/json; charset=utf-8")) 57 | (is (api-echo {:headers {"accept" "foo/bar"}}))) 58 | (let [ok-accept "application/json" 59 | msg {"test" "ok"} 60 | ok-req {:headers {"accept" ok-accept 61 | "content-type" ok-accept} 62 | :body (stream (json/encode msg))} 63 | r-trip (api-echo-json ok-req)] 64 | (is (= (get-in r-trip [:headers "Content-Type"]) 65 | "application/json; charset=utf-8")) 66 | (is (= (json/decode (slurp (:body r-trip))) msg)) 67 | (is (= (:params r-trip) {:test "ok"})) 68 | (is (.contains (get-in (api-echo-json 69 | {:headers {"accept" "application/edn" 70 | "content-type" "application/json"} 71 | :body (stream (json/encode []))}) 72 | [:headers "Content-Type"]) 73 | "application/json"))) 74 | (let [ok-accept "application/x-yaml" 75 | msg {"test" "ok"} 76 | ok-req {:headers {"accept" ok-accept 77 | "content-type" ok-accept} 78 | :body (stream (yaml/generate-string msg))} 79 | r-trip (api-echo-yaml ok-req)] 80 | (is (= (:params r-trip) {:test "ok"})))) 81 | -------------------------------------------------------------------------------- /test/muuntaja/parse_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.parse-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.parse :as parse] 4 | [muuntaja.test_utils :as tu] 5 | [criterium.core :as cc])) 6 | 7 | (deftest parse-test 8 | (are [s r] 9 | (= r (parse/parse-content-type s)) 10 | 11 | "application/json" 12 | ["application/json" nil] 13 | 14 | "text/html; charset=UTF-16" 15 | ["text/html" "utf-16"] 16 | 17 | "application/edn;CharSet=UTF-32" 18 | ["application/edn" "utf-32"]) 19 | 20 | (are [s r] 21 | (= r (parse/parse-accept-charset s)) 22 | 23 | "utf-8" 24 | ["utf-8"] 25 | 26 | "utf-8, iso-8859-1" 27 | ["utf-8" "iso-8859-1"] 28 | 29 | "utf-8, iso-8859-1;q=0.5" 30 | ["utf-8" "iso-8859-1"] 31 | 32 | "UTF-8;q=0.3,iso-8859-1;q=0.5" 33 | ["iso-8859-1" "utf-8"] 34 | 35 | ;; invalid q 36 | "UTF-8;q=x" 37 | ["utf-8"]) 38 | 39 | (are [s r] 40 | (= r (parse/parse-accept s)) 41 | 42 | nil 43 | nil 44 | 45 | ;; simple case 46 | "application/json" 47 | ["application/json"] 48 | 49 | ;; reordering 50 | "application/xml,application/xhtml+xml,text/html;q=0.9, 51 | text/plain;q=0.8,image/png,*/*;q=0.5" 52 | ["application/xml" 53 | "application/xhtml+xml" 54 | "image/png" 55 | "text/html" 56 | "text/plain" 57 | "*/*"] 58 | 59 | ;; internet explorer horror case 60 | "image/gif, image/jpeg, image/pjpeg, application/x-ms-application, 61 | application/vnd.ms-xpsdocument, application/xaml+xml, 62 | application/x-ms-xbap, application/x-shockwave-flash, 63 | application/x-silverlight-2-b2, application/x-silverlight, 64 | application/vnd.ms-excel, application/vnd.ms-powerpoint, 65 | application/msword, */*" 66 | ["image/gif" 67 | "image/jpeg" 68 | "image/pjpeg" 69 | "application/x-ms-application" 70 | "application/vnd.ms-xpsdocument" 71 | "application/xaml+xml" 72 | "application/x-ms-xbap" 73 | "application/x-shockwave-flash" 74 | "application/x-silverlight-2-b2" 75 | "application/x-silverlight" 76 | "application/vnd.ms-excel" 77 | "application/vnd.ms-powerpoint" 78 | "application/msword" 79 | "*/*"] 80 | 81 | ;; non q parameters 82 | "multipart/form-data; boundary=x; charset=US-ASCII" 83 | ["multipart/form-data"] 84 | 85 | ;; invalid q 86 | "text/*;q=x" 87 | ["text/*"] 88 | 89 | ;; separators in parameter values are ignored 90 | "text/*;x=0.0=x" 91 | ["text/*"] 92 | 93 | ;; quoted values can contain separators 94 | "text/*;x=\"0.0=x\"" 95 | ["text/*"])) 96 | 97 | (defn perf [] 98 | 99 | (tu/title "parse-content-type") 100 | 101 | ;; 17ns 102 | (cc/quick-bench 103 | (parse/parse-content-type 104 | "application/json")) 105 | 106 | ;; 186ns 107 | (cc/quick-bench 108 | (parse/parse-content-type 109 | "application/edn;CharSet=UTF-32")) 110 | 111 | (tu/title "parse-accept-charset") 112 | 113 | ;; 1280ns 114 | (cc/quick-bench 115 | (parse/parse-accept-charset 116 | "utf-8")) 117 | 118 | ;; 2800ns 119 | (cc/quick-bench 120 | (parse/parse-accept-charset 121 | "UTF-8;q=0.3,iso-8859-1;q=0.5")) 122 | 123 | (tu/title "parse-accept") 124 | 125 | ;; 1100ns 126 | ;; 1.06us -> 3.36us 127 | (cc/quick-bench 128 | (parse/parse-accept 129 | "application/json")) 130 | 131 | ;; 8200ns 132 | ;; 7.14us -> 25.7us 133 | (cc/quick-bench 134 | (parse/parse-accept 135 | "application/xml,application/xhtml+xml,text/html;q=0.9, 136 | text/plain;q=0.8,image/png,*/*;q=0.5"))) 137 | 138 | (comment 139 | (perf)) 140 | -------------------------------------------------------------------------------- /doc/With-Ring.md: -------------------------------------------------------------------------------- 1 | # Usage with Ring 2 | 3 | ## Simplest thing that works 4 | 5 | Ring application that can read and write JSON, EDN and Transit: 6 | 7 | ```clj 8 | (require '[muuntaja.middleware :as mw]) 9 | 10 | (defn handler [_] 11 | {:status 200 12 | :body {:ping "pong"}}) 13 | 14 | ;; with defaults 15 | (def app (mw/wrap-format handler)) 16 | 17 | (def request {:headers {"accept" "application/json"}}) 18 | 19 | (->> request app) 20 | ; {:status 200, 21 | ; :body #object[java.io.ByteArrayInputStream 0x1d07d794 "java.io.ByteArrayInputStream@1d07d794"], 22 | ; :headers {"Content-Type" "application/json; charset=utf-8"}} 23 | 24 | (->> request app :body slurp) 25 | ; "{\"ping\":\"pong\"}" 26 | ``` 27 | 28 | ## With Muuntaja instance 29 | 30 | Like previous, but with custom Transit options and a standalone Muuntaja: 31 | 32 | ```clj 33 | (require '[cognitect.transit :as transit]) 34 | (require '[muuntaja.middleware :as mw]) 35 | (require '[muuntaja.core :as m]) 36 | 37 | ;; custom Record 38 | (defrecord Ping []) 39 | 40 | ;; custom transit handlers 41 | (def write-handlers 42 | {Ping (transit/write-handler (constantly "Ping") (constantly {}))}) 43 | 44 | (def read-handlers 45 | {"Ping" (transit/read-handler map->Ping)}) 46 | 47 | ;; a configured Muuntaja 48 | (def muuntaja 49 | (m/create 50 | (update-in 51 | m/default-options 52 | [:formats "application/transit+json"] 53 | merge 54 | {:encoder-opts {:handlers write-handlers} 55 | :decoder-opts {:handlers read-handlers}}))) 56 | 57 | (defn endpoint [_] 58 | {:status 200 59 | :body {:ping (->Ping)}}) 60 | 61 | (def app (-> endpoint (mw/wrap-format muuntaja))) 62 | 63 | (def request {:headers {"accept" "application/transit+json"}}) 64 | 65 | (->> request app) 66 | ; {:status 200, 67 | ; :body #object[java.io.ByteArrayInputStream 0x3478e74b "java.io.ByteArrayInputStream@3478e74b"], 68 | ; :headers {"Content-Type" "application/transit+json; charset=utf-8"}} 69 | 70 | (->> request app :body slurp) 71 | ; "[\"^ \",\"~:ping\",[\"~#Ping\",[\"^ \"]]]" 72 | 73 | (->> request app :body (m/decode muuntaja "application/transit+json")) 74 | ; {:ping #user.Ping{}} 75 | ``` 76 | 77 | ## Middleware Chain 78 | 79 | Muuntaja doesn't catch formatting exceptions itself, but throws them instead. If you want to format those also, you need to split the `wrap-format` into parts. 80 | 81 | This: 82 | 83 | ```clj 84 | (-> app (mw/wrap-format muuntaja)) 85 | ``` 86 | 87 | Can be written as: 88 | 89 | ```clj 90 | (-> app 91 | ;; format the request 92 | (mw/wrap-format-request muuntaja) 93 | ;; format the response 94 | (mw/wrap-format-response muuntaja) 95 | ;; negotiate the request & response formats 96 | (mw/wrap-format-negotiate muuntaja)) 97 | ``` 98 | 99 | Now you can add your own exception-handling middleware between the `wrap-format-request` and `wrap-format-response`. It can catch the formatting exceptions and it's results are written with the response formatter. 100 | 101 | Here's a "complete" stack: 102 | 103 | ```clj 104 | (-> app 105 | ;; support for `:params` 106 | (mw/wrap-params) 107 | ;; format the request 108 | (mw/wrap-format-request muuntaja) 109 | ;; catch exceptions 110 | (mw/wrap-exceptions my-exception-handler) 111 | ;; format the response 112 | (mw/wrap-format-response muuntaja) 113 | ;; negotiate the request & response formats 114 | (mw/wrap-format-negotiate muuntaja)) 115 | ``` 116 | 117 | See example of real-life use from [compojure-api](https://github.com/metosin/compojure-api/blob/master/src/compojure/api/middleware.clj). It also reads the `:produces` and `:consumes` from Muuntaja instance and passed them to [Swagger](swagger.io) docs. `:params`-support is needed to allow compojure destucturing syntax. 118 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/parse.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.parse 2 | (:require [clojure.string :as str]) 3 | (:import (java.util.concurrent ConcurrentHashMap))) 4 | 5 | ;; 6 | ;; Cache 7 | ;; 8 | 9 | (defn fast-memoize [size f] 10 | (let [size (int size) 11 | cache (ConcurrentHashMap. size 0.8 4) 12 | sentinel (Object.) 13 | miss (Object.) 14 | cache! (fn [args] 15 | (let [value (or (apply f args) miss)] 16 | (when (> (.size cache) size) 17 | (.clear cache)) 18 | (.putIfAbsent cache args value) 19 | (if (identical? value miss) 20 | nil 21 | value)))] 22 | (fn [& args] 23 | (let [cached (.getOrDefault cache args sentinel)] 24 | (cond 25 | (identical? cached sentinel) (cache! args) 26 | (identical? cached miss) nil 27 | :else cached))))) 28 | 29 | ;; 30 | ;; Parse content-type 31 | ;; 32 | 33 | (defn- extract-charset [^String s] 34 | (if (.startsWith s "charset=") 35 | (.trim (subs s 8)))) 36 | 37 | (defn parse-content-type [^String s] 38 | (let [i (.indexOf s ";")] 39 | (if (neg? i) 40 | [s nil] 41 | [(.substring s 0 i) (extract-charset (.toLowerCase (.trim (.substring s (inc i)))))]))) 42 | 43 | ;; 44 | ;; Parse accept (ported from ring-middleware-format and liberator) 45 | ;; https://github.com/clojure-liberator/liberator/blob/master/src/liberator/conneg.clj#L13 46 | ;; 47 | 48 | (def ^:private accept-fragment-re 49 | #"^\s*((\*|[^()<>@,;:\"/\[\]?={} ]+)/(\*|[^()<>@,;:\"/\[\]?={} ]+))$") 50 | 51 | (def ^:private accept-fragment-param-re 52 | #"([^()<>@,;:\"/\[\]?={} ]+)=([^()<>@,;:\"/\[\]?={} ]+|\"[^\"]*\")$") 53 | 54 | (defn- parse-q [s] 55 | (try 56 | (->> (Double/parseDouble s) 57 | (min 1) 58 | (max 0)) 59 | (catch NumberFormatException e 60 | nil))) 61 | 62 | (defn- sort-by-check 63 | [by check headers] 64 | (sort-by by (fn [a b] 65 | (cond (= (= a check) (= b check)) 0 66 | (= a check) 1 67 | :else -1)) 68 | headers)) 69 | 70 | (defn parse-accept 71 | "Parse Accept headers into a sorted sequence of content-types. 72 | \"application/json;level=1;q=0.4\" 73 | => (\"application/json\"})" 74 | [accept-header] 75 | (if accept-header 76 | (->> (map (fn [fragment] 77 | (let [[media-range & params-list] (str/split fragment #"\s*;\s*") 78 | type (second (re-matches accept-fragment-re media-range))] 79 | (-> (reduce (fn [m s] 80 | (if-let [[k v] (seq (rest (re-matches accept-fragment-param-re s)))] 81 | (if (= "q" k) 82 | (update-in m [:q] #(or % (parse-q v))) 83 | (assoc m (keyword k) v)) 84 | m)) 85 | {:type type} 86 | params-list) 87 | (update-in [:q] #(or % 1.0))))) 88 | (str/split accept-header #"[\s\n\r]*,[\s\n\r]*")) 89 | (sort-by-check :type "*/*") 90 | (sort-by :q >) 91 | (map :type)))) 92 | 93 | (defn- reverse-compare [x y] (compare y x)) 94 | 95 | (defn parse-accept-charset [^String s] 96 | (if s 97 | (let [segments (str/split s #",") 98 | choices (for [segment segments 99 | :when (not (empty? segment)) 100 | :let [[_ charset qs] (re-find #"([^;]+)(?:;\s*q\s*=\s*([0-9\.]+))?" segment)] 101 | :when charset 102 | :let [qscore (try 103 | (Double/parseDouble (str/trim qs)) 104 | (catch Exception e 1))]] 105 | [(str/trim charset) qscore])] 106 | (->> choices 107 | (sort-by second reverse-compare) 108 | (map first) 109 | (map str/lower-case))))) 110 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject muuntaja-dev "0.6.11" 2 | ;; See modules/muuntaja/project.clj for actual project.clj used when releasing. 3 | ;; This project.clj is just for local development. 4 | :description "Clojure library for format encoding, decoding and content-negotiation" 5 | :url "https://github.com/metosin/muuntaja" 6 | :license {:name "Eclipse Public License" 7 | :url "http://www.eclipse.org/legal/epl-v20.html"} 8 | :managed-dependencies [[metosin/muuntaja "0.6.11"] 9 | [ring/ring-codec "1.2.0"] 10 | [metosin/jsonista "0.3.13"] 11 | [com.cognitect/transit-clj "1.0.333"] 12 | [com.cnuernber/charred "1.034"] 13 | [cheshire "5.13.0"] 14 | [clj-commons/clj-yaml "1.0.29"] 15 | [clojure-msgpack "1.2.1" :exclusions [org.clojure/clojure]]] 16 | :deploy-repositories [["releases" {:url "https://repo.clojars.org/" 17 | :sign-releases false 18 | :username :env/CLOJARS_USER 19 | :password :env/CLOJARS_DEPLOY_TOKEN}]] 20 | :source-paths ["modules/muuntaja/src"] 21 | :plugins [[lein-codox "0.10.7"] 22 | [lein-ancient "1.0.0-RC3"]] 23 | :codox {:src-uri "http://github.com/metosin/muuntaja/blob/master/{filepath}#L{line}" 24 | :output-path "doc" 25 | :metadata {:doc/format :markdown}} 26 | :scm {:name "git" 27 | :url "https://github.com/metosin/muuntaja"} 28 | :profiles {:dev {:jvm-opts ^:replace ["-server"] 29 | 30 | ;; all module sources for development 31 | :source-paths ["modules/muuntaja-charred/src" 32 | "modules/muuntaja-cheshire/src" 33 | "modules/muuntaja-form/src" 34 | "modules/muuntaja-yaml/src" 35 | "modules/muuntaja-msgpack/src" 36 | "modules/muuntaja/src"] 37 | 38 | :dependencies [[org.clojure/clojure "1.12.0"] 39 | [com.cnuernber/charred "1.034"] 40 | [ring/ring-core "1.13.0"] 41 | [ring-middleware-format "0.7.5"] 42 | [ring-transit "0.1.6"] 43 | [ring/ring-json "0.5.1"] 44 | [metosin/jsonista "0.3.13"] 45 | 46 | ;; correct jackson 47 | [com.fasterxml.jackson.core/jackson-databind "2.18.2"] 48 | 49 | ;; Sieppari 50 | [metosin/sieppari "0.0.0-alpha5"] 51 | 52 | ;; Pedestal 53 | [org.clojure/core.async "1.7.701" :exclusions [org.clojure/tools.reader]] 54 | [io.pedestal/pedestal.service "0.7.2" :exclusions [org.clojure/tools.reader 55 | org.clojure/core.async 56 | org.clojure/core.memoize]] 57 | [jakarta.servlet/jakarta.servlet-api "5.0.0"] 58 | [org.slf4j/slf4j-log4j12 "2.0.16"] 59 | 60 | [criterium "0.4.6"]]} 61 | :1.8 {:dependencies [[org.clojure/clojure "1.8.0"]]} 62 | :1.9 {:dependencies [[org.clojure/clojure "1.9.0"]]} 63 | :1.10 {:dependencies [[org.clojure/clojure "1.10.2"]]} 64 | :perf {:jvm-opts ^:replace ["-server" 65 | "-Xmx4096m" 66 | "-Dclojure.compiler.direct-linking=true"]} 67 | :analyze {:jvm-opts ^:replace ["-server" 68 | "-Dclojure.compiler.direct-linking=true" 69 | "-XX:+PrintCompilation" 70 | "-XX:+UnlockDiagnosticVMOptions" 71 | "-XX:+PrintInlining"]}} 72 | :aliases {"all" ["with-profile" "dev:dev,1.8:dev,1.9:dev,1.10"] 73 | "perf" ["with-profile" "default,dev,perf"] 74 | "analyze" ["with-profile" "default,dev,analyze"]}) 75 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/interceptor.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.interceptor 2 | (:refer-clojure :exclude [format]) 3 | (:require [muuntaja.core :as m])) 4 | 5 | ; [^Exception e format request] 6 | (defn- default-on-exception [_ format _] 7 | {:status 400 8 | :headers {"Content-Type" "text/plain"} 9 | :body (str "Malformed " format " request.")}) 10 | 11 | (defn exception-interceptor 12 | "Interceptor that catches exceptions of type `:muuntaja/decode` 13 | and invokes a 3-arity callback [^Exception e format request] which 14 | returns a response." 15 | ([] 16 | (exception-interceptor default-on-exception)) 17 | ([on-exception] 18 | {:name ::exceptions 19 | :error (fn [ctx] 20 | (let [error (:error ctx)] 21 | (if-let [data (ex-data error)] 22 | (if (-> data :type (= :muuntaja/decode)) 23 | (-> ctx 24 | (dissoc :error) 25 | (assoc :response (on-exception error (:format data) (:request ctx))))))))})) 26 | 27 | (defn params-interceptor 28 | "Interceptor that merges request `:body-params` into `:params`." 29 | [] 30 | {:name ::params 31 | :enter (fn [ctx] 32 | (letfn [(set-params 33 | ([request] 34 | (let [params (:params request) 35 | body-params (:body-params request)] 36 | (cond 37 | (not (map? body-params)) request 38 | (empty? body-params) request 39 | (empty? params) (assoc request :params body-params) 40 | :else (update request :params merge body-params)))))] 41 | (let [request (:request ctx)] 42 | (assoc ctx :request (set-params request)))))}) 43 | 44 | (defn format-interceptor 45 | "Interceptor that negotiates a request body based on accept, accept-charset 46 | and content-type headers and decodes the body with an attached Muuntaja 47 | instance into `:body-params`. Encodes also the response body with the same 48 | Muuntaja instance based on the negotiation information or override information 49 | provided by the handler. 50 | 51 | Takes a pre-configured Muuntaja or options maps an argument. 52 | See https://github.com/metosin/muuntaja for all options and defaults." 53 | ([] 54 | (format-interceptor m/instance)) 55 | ([prototype] 56 | (let [m (m/create prototype)] 57 | {:name ::format 58 | :enter (fn [ctx] 59 | (let [request (:request ctx)] 60 | (assoc ctx :request (m/negotiate-and-format-request m request)))) 61 | :leave (fn [ctx] 62 | (let [request (:request ctx) 63 | response (:response ctx)] 64 | (assoc ctx :response (m/format-response m request response))))}))) 65 | 66 | (defn format-negotiate-interceptor 67 | "Interceptor that negotiates a request body based on accept, accept-charset 68 | and content-type headers with an attached Muuntaja instance. Injects negotiation 69 | results into request for `format-request` interceptor to use. 70 | 71 | Takes a pre-configured Muuntaja or options maps an argument. 72 | See https://github.com/metosin/muuntaja for all options and defaults." 73 | ([] 74 | (format-negotiate-interceptor m/instance)) 75 | ([prototype] 76 | (let [m (m/create prototype)] 77 | {:name ::format-negotiate 78 | :enter (fn [ctx] 79 | (let [request (:request ctx)] 80 | (assoc ctx :request (m/negotiate-request-response m request))))}))) 81 | 82 | (defn format-request-interceptor 83 | "Interceptor that decodes the request body with an attached Muuntaja 84 | instance into `:body-params` based on the negotiation information provided 85 | by `format-negotiate` interceptor. 86 | 87 | Takes a pre-configured Muuntaja or options maps an argument. 88 | See https://github.com/metosin/muuntaja for all options and defaults." 89 | ([] 90 | (format-request-interceptor m/instance)) 91 | ([prototype] 92 | (let [m (m/create prototype)] 93 | {:name ::format-request 94 | :enter (fn [ctx] 95 | (let [request (:request ctx)] 96 | (assoc ctx :request (m/format-request m request))))}))) 97 | 98 | (defn format-response-interceptor 99 | "Interceptor that encodes also the response body with the attached 100 | Muuntaja instance, based on request negotiation information provided by 101 | `format-negotiate` interceptor or override information provided by the handler. 102 | 103 | Takes a pre-configured Muuntaja or options maps an argument. 104 | See https://github.com/metosin/muuntaja for all options and defaults." 105 | ([] 106 | (format-response-interceptor m/instance)) 107 | ([prototype] 108 | (let [m (m/create prototype)] 109 | {:name ::format-response 110 | :leave (fn [ctx] 111 | (let [request (:request ctx) 112 | response (:response ctx)] 113 | (assoc ctx :response (m/format-response m request response))))}))) 114 | -------------------------------------------------------------------------------- /modules/muuntaja/src/muuntaja/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.middleware 2 | (:require [muuntaja.core :as m])) 3 | 4 | ; [^Exception e format request] 5 | (defn- default-on-exception [_ format _] 6 | {:status 400 7 | :headers {"Content-Type" "text/plain"} 8 | :body (str "Malformed " format " request.")}) 9 | 10 | (defn- handle-exception [exception request on-exception respond raise] 11 | (if-let [data (ex-data exception)] 12 | (if (-> data :type (= :muuntaja/decode)) 13 | (respond (on-exception exception (:format data) request)) 14 | (raise exception)) 15 | (raise exception))) 16 | 17 | (defn wrap-exception 18 | "Middleware that catches exceptions of type `:muuntaja/decode` 19 | and invokes a 3-arity callback [^Exception e format request] which 20 | returns a response. Support async-ring." 21 | ([handler] 22 | (wrap-exception handler default-on-exception)) 23 | ([handler on-exception] 24 | (let [throw #(throw %)] 25 | (fn 26 | ([request] 27 | (try 28 | (handler request) 29 | (catch Exception e 30 | (handle-exception e request on-exception identity throw)))) 31 | ([request respond raise] 32 | (try 33 | (handler request respond #(handle-exception % request on-exception respond raise)) 34 | (catch Exception e 35 | (handle-exception e request on-exception respond throw)))))))) 36 | 37 | (defn wrap-params 38 | "Middleware that merges request `:body-params` into `:params`. 39 | Supports async-ring." 40 | [handler] 41 | (letfn [(set-params 42 | ([request] 43 | (let [params (:params request) 44 | body-params (:body-params request)] 45 | (cond 46 | (not (map? body-params)) request 47 | (empty? body-params) request 48 | (empty? params) (assoc request :params body-params) 49 | :else (update request :params merge body-params)))))] 50 | (fn 51 | ([request] 52 | (handler (set-params request))) 53 | ([request respond raise] 54 | (handler (set-params request) respond raise))))) 55 | 56 | (defn wrap-format 57 | "Middleware that negotiates a request body based on accept, accept-charset 58 | and content-type headers and decodes the body with an attached Muuntaja 59 | instance into `:body-params`. Encodes also the response body with the same 60 | Muuntaja instance based on the negotiation information or override information 61 | provided by the handler. 62 | 63 | Takes a pre-configured Muuntaja or options maps as second argument. 64 | See https://github.com/metosin/muuntaja for all options and defaults. 65 | Supports async-ring." 66 | ([handler] 67 | (wrap-format handler m/default-options)) 68 | ([handler prototype] 69 | (let [m (m/create prototype)] 70 | (fn 71 | ([request] 72 | (let [req (m/negotiate-and-format-request m request)] 73 | (some->> (handler req) (m/format-response m req)))) 74 | ([request respond raise] 75 | (let [req (m/negotiate-and-format-request m request)] 76 | (handler req #(respond (m/format-response m req %)) raise))))))) 77 | 78 | ;; 79 | ;; separate mw for negotiate, request & response 80 | ;; 81 | 82 | (defn wrap-format-negotiate 83 | "Middleware that negotiates a request body based on accept, accept-charset 84 | and content-type headers with an attached Muuntaja instance. Injects negotiation 85 | results into request for `wrap-format-request` to use. 86 | 87 | Takes a pre-configured Muuntaja or options maps as second argument. 88 | See https://github.com/metosin/muuntaja for all options and defaults. 89 | Supports async-ring." 90 | ([handler] 91 | (wrap-format-negotiate handler m/default-options)) 92 | ([handler prototype] 93 | (let [m (m/create prototype)] 94 | (fn 95 | ([request] 96 | (handler (m/negotiate-request-response m request))) 97 | ([request respond raise] 98 | (handler (m/negotiate-request-response m request) respond raise)))))) 99 | 100 | (defn wrap-format-request 101 | "Middleware that decodes the request body with an attached Muuntaja 102 | instance into `:body-params` based on the negotiation information provided 103 | by `wrap-format-negotiate`. 104 | 105 | Takes a pre-configured Muuntaja or options maps as second argument. 106 | See https://github.com/metosin/muuntaja for all options and defaults. 107 | Supports async-ring." 108 | ([handler] 109 | (wrap-format-request handler m/default-options)) 110 | ([handler prototype] 111 | (let [m (m/create prototype)] 112 | (fn 113 | ([request] 114 | (handler (m/format-request m request))) 115 | ([request respond raise] 116 | (handler (m/format-request m request) respond raise)))))) 117 | 118 | (defn wrap-format-response 119 | "Middleware that encodes also the response body with the attached 120 | Muuntaja instance, based on request negotiation information provided by 121 | `wrap-format-negotiate` or override information provided by the handler. 122 | 123 | Takes a pre-configured Muuntaja or options maps as second argument. 124 | See https://github.com/metosin/muuntaja for all options and defaults. 125 | Supports async-ring." 126 | ([handler] 127 | (wrap-format-response handler m/default-options)) 128 | ([handler prototype] 129 | (let [m (m/create prototype)] 130 | (fn 131 | ([request] 132 | (some->> (handler request) (m/format-response m request))) 133 | ([request respond raise] 134 | (handler request #(respond (m/format-response m request %)) raise)))))) 135 | -------------------------------------------------------------------------------- /test/muuntaja/formats_perf_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.formats-perf-test 2 | (:require [criterium.core :as cc] 3 | [muuntaja.test_utils :refer :all] 4 | [muuntaja.format.edn :as edn-format] 5 | [ring.core.protocols :as protocols] 6 | [cheshire.core :as cheshire]) 7 | (:import (java.io ByteArrayOutputStream))) 8 | 9 | (set! *warn-on-reflection* true) 10 | 11 | ;; 12 | ;; start repl with `lein perf repl` 13 | ;; perf measured with the following setup: 14 | ;; 15 | ;; Model Name: MacBook Pro 16 | ;; Model Identifier: MacBookPro11,3 17 | ;; Processor Name: Intel Core i7 18 | ;; Processor Speed: 2,5 GHz 19 | ;; Number of Processors: 1 20 | ;; Total Number of Cores: 4 21 | ;; L2 Cache (per Core): 256 KB 22 | ;; L3 Cache: 6 MB 23 | ;; Memory: 16 GB 24 | ;; 25 | 26 | (set! *warn-on-reflection* true) 27 | 28 | (def +data+ {:kikka 2}) 29 | (def +json-string+ "{\"kikka\":2}") 30 | (def +transit-string+ "[\"^ \",\"~:kikka\",2]") 31 | (def +edn-string+ "{:kikka 2}") 32 | 33 | (defn ^ByteArrayOutputStream stream [] 34 | (ByteArrayOutputStream. 16384)) 35 | 36 | (defn ring-write [data stream] 37 | (protocols/write-body-to-stream 38 | data 39 | {:headers {"Content-Type" "application/json;charset=utf-8"}} 40 | stream)) 41 | 42 | (def +charset+ "utf-8") 43 | 44 | (defn make-json-string-encoder [options] 45 | (fn [data _] 46 | (cheshire/generate-string data options))) 47 | 48 | (defn make-cheshire-string-encoder [options] 49 | (fn [data _] 50 | (cheshire/generate-string data options))) 51 | 52 | #_(defn encode-json [] 53 | (let [encode0 (make-json-string-encoder {}) 54 | encode1 (cheshire-format/make-streaming-json-encoder {}) 55 | encode2 (cheshire-format/encoder {}) 56 | encode3 (json-format/make-json-encoder {})] 57 | 58 | ;; 4.7µs 59 | (title "json: string") 60 | (let [call #(let [baos (stream)] 61 | (with-open [writer (io/writer baos)] 62 | (.write writer ^String (encode0 +data+ +charset+))) 63 | baos)] 64 | 65 | (assert (= +json-string+ (str (call)))) 66 | (cc/quick-bench 67 | (call))) 68 | 69 | ;; 3.1µs 70 | (title "json: write-to-stream") 71 | (let [call #(let [baos (stream)] 72 | ((encode1 +data+ +charset+) baos) 73 | baos)] 74 | 75 | (assert (= +json-string+ (str (call)))) 76 | (cc/quick-bench 77 | (call))) 78 | 79 | ;; 2.9µs 80 | (title "json: inputstream") 81 | (let [call #(let [baos (stream) 82 | is (encode2 +data+ +charset+)] 83 | (io/copy is baos) 84 | baos)] 85 | 86 | (assert (= +json-string+ (str (call)))) 87 | (cc/quick-bench 88 | (call))))) 89 | 90 | #_(defn encode-json-ring [] 91 | (let [encode0 (make-cheshire-string-encoder {}) 92 | encode1 (cheshire-format/make-streaming-json-encoder {}) 93 | encode2 (cheshire-format/encoder {})] 94 | 95 | ;; 6.4µs 96 | (title "ring: json: string") 97 | (let [call #(let [baos (stream)] 98 | (ring-write (encode0 +data+ +charset+) baos) 99 | baos)] 100 | 101 | (assert (= +json-string+ (str (call)))) 102 | (cc/quick-bench 103 | (call))) 104 | 105 | ;; 3.8µs 106 | (title "ring: json: write-to-stream") 107 | (let [call #(let [baos (stream)] 108 | (ring-write 109 | (reify 110 | protocols/StreamableResponseBody 111 | (write-body-to-stream [_ _ stream] 112 | ((encode1 +data+ +charset+) stream))) 113 | baos) 114 | baos)] 115 | (assert (= +json-string+ (str (call)))) 116 | (cc/quick-bench 117 | (call))) 118 | 119 | ;; 3.7µs 120 | (title "ring: json: inputstream") 121 | (let [call #(let [baos (stream)] 122 | (ring-write (encode2 +data+ +charset+) baos) 123 | baos)] 124 | 125 | (assert (= +json-string+ (str (call)))) 126 | (cc/quick-bench 127 | (call))) 128 | 129 | ;; 2.4µs 130 | (title "ring: json: inputstream (jsonista)") 131 | (let [call #(let [baos (stream)] 132 | (ring-write 133 | (ByteArrayInputStream. (.getBytes ^String (j/write-value-as-string {"kikka" 2}))) 134 | baos) 135 | baos)] 136 | 137 | (assert (= +json-string+ (str (call)))) 138 | (cc/quick-bench 139 | (call))) 140 | 141 | ;; 1.8µs 142 | (title "ring: json: ByteResponse (jsonista)") 143 | (let [call #(let [baos (stream)] 144 | (ring-write 145 | (mp/->ByteResponse (j/write-value-as-bytes {"kikka" 2})) 146 | baos) 147 | baos)] 148 | 149 | (assert (= +json-string+ (str (call)))) 150 | (cc/quick-bench 151 | (call))) 152 | 153 | ;; 6.0µs (wooot?) 154 | (title "ring: json: inputstream (jsonista)") 155 | (let [call #(let [baos (stream)] 156 | (ring-write 157 | (j/write-value-as-string {"kikka" 2}) 158 | baos) 159 | baos)] 160 | 161 | (assert (= +json-string+ (str (call)))) 162 | (cc/quick-bench 163 | (call))))) 164 | 165 | #_(defn encode-transit-ring [] 166 | (let [encode1 (transit-format/make-streaming-transit-encoder :json {}) 167 | encode2 (transit-format/encoder :json {})] 168 | 169 | ;; 6.6µs 170 | (title "ring: transit-json: write-to-stream") 171 | (let [call #(let [baos (stream)] 172 | (ring-write 173 | (reify 174 | protocols/StreamableResponseBody 175 | (write-body-to-stream [_ _ stream] 176 | ((encode1 +data+ +charset+) stream))) 177 | baos) 178 | baos)] 179 | 180 | (assert (= +transit-string+ (str (call)))) 181 | (cc/quick-bench 182 | (call))) 183 | 184 | ;; 7.4µs 185 | (title "ring: transit-json: inputstream") 186 | (let [call #(let [baos (stream)] 187 | (ring-write (encode2 +data+ +charset+) baos) 188 | baos)] 189 | 190 | (assert (= +transit-string+ (str (call)))) 191 | (cc/quick-bench 192 | (call))))) 193 | 194 | (defn make-edn-string-encoder [_] 195 | (fn [data _] 196 | (pr-str data))) 197 | 198 | (defn encode-edn-ring [] 199 | (let [encode1 (make-edn-string-encoder {}) 200 | encode2 (edn-format/encoder {})] 201 | 202 | ;; 8.8µs 203 | (title "ring: edn: string") 204 | (let [call #(let [baos (stream)] 205 | (ring-write (encode1 +data+ +charset+) baos) 206 | baos)] 207 | 208 | (assert (= +edn-string+ (str (call)))) 209 | (cc/quick-bench 210 | (call))) 211 | 212 | ;; 4.4µs 213 | (title "ring: edn: inputstream") 214 | (let [call #(let [baos (stream)] 215 | (ring-write (encode2 +data+ +charset+) baos) 216 | baos)] 217 | 218 | (assert (= +edn-string+ (str (call)))) 219 | (cc/quick-bench 220 | (call))))) 221 | 222 | (comment 223 | (encode-json) 224 | (encode-json-ring) 225 | (encode-transit-ring) 226 | (encode-edn-ring)) 227 | -------------------------------------------------------------------------------- /doc/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Muuntaja is data-driven, allowing [mostly](#evil-global-state) everything to be defined via options. Muuntaja is created with `muuntaja.core/create` function. It takes either an existing Muuntaja instance, options-map or nothing as a parameter. 4 | 5 | ## Examples 6 | 7 | ```clj 8 | (require '[muuntaja.core :as muuntaja]) 9 | 10 | ;; with defaults 11 | (def m (muuntaja/create)) 12 | 13 | ;; with a muuntaja as prototype (no-op) 14 | (muuntaja/create m) 15 | 16 | ;; with default options (same features, different instance) 17 | (muuntaja/create muuntaja/default-options) 18 | ``` 19 | 20 | ## Default options 21 | 22 | ```clj 23 | {:http {:extract-content-type extract-content-type-ring 24 | :extract-accept-charset extract-accept-charset-ring 25 | :extract-accept extract-accept-ring 26 | :decode-request-body? (constantly true) 27 | :encode-response-body? encode-collections} 28 | 29 | :allow-empty-input? true 30 | :return :input-stream 31 | 32 | :default-charset "utf-8" 33 | :charsets available-charsets 34 | 35 | :default-format "application/json" 36 | :formats {"application/json" json-format/json-format 37 | "application/edn" edn-format/edn-format 38 | "application/transit+json" transit-format/transit-json-format 39 | "application/transit+msgpack" transit-format/transit-msgpack-format}} 40 | ``` 41 | 42 | ## Custom options 43 | 44 | As options are just data, normal Clojure functions can be used to modify them. Options are sanity checked at creation time. There are also the following helpers in the `muuntaja.core`: 45 | 46 | * `select-formats`: takes a sequence of formats selecting them and setting the first one as default format. 47 | * `transform-formats` takes an 2-arity function [format, format-options], which returns new format-options for that format. Returning `nil` removes that format. Called for all registered formats. 48 | 49 | ## Examples 50 | 51 | ### Modifying JSON encoding opts 52 | 53 | Easiest way is to add a `:encoder-opts` key to the format with the options map as the value. 54 | 55 | ```clj 56 | (def m 57 | (muuntaja/create 58 | (assoc-in 59 | muuntaja/default-options 60 | [:formats "application/json" :encoder-opts] 61 | {:key-fn #(.toUpperCase (name %))}))) 62 | 63 | (slurp (muuntaja/encode m "application/json" {:kikka 42})) 64 | ;; "{\"KIKKA\":42}" 65 | ``` 66 | 67 | Check [jsonista object-mapper docs](https://cljdoc.org/d/metosin/jsonista/CURRENT/api/jsonista.core#object-mapper) 68 | for available options. 69 | 70 | ### Setting Transit writers and readers 71 | 72 | ```clj 73 | (def m 74 | (muuntaja/create 75 | (update-in 76 | muuntaja/default-options 77 | [:formats "application/transit+json"] 78 | merge {:decoder-opts {:handlers transit-dates/readers} 79 | :encoder-opts {:handlers transit-dates/writers}}))) 80 | ``` 81 | 82 | `:decoder-opts` is passed to [`reader`](https://cognitect.github.io/transit-clj/#cognitect.transit/reader) 83 | and `:encoder-opts` to [`writer`](https://cognitect.github.io/transit-clj/#cognitect.transit/writer). 84 | 85 | ### Using only selected formats 86 | 87 | Supporting only `application/edn` and `application/transit+json` formats. 88 | 89 | ```clj 90 | (def m 91 | (muuntaja/create 92 | (-> muuntaja/default-options 93 | (update 94 | :formats 95 | select-keys 96 | ["application/edn" 97 | "application/transit+json"])))) 98 | ;; clojure.lang.ExceptionInfo Invalid default format application/json 99 | ``` 100 | 101 | Ups. That didn't work. The `:default-format` is now illegal. This works: 102 | 103 | ```clj 104 | (def m 105 | (muuntaja/create 106 | (-> muuntaja/default-options 107 | (update 108 | :formats 109 | select-keys 110 | ["application/edn" 111 | "application/transit+json"]) 112 | (assoc :default-format "application/edn")))) 113 | ``` 114 | 115 | Same with `select-formats`: 116 | 117 | ```clj 118 | (def m 119 | (muuntaja/create 120 | (muuntaja/select-formats 121 | muuntaja/default-options 122 | ["application/json" 123 | "application/edn"]))) 124 | 125 | (muuntaja/default-format m) 126 | ; "application/edn" 127 | ``` 128 | 129 | ### Disabling decoding 130 | 131 | We have to remove `:decoder` key from all formats. 132 | 133 | ```clj 134 | (def m 135 | (muuntaja/create 136 | (update 137 | muuntaja/default-options 138 | :formats 139 | #(into 140 | (empty %) 141 | (map (fn [[k v]] 142 | [k (dissoc v :decoder)]) %))))) 143 | 144 | (muuntaja/encoder m "application/json") 145 | ;; #object[...] 146 | 147 | (muuntaja/decoder m "application/json") 148 | ;; nil 149 | ``` 150 | 151 | Same with `transform-formats`: 152 | 153 | ```clj 154 | (def m 155 | (muuntaja/create 156 | (muuntaja/transform-formats 157 | muuntaja/default-options 158 | #(dissoc %2 :encoder)))) 159 | ``` 160 | 161 | ### Using Streaming JSON and Transit encoders 162 | 163 | To be used with Ring 1.6.0+, with Pedestal & other streaming libs. We have 164 | to change the `:encoder` of the given formats: 165 | 166 | ```clj 167 | (require '[muuntaja.format.json :as json-format]) 168 | (require '[muuntaja.format.transit :as transit-format]) 169 | 170 | (def m 171 | (muuntaja/create 172 | (-> muuntaja/default-options 173 | (assoc-in 174 | [:formats "application/json" :encoder 0] 175 | json-format/make-streaming-json-encoder) 176 | (assoc-in 177 | [:formats "application/transit+json" :encoder 0] 178 | (partial transit-format/make-streaming-transit-encoder :json)) 179 | (assoc-in 180 | [:formats "application/transit+msgpack" :encoder 0] 181 | (partial transit-format/make-streaming-transit-encoder :msgpack))))) 182 | 183 | (muuntaja/encode m "application/json" {:kikka 2}) 184 | ;; <> 185 | 186 | (slurp (muuntaja/encode m "application/json" {:kikka 2})) 187 | ; "{\"kikka\":2}" 188 | ``` 189 | 190 | ### Loose matching on content-type 191 | 192 | If, for some reason, you want use the RegExps to match the content-type (like 193 | `ring-json`, `ring-transit` & `ring-middleware-format` do. We need just to add 194 | a `:matches` key to format with a regexp as a value. The following procudes loose 195 | matching like in `ring-middleware-format`: 196 | 197 | ```clj 198 | (def m 199 | (muuntaja/create 200 | (-> muuntaja/default-options 201 | (assoc-in [:formats "application/json" :matches] #"^application/(.+\+)?json$") 202 | (assoc-in [:formats "application/edn" :matches] #"^application/(vnd.+)?(x-)?(clojure|edn)$") 203 | ;(assoc-in [:formats "application/msgpack" :matches] #"^application/(vnd.+)?(x-)?msgpack$") 204 | ;(assoc-in [:formats "application/x-yaml" :matches] #"^(application|text)/(vnd.+)?(x-)?yaml$") 205 | (assoc-in [:formats "application/transit+json" :matches] #"^application/(vnd.+)?(x-)?transit\+json$") 206 | (assoc-in [:formats "application/transit+msgpack" :matches] #"^application/(vnd.+)?(x-)?transit\+msgpack$")))) 207 | 208 | ((:negotiate-content-type m) "application/vnd.foobar+json; charset=utf-8") 209 | ;; #muuntaja.core.FormatAndCharset{:format "application/json", :charset "utf-8"} 210 | ``` 211 | 212 | ### Creating a new format 213 | 214 | See [[Creating new formats]]. 215 | 216 | ### Implicit configuration 217 | 218 | Currently, both `cheshire` & `clojure-msgpack` allow new type encoders to be defined via Clojure protocol extensions. Importing a namespace could bring new type mappings or override existings without a warning, potentially breaking things. Ordering of the imports should not matter! 219 | 220 | TODO: Make all options explicit. 221 | 222 | * See: https://github.com/dakrone/cheshire/issues/7 223 | -------------------------------------------------------------------------------- /dev-resources/json10k.json: -------------------------------------------------------------------------------- 1 | {"results":[{"gender":"male","name":{"title":"mr","first":"علی رضا","last":"محمدخان"},"location":{"street":"4326 پارک دانشجو","city":"آبادان","state":"خوزستان","postcode":36902},"email":"علی رضا.محمدخان@example.com","login":{"username":"lazypeacock819","password":"taxman","salt":"peX2qakA","md5":"0c39ef1320ec7f799065f3b3385a2f4e","sha1":"cd51cda2b75943b111707094d8a5652542d2dff0","sha256":"a1f97d14b6a878b963482de8d6aa789f5baaf9872e510031028b213241787a73"},"dob":"1953-04-25 02:27:20","registered":"2004-10-03 21:41:05","phone":"050-46102037","cell":"0990-753-1209","id":{"name":"","value":null},"picture":{"large":"https://randomuser.me/api/portraits/men/24.jpg","medium":"https://randomuser.me/api/portraits/med/men/24.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/men/24.jpg"},"nat":"IR"},{"gender":"female","name":{"title":"ms","first":"andrea","last":"peña"},"location":{"street":"7112 calle de la almudena","city":"la palma","state":"islas baleares","postcode":63878},"email":"andrea.peña@example.com","login":{"username":"purpleleopard218","password":"harvard","salt":"hc9Uu10H","md5":"0d1c50b840053c61f68eb11b9ff5c44b","sha1":"5bd624f5e4567f6340cb00a07d6d9cdb2046b219","sha256":"839fee4ad0d19068b4dab72546bed62fe48ae69456e4b1b383125484510950b7"},"dob":"1953-01-27 09:21:17","registered":"2005-07-01 21:44:40","phone":"986-752-877","cell":"623-685-112","id":{"name":"DNI","value":"93357290-Y"},"picture":{"large":"https://randomuser.me/api/portraits/women/81.jpg","medium":"https://randomuser.me/api/portraits/med/women/81.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/81.jpg"},"nat":"ES"},{"gender":"female","name":{"title":"miss","first":"sue","last":"romero"},"location":{"street":"247 white oak dr","city":"mackay","state":"new south wales","postcode":1327},"email":"sue.romero@example.com","login":{"username":"orangefish402","password":"llll","salt":"AjO4nICn","md5":"19521ff63dc4e49e85c5a80ed219231c","sha1":"dde550b31afc7ff7d852e4b3252e1fa5070e00e3","sha256":"d7b83280c4eb3b095295af9b7ef51b306fc3c7df3843c26bfec320da42bbf16f"},"dob":"1952-01-13 15:26:37","registered":"2014-04-04 13:07:34","phone":"01-5269-1704","cell":"0492-962-203","id":{"name":"TFN","value":"599157736"},"picture":{"large":"https://randomuser.me/api/portraits/women/13.jpg","medium":"https://randomuser.me/api/portraits/med/women/13.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/13.jpg"},"nat":"AU"},{"gender":"female","name":{"title":"mrs","first":"donna","last":"murphy"},"location":{"street":"8988 north road","city":"greystones","state":"kilkenny","postcode":53668},"email":"donna.murphy@example.com","login":{"username":"silverkoala656","password":"33333333","salt":"wWUk5zn2","md5":"9172751676aa17561fdd3174dd4c9326","sha1":"5c073d90d70e6e72c70e8c08dde95734cdad840e","sha256":"6fe569f123ad796a7ff0649f542a2333ccacd92bd70ca6d52d64cd317cdd0a33"},"dob":"1969-09-12 21:14:56","registered":"2012-06-10 23:04:02","phone":"061-625-5539","cell":"081-759-2651","id":{"name":"PPS","value":"7736182T"},"picture":{"large":"https://randomuser.me/api/portraits/women/68.jpg","medium":"https://randomuser.me/api/portraits/med/women/68.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/68.jpg"},"nat":"IE"},{"gender":"female","name":{"title":"mademoiselle","first":"marion","last":"faure"},"location":{"street":"4623 rue de l'abbé-roger-derry","city":"gollion","state":"bern","postcode":1333},"email":"marion.faure@example.com","login":{"username":"yellowbear160","password":"747474","salt":"ZIx9zcNs","md5":"15969c42e7eaf5f2ce0492b86a165393","sha1":"7c578c86f87b28a0f396a0f14d1ce3a870b31d16","sha256":"fe1bc95f4fdbb789c80d5912c576074e03fa3ab509a6de3e6fe9ad256d67233d"},"dob":"1946-02-10 21:20:20","registered":"2005-03-07 01:57:56","phone":"(805)-136-2619","cell":"(066)-198-2825","id":{"name":"AVS","value":"756.TAAE.FXMI.39"},"picture":{"large":"https://randomuser.me/api/portraits/women/20.jpg","medium":"https://randomuser.me/api/portraits/med/women/20.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/20.jpg"},"nat":"CH"},{"gender":"female","name":{"title":"ms","first":"carmen","last":"ellis"},"location":{"street":"90 bruce st","city":"hobart","state":"south australia","postcode":7153},"email":"carmen.ellis@example.com","login":{"username":"smallrabbit833","password":"berkeley","salt":"wWW0PXS7","md5":"16cdf0978f51bcede9fe63fbf52c7744","sha1":"99bf5b7219f65c2f19a6e1b7bfe9642a8c6465a4","sha256":"d96f60965aedf1f82f2b59b1789e32230bd26495a1b7257df8efc1e92bcf537d"},"dob":"1960-12-15 09:15:17","registered":"2007-06-30 17:58:06","phone":"02-5809-6469","cell":"0428-863-957","id":{"name":"TFN","value":"346283503"},"picture":{"large":"https://randomuser.me/api/portraits/women/85.jpg","medium":"https://randomuser.me/api/portraits/med/women/85.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/85.jpg"},"nat":"AU"},{"gender":"female","name":{"title":"miss","first":"emma","last":"gregory"},"location":{"street":"8541 mill road","city":"birmingham","state":"berkshire","postcode":"T3E 2XL"},"email":"emma.gregory@example.com","login":{"username":"goldenrabbit210","password":"cameron1","salt":"UvELNRRe","md5":"0db2f9fd269b3c43d3b906f46ebf5d78","sha1":"4881220c87d85434553f31be9d79893a1f8e35fa","sha256":"a6349fffe4c01b245d2d7107059dac008711174fd88201c908736dedc3b6448e"},"dob":"1947-05-04 06:23:14","registered":"2015-03-16 01:40:15","phone":"016977 97466","cell":"0795-686-594","id":{"name":"NINO","value":"PL 94 41 63 V"},"picture":{"large":"https://randomuser.me/api/portraits/women/82.jpg","medium":"https://randomuser.me/api/portraits/med/women/82.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/82.jpg"},"nat":"GB"},{"gender":"female","name":{"title":"miss","first":"debbie","last":"gilbert"},"location":{"street":"7338 grange road","city":"letterkenny","state":"tipperary","postcode":52466},"email":"debbie.gilbert@example.com","login":{"username":"heavygoose267","password":"coventry","salt":"NS7ZrqkE","md5":"775729a0cf119f1c32fdab5bf93e1927","sha1":"15052613cdce515dc8e1fccaa0c9f9a584e48faa","sha256":"4cb3c00151faf26ed85e9ba61f22376973e755f5219633b0247a69e7ef22bc01"},"dob":"1987-02-05 19:15:58","registered":"2005-10-14 13:25:45","phone":"031-061-6514","cell":"081-331-8027","id":{"name":"PPS","value":"9152780T"},"picture":{"large":"https://randomuser.me/api/portraits/women/69.jpg","medium":"https://randomuser.me/api/portraits/med/women/69.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/69.jpg"},"nat":"IE"},{"gender":"female","name":{"title":"ms","first":"charlene","last":"miller"},"location":{"street":"2718 oak lawn ave","city":"chesapeake","state":"rhode island","postcode":16733},"email":"charlene.miller@example.com","login":{"username":"bluetiger146","password":"older","salt":"tCyeeeyO","md5":"d567deed2bd685e872c7672844d416cc","sha1":"35dd6ce7ab488a5b1a4eed2d136860c8a0e1d5dd","sha256":"c5eb257b13d5bef4ba16cd7fd7189c1c7d0e09967a44d80ebc14e12e9aa73d10"},"dob":"1982-02-22 18:45:05","registered":"2010-06-04 18:47:51","phone":"(048)-109-3917","cell":"(867)-929-6436","id":{"name":"SSN","value":"172-89-2931"},"picture":{"large":"https://randomuser.me/api/portraits/women/93.jpg","medium":"https://randomuser.me/api/portraits/med/women/93.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/93.jpg"},"nat":"US"},{"gender":"male","name":{"title":"mr","first":"آرمین","last":"نكو نظر"},"location":{"street":"5745 میرزای شیرازی","city":"ساری","state":"سمنان","postcode":68243},"email":"آرمین.نكونظر@example.com","login":{"username":"heavymouse181","password":"daewoo","salt":"lAREYXd7","md5":"0cfffb14d991d31eeb27dc04734c2595","sha1":"65a84f96764d5abf58ffcc0df42ec8632a51cb05","sha256":"adad007af1894f671f58d841321a8db8ca4cd44073c3d1d1f443bf737b49da94"},"dob":"1981-12-12 14:53:24","registered":"2007-04-02 21:59:32","phone":"089-20174880","cell":"0999-186-4226","id":{"name":"","value":null},"picture":{"large":"https://randomuser.me/api/portraits/men/90.jpg","medium":"https://randomuser.me/api/portraits/med/men/90.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/men/90.jpg"},"nat":"IR"},{"gender":"female","name":{"title":"miss","first":"sheila","last":"dean"},"location":{"street":"8647 robinson rd","city":"grants pass","state":"nevada","postcode":48562},"email":"sheila.dean@example.com","login":{"username":"smallelephant691","password":"qian","salt":"M7a9TwKA","md5":"0814e040674d1081eb2f690a1b0d4ef3","sha1":"47dbb30166d7af1c35eb5ad45106bd328b5f095e","sha256":"5c687ab2e9a13108c280467f4ef20c092a49d9b5be722c586e9a40db708e1bf9"},"dob":"1991-07-13 13:12:06","registered":"2008-11-27 02:19:50","phone":"(877)-584-7016","cell":"(981)-462-9845","id":{"name":"SSN","value":"295-99-5901"},"picture":{"large":"https://randomuser.me/api/portraits/women/24.jpg","medium":"https://randomuser.me/api/portraits/med/women/24.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/24.jpg"},"nat":"US"},{"gender":"female","name":{"title":"miss","first":"olivia","last":"hein"},"location":{"street":"9022 mühlenweg","city":"holzminden","state":"thüringen","postcode":65296},"email":"olivia.hein@example.com","login":{"username":"orangeleopard758","password":"excite","salt":"QdkX2c25","md5":"68052177fb719d2e57a52da40318e2fe","sha1":"cb00df46d06887e0732b3ff94bba37ed33413a64","sha256":"89147b5a4ee19723cc367fc2b7f0a4a482ce692076111bd48212b88a6d74873a"},"dob":"1988-05-02 15:15:45","registered":"2010-01-24 00:08:23","phone":"0601-9202186","cell":"0179-7462091","id":{"name":"","value":null},"picture":{"large":"https://randomuser.me/api/portraits/women/26.jpg","medium":"https://randomuser.me/api/portraits/med/women/26.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/26.jpg"},"nat":"DE"},{"gender":"male","name":{"title":"mr","first":"mathias","last":"johansen"},"location":{"street":"2875 gyvelvej","city":"askeby","state":"midtjylland","postcode":88770},"email":"mathias.johansen@example.com","login":{"username":"bigmeercat405","password":"hughes","salt":"tRVWblrQ","md5":"69220f7f2fda0a922024babf5c71f548","sha1":"64264d1f3703fdb529e4d7e2a2ce4c7bfe5e4ea6","sha256":"ad73321a24ddf3c9cc9b23e056c97e50b006cfde75c8a1f1fbb3d06e5ab04b82"},"dob":"1957-04-07 17:58:27","registered":"2003-05-10 09:57:39","phone":"13309317","cell":"70639222","id":{"name":"CPR","value":"197517-4938"},"picture":{"large":"https://randomuser.me/api/portraits/men/35.jpg","medium":"https://randomuser.me/api/portraits/med/men/35.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/men/35.jpg"},"nat":"DK"}],"info":{"seed":"b13cc7728ab0cf73","results":13,"page":1,"version":"1.1"}} 2 | -------------------------------------------------------------------------------- /test/muuntaja/interceptor_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.interceptor-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.core :as m] 4 | [muuntaja.interceptor :as interceptor] 5 | [sieppari.core] 6 | [muuntaja.core])) 7 | 8 | (defn echo [request] 9 | {:status 200 10 | :body (:body-params request)}) 11 | 12 | (defn async-echo [request respond _] 13 | (respond 14 | {:status 200 15 | :body (:body-params request)})) 16 | 17 | (defn ->request [content-type accept accept-charset body] 18 | {:headers {"content-type" content-type 19 | "accept-charset" accept-charset 20 | "accept" accept} 21 | :body body}) 22 | 23 | (defn execute [interceptors request] 24 | (sieppari.core/execute interceptors request)) 25 | 26 | (deftest interceptor-test 27 | (let [m (m/create) 28 | data {:kikka 42} 29 | edn-string (slurp (m/encode m "application/edn" data)) 30 | json-string (slurp (m/encode m "application/json" data))] 31 | 32 | (testing "multiple way to initialize the interceptor" 33 | (let [request (->request "application/edn" "application/edn" nil edn-string)] 34 | (is (= "{:kikka 42}" edn-string)) 35 | (are [interceptors] 36 | (= edn-string (some-> (execute (conj interceptors echo) request) :body slurp)) 37 | 38 | ;; without arguments 39 | [(interceptor/format-interceptor)] 40 | 41 | ;; with default options 42 | [(interceptor/format-interceptor m/default-options)] 43 | 44 | ;; with compiled muuntaja 45 | [(interceptor/format-interceptor m)] 46 | 47 | ;; without arguments 48 | [(interceptor/format-negotiate-interceptor) 49 | (interceptor/format-response-interceptor) 50 | (interceptor/format-request-interceptor)] 51 | 52 | ;; with default options 53 | [(interceptor/format-negotiate-interceptor m/default-options) 54 | (interceptor/format-response-interceptor m/default-options) 55 | (interceptor/format-request-interceptor m/default-options)] 56 | 57 | ;; with compiled muuntaja 58 | [(interceptor/format-negotiate-interceptor m) 59 | (interceptor/format-response-interceptor m) 60 | (interceptor/format-request-interceptor m)]))) 61 | 62 | (testing "with defaults" 63 | (let [interceptors [(interceptor/format-interceptor) echo]] 64 | 65 | (testing "symmetric request decode + response encode" 66 | (are [format] 67 | (let [payload (m/encode m format data) 68 | decode (partial m/decode m format)] 69 | (= data (->> (execute interceptors (->request format format nil payload)) 70 | :body 71 | decode))) 72 | "application/json" 73 | "application/edn" 74 | ;"application/x-yaml" 75 | ;"application/msgpack" 76 | "application/transit+json" 77 | "application/transit+msgpack")) 78 | 79 | (testing "content-type & accept" 80 | (let [call (fn [content-type accept] 81 | (some-> (execute interceptors (->request content-type accept nil json-string)) 82 | :body 83 | slurp))] 84 | 85 | (is (= "{\"kikka\":42}" json-string)) 86 | 87 | (testing "with content-type & accept" 88 | (is (= json-string (call "application/json" "application/json")))) 89 | 90 | (testing "without accept, :default-format is used in encode" 91 | (is (= json-string (call "application/json" nil)))) 92 | 93 | (testing "without content-type, body is not parsed" 94 | (is (= nil (call nil nil)))) 95 | 96 | (testing "different json content-type (regexp match) - don't match by default" 97 | (are [content-type] 98 | (nil? (call content-type nil)) 99 | "application/json-patch+json" 100 | "application/vnd.foobar+json" 101 | "application/schema+json"))) 102 | (testing "different content-type & accept" 103 | (let [edn-string (slurp (m/encode m "application/edn" data)) 104 | json-string (slurp (m/encode m "application/json" data)) 105 | request (->request "application/edn" "application/json" nil edn-string)] 106 | (is (= json-string (some-> (execute interceptors request) :body slurp)))))))) 107 | 108 | (testing "with regexp matchers" 109 | (let [interceptors [(interceptor/format-interceptor 110 | (assoc-in 111 | m/default-options 112 | [:formats "application/json" :matches] 113 | #"^application/(.+\+)?json$")) 114 | echo]] 115 | 116 | (testing "content-type & accept" 117 | (let [call (fn [content-type accept] 118 | (some-> (execute interceptors (->request content-type accept nil json-string)) 119 | :body 120 | slurp))] 121 | (is (= "{\"kikka\":42}" json-string)) 122 | 123 | (testing "different json content-type (regexp match)" 124 | (are [content-type] 125 | (= json-string (call content-type nil)) 126 | "application/json" 127 | "application/json-patch+json" 128 | "application/vnd.foobar+json" 129 | "application/schema+json")) 130 | 131 | (testing "missing the regexp match" 132 | (are [content-type] 133 | (nil? (call content-type nil)) 134 | "application/jsonz" 135 | "applicationz/+json")))))) 136 | 137 | (testing "without :default-format & valid accept format, response format negotiation fails" 138 | (let [interceptors [(interceptor/format-interceptor 139 | (dissoc m/default-options :default-format)) 140 | echo]] 141 | (try 142 | (let [response (-> (execute interceptors (->request "application/json" nil nil json-string)) :body slurp)] 143 | (is (= response ::invalid))) 144 | (catch Exception e 145 | (is (= (-> e ex-data :type) :muuntaja/response-format-negotiation)))))) 146 | 147 | (testing "without :default-charset" 148 | 149 | (testing "without valid request charset, request charset negotiation fails" 150 | (let [interceptors [(interceptor/format-interceptor 151 | (dissoc m/default-options :default-charset)) 152 | echo]] 153 | (try 154 | (let [response (-> (execute interceptors (->request "application/json" nil nil json-string)) :body slurp)] 155 | (is (= response ::invalid))) 156 | (catch Exception e 157 | (is (= (-> e ex-data :type) :muuntaja/request-charset-negotiation)))))) 158 | 159 | (testing "without valid accept charset, response charset negotiation fails" 160 | (let [interceptors [(interceptor/format-interceptor 161 | (dissoc m/default-options :default-charset)) 162 | echo]] 163 | (try 164 | (let [response (-> (execute interceptors (->request "application/json; charset=utf-8" nil nil json-string)) :body slurp)] 165 | (is (= response ::invalid))) 166 | (catch Exception e 167 | (is (= (-> e ex-data :type) :muuntaja/response-charset-negotiation))))))) 168 | 169 | (testing "runtime options for encoding & decoding" 170 | (testing "forcing a content-type on a handler (bypass negotiate)" 171 | (let [echo-edn (fn [request] 172 | {:status 200 173 | :muuntaja/content-type "application/edn" 174 | :body (:body-params request)}) 175 | interceptors [(interceptor/format-interceptor) echo-edn] 176 | request (->request "application/json" "application/json" nil "{\"kikka\":42}") 177 | response (execute interceptors request)] 178 | (is (= "{:kikka 42}" (-> response :body slurp))) 179 | (is (not (contains? response :muuntaja/content-type))) 180 | (is (= "application/edn; charset=utf-8" (get-in response [:headers "Content-Type"])))))) 181 | 182 | (testing "different bodies" 183 | (let [m (m/create (assoc-in m/default-options [:http :encode-response-body?] (constantly true))) 184 | interceptors [(interceptor/format-interceptor m) echo] 185 | edn-request #(->request "application/edn" "application/edn" nil (pr-str %)) 186 | e2e #(m/decode m "application/edn" (:body (execute interceptors (edn-request %))))] 187 | (are [primitive] 188 | (is (= primitive (e2e primitive))) 189 | 190 | [:a 1] 191 | {:a 1} 192 | "kikka" 193 | :kikka 194 | true 195 | false 196 | nil 197 | 1))))) 198 | 199 | (deftest params-interceptor-test 200 | (let [interceptors [(interceptor/params-interceptor) identity]] 201 | (is (= {:params {:a 1, :b {:c 1}} 202 | :body-params {:b {:c 1}}} 203 | (execute interceptors {:params {:a 1} 204 | :body-params {:b {:c 1}}}))) 205 | (is (= {:params {:b {:c 1}} 206 | :body-params {:b {:c 1}}} 207 | (execute interceptors {:body-params {:b {:c 1}}}))) 208 | (is (= {:body-params [1 2 3]} 209 | (execute interceptors {:body-params [1 2 3]}))))) 210 | 211 | (deftest exceptions-interceptor-test 212 | (let [->handler (fn [type] 213 | (fn [_] 214 | (condp = type 215 | :decode (throw (ex-info "kosh" {:type :muuntaja/decode})) 216 | :runtime (throw (RuntimeException.)) 217 | :return nil))) 218 | interceptors (fn [handler] [(interceptor/exception-interceptor) handler])] 219 | (is (nil? (execute (interceptors (->handler :return)) {}))) 220 | (is (thrown? RuntimeException (execute (interceptors (->handler :runtime)) {}))) 221 | (is (= 400 (:status (execute (interceptors (->handler :decode)) {})))))) 222 | -------------------------------------------------------------------------------- /test/muuntaja/ring_middleware/format_params_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.ring-middleware.format-params-test 2 | (:require [clojure.test :refer :all] 3 | [cognitect.transit :as transit] 4 | [clojure.walk :refer [stringify-keys keywordize-keys]] 5 | [muuntaja.format.msgpack :as msgpack-format] 6 | [muuntaja.format.yaml :as yaml-format] 7 | [msgpack.core :as msgpack] 8 | [clojure.string :as string] 9 | [muuntaja.core :as m] 10 | [muuntaja.middleware :as middleware] 11 | [clojure.java.io :as io]) 12 | (:import [java.io ByteArrayInputStream ByteArrayOutputStream])) 13 | 14 | (defn stream [s] 15 | (ByteArrayInputStream. (.getBytes s "UTF-8"))) 16 | 17 | (def default-options 18 | (-> m/default-options 19 | (m/install msgpack-format/format) 20 | (m/install yaml-format/format) 21 | (assoc-in [:formats "application/json" :matches] #"^application/(.+\+)?json$") 22 | (assoc-in [:formats "application/edn" :matches] #"^application/(vnd.+)?(x-)?(clojure|edn)$") 23 | (assoc-in [:formats "application/msgpack" :matches] #"^application/(vnd.+)?(x-)?msgpack$") 24 | (assoc-in [:formats "application/x-yaml" :matches] #"^(application|text)/(vnd.+)?(x-)?yaml$") 25 | (assoc-in [:formats "application/transit+json" :matches] #"^application/(vnd.+)?(x-)?transit\+json$") 26 | (assoc-in [:formats "application/transit+msgpack" :matches] #"^application/(vnd.+)?(x-)?transit\+msgpack$") 27 | (m/transform-formats 28 | #(dissoc %2 :encoder)))) 29 | 30 | (defn wrap-api-params 31 | ([handler] 32 | (wrap-api-params handler default-options)) 33 | ([handler opts] 34 | (-> handler 35 | (middleware/wrap-params) 36 | (middleware/wrap-format opts)))) 37 | 38 | (defn key-fn [s] 39 | (-> s (string/replace #"_" "-") keyword)) 40 | 41 | (deftest json-options-test 42 | (is (= {:foo-bar "bar"} 43 | (:body-params ((wrap-api-params 44 | identity 45 | (-> default-options 46 | (m/select-formats ["application/json"]) 47 | (assoc-in 48 | [:formats "application/json" :decoder-opts] 49 | {:decode-key-fn key-fn}))) 50 | {:headers {"content-type" "application/json"} 51 | :body (stream "{\"foo_bar\":\"bar\"}")})))) 52 | (is (= {:foo-bar "bar"} 53 | (:body-params ((wrap-api-params 54 | identity 55 | (-> default-options 56 | (assoc-in 57 | [:formats "application/json" :decoder-opts] 58 | {:decode-key-fn key-fn}))) 59 | {:headers {"content-type" "application/json"} 60 | :body (stream "{\"foo_bar\":\"bar\"}")}))))) 61 | 62 | (defn yaml-echo [opts] 63 | (-> identity 64 | (wrap-api-params 65 | (-> default-options 66 | (m/select-formats ["application/x-yaml"]) 67 | (assoc-in 68 | [:formats "application/x-yaml" :decoder-opts] 69 | opts))))) 70 | 71 | (deftest augments-with-yaml-content-type 72 | (let [req {:headers {"content-type" "application/x-yaml; charset=UTF-8"} 73 | :body (stream "foo: bar") 74 | :params {"id" 3}} 75 | resp ((yaml-echo {:keywords false}) req)] 76 | (is (= {"id" 3 "foo" "bar"} (:params resp))) 77 | (is (= {"foo" "bar"} (:body-params resp))))) 78 | 79 | (def yaml-kw-echo 80 | (-> identity 81 | (wrap-api-params 82 | (-> default-options 83 | (m/select-formats ["application/x-yaml"]) 84 | (assoc-in 85 | [:formats "application/x-yaml" :decoder-opts] 86 | {:keywords true}))))) 87 | 88 | (deftest augments-with-yaml-kw-content-type 89 | (let [req {:headers {"content-type" "application/x-yaml; charset=UTF-8"} 90 | :body (stream "foo: bar") 91 | :params {"id" 3}} 92 | resp (yaml-kw-echo req)] 93 | (is (= {"id" 3 :foo "bar"} (:params resp))) 94 | (is (= {:foo "bar"} (:body-params resp))))) 95 | 96 | (def msgpack-echo 97 | (-> identity 98 | (wrap-api-params 99 | (-> default-options 100 | (m/select-formats ["application/msgpack"]) 101 | (assoc-in 102 | [:formats "application/msgpack" :decoder-opts] 103 | {:keywords? false}))))) 104 | 105 | (deftest augments-with-msgpack-content-type 106 | (let [req {:headers {"content-type" "application/msgpack"} 107 | :body (ByteArrayInputStream. (msgpack/pack (stringify-keys {:foo "bar"}))) 108 | :params {"id" 3}} 109 | resp (msgpack-echo req)] 110 | (is (= {"id" 3 "foo" "bar"} (:params resp))) 111 | (is (= {"foo" "bar"} (:body-params resp))))) 112 | 113 | (def msgpack-kw-echo 114 | (-> identity 115 | (wrap-api-params 116 | (-> default-options 117 | (m/select-formats ["application/msgpack"]))))) 118 | 119 | (deftest augments-with-msgpack-kw-content-type 120 | (let [req {:headers {"content-type" "application/msgpack"} 121 | :body (ByteArrayInputStream. (msgpack/pack (stringify-keys {:foo "bar"}))) 122 | :params {"id" 3}} 123 | resp (msgpack-kw-echo req)] 124 | (is (= {"id" 3 :foo "bar"} (:params resp))) 125 | (is (= {:foo "bar"} (:body-params resp))))) 126 | 127 | (def clojure-echo 128 | (-> identity 129 | (wrap-api-params 130 | (-> default-options 131 | (m/select-formats ["application/edn"]))))) 132 | 133 | (deftest augments-with-clojure-content-type 134 | (let [req {:headers {"content-type" "application/clojure; charset=UTF-8"} 135 | :body (stream "{:foo \"bar\"}") 136 | :params {"id" 3}} 137 | resp (clojure-echo req)] 138 | (is (= {"id" 3 :foo "bar"} (:params resp))) 139 | (is (= {:foo "bar"} (:body-params resp))))) 140 | 141 | (deftest augments-with-clojure-content-prohibit-eval-in-reader 142 | (let [req {:headers {"content-type" "application/clojure; charset=UTF-8"} 143 | :body (stream "{:foo #=(java.util.Date.)}") 144 | :params {"id" 3}}] 145 | (try 146 | (clojure-echo req) 147 | (is false "Eval in reader permits arbitrary code execution.") 148 | (catch Exception _ 149 | (is true))))) 150 | 151 | (deftest no-body-with-clojure-content-type 152 | (let [req {:content-type "application/clojure; charset=UTF-8" 153 | :body (stream "") 154 | :params {"id" 3}} 155 | resp (clojure-echo req)] 156 | (is (= {"id" 3} (:params resp))) 157 | (is (= nil (:body-params resp))))) 158 | 159 | (deftest whitespace-body-with-clojure-content-type 160 | (let [req {:content-type "application/clojure; charset=UTF-8" 161 | :body (stream "\t ") 162 | :params {"id" 3}} 163 | resp (clojure-echo req)] 164 | (is (= {"id" 3} (:params resp))) 165 | (is (= nil (:body-params resp))))) 166 | 167 | ;; Transit 168 | 169 | (defn stream-transit 170 | [fmt data] 171 | (let [out (ByteArrayOutputStream.) 172 | wrt (transit/writer out fmt)] 173 | (transit/write wrt data) 174 | (io/input-stream (.toByteArray out)))) 175 | 176 | (def transit-json-echo 177 | (-> identity 178 | (wrap-api-params 179 | (-> default-options 180 | (m/select-formats ["application/transit+json"]))))) 181 | 182 | (deftest augments-with-transit-json-content-type 183 | (let [req {:headers {"content-type" "application/transit+json"} 184 | :body (stream-transit :json {:foo "bar"}) 185 | :params {"id" 3}} 186 | resp (transit-json-echo req)] 187 | (is (= {"id" 3 :foo "bar"} (:params resp))) 188 | (is (= {:foo "bar"} (:body-params resp))))) 189 | 190 | (def transit-msgpack-echo 191 | (-> identity 192 | (wrap-api-params 193 | (-> default-options 194 | (m/select-formats ["application/transit+msgpack"]))))) 195 | 196 | (deftest augments-with-transit-msgpack-content-type 197 | (let [req {:headers {"content-type" "application/transit+msgpack"} 198 | :body (stream-transit :msgpack {:foo "bar"}) 199 | :params {"id" 3}} 200 | resp (transit-msgpack-echo req)] 201 | (is (= {"id" 3 :foo "bar"} (:params resp))) 202 | (is (= {:foo "bar"} (:body-params resp))))) 203 | 204 | ;; HTTP Params 205 | 206 | (def api-echo 207 | (-> identity 208 | (wrap-api-params))) 209 | 210 | (def safe-api-echo 211 | (-> identity 212 | (wrap-api-params) 213 | (middleware/wrap-exception (constantly {:status 500})))) 214 | 215 | (deftest test-api-params-wrapper 216 | (let [req {:headers {"content-type" "application/clojure; charset=UTF-8"} 217 | :body (stream "{:foo \"bar\"}") 218 | :params {"id" 3}} 219 | resp (api-echo req)] 220 | (is (= {"id" 3 :foo "bar"} (:params resp))) 221 | (is (= {:foo "bar"} (:body-params resp))) 222 | (is (= 500 (get (safe-api-echo (assoc req :body (stream "{:foo \"bar}"))) :status))))) 223 | 224 | (defn stream-iso [s] 225 | (ByteArrayInputStream. (.getBytes s "ISO-8859-1"))) 226 | 227 | (deftest test-different-params-charset 228 | #_(testing "with fixed charset" 229 | (let [req {:headers {"content-type" "application/clojure; charset=ISO-8859-1"} 230 | :body (stream-iso "{:fée \"böz\"}") 231 | :params {"id" 3}} 232 | app (-> identity 233 | (wrap-api-params)) 234 | resp (app req)] 235 | (is (not= {"id" 3 :fée "böz"} (:params resp))) 236 | (is (not= {:fée "böz"} (:body-params resp))))) 237 | (testing "with fixed charset" 238 | (let [req {:headers {"content-type" "application/clojure; charset=ISO-8859-1"} 239 | :body (stream-iso "{:fée \"böz\"}") 240 | :params {"id" 3}} 241 | app (-> identity 242 | (wrap-api-params 243 | (assoc default-options :charsets m/available-charsets))) 244 | resp (app req)] 245 | (is (= {"id" 3 :fée "böz"} (:params resp))) 246 | (is (= {:fée "böz"} (:body-params resp)))))) 247 | 248 | (deftest test-list-body-request 249 | (let [req {:headers {"content-type" "application/json"} 250 | :body (ByteArrayInputStream. 251 | (.getBytes "[\"gregor\", \"samsa\"]"))}] 252 | ((wrap-api-params 253 | (fn [{:keys [body-params]}] (is (= ["gregor" "samsa"] body-params)))) 254 | req))) 255 | 256 | (deftest test-optional-body 257 | ((wrap-api-params 258 | (fn [request] 259 | (is (nil? (:body request))))) 260 | {:body nil})) 261 | 262 | (deftest test-custom-handle-error 263 | (are [format content-type body] 264 | (let [req {:body body 265 | :headers {"content-type" content-type}} 266 | resp ((-> identity 267 | (wrap-api-params 268 | (-> default-options 269 | (m/select-formats [format]))) 270 | (middleware/wrap-exception (constantly {:status 999}))) 271 | req)] 272 | (= 999 (:status resp))) 273 | "application/json" "application/json" "{:a 1}" 274 | "application/edn" "application/edn" "{\"a\": 1}")) 275 | 276 | ;; Transit options 277 | 278 | (defrecord Point [x y]) 279 | 280 | (def readers 281 | {"Point" (transit/read-handler (fn [[x y]] (Point. x y)))}) 282 | 283 | (def custom-transit-json-echo 284 | (-> identity 285 | (wrap-api-params 286 | (-> default-options 287 | (m/select-formats ["application/transit+json"]) 288 | (assoc-in 289 | [:formats "application/transit+json" :decoder-opts] 290 | {:handlers readers}))))) 291 | 292 | (def transit-body "[\"^ \", \"~:p\", [\"~#Point\",[1,2]]]") 293 | 294 | (deftest read-custom-transit 295 | (testing "wrap-api-params, transit options" 296 | (let [req (custom-transit-json-echo {:headers {"content-type" "application/transit+json"} 297 | :body (stream transit-body)})] 298 | (is (= {:p (Point. 1 2)} 299 | (:params req) 300 | (:body-params req)))))) 301 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Muuntaja 2 | 3 | [![Build Status](https://github.com/metosin/muuntaja/actions/workflows/test.yml/badge.svg)](https://github.com/metosin/muuntaja/actions) 4 | [![cljdoc badge](https://cljdoc.org/badge/metosin/muuntaja)](https://cljdoc.org/jump/release/metosin/muuntaja) 5 | [![Clojars Project](https://img.shields.io/clojars/v/metosin/muuntaja.svg)](https://clojars.org/metosin/muuntaja) 6 | 7 | 8 | 9 | Clojure library for fast HTTP format negotiation, encoding and decoding. Standalone library, but ships with adapters for ring (async) middleware & Pedestal-style interceptors. Explicit & extendable, supporting out-of-the-box [JSON](http://www.json.org/), [EDN](https://github.com/edn-format/edn) and [Transit](https://github.com/cognitect/transit-format) (both JSON & Msgpack). Ships with optional adapters for [MessagePack](http://msgpack.org/) and [YAML](http://yaml.org/). 10 | 11 | Based on [ring-middleware-format](https://github.com/ngrunwald/ring-middleware-format), 12 | but a complete rewrite ([and up to 30x faster](doc/Performance.md)). 13 | 14 | > Hi! We are [Metosin](https://metosin.fi), a consulting company. These libraries have evolved out of the work we do for our clients. 15 | > We maintain & develop this project, for you, for free. Issues and pull requests welcome! 16 | > However, if you want more help using the libraries, or want us to build something as cool for you, consider our [commercial support](https://www.metosin.fi/en/open-source-support). 17 | 18 | ## Rationale 19 | 20 | - explicit configuration 21 | - fast with good defaults 22 | - extendable & pluggable: new formats, behavior 23 | - typed exceptions - caught elsewhere 24 | - support runtime docs (like swagger) & inspection (negotiation results) 25 | - support runtime configuration (negotiation overrides) 26 | 27 | ## Modules 28 | 29 | * `metosin/muuntaja` - the core abstractions + [Jsonista JSON](https://github.com/metosin/jsonista), EDN and Transit formats 30 | * `metosin/muuntaja-cheshire` - optional [Cheshire JSON](https://github.com/dakrone/cheshire) format 31 | * `metosin/muuntaja-charred` - optional [Charred](https://github.com/cnuernber/charred) format 32 | * `metosin/muuntaja-form` - optional `application/x-www-form-urlencoded` formatter using [ring-codec](https://github.com/ring-clojure/ring-codec) 33 | * `metosin/muuntaja-msgpack` - Messagepack format 34 | * `metosin/muuntaja-yaml` - YAML format 35 | 36 | ## Posts 37 | 38 | * [Muuntaja, a boring library everyone should use](https://www.metosin.fi/blog/muuntaja/) 39 | 40 | Check [the docs on cljdoc.org](https://cljdoc.org/d/metosin/muuntaja) 41 | for detailed API documentation as well as more guides on how to use Muuntaja. 42 | 43 | ## Latest version 44 | 45 | ```clj 46 | [metosin/muuntaja "0.6.11"] 47 | ``` 48 | 49 | Optionally, the parts can be required separately: 50 | 51 | ```clj 52 | [metosin/muuntaja-form "0.6.11"] 53 | [metosin/muuntaja-cheshire "0.6.11"] 54 | [fi.metosin/muuntaja-charred "0.6.11"] 55 | [metosin/muuntaja-msgpack "0.6.11"] 56 | [metosin/muuntaja-yaml "0.6.11"] 57 | ``` 58 | 59 | Muuntaja requires Java 1.8+ 60 | 61 | ## Quickstart 62 | 63 | ### Standalone 64 | 65 | Use default Muuntaja instance to encode & decode JSON: 66 | 67 | ```clj 68 | (require '[muuntaja.core :as m]) 69 | 70 | (->> {:kikka 42} 71 | (m/encode "application/json")) 72 | ; => #object[java.io.ByteArrayInputStream] 73 | 74 | (->> {:kikka 42} 75 | (m/encode "application/json") 76 | slurp) 77 | ; => "{\"kikka\":42}" 78 | 79 | (->> {:kikka 42} 80 | (m/encode "application/json") 81 | (m/decode "application/json")) 82 | ; => {:kikka 42} 83 | ``` 84 | 85 | ### Ring 86 | 87 | Automatic decoding of request body and response body encoding based on `Content-Type`, `Accept` and `Accept-Charset` headers: 88 | 89 | ```clj 90 | (require '[muuntaja.middleware :as middleware]) 91 | 92 | (defn echo [request] 93 | {:status 200 94 | :body (:body-params request)}) 95 | 96 | ; with defaults 97 | (def app (middleware/wrap-format echo)) 98 | 99 | (def request 100 | {:headers 101 | {"content-type" "application/edn" 102 | "accept" "application/transit+json"} 103 | :body "{:kikka 42}"}) 104 | 105 | (app request) 106 | ; {:status 200, 107 | ; :body #object[java.io.ByteArrayInputStream] 108 | ; :headers {"Content-Type" "application/transit+json; charset=utf-8"}} 109 | ``` 110 | 111 | Automatic decoding of response body based on `Content-Type` header: 112 | 113 | ```clj 114 | (-> request app m/decode-response-body) 115 | ; {:kikka 42} 116 | ``` 117 | 118 | There is a more detailed [Ring guide](doc/With-Ring.md) too. See also [differences](doc/Differences-to-existing-formatters.md) to ring-middleware-format & ring-json. 119 | 120 | ### Reitit 121 | 122 | The [Reitit](https://github.com/metosin/reitit) routing library has Muuntaja integration. 123 | See the [Content Negotiation docs](https://github.com/metosin/reitit/blob/master/doc/ring/content_negotiation.md) for more info. 124 | 125 | ### Interceptors 126 | 127 | Muuntaja support [Sieppari](https://github.com/metosin/sieppari) -style interceptors too. See [`muuntaja.interceptor`](https://github.com/metosin/muuntaja/blob/master/modules/muuntaja/src/muuntaja/interceptor.clj) for details. 128 | 129 | Interceptors can be used with [Pedestal](http://pedestal.io/) too, all but the `exception-interceptor` which conforms to the simplified exception handling model of Sieppari. 130 | 131 | ### Configuration 132 | 133 | Explicit Muuntaja instance with custom EDN decoder options: 134 | 135 | ```clj 136 | (def m 137 | (m/create 138 | (assoc-in 139 | m/default-options 140 | [:formats "application/edn" :decoder-opts] 141 | {:readers {'INC inc}}))) 142 | 143 | (->> "{:value #INC 41}" 144 | (m/decode m "application/edn")) 145 | ; => {:value 42} 146 | ``` 147 | 148 | Explicit Muuntaja instance with custom date formatter: 149 | 150 | ```clj 151 | (def m 152 | (m/create 153 | (assoc-in 154 | m/default-options 155 | [:formats "application/json" :encoder-opts] 156 | {:date-format "yyyy-MM-dd"}))) 157 | 158 | (->> {:value (java.util.Date.)} 159 | (m/encode m "application/json") 160 | slurp) 161 | ; => "{\"value\":\"2019-10-15\"}" 162 | ``` 163 | 164 | Explicit Muuntaja instance with camelCase encode-key-fn: 165 | 166 | ```clj 167 | (require '[camel-snake-kebab.core :as csk]) 168 | 169 | (def m 170 | (m/create 171 | (assoc-in 172 | m/default-options 173 | [:formats "application/json" :encoder-opts] 174 | {:encode-key-fn csk/->camelCase}))) 175 | 176 | (->> {:some-property "some-value"} 177 | (m/encode m "application/json") 178 | slurp) 179 | ; => "{\":someProperty\":\"some-value\"}" 180 | ``` 181 | 182 | Returning a function to encode transit-json: 183 | 184 | ```clj 185 | (def encode-transit-json 186 | (m/encoder m "application/transit+json")) 187 | 188 | (slurp (encode-transit-json {:kikka 42})) 189 | ; => "[\"^ \",\"~:kikka\",42]" 190 | ``` 191 | 192 | ## Encoding format 193 | 194 | By default, `encode` writes value into a `java.io.ByteArrayInputStream`. This can be changed with a `:return` option, accepting the following values: 195 | 196 | | value | description 197 | |------------------|---------------------------------------------------------------------------------- 198 | | `:input-stream` | encodes into `java.io.ByteArrayInputStream` (default) 199 | | `:bytes` | encodes into `byte[]`. Faster than Stream, enables NIO for servers supporting it 200 | | `:output-stream` | encodes lazily into `java.io.OutputStream` via a callback function 201 | 202 | All return types satisfy the following Protocols & Interfaces: 203 | 204 | * `ring.protocols.StreamableResponseBody`, Ring 1.6.0+ will stream these for you 205 | * `clojure.io.IOFactory`, so you can slurp the response 206 | 207 | ### `:input-stream` 208 | 209 | ```clj 210 | (def m (m/create (assoc m/default-options :return :input-stream))) 211 | 212 | (->> {:kikka 42} 213 | (m/encode m "application/json")) 214 | ; #object[java.io.ByteArrayInputStream] 215 | ``` 216 | 217 | ### `:bytes` 218 | 219 | ```clj 220 | (def m (m/create (assoc m/default-options :return :bytes))) 221 | 222 | (->> {:kikka 42} 223 | (m/encode m "application/json")) 224 | ; #object["[B" 0x31f5d734 "[B@31f5d734"] 225 | ``` 226 | 227 | ### `:output-stream` 228 | 229 | ```clj 230 | (def m (m/create (assoc m/default-options :return :output-stream))) 231 | 232 | (->> {:kikka 42} 233 | (m/encode m "application/json")) 234 | ; <> 235 | ``` 236 | 237 | ### Format-based return 238 | 239 | ```clj 240 | (def m (m/create (assoc-in m/default-options [:formats "application/edn" :return] :output-stream))) 241 | 242 | (->> {:kikka 42} 243 | (m/encode m "application/json")) 244 | ; #object[java.io.ByteArrayInputStream] 245 | 246 | (->> {:kikka 42} 247 | (m/encode m "application/edn")) 248 | ; <> 249 | ``` 250 | 251 | ## HTTP format negotiation 252 | 253 | HTTP format negotiation is done using request headers for both request (`content-type`, including the charset) and response (`accept` and `accept-charset`). With the default options, a full match on the content-type is required, e.g. `application/json`. Adding a `:matches` regexp for formats enables more loose matching. See [Configuration docs](doc/Configuration.md#loose-matching-on-content-type) for more info. 254 | 255 | Results of the negotiation are published into request & response under namespaced keys for introspection. These keys can also be set manually, overriding the content negotiation process. 256 | 257 | ## Exceptions 258 | 259 | When something bad happens, an typed exception is thrown. You should handle it elsewhere. Thrown exceptions have an `ex-data` with the following `:type` value (plus extra info to enable generating descriptive erros to clients): 260 | 261 | * `:muuntaja/decode`, input can't be decoded with the negotiated `format` & `charset`. 262 | * `:muuntaja/request-charset-negotiation`, request charset is illegal. 263 | * `:muuntaja/response-charset-negotiation`, could not negotiate a charset for the response. 264 | * `:muuntaja/response-format-negotiation`, could not negotiate a format for the response. 265 | 266 | ## Server Spec 267 | 268 | ### Request 269 | 270 | * `:muuntaja/request`, client-negotiated request format and charset as `FormatAndCharset` record. Will 271 | be used in the request pipeline. 272 | * `:muuntaja/response`, client-negotiated response format and charset as `FormatAndCharset` record. Will 273 | be used in the response pipeline. 274 | * `:body-params` contains the decoded body. 275 | 276 | ### Response 277 | 278 | * `:muuntaja/encode`, if set to truthy value, the response body will be encoded regardles of the type (primitives!) 279 | * `:muuntaja/content-type`, handlers can use this to override the negotiated content-type for response encoding, e.g. setting it to `application/edn` will cause the response to be formatted in JSON. 280 | 281 | ## Options 282 | 283 | ### Default options 284 | 285 | ```clj 286 | {:http {:extract-content-type extract-content-type-ring 287 | :extract-accept-charset extract-accept-charset-ring 288 | :extract-accept extract-accept-ring 289 | :decode-request-body? (constantly true) 290 | :encode-response-body? encode-collections} 291 | 292 | :allow-empty-input? true 293 | :return :input-stream 294 | 295 | :default-charset "utf-8" 296 | :charsets available-charsets 297 | 298 | :default-format "application/json" 299 | :formats {"application/json" json-format/json-format 300 | "application/edn" edn-format/edn-format 301 | "application/transit+json" transit-format/transit-json-format 302 | "application/transit+msgpack" transit-format/transit-msgpack-format}} 303 | ``` 304 | 305 | ## Profiling 306 | 307 | 308 | 309 | YourKit supports open source projects with its full-featured Java Profiler. YourKit, LLC is the creator of YourKit Java Profiler and YourKit .NET Profiler, innovative and intelligent tools for profiling Java and .NET applications. 310 | 311 | ## License 312 | 313 | ### [Picture](https://commons.wikimedia.org/wiki/File:Oudin_coil_Turpain.png) 314 | 315 | By Unknown. The drawing is signed "E. Ducretet", indicating that the apparatus was made by Eugene Ducretet, a prominent Paris scientific instrument manufacturer and radio researcher. The drawing was undoubtedly originally from the Ducretet instrument catalog. [Public domain], via Wikimedia Commons. 316 | 317 | ### Original Code (ring-middleware-format) 318 | 319 | Copyright © 2011, 2012, 2013, 2014 Nils Grunwald
320 | Copyright © 2015, 2016 Juho Teperi 321 | 322 | ### This library 323 | 324 | Copyright © 2016-2020 [Metosin Oy](http://www.metosin.fi) 325 | 326 | Distributed under the Eclipse Public License 2.0. 327 | -------------------------------------------------------------------------------- /test/muuntaja/ring_json/json_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.ring-json.json-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.core :as m] 4 | [muuntaja.middleware :as middleware] 5 | [cheshire.parse] 6 | [ring.util.io :refer [string-input-stream]])) 7 | 8 | (def +handler+ (fn [{:keys [body-params body]}] 9 | {:body (or body-params body)})) 10 | 11 | ;; 12 | ;; create ring-json middlewares using muuntaja 13 | ;; 14 | 15 | (defn wrap-json-params 16 | ([handler] 17 | (wrap-json-params handler {})) 18 | ([handler opts] 19 | (-> handler 20 | (middleware/wrap-params) 21 | (middleware/wrap-format 22 | (-> m/default-options 23 | (m/select-formats 24 | ["application/json"]) 25 | (update-in 26 | [:formats "application/json"] 27 | merge 28 | {:matches #"^application/(.+\+)?json$" 29 | :encoder nil 30 | :decoder-opts (merge {:decode-key-fn false} opts)}))) 31 | (middleware/wrap-exception (constantly 32 | (or 33 | (:malformed-response opts) 34 | {:status 400 35 | :headers {"Content-Type" "text/plain"} 36 | :body "Malformed JSON in request body."})))))) 37 | 38 | (defn wrap-params [handler] 39 | (fn [request] 40 | (let [body-params (:body-params request)] 41 | (handler (assoc request :json-params body-params))))) 42 | 43 | (defn wrap-json-response 44 | ([handler] 45 | (wrap-json-response handler {})) 46 | ([handler opts] 47 | (-> handler 48 | (middleware/wrap-format 49 | (-> m/default-options 50 | (m/transform-formats #(dissoc %2 :decoder)) 51 | (assoc-in 52 | [:formats "application/json" :encoder-opts] 53 | opts))) 54 | (middleware/wrap-exception (constantly 55 | (or 56 | (:malformed-response opts) 57 | {:status 400 58 | :headers {"Content-Type" "text/plain"} 59 | :body "Malformed JSON in request body."})))))) 60 | 61 | ;; 62 | ;; tests 63 | ;; 64 | 65 | (deftest test-json-body 66 | (let [wrap (wrap-json-params +handler+)] 67 | (testing "xml body" 68 | (let [request {:headers {"content-type" "application/xml"} 69 | :body (string-input-stream "")} 70 | response (wrap request)] 71 | (is (= "" (slurp (:body response)))))) 72 | 73 | (testing "json body" 74 | (let [request {:headers {"content-type" "application/json; charset=UTF-8"} 75 | :body (string-input-stream "{\"foo\": \"bar\"}")} 76 | response (wrap request)] 77 | (is (= {"foo" "bar"} (:body response))))) 78 | 79 | (testing "custom json body" 80 | (let [request {:headers {"content-type" "application/vnd.foobar+json; charset=UTF-8"} 81 | :body (string-input-stream "{\"foo\": \"bar\"}")} 82 | response (wrap request)] 83 | (is (= {"foo" "bar"} (:body response))))) 84 | 85 | (testing "json patch body" 86 | (let [json-string "[{\"op\": \"add\",\"path\":\"/foo\",\"value\": \"bar\"}]" 87 | request {:headers {"content-type" "application/json-patch+json; charset=UTF-8"} 88 | :body (string-input-stream json-string)} 89 | response (wrap request)] 90 | (is (= [{"op" "add" "path" "/foo" "value" "bar"}] (:body response))))) 91 | 92 | (testing "malformed json" 93 | (let [request {:headers {"content-type" "application/json; charset=UTF-8"} 94 | :body (string-input-stream "{\"foo\": \"bar\"")}] 95 | (is (= (wrap request) 96 | {:status 400 97 | :headers {"Content-Type" "text/plain"} 98 | :body "Malformed JSON in request body."}))))) 99 | 100 | (let [handler (wrap-json-params +handler+ {:decode-key-fn true})] 101 | (testing "keyword keys" 102 | (let [request {:headers {"content-type" "application/json"} 103 | :body (string-input-stream "{\"foo\": \"bar\"}")} 104 | response (handler request)] 105 | (is (= {:foo "bar"} (:body response)))))) 106 | 107 | (let [handler (wrap-json-params +handler+ {:decode-key-fn true, :bigdecimals true})] 108 | (testing "bigdecimal floats" 109 | (let [request {:headers {"content-type" "application/json"} 110 | :body (string-input-stream "{\"foo\": 5.5}")} 111 | response (handler request)] 112 | (is (decimal? (-> response :body :foo))) 113 | (is (= {:foo 5.5M} (:body response)))))) 114 | 115 | (testing "custom malformed json" 116 | (let [malformed {:status 400 117 | :headers {"Content-Type" "text/html"} 118 | :body "Your JSON is wrong!"} 119 | handler (wrap-json-params +handler+ {:malformed-response malformed}) 120 | request {:headers {"content-type" "application/json"} 121 | :body (string-input-stream "{\"foo\": \"bar\"")}] 122 | (is (= (handler request) malformed)))) 123 | 124 | (let [handler (fn [_] {:status 200 :headers {} :body {:bigdecimals cheshire.parse/*use-bigdecimals?*}})] 125 | (testing "don't overwrite bigdecimal binding" 126 | (binding [cheshire.parse/*use-bigdecimals?* false] 127 | (let [response ((wrap-json-params handler {:bigdecimals true}) {})] 128 | (is (= (get-in response [:body :bigdecimals]) false)))) 129 | (binding [cheshire.parse/*use-bigdecimals?* true] 130 | (let [response ((wrap-json-params handler {:bigdecimals false}) {})] 131 | (is (= (get-in response [:body :bigdecimals]) true))))))) 132 | 133 | (deftest test-json-params 134 | (let [handler (-> identity wrap-params wrap-json-params)] 135 | (testing "xml body" 136 | (let [request {:headers {"content-type" "application/xml"} 137 | :body (string-input-stream "") 138 | :params {"id" 3}} 139 | response (handler request)] 140 | (is (= "" (slurp (:body response)))) 141 | (is (= {"id" 3} (:params response))) 142 | (is (nil? (:json-params response))))) 143 | 144 | (testing "json body" 145 | (let [request {:headers {"content-type" "application/json; charset=UTF-8"} 146 | :body (string-input-stream "{\"foo\": \"bar\"}") 147 | :params {"id" 3}} 148 | response (handler request)] 149 | (is (= {"id" 3, "foo" "bar"} (:params response))) 150 | (is (= {"foo" "bar"} (:json-params response))))) 151 | 152 | (testing "json body with bigdecimals" 153 | (let [handler (-> identity wrap-params (wrap-json-params {:bigdecimals true})) 154 | request {:headers {"content-type" "application/json; charset=UTF-8"} 155 | :body (string-input-stream "{\"foo\": 5.5}") 156 | :params {"id" 3}} 157 | response (handler request)] 158 | (is (decimal? (get-in response [:params "foo"]))) 159 | (is (decimal? (get-in response [:json-params "foo"]))) 160 | (is (= {"id" 3, "foo" 5.5M} (:params response))) 161 | (is (= {"foo" 5.5M} (:json-params response))))) 162 | 163 | (testing "custom json body" 164 | (let [request {:headers {"content-type" "application/vnd.foobar+json; charset=UTF-8"} 165 | :body (string-input-stream "{\"foo\": \"bar\"}") 166 | :params {"id" 3}} 167 | response (handler request)] 168 | (is (= {"id" 3, "foo" "bar"} (:params response))) 169 | (is (= {"foo" "bar"} (:json-params response))))) 170 | 171 | (testing "json schema body" 172 | (let [request {:headers {"content-type" "application/schema+json; charset=UTF-8"} 173 | :body (string-input-stream "{\"type\": \"schema\",\"properties\":{}}") 174 | :params {"id" 3}} 175 | response (handler request)] 176 | (is (= {"id" 3, "type" "schema", "properties" {}} (:params response))) 177 | (is (= {"type" "schema", "properties" {}} (:json-params response))))) 178 | 179 | (testing "array json body" 180 | (let [request {:headers {"content-type" "application/vnd.foobar+json; charset=UTF-8"} 181 | :body (string-input-stream "[\"foo\"]") 182 | :params {"id" 3}} 183 | response (handler request)] 184 | (is (= {"id" 3} (:params response))))) 185 | 186 | (testing "malformed json" 187 | (let [request {:headers {"content-type" "application/json; charset=UTF-8"} 188 | :body (string-input-stream "{\"foo\": \"bar\"")}] 189 | (is (= (handler request) 190 | {:status 400 191 | :headers {"Content-Type" "text/plain"} 192 | :body "Malformed JSON in request body."}))))) 193 | 194 | (testing "custom malformed json" 195 | (let [malformed {:status 400 196 | :headers {"Content-Type" "text/html"} 197 | :body "Your JSON is wrong!"} 198 | handler (wrap-json-params identity {:malformed-response malformed}) 199 | request {:headers {"content-type" "application/json"} 200 | :body (string-input-stream "{\"foo\": \"bar\"")}] 201 | (is (= (handler request) malformed)))) 202 | 203 | (testing "don't overwrite bigdecimal binding" 204 | (let [handler (fn [_] {:status 200 :headers {} :body {:bigdecimals cheshire.parse/*use-bigdecimals?*}})] 205 | (binding [cheshire.parse/*use-bigdecimals?* false] 206 | (let [response ((wrap-json-params handler {:bigdecimals true}) {})] 207 | (is (= (get-in response [:body :bigdecimals]) false)))) 208 | (binding [cheshire.parse/*use-bigdecimals?* true] 209 | (let [response ((wrap-json-params handler {:bigdecimals false}) {})] 210 | (is (= (get-in response [:body :bigdecimals]) true))))))) 211 | 212 | (deftest test-json-response 213 | (testing "map body" 214 | (let [handler (constantly {:status 200 :headers {} :body {:foo "bar"}}) 215 | response ((wrap-json-response handler) {}) 216 | body (-> response :body slurp)] 217 | (is (= (get-in response [:headers "Content-Type"]) "application/json; charset=utf-8")) 218 | (is (= body "{\"foo\":\"bar\"}")))) 219 | 220 | (testing "string body" 221 | (let [handler (constantly {:status 200 :headers {} :body "foobar"}) 222 | response ((wrap-json-response handler) {})] 223 | (is (= (:headers response) {})) 224 | (is (= (:body response) "foobar")))) 225 | 226 | (testing "vector body" 227 | (let [handler (constantly {:status 200 :headers {} :body [:foo :bar]}) 228 | response ((wrap-json-response handler) {}) 229 | body (-> response :body slurp)] 230 | (is (= (get-in response [:headers "Content-Type"]) "application/json; charset=utf-8")) 231 | (is (= body "[\"foo\",\"bar\"]")))) 232 | 233 | (testing "list body" 234 | (let [handler (constantly {:status 200 :headers {} :body '(:foo :bar)}) 235 | response ((wrap-json-response handler) {}) 236 | body (-> response :body slurp)] 237 | (is (= (get-in response [:headers "Content-Type"]) "application/json; charset=utf-8")) 238 | (is (= body "[\"foo\",\"bar\"]")))) 239 | 240 | (testing "set body" 241 | (let [handler (constantly {:status 200 :headers {} :body #{:foo :bar}}) 242 | response ((wrap-json-response handler) {}) 243 | body (-> response :body slurp)] 244 | (is (= (get-in response [:headers "Content-Type"]) "application/json; charset=utf-8")) 245 | (is (or (= body "[\"foo\",\"bar\"]") 246 | (= body "[\"bar\",\"foo\"]"))))) 247 | 248 | (testing "JSON options" 249 | (let [handler (constantly {:status 200 :headers {} :body {:foo "bar" :baz "quz"}}) 250 | response ((wrap-json-response handler {:pretty true}) {}) 251 | body (-> response :body slurp)] 252 | (is (or (= body "{\n \"foo\" : \"bar\",\n \"baz\" : \"quz\"\n}") 253 | (= body "{\n \"baz\" : \"quz\",\n \"foo\" : \"bar\"\n}"))))) 254 | 255 | (testing "CHANGED: don’t overwrite Content-Type if already set - format if :muuntaja/encode is truthy" 256 | (let [handler (constantly {:status 200 :headers {"Content-Type" "application/json; some-param=some-value"} :body {:foo "bar"}, :muuntaja/encode true}) 257 | response ((wrap-json-response handler) {}) 258 | body (-> response :body slurp)] 259 | (is (= (get-in response [:headers "Content-Type"]) "application/json; some-param=some-value")) 260 | (is (= body "{\"foo\":\"bar\"}")))) 261 | 262 | (testing "CHANGED: don’t overwrite Content-Type if already set - don't format if :muuntaja/encode is not set" 263 | (let [handler (constantly {:status 200 :headers {"Content-Type" "application/json; some-param=some-value"} :body {:foo "bar"}}) 264 | response ((wrap-json-response handler) {}) 265 | body (-> response :body)] 266 | (is (= (get-in response [:headers "Content-Type"]) "application/json; some-param=some-value")) 267 | (is (= body {:foo "bar"}))))) 268 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. -------------------------------------------------------------------------------- /test/muuntaja/middleware_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.middleware-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.core :as m] 4 | [muuntaja.middleware :as middleware] 5 | [muuntaja.core]) 6 | (:import (java.util Date))) 7 | 8 | (defn echo [request] 9 | {:status 200 10 | :body (:body-params request)}) 11 | 12 | (defn async-echo [request respond _] 13 | (respond 14 | {:status 200 15 | :body (:body-params request)})) 16 | 17 | (defn ->request [content-type accept accept-charset body] 18 | {:headers {"content-type" content-type 19 | "accept-charset" accept-charset 20 | "accept" accept} 21 | :body body}) 22 | 23 | (deftest middleware-test 24 | (let [m (m/create) 25 | data {:kikka 42} 26 | edn-string (slurp (m/encode m "application/edn" data)) 27 | json-string (slurp (m/encode m "application/json" data))] 28 | 29 | (testing "multiple way to initialize the middleware" 30 | (let [request (->request "application/edn" "application/edn" nil edn-string)] 31 | (is (= "{:kikka 42}" edn-string)) 32 | (are [app] 33 | (= edn-string (slurp (:body (app request)))) 34 | 35 | ;; without paramters 36 | (middleware/wrap-format echo) 37 | 38 | ;; with default options 39 | (middleware/wrap-format echo m/default-options) 40 | 41 | ;; with compiled muuntaja 42 | (middleware/wrap-format echo m) 43 | 44 | ;; without paramters 45 | (-> echo 46 | (middleware/wrap-format-request) 47 | (middleware/wrap-format-response) 48 | (middleware/wrap-format-negotiate)) 49 | 50 | ;; with default options 51 | (-> echo 52 | (middleware/wrap-format-request m/default-options) 53 | (middleware/wrap-format-response m/default-options) 54 | (middleware/wrap-format-negotiate m/default-options)) 55 | 56 | ;; with compiled muuntaja 57 | (-> echo 58 | (middleware/wrap-format-request m) 59 | (middleware/wrap-format-response m) 60 | (middleware/wrap-format-negotiate m))))) 61 | 62 | (testing "with defaults" 63 | (let [app (middleware/wrap-format echo)] 64 | 65 | (testing "symmetric request decode + response encode" 66 | (are [format] 67 | (let [payload (m/encode m format data) 68 | decode (partial m/decode m format) 69 | request (->request format format nil payload)] 70 | (= data (-> request app :body decode))) 71 | "application/json" 72 | "application/edn" 73 | ;"application/x-yaml" 74 | ;"application/msgpack" 75 | "application/transit+json" 76 | "application/transit+msgpack")) 77 | 78 | (testing "auto-decoding response body" 79 | (let [data {:kikka (Date. 0)} 80 | app (middleware/wrap-format (constantly {:status 200, :body data}))] 81 | (are [format] 82 | (= data (-> {:headers {"accept" format}} app m/decode-response-body)) 83 | ;"application/json" 84 | "application/edn" 85 | ;"application/x-yaml" 86 | ;"application/msgpack" 87 | "application/transit+json" 88 | "application/transit+msgpack"))) 89 | 90 | (testing "failing auto-decoding response body" 91 | (testing "on decoding exception" 92 | (let [app (middleware/wrap-format 93 | (constantly 94 | {:status 200 95 | :body (m/encode "application/edn" {:kikka 123}) 96 | :headers {"Content-Type" "application/json"}}))] 97 | (is (thrown-with-msg? 98 | Exception 99 | #"Malformed application/json response" 100 | (m/decode-response-body (app {})))))) 101 | 102 | (testing "when no content-type is found" 103 | (let [app (middleware/wrap-format 104 | (constantly 105 | {:status 200 106 | :body (m/encode "application/edn" {:kikka 123})}))] 107 | (is (thrown-with-msg? 108 | Exception 109 | #"No Content-Type found" 110 | (m/decode-response-body (app {})))))) 111 | 112 | (testing "when no decoder is found" 113 | (let [app (middleware/wrap-format 114 | (constantly 115 | {:status 200 116 | :body (m/encode "application/edn" {:kikka 123}) 117 | :headers {"Content-Type" "application/json2"}}))] 118 | (is (thrown-with-msg? 119 | Exception 120 | #"Unknown response Content-Type: application/json2" 121 | (m/decode-response-body (app {}))))))) 122 | 123 | (testing "content-type & accept" 124 | (let [call (fn [content-type accept] 125 | (some-> (->request content-type accept nil json-string) app :body slurp))] 126 | 127 | (is (= "{\"kikka\":42}" json-string)) 128 | 129 | (testing "with content-type & accept" 130 | (is (= json-string (call "application/json" "application/json")))) 131 | 132 | (testing "without accept, :default-format is used in encode" 133 | (is (= json-string (call "application/json" nil)))) 134 | 135 | (testing "without content-type, body is not parsed" 136 | (is (= nil (call nil nil)))) 137 | 138 | (testing "different json content-type (regexp match) - don't match by default" 139 | (are [content-type] 140 | (nil? (call content-type nil)) 141 | "application/json-patch+json" 142 | "application/vnd.foobar+json" 143 | "application/schema+json"))) 144 | 145 | (testing "different content-type & accept" 146 | (let [edn-string (slurp (m/encode m "application/edn" data)) 147 | json-string (slurp (m/encode m "application/json" data)) 148 | request (->request "application/edn" "application/json" nil edn-string)] 149 | (is (= json-string (some-> request app :body slurp)))))))) 150 | 151 | (testing "with regexp matchers" 152 | (let [app (middleware/wrap-format 153 | echo 154 | (-> m/default-options 155 | (assoc-in [:formats "application/json" :matches] #"^application/(.+\+)?json$")))] 156 | 157 | (testing "content-type & accept" 158 | (let [call (fn [content-type accept] 159 | (some-> (->request content-type accept nil json-string) app :body slurp))] 160 | 161 | (is (= "{\"kikka\":42}" json-string)) 162 | 163 | (testing "different json content-type (regexp match)" 164 | (are [content-type] 165 | (= json-string (call content-type nil)) 166 | "application/json" 167 | "application/json-patch+json" 168 | "application/vnd.foobar+json" 169 | "application/schema+json")) 170 | 171 | (testing "missing the regexp match" 172 | (are [content-type] 173 | (nil? (call content-type nil)) 174 | "application/jsonz" 175 | "applicationz/+json")))))) 176 | 177 | (testing "without :default-format & valid accept format, response format negotiation fails" 178 | (let [app (middleware/wrap-format echo (dissoc m/default-options :default-format))] 179 | (try 180 | (let [response (app (->request "application/json" nil nil json-string))] 181 | (is (= response ::invalid))) 182 | (catch Exception e 183 | (is (= (-> e ex-data :type) :muuntaja/response-format-negotiation)))))) 184 | 185 | (testing "without :default-charset" 186 | 187 | (testing "without valid request charset, request charset negotiation fails" 188 | (let [app (middleware/wrap-format echo (dissoc m/default-options :default-charset))] 189 | (try 190 | (let [response (app (->request "application/json" nil nil json-string))] 191 | (is (= response ::invalid))) 192 | (catch Exception e 193 | (is (= (-> e ex-data :type) :muuntaja/request-charset-negotiation)))))) 194 | 195 | (testing "without valid accept charset, response charset negotiation fails" 196 | (let [app (middleware/wrap-format echo (dissoc m/default-options :default-charset))] 197 | (try 198 | (let [response (app (->request "application/json; charset=utf-8" nil nil json-string))] 199 | (is (= response ::invalid))) 200 | (catch Exception e 201 | (is (= (-> e ex-data :type) :muuntaja/response-charset-negotiation))))))) 202 | 203 | (testing "runtime options for encoding & decoding" 204 | (testing "forcing a content-type on a handler (bypass negotiate)" 205 | (let [echo-edn (fn [request] 206 | {:status 200 207 | :muuntaja/content-type "application/edn" 208 | :body (:body-params request)}) 209 | app (middleware/wrap-format echo-edn) 210 | request (->request "application/json" "application/json" nil "{\"kikka\":42}") 211 | response (-> request app)] 212 | (is (= "{:kikka 42}" (-> response :body slurp))) 213 | (is (not (contains? response :muuntaja/content-type))) 214 | (is (= "application/edn; charset=utf-8" (get-in response [:headers "Content-Type"])))))) 215 | 216 | (testing "different bodies" 217 | (let [m (m/create (assoc-in m/default-options [:http :encode-response-body?] (constantly true))) 218 | app (middleware/wrap-format echo m) 219 | edn-request #(->request "application/edn" "application/edn" nil (pr-str %)) 220 | e2e #(m/decode m "application/edn" (:body (app (edn-request %))))] 221 | (are [primitive] 222 | (is (= primitive (e2e primitive))) 223 | 224 | [:a 1] 225 | {:a 1} 226 | "kikka" 227 | :kikka 228 | true 229 | false 230 | nil 231 | 1))))) 232 | 233 | (deftest wrap-params-test 234 | (testing "sync" 235 | (let [mw (middleware/wrap-params identity)] 236 | (is (= {:params {:a 1, :b {:c 1}} 237 | :body-params {:b {:c 1}}} 238 | (mw {:params {:a 1} 239 | :body-params {:b {:c 1}}}))) 240 | (is (= {:params {:b {:c 1}} 241 | :body-params {:b {:c 1}}} 242 | (mw {:body-params {:b {:c 1}}}))) 243 | (is (= {:body-params [1 2 3]} 244 | (mw {:body-params [1 2 3]}))))) 245 | (testing "async" 246 | (let [mw (middleware/wrap-params (fn [request respond _] (respond request))) 247 | respond (promise), raise (promise)] 248 | (mw {:params {:a 1} 249 | :body-params {:b {:c 1}}} respond raise) 250 | (is (= {:params {:a 1, :b {:c 1}} 251 | :body-params {:b {:c 1}}} 252 | @respond))))) 253 | 254 | (deftest wrap-exceptions-test 255 | (let [->handler (fn [type] 256 | (fn 257 | ([_] 258 | (condp = type 259 | :decode (throw (ex-info "kosh" {:type :muuntaja/decode})) 260 | :runtime (throw (RuntimeException.)) 261 | :return nil)) 262 | ([_ respond raise] 263 | (condp = type 264 | :decode (raise (ex-info "kosh" {:type :muuntaja/decode})) 265 | :runtime (raise (RuntimeException.)) 266 | :return (respond nil))))) 267 | ->mw (partial middleware/wrap-exception)] 268 | (testing "sync" 269 | (is (nil? ((->mw (->handler :return)) {}))) 270 | (is (thrown? RuntimeException ((->mw (->handler :runtime)) {}))) 271 | (is (= 400 (:status ((->mw (->handler :decode)) {}))))) 272 | (testing "async" 273 | (let [respond (promise), raise (promise)] 274 | ((->mw (->handler :return)) {} respond raise) 275 | (is (nil? @respond))) 276 | (let [respond (promise), raise (promise)] 277 | ((->mw (->handler :runtime)) {} respond raise) 278 | (is (= RuntimeException (class @raise)))) 279 | (let [respond (promise), raise (promise)] 280 | ((->mw (->handler :decode)) {} respond raise) 281 | (is (= 400 (:status @respond))))))) 282 | 283 | (deftest async-normal 284 | (let [m (m/create) 285 | data {:kikka 42} 286 | json-string (slurp (m/encode m "application/json" data))] 287 | 288 | (testing "happy case" 289 | (let [app (middleware/wrap-format async-echo) 290 | respond (promise), raise (promise)] 291 | (app (->request "application/json" nil nil json-string) respond raise) 292 | (is (= (m/decode m "application/json" (:body @respond)) data)))))) 293 | 294 | (deftest negotiation-results-helpers 295 | (let [types (atom nil) 296 | app (middleware/wrap-format 297 | (fn [request] 298 | (reset! types [(m/get-request-format-and-charset request) 299 | (m/get-response-format-and-charset request)]) 300 | nil))] 301 | 302 | (testing "managed formats" 303 | (app {:headers {"content-type" "application/edn; charset=utf-16" 304 | "accept" "application/transit+json, text/html, application/edn" 305 | "accept-charset" "utf-16"}}) 306 | (is (= [(m/map->FormatAndCharset 307 | {:charset "utf-16" 308 | :format "application/edn" 309 | :raw-format "application/edn"}) 310 | (m/map->FormatAndCharset 311 | {:charset "utf-16" 312 | :format "application/transit+json" 313 | :raw-format "application/transit+json"})] 314 | @types))) 315 | 316 | (testing "pick default-charset if accepted, #79" 317 | (app {:headers {"content-type" "application/cheese; charset=utf-16" 318 | "accept" "application/cake, text/html, application/edn" 319 | "accept-charset" "x-ibm300, cheese/cake, utf-8, ibm775"}}) 320 | (is (= [(m/map->FormatAndCharset 321 | {:charset "utf-16" 322 | :format nil 323 | :raw-format "application/cheese"}) 324 | (m/map->FormatAndCharset 325 | {:charset "utf-8" ;; the default 326 | :format "application/edn" 327 | :raw-format "application/cake"})] 328 | @types))) 329 | 330 | (testing "non-managed formats" 331 | (app {:headers {"content-type" "application/cheese; charset=utf-16" 332 | "accept" "application/cake, text/html, application/edn" 333 | "accept-charset" "utf-16"}}) 334 | (is (= [(m/map->FormatAndCharset 335 | {:charset "utf-16" 336 | :format nil 337 | :raw-format "application/cheese"}) 338 | (m/map->FormatAndCharset 339 | {:charset "utf-16" 340 | :format "application/edn" 341 | :raw-format "application/cake"})] 342 | @types))))) 343 | -------------------------------------------------------------------------------- /test/muuntaja/ring_middleware/format_response_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.ring-middleware.format-response-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.core :as m] 4 | [muuntaja.middleware :as middleware] 5 | [cheshire.core :as json] 6 | [clj-yaml.core :as yaml] 7 | [clojure.walk :refer [keywordize-keys]] 8 | [cognitect.transit :as transit] 9 | [muuntaja.format.msgpack :as msgpack-format] 10 | [muuntaja.format.yaml :as yaml-format] 11 | [msgpack.core :as msgpack] 12 | [clojure.string :as str] 13 | [clojure.java.io :as io] 14 | [muuntaja.format.core :as format]) 15 | (:import [java.io ByteArrayInputStream])) 16 | 17 | (defn stream [s] 18 | (ByteArrayInputStream. (.getBytes s "UTF-8"))) 19 | 20 | (defn wrap-api-response 21 | ([handler] 22 | (wrap-api-response handler m/default-options)) 23 | ([handler opts] 24 | (-> handler 25 | (middleware/wrap-format 26 | (m/transform-formats 27 | (-> opts 28 | (m/install yaml-format/format) 29 | (m/install msgpack-format/format)) 30 | #(dissoc %2 :decoder)))))) 31 | 32 | (def api-echo 33 | (wrap-api-response identity)) 34 | 35 | (deftest noop-with-string 36 | (let [body "" 37 | req {:body body} 38 | resp (api-echo req)] 39 | (is (= body (:body resp))))) 40 | 41 | (deftest noop-with-stream 42 | (let [body "" 43 | req {:body (stream body)} 44 | resp (api-echo req)] 45 | (is (= body (slurp (:body resp)))))) 46 | 47 | (deftest format-json-hashmap 48 | (let [body {:foo "bar"} 49 | req {:body body} 50 | resp (api-echo req)] 51 | (is (= (json/generate-string body) (slurp (:body resp)))) 52 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/json")) 53 | ;; we do not set the "Content-Length" 54 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 55 | 56 | (deftest format-json-prettily 57 | (let [body {:foo "bar"} 58 | req {:body body} 59 | resp ((wrap-api-response 60 | identity 61 | (-> m/default-options 62 | (m/select-formats ["application/json"]) 63 | (assoc-in 64 | [:formats "application/json" :encoder-opts] 65 | {:pretty true}))) req)] 66 | (is (.contains (slurp (:body resp)) "\n ")))) 67 | 68 | (deftest returns-correct-charset 69 | (testing "with fixed charset" 70 | (let [body {:foo "bârçï"} 71 | req {:body body :headers {"accept-charset" "utf8; q=0.8 , utf-16"}} 72 | resp ((-> identity 73 | (wrap-api-response 74 | (assoc m/default-options :charsets #{"utf-8"}))) req)] 75 | (is (not (.contains (get-in resp [:headers "Content-Type"]) "utf-16"))) 76 | #_(is (not= 32 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 77 | (testing "with defaults charsets " 78 | (let [body {:foo "bârçï"} 79 | req {:body body :headers {"accept-charset" "utf8; q=0.8 , utf-16"}} 80 | resp ((-> identity 81 | (wrap-api-response m/default-options)) req)] 82 | (is (.contains (get-in resp [:headers "Content-Type"]) "utf-16")) 83 | #_(is (= 32 (Integer/parseInt (get-in resp [:headers "Content-Length"]))))))) 84 | 85 | (deftest returns-utf8-by-default 86 | (let [body {:foo "bârçï"} 87 | req {:body body :headers {"accept-charset" "foo"}} 88 | resp ((wrap-api-response identity) req)] 89 | (is (.contains (get-in resp [:headers "Content-Type"]) "utf-8")) 90 | #_(is (= 18 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 91 | 92 | (deftest format-json-options 93 | (let [body {:foo-bar "bar"} 94 | req {:body body} 95 | resp2 ((-> identity 96 | (wrap-api-response 97 | (-> m/default-options 98 | (assoc-in 99 | [:formats "application/json" :encoder-opts] 100 | {:encode-key-fn (comp str/upper-case name)})))) 101 | req)] 102 | (is (= "{\"FOO-BAR\":\"bar\"}" 103 | (slurp (:body resp2)))))) 104 | 105 | (def msgpack-echo 106 | (wrap-api-response 107 | identity 108 | (-> m/default-options 109 | (m/install msgpack-format/format) 110 | (m/select-formats ["application/msgpack"])))) 111 | 112 | (deftest format-msgpack-hashmap 113 | (let [body {:foo "bar"} 114 | req {:body body} 115 | resp (msgpack-echo req)] 116 | (is (= body (keywordize-keys (msgpack/unpack (:body resp))))) 117 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/msgpack")) 118 | ;; we do not set the "Content-Length" 119 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 120 | 121 | (def clojure-echo 122 | (wrap-api-response 123 | identity 124 | (-> m/default-options 125 | (m/select-formats ["application/edn"])))) 126 | 127 | (deftest format-clojure-hashmap 128 | (let [body {:foo "bar"} 129 | req {:body body} 130 | resp (clojure-echo req)] 131 | (is (= body (read-string (slurp (:body resp))))) 132 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/edn")) 133 | ;; we do not set the "Content-Length" 134 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 135 | 136 | (defn- produce-element-with-log [n] 137 | (prn "reading from db" n) 138 | {:element n}) 139 | 140 | (defn- dummy-handler-with-lazy-seq [_] 141 | {:status 200 142 | :body (map produce-element-with-log (range 4))}) 143 | 144 | (deftest lazy-sequences-with-logs 145 | (let [handler (wrap-api-response 146 | dummy-handler-with-lazy-seq 147 | (-> m/default-options 148 | (m/select-formats ["application/edn"]))) 149 | resp (handler {}) 150 | body (slurp (:body resp))] 151 | ;; Lazy sequence realization interferes with `with-out-str` 152 | #_(is (= body "(\"reading from db\" 0\n\"reading from db\" 1\n\"reading from db\" 2\n\"reading from db\" 3\n{:element 0} {:element 1} {:element 2} {:element 3})")) 153 | (is (= body "({:element 0} {:element 1} {:element 2} {:element 3})")))) 154 | 155 | (def yaml-echo 156 | (wrap-api-response 157 | identity 158 | (-> m/default-options 159 | (m/install yaml-format/format) 160 | (m/select-formats ["application/x-yaml"])))) 161 | 162 | (deftest format-yaml-hashmap 163 | (let [body {:foo "bar"} 164 | req {:body body} 165 | resp (yaml-echo req)] 166 | (is (= (yaml/generate-string body) (slurp (:body resp)))) 167 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/x-yaml")) 168 | ;; we do not set the "Content-Length" 169 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 170 | 171 | ;; not implemented 172 | #_(deftest html-escape-yaml-in-html 173 | (let [req {:body {:foo ""}} 174 | resp ((rmfr/wrap-api-response identity {:formats [:yaml-in-html]}) req) 175 | body (slurp (:body resp))] 176 | (is (= "\n\n
\n{foo: <bar>}\n
" body)))) 177 | 178 | ;; Transit 179 | 180 | (defn read-transit 181 | [fmt in] 182 | (let [rdr (transit/reader (io/input-stream in) fmt)] 183 | (transit/read rdr))) 184 | 185 | (def transit-json-echo 186 | (wrap-api-response 187 | identity 188 | (-> m/default-options 189 | (m/select-formats ["application/transit+json"])))) 190 | 191 | (deftest format-transit-json-hashmap 192 | (let [body {:foo "bar"} 193 | req {:body body} 194 | resp (transit-json-echo req)] 195 | (is (= body (read-transit :json (:body resp)))) 196 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/transit+json")) 197 | ;; we do not set the "Content-Length" 198 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 199 | 200 | (def transit-msgpack-echo 201 | (wrap-api-response 202 | identity 203 | (-> m/default-options 204 | (m/select-formats ["application/transit+msgpack"])))) 205 | 206 | (deftest format-transit-msgpack-hashmap 207 | (let [body {:foo "bar"} 208 | req {:body body} 209 | resp (transit-msgpack-echo req)] 210 | (is (= body (read-transit :msgpack (:body resp)))) 211 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/transit+msgpack")) 212 | ;; we do not set the "Content-Length" 213 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 214 | 215 | #_(comment 216 | ;; Content-Type parsing 217 | 218 | (deftest can-encode?-accept-any-type 219 | (is (#'rmfr/can-encode? {:enc-type {:type "foo" :sub-type "bar"}} 220 | {:type "*" :sub-type "*"}))) 221 | 222 | (deftest can-encode?-accept-any-sub-type 223 | (let [encoder {:enc-type {:type "foo" :sub-type "bar"}}] 224 | (is (#'rmfr/can-encode? encoder 225 | {:type "foo" :sub-type "*"})) 226 | (is (not (#'rmfr/can-encode? encoder 227 | {:type "foo" :sub-type "buzz"}))))) 228 | 229 | (deftest can-encode?-accept-specific-type 230 | (let [encoder {:enc-type {:type "foo" :sub-type "bar"}}] 231 | (is (#'rmfr/can-encode? encoder 232 | {:type "foo" :sub-type "bar"})) 233 | (is (not (#'rmfr/can-encode? encoder 234 | {:type "foo" :sub-type "buzz"}))))) 235 | 236 | (deftest orders-values-correctly 237 | (let [accept "text/plain, */*, text/plain;level=1, text/*, text/*;q=0.1"] 238 | (is (= (#'rmfr/parse-accept-header accept) 239 | (list {:type "text" 240 | :sub-type "plain" 241 | :parameter "level=1" 242 | :q 1.0} 243 | {:type "text" 244 | :sub-type "plain" 245 | :q 1.0} 246 | {:type "text" 247 | :sub-type "*" 248 | :q 1.0} 249 | {:type "*" 250 | :sub-type "*" 251 | :q 1.0} 252 | {:type "text" 253 | :sub-type "*" 254 | :q 0.1}))))) 255 | 256 | (deftest gives-preferred-encoder 257 | (let [accept [{:type "text" 258 | :sub-type "*"} 259 | {:type "application" 260 | :sub-type "json" 261 | :q 0.5}] 262 | req {:headers {"accept" accept}} 263 | html-encoder {:enc-type {:type "text" :sub-type "html"}} 264 | json-encoder {:enc-type {:type "application" :sub-type "json"}}] 265 | (is (= (#'rmfr/preferred-adapter [json-encoder html-encoder] req) 266 | html-encoder)) 267 | (is (nil? (#'rmfr/preferred-adapter [json-encoder html-encoder] {}))) 268 | (is (nil? (#'rmfr/preferred-adapter [{:enc-type {:type "application" 269 | :sub-type "edn"}}] 270 | req)))))) 271 | 272 | (comment 273 | 274 | (def safe-api-echo-opts-map 275 | (rmfr/wrap-api-response identity 276 | {:handle-error (fn [_ _ _] {:status 500}) 277 | :formats [{:content-type "foo/bar" 278 | :encoder (fn [_] (throw (RuntimeException. "Memento mori")))}]})) 279 | 280 | (deftest format-hashmap-to-preferred 281 | (let [ok-accept "application/edn, application/json;q=0.5" 282 | ok-req {:headers {"accept" ok-accept}}] 283 | (is (= (get-in (api-echo ok-req) [:headers "Content-Type"]) 284 | "application/edn; charset=utf-8")) 285 | (is (.contains (get-in (api-echo {:headers {"accept" "foo/bar"}}) 286 | [:headers "Content-Type"]) 287 | "application/json")) 288 | (is (= 500 (get (safe-api-echo-opts-map {:status 200 289 | :headers {"accept" "foo/bar"} 290 | :body {}}) :status)))))) 291 | 292 | (deftest format-api-hashmap 293 | (let [body {:foo "bar"}] 294 | (doseq [accept ["application/edn" 295 | "application/json" 296 | "application/msgpack" 297 | "application/x-yaml" 298 | "application/transit+json" 299 | "application/transit+msgpack" 300 | #_"text/html"]] 301 | (let [req {:body body :headers {"accept" accept}} 302 | resp (api-echo req)] 303 | (is (.contains (get-in resp [:headers "Content-Type"]) accept)) 304 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 305 | (let [req {:body body} 306 | resp (api-echo req)] 307 | (is (.contains (get-in resp [:headers "Content-Type"]) "application/json")) 308 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"]))))))) 309 | 310 | (def custom-api-echo 311 | (wrap-api-response 312 | identity 313 | (-> m/default-options 314 | (m/install {:name "text/foo" 315 | :encoder (reify 316 | format/EncodeToBytes 317 | (encode-to-bytes [_ _ _] 318 | (.getBytes "foobar")))})))) 319 | 320 | (deftest format-custom-api-hashmap 321 | (let [req {:body {:foo "bar"} :headers {"accept" "text/foo"}} 322 | resp (custom-api-echo req)] 323 | (is (.contains (get-in resp [:headers "Content-Type"]) "text/foo")) 324 | #_(is (< 2 (Integer/parseInt (get-in resp [:headers "Content-Length"])))))) 325 | 326 | (deftest nil-body-handling 327 | (let [req {:body {:headers {"accept" "application/json"}}} 328 | handler (-> (constantly {:status 200 329 | :headers {}}) 330 | wrap-api-response) 331 | resp (handler req)] 332 | (is (nil? (get-in resp [:headers "Content-Type"]))) 333 | (is (nil? (get-in resp [:headers "Content-Length"]))) 334 | (is (nil? (:body resp))))) 335 | 336 | (def api-echo-pred 337 | (wrap-api-response 338 | identity 339 | (-> m/default-options 340 | (assoc-in [:http :encode-response-body?] ::serializable?)))) 341 | 342 | (deftest custom-predicate 343 | (let [req {:body {:foo "bar"}} 344 | resp-non-serialized (api-echo-pred (assoc req ::serializable? false)) 345 | resp-serialized (api-echo-pred (assoc req ::serializable? true))] 346 | (is (map? (:body resp-non-serialized))) 347 | (is (= "{\"foo\":\"bar\"}" (slurp (:body resp-serialized)))))) 348 | 349 | (def custom-encoder 350 | (get-in m/default-options [:formats "application/json"])) 351 | 352 | (def custom-content-type 353 | (wrap-api-response 354 | (fn [_] 355 | {:status 200 356 | :body {:foo "bar"}}) 357 | (-> m/default-options 358 | (assoc-in [:formats "application/vnd.mixradio.something+json"] custom-encoder) 359 | (m/select-formats ["application/vnd.mixradio.something+json" "application/json"])))) 360 | 361 | (deftest custom-content-type-test 362 | (let [resp (custom-content-type {:body {:foo "bar"} :headers {"accept" "application/vnd.mixradio.something+json"}})] 363 | (is (= "application/vnd.mixradio.something+json; charset=utf-8" (get-in resp [:headers "Content-Type"]))))) 364 | 365 | ;; Transit options 366 | 367 | (defrecord Point [x y]) 368 | 369 | (def writers 370 | {Point (transit/write-handler (constantly "Point") (fn [p] [(:x p) (:y p)]))}) 371 | 372 | (def custom-transit-echo 373 | (wrap-api-response 374 | identity 375 | (-> m/default-options 376 | (m/select-formats ["application/transit+json"]) 377 | (assoc-in 378 | [:formats "application/transit+json" :encoder-opts] 379 | {:handlers writers})))) 380 | 381 | (def custom-api-transit-echo 382 | (wrap-api-response 383 | identity 384 | (-> m/default-options 385 | (assoc-in 386 | [:formats "application/transit+json" :encoder-opts] 387 | {:handlers writers})))) 388 | 389 | (def transit-resp {:body (Point. 1 2)}) 390 | 391 | (deftest write-custom-transit 392 | (is (= "[\"~#Point\",[1,2]]" 393 | (slurp (:body (custom-transit-echo transit-resp))))) 394 | (is (= "[\"~#Point\",[1,2]]" 395 | (slurp (:body (custom-api-transit-echo (assoc transit-resp :headers {"accept" "application/transit+json"}))))))) 396 | -------------------------------------------------------------------------------- /test/muuntaja/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns muuntaja.core-test 2 | (:require [clojure.test :refer :all] 3 | [muuntaja.core :as m] 4 | [clojure.string :as str] 5 | [muuntaja.format.core :as core] 6 | [muuntaja.format.form :as form-format] 7 | [muuntaja.format.cheshire :as cheshire-format] 8 | [muuntaja.format.msgpack :as msgpack-format] 9 | [muuntaja.format.yaml :as yaml-format] 10 | [muuntaja.format.charred :as charred-format] 11 | [jsonista.core :as j] 12 | [clojure.java.io :as io] 13 | [muuntaja.protocols :as protocols] 14 | [muuntaja.util :as util]) 15 | (:import (java.nio.charset Charset) 16 | (java.io FileInputStream) 17 | (java.nio.file Files))) 18 | 19 | ;;; Charset overriding doesn't work on newer JVMs 20 | #_(defn set-jvm-default-charset! [charset] 21 | (System/setProperty "file.encoding" charset) 22 | (doto 23 | (.getDeclaredField Charset "defaultCharset") 24 | (.setAccessible true) 25 | (.set nil nil)) 26 | nil) 27 | 28 | #_(defmacro with-default-charset [charset & body] 29 | `(let [old-charset# (str (Charset/defaultCharset))] 30 | (try 31 | (set-jvm-default-charset! ~charset) 32 | ~@body 33 | (finally 34 | (set-jvm-default-charset! old-charset#))))) 35 | 36 | (def m 37 | (m/create 38 | (-> m/default-options 39 | (m/install form-format/format) 40 | (m/install msgpack-format/format) 41 | (m/install yaml-format/format) 42 | (m/install cheshire-format/format "application/json+cheshire") 43 | (m/install charred-format/format "application/json+charred")))) 44 | 45 | (deftest core-test 46 | (testing "muuntaja?" 47 | (is (m/muuntaja? m))) 48 | 49 | (testing "default encodes & decodes" 50 | (is (= #{"application/edn" 51 | "application/json" 52 | "application/transit+json" 53 | "application/transit+msgpack"} 54 | (m/encodes m/instance) 55 | (m/decodes m/instance)))) 56 | 57 | (testing "custom encodes & decodes" 58 | (is (= #{"application/edn" 59 | "application/json" 60 | "application/json+cheshire" 61 | "application/json+charred" 62 | "application/msgpack" 63 | "application/transit+json" 64 | "application/transit+msgpack" 65 | "application/x-www-form-urlencoded" 66 | "application/x-yaml"} 67 | (m/encodes m) 68 | (m/decodes m)))) 69 | 70 | (testing "encode & decode" 71 | (let [data {:kikka 42}] 72 | (testing "with default instance" 73 | (is (= "{\"kikka\":42}" (slurp (m/encode "application/json" data)))) 74 | (is (= data (m/decode "application/json" (m/encode "application/json" data))))) 75 | (testing "with muuntaja instance" 76 | (is (= "{\"kikka\":42}" (slurp (m/encode m "application/json" data)))) 77 | (is (= data (m/decode m "application/json" (m/encode m "application/json" data))))))) 78 | 79 | (testing "symmetic encode + decode for all formats" 80 | (let [data {:kikka 42, :childs {:facts [1.2 true {:so "nested"}]}}] 81 | (are [format] 82 | (= data (m/decode m format (m/encode m format data))) 83 | "application/json" 84 | "application/json+cheshire" 85 | "application/json+charred" 86 | "application/edn" 87 | "application/x-yaml" 88 | "application/msgpack" 89 | "application/transit+json" 90 | "application/transit+msgpack"))) 91 | 92 | ;;; Charset overriding doesn't work on newer JVMs 93 | #_ (testing "charsets" 94 | (testing "default is UTF-8" 95 | (is (= "UTF-8" (str (Charset/defaultCharset))))) 96 | (testing "default can be changed" 97 | (with-default-charset 98 | "UTF-16" 99 | (is (= "UTF-16" (str (Charset/defaultCharset))))))) 100 | 101 | (testing "on empty input" 102 | (let [empty (fn [] (util/byte-stream (byte-array 0))) 103 | m2 (m/create 104 | (-> m/default-options 105 | (m/install msgpack-format/format) 106 | (m/install yaml-format/format) 107 | (m/install cheshire-format/format "application/json+cheshire") 108 | (m/install charred-format/format "application/json+charred") 109 | (assoc :allow-empty-input? false)))] 110 | 111 | (testing "by default - nil is returned for empty stream" 112 | (is (nil? (m/decode m "application/transit+json" (empty))))) 113 | 114 | (testing "by default - nil input returns nil stream" 115 | (is (nil? (m/decode m "application/transit+json" nil)))) 116 | 117 | (testing "optionally decoder can decide to throw" 118 | (is (thrown? Exception (m/decode m2 "application/transit+json" (empty)))) 119 | (is (thrown? Exception (m/decode m2 "application/transit+json" nil)))) 120 | 121 | (testing "all formats" 122 | (testing "with :allow-empty-input? false" 123 | 124 | (testing "cheshire json & yaml return nil" 125 | (are [format] 126 | (= nil (m/decode m2 format (empty))) 127 | "application/json+cheshire" 128 | "application/x-yaml")) 129 | 130 | (testing "others fail" 131 | (are [format] 132 | (thrown-with-msg? Exception #"Malformed" (m/decode m2 format (empty))) 133 | "application/edn" 134 | "application/json" 135 | "application/json+charred" 136 | "application/msgpack" 137 | "application/transit+json" 138 | "application/transit+msgpack"))) 139 | 140 | (testing "with defaults" 141 | (testing "all formats return nil" 142 | (are [format] 143 | (= nil (m/decode m format (empty))) 144 | "application/json" 145 | "application/json+cheshire" 146 | "application/json+charred" 147 | "application/edn" 148 | "application/x-yaml" 149 | "application/msgpack" 150 | "application/transit+json" 151 | "application/transit+msgpack")))))) 152 | 153 | (testing "non-binary-formats encoding with charsets" 154 | (let [data {:fée "böz"} 155 | iso-encoded #(slurp (m/encode m % data "ISO-8859-1"))] 156 | (testing "application/json & application/edn use the given charset" 157 | (is (= "{\"f�e\":\"b�z\"}" (iso-encoded "application/json"))) 158 | (is (= "{\"f�e\":\"b�z\"}" (iso-encoded "application/json+cheshire"))) 159 | (is (= "{\"f�e\":\"b�z\"}" (iso-encoded "application/json+charred"))) 160 | (is (= "{:f�e \"b�z\"}" (iso-encoded "application/edn")))) 161 | 162 | (testing "application/x-yaml & application/transit+json use the platform charset" 163 | (testing "utf-8" 164 | (is (= "{fée: böz}\n" (iso-encoded "application/x-yaml"))) 165 | (is (= "[\"^ \",\"~:fée\",\"böz\"]" (iso-encoded "application/transit+json")))) 166 | ;;; Charset overriding does not work on newer JVMs 167 | #_ (testing "when default charset is ISO-8859-1" 168 | (with-default-charset 169 | "ISO-8859-1" 170 | (testing "application/x-yaml works" 171 | (is (= "{f�e: b�z}\n" (iso-encoded "application/x-yaml")))) 172 | (testing "application/transit IS BROKEN" 173 | (is (not= "[\"^ \",\"~:f�e\",\"b�z\"]" (iso-encoded "application/transit+json"))))))))) 174 | 175 | (testing "all formats handle different charsets symmetrically" 176 | (let [data {:fée "böz"} 177 | encode-decode #(as-> data $ 178 | (m/encode m % $ "ISO-8859-1") 179 | (m/decode m % $ "ISO-8859-1"))] 180 | (are [format] 181 | (= data (encode-decode format)) 182 | "application/json" 183 | "application/json+cheshire" 184 | "application/json+charred" 185 | "application/edn" 186 | ;; platform charset 187 | "application/x-yaml" 188 | ;; binary 189 | "application/msgpack" 190 | ;; platform charset 191 | "application/transit+json" 192 | ;; binary 193 | "application/transit+msgpack"))) 194 | 195 | (testing "encoder & decoder" 196 | (let [m (m/create) 197 | data {:kikka 42} 198 | json-encoder (m/encoder m "application/json") 199 | json-decoder (m/decoder m "application/json")] 200 | (is (= "{\"kikka\":42}" (slurp (json-encoder data)))) 201 | (is (= data (-> data json-encoder json-decoder))) 202 | 203 | (testing "invalid encoder /decoder returns nil" 204 | (is (nil? (m/encoder m "application/INVALID"))) 205 | (is (nil? (m/decoder m "application/INVALID")))) 206 | 207 | (testing "decode exception" 208 | (is (thrown? 209 | Exception 210 | (json-decoder "{:invalid :syntax}")))))) 211 | 212 | (testing "adding new format" 213 | (let [name "application/upper" 214 | upper-case-format {:name name 215 | :decoder (reify 216 | core/Decode 217 | (decode [_ data _] 218 | (str/lower-case (slurp data)))) 219 | :encoder (reify 220 | core/EncodeToBytes 221 | (encode-to-bytes [_ data _] 222 | (.getBytes (str/upper-case data))))} 223 | m (m/create (m/install m/default-options upper-case-format)) 224 | encode (m/encoder m name) 225 | decode (m/decoder m name) 226 | data "olipa kerran avaruus"] 227 | (is (= "OLIPA KERRAN AVARUUS" (slurp (encode data)))) 228 | (is (= data (decode (encode data)))))) 229 | 230 | (testing "invalid format fails fast" 231 | (let [upper-case-format {:name "application/upper" 232 | :decoder (fn [_ data _] 233 | (str/lower-case (slurp data)))}] 234 | (is (thrown? Exception (m/create (m/install m/default-options upper-case-format)))))) 235 | 236 | (testing "implementing wrong protocol fails fast" 237 | (let [upper-case-format {:name "application/upper" 238 | :return :output-stream 239 | :encoder (reify 240 | core/EncodeToBytes 241 | (encode-to-bytes [_ data _] 242 | (.getBytes (str/upper-case data))))}] 243 | (is (thrown? Exception (m/create (m/install m/default-options upper-case-format)))))) 244 | 245 | (testing "setting non-existing format as default throws exception" 246 | (is (thrown? 247 | Exception 248 | (m/create 249 | (-> m/default-options 250 | (assoc :default-format "kikka")))))) 251 | 252 | (testing "selecting non-existing format as default throws exception" 253 | (is (thrown? 254 | Exception 255 | (m/create 256 | (-> m/default-options 257 | (m/select-formats ["kikka"])))))) 258 | 259 | (testing "overriding adapter options" 260 | (let [decode-json-kw (m/decoder 261 | (m/create) 262 | "application/json") 263 | decode-json (m/decoder 264 | (m/create 265 | (assoc-in 266 | m/default-options 267 | [:formats "application/json" :decoder-opts] 268 | {:decode-key-fn false})) 269 | "application/json") 270 | decode-json2 (m/decoder 271 | (m/create 272 | (assoc-in 273 | m/default-options 274 | [:formats "application/json" :opts] 275 | {:decode-key-fn false})) 276 | "application/json") 277 | decode-json3 (m/decoder 278 | (m/create 279 | (assoc-in 280 | m/default-options 281 | [:formats "application/json" :opts] 282 | {:mapper (j/object-mapper {:decode-key-fn false})})) 283 | "application/json")] 284 | (is (= {:kikka true} (decode-json-kw "{\"kikka\":true}"))) 285 | (is (= {"kikka" true} (decode-json "{\"kikka\":true}"))) 286 | (is (= {"kikka" true} (decode-json2 "{\"kikka\":true}"))) 287 | (is (= {"kikka" true} (decode-json3 "{\"kikka\":true}"))))) 288 | 289 | (testing "overriding invalid adapter options fails" 290 | (is (thrown? 291 | Exception 292 | (m/create 293 | (-> m/default-options 294 | (assoc-in 295 | [:formats "application/jsonz" :encoder-opts] 296 | {:keywords? false}))))) 297 | (is (thrown? 298 | Exception 299 | (m/create 300 | (-> m/default-options 301 | (assoc-in 302 | [:formats "application/jsonz" :decoder-opts] 303 | {:keywords? false})))))) 304 | 305 | (testing "decode response body for all formats" 306 | (let [m (m/create 307 | (-> m/default-options 308 | (assoc :return :output-stream) 309 | (m/install form-format/format) 310 | (m/install msgpack-format/format) 311 | (m/install yaml-format/format) 312 | (m/install cheshire-format/format "application/json+cheshire"))) 313 | dataset [{:kikka 42, :childs {:facts [1.2 true {:so "nested"}]}} 314 | nil 315 | false]] 316 | (doseq [data dataset] 317 | (are [format] 318 | (= data (m/decode-response-body m {:body (m/encode m format data) :headers {"Content-Type" format}})) 319 | "application/json" 320 | "application/json+cheshire" 321 | "application/edn" 322 | "application/x-yaml" 323 | "application/msgpack" 324 | "application/transit+json" 325 | "application/transit+msgpack"))))) 326 | 327 | (deftest form-data 328 | (testing "basic form encoding" 329 | (let [data {:kikka 42, :childs ['not "so" "nested"]} 330 | format "application/x-www-form-urlencoded"] 331 | (is (= "kikka=42&childs=not&childs=so&childs=nested" 332 | (slurp (m/encode m format data)))))) 333 | 334 | (testing "basic form encoding with string keywords" 335 | (let [data {"kikka" 42, "childs" ['not "so" "nested"]} 336 | format "application/x-www-form-urlencoded"] 337 | (is (= "kikka=42&childs=not&childs=so&childs=nested" 338 | (slurp (m/encode m format data)))))) 339 | 340 | (testing "basic form decoding" 341 | (let [data "kikka=42&childs=not&childs=so&childs=nested=but+messed+up" 342 | format "application/x-www-form-urlencoded"] 343 | (is (= {:kikka "42", :childs ["not" "so" "nested=but messed up"]} 344 | (m/decode m format data))))) 345 | 346 | (testing "form decoding with different decode-key-fn" 347 | (let [data "kikka=42&childs=not&childs=so&childs=nested=but+messed+up" 348 | format "application/x-www-form-urlencoded"] 349 | (is (= {"kikka" "42", "childs" ["not" "so" "nested=but messed up"]} 350 | (-> (m/options m) 351 | (assoc-in [:formats "application/x-www-form-urlencoded" :decoder-opts :decode-key-fn] identity) 352 | (m/create) 353 | (m/decode format data))))))) 354 | 355 | (deftest cheshire-json-options 356 | (testing "pre 0.6.0 options fail at creation time" 357 | (testing ":bigdecimals?" 358 | (is (thrown-with-msg? 359 | AssertionError 360 | #"default JSON formatter has changed" 361 | (m/create 362 | (-> m/default-options 363 | (assoc-in 364 | [:formats "application/json" :decoder-opts] 365 | {:bigdecimals? false})))))) 366 | (testing ":key-fn" 367 | (is (thrown-with-msg? 368 | Error 369 | #"default JSON formatter has changed" 370 | (m/create 371 | (-> m/default-options 372 | (assoc-in 373 | [:formats "application/json" :decoder-opts] 374 | {:key-fn false})))))))) 375 | 376 | (deftest slurp-test 377 | (let [file (io/file "dev-resources/json10b.json") 378 | expected (slurp file)] 379 | (testing "bytes" 380 | (is (= expected (m/slurp (Files/readAllBytes (.toPath file)))))) 381 | (testing "File" 382 | (is (= expected (m/slurp file)))) 383 | (testing "InputStream" 384 | (is (= expected (m/slurp (FileInputStream. file))))) 385 | (testing "StreamableResponse" 386 | (is (= expected (m/slurp (protocols/->StreamableResponse (partial io/copy file)))))) 387 | (testing "String" 388 | (is (= expected (m/slurp expected)))) 389 | (testing "nil" 390 | (is (= nil (m/slurp nil)))))) 391 | 392 | (deftest encode-to-byte-stream-test 393 | (testing "symmetic encode + decode for all formats" 394 | (let [m (m/create 395 | (-> m/default-options 396 | (assoc :return :output-stream) 397 | (m/install form-format/format) 398 | (m/install msgpack-format/format) 399 | (m/install yaml-format/format) 400 | (m/install cheshire-format/format "application/json+cheshire") 401 | (m/install charred-format/format "application/json+charred")))] 402 | (let [data {:kikka 42, :childs {:facts [1.2 true {:so "nested"}]}}] 403 | (are [format] 404 | (= data (m/decode m format (m/encode m format data))) 405 | "application/json" 406 | "application/json+cheshire" 407 | "application/json+charred" 408 | "application/edn" 409 | "application/x-yaml" 410 | "application/msgpack" 411 | "application/transit+json" 412 | "application/transit+msgpack"))))) 413 | --------------------------------------------------------------------------------