Skip to content
Snippets Groups Projects
Commit c65a7eaa authored by Cam Saül's avatar Cam Saül
Browse files

Merge pull request #1058 from metabase/middleware-refactor

Middleware refactor
parents dbee7489 cf923164
No related branches found
No related tags found
No related merge requests found
......@@ -33,6 +33,7 @@
(expect-with-all-drivers 1)
(expect-with-dataset 1)
(expect-with-datasets 1)
(format-color 2)
(ins 1)
(let-400 1)
(let-404 1)
......
......@@ -17,15 +17,15 @@
[tiles :as tiles]
[user :as user]
[util :as util])
[metabase.middleware.auth :as auth]))
[metabase.middleware :as middleware]))
(def ^:private +apikey
"Wrap API-ROUTES so they may only be accessed with proper apikey credentials."
auth/enforce-api-key)
middleware/enforce-api-key)
(def ^:private +auth
"Wrap API-ROUTES so they may only be accessed with proper authentiaction credentials."
auth/enforce-authentication)
middleware/enforce-authentication)
(defroutes routes
(context "/activity" [] (+auth activity/routes))
......
;; -*- comment-column: 35; -*-
(ns metabase.core
(:gen-class)
(:require [clojure.java.browse :refer [browse-url]]
[clojure.string :as s]
(:require [clojure.string :as s]
[clojure.tools.logging :as log]
[colorize.core :as color]
[ring.adapter.jetty :as ring-jetty]
......@@ -13,17 +12,15 @@
[keyword-params :refer [wrap-keyword-params]]
[params :refer [wrap-params]]
[session :refer [wrap-session]])
[medley.core :as medley]
[medley.core :as m]
(metabase [config :as config]
[db :as db]
[driver :as driver]
[events :as events]
[middleware :as mb-middleware]
[routes :as routes]
[setup :as setup]
[task :as task])
(metabase.middleware [auth :as auth]
[log-api-call :refer :all]
[format :refer :all])
(metabase.models [setting :refer [defsetting]]
[database :refer [Database]]
[user :refer [User]])))
......@@ -54,21 +51,21 @@
(def app
"The primary entry point to the HTTP server"
(-> routes/routes
(log-api-call :request :response)
add-security-headers ; [METABASE] Add HTTP headers to API responses to prevent them from being cached
format-response ; [METABASE] Do formatting before converting to JSON so serializer doesn't barf
(wrap-json-body ; extracts json POST body and makes it avaliable on request
(mb-middleware/log-api-call :request :response)
mb-middleware/add-security-headers ; [METABASE] Add HTTP headers to API responses to prevent them from being cached
mb-middleware/format-response ; [METABASE] Do formatting before converting to JSON so serializer doesn't barf
(wrap-json-body ; extracts json POST body and makes it avaliable on request
{:keywords? true})
wrap-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
auth/bind-current-user ; Binds *current-user* and *current-user-id* if :metabase-user-id is non-nil
auth/wrap-current-user-id ; looks for :metabase-session-id and sets :metabase-user-id if Session ID is valid
auth/wrap-api-key ; looks for a Metabase API Key on the request and assocs as :metabase-api-key
auth/wrap-session-id ; looks for a Metabase Session ID and assoc as :metabase-session-id
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key
wrap-gzip)) ; GZIP response if client can handle it
wrap-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
mb-middleware/bind-current-user ; Binds *current-user* and *current-user-id* if :metabase-user-id is non-nil
mb-middleware/wrap-current-user-id ; looks for :metabase-session-id and sets :metabase-user-id if Session ID is valid
mb-middleware/wrap-api-key ; looks for a Metabase API Key on the request and assocs as :metabase-api-key
mb-middleware/wrap-session-id ; looks for a Metabase Session ID and assoc as :metabase-session-id
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key
wrap-gzip)) ; GZIP response if client can handle it
(defn- -init-create-setup-token
"Create and set a new setup token, and open the setup URL on the user's system."
......@@ -129,14 +126,14 @@
"Start the embedded Jetty web server."
[]
(when-not @jetty-instance
(let [jetty-config (cond-> (medley/filter-vals identity {:port (config/config-int :mb-jetty-port)
:host (config/config-str :mb-jetty-host)
:max-threads (config/config-int :mb-jetty-maxthreads)
:min-threads (config/config-int :mb-jetty-minthreads)
:max-queued (config/config-int :mb-jetty-maxqueued)
:max-idle-time (config/config-int :mb-jetty-maxidletime)})
(config/config-str :mb-jetty-join) (assoc :join? (config/config-bool :mb-jetty-join))
(config/config-str :mb-jetty-daemon) (assoc :daemon? (config/config-bool :mb-jetty-daemon)))]
(let [jetty-config (cond-> (m/filter-vals identity {:port (config/config-int :mb-jetty-port)
:host (config/config-str :mb-jetty-host)
:max-threads (config/config-int :mb-jetty-maxthreads)
:min-threads (config/config-int :mb-jetty-minthreads)
:max-queued (config/config-int :mb-jetty-maxqueued)
:max-idle-time (config/config-int :mb-jetty-maxidletime)})
(config/config-str :mb-jetty-join) (assoc :join? (config/config-bool :mb-jetty-join))
(config/config-str :mb-jetty-daemon) (assoc :daemon? (config/config-bool :mb-jetty-daemon)))]
(log/info "Launching Embedded Jetty Webserver with config:\n" (with-out-str (clojure.pprint/pprint jetty-config)))
(->> (ring-jetty/run-jetty app jetty-config)
(reset! jetty-instance)))))
......
(ns metabase.middleware.auth
"Middleware for dealing with authentication and session management."
(:require [korma.core :as k]
(ns metabase.middleware
"Metabase-specific middleware functions & configuration."
(:require [clojure.math.numeric-tower :as math]
[clojure.tools.logging :as log]
[clojure.walk :as walk]
(cheshire factory
[generate :refer [add-encoder encode-str]])
[korma.core :as k]
[medley.core :refer [filter-vals map-vals]]
[metabase.api.common :refer [*current-user* *current-user-id*]]
[metabase.config :as config]
[metabase.db :refer [sel]]
[metabase.api.common :refer [*current-user* *current-user-id*]]
(metabase.models [session :refer [Session]]
[user :refer [User current-user-fields]])))
(metabase.models [interface :refer [api-serialize]]
[session :refer [Session]]
[user :refer [User]])
[metabase.util :as u]))
;;; # ------------------------------------------------------------ UTIL FNS ------------------------------------------------------------
(defn- api-call?
"Is this ring request an API call (does path start with `/api`)?"
[{:keys [^String uri]}]
(and (>= (count uri) 4)
(= (.substring uri 0 4) "/api")))
;;; # ------------------------------------------------------------ AUTH & SESSION MANAGEMENT ------------------------------------------------------------
(def ^:const metabase-session-cookie "metabase.SESSION_ID")
(def ^:const metabase-session-header "x-metabase-session")
(def ^:const metabase-api-key-header "x-metabase-apikey")
(def ^:const response-unauthentic {:status 401 :body "Unauthenticated"})
(def ^:const response-forbidden {:status 403 :body "Forbidden"})
(def ^:const response-forbidden {:status 403 :body "Forbidden"})
(defn wrap-session-id
......@@ -28,6 +46,7 @@
(handler (assoc request :metabase-session-id session-id))
(handler request))))
(defn wrap-current-user-id
"Add `:metabase-user-id` to the request if a valid session token was passed."
[handler]
......@@ -55,28 +74,22 @@
response-unauthentic)))
(defmacro sel-current-user [current-user-id]
`(sel :one [User ~@current-user-fields]
:id ~current-user-id))
(defn bind-current-user
"Middleware that binds `metabase.api.common/*current-user*` and `*current-user-id*`
*current-user-id* int ID or nil of user associated with request
*current-user* delay that returns current user (or nil) from DB"
* `*current-user-id*` int ID or nil of user associated with request
* `*current-user*` delay that returns current user (or nil) from DB"
[handler]
(fn [request]
(if-let [current-user-id (:metabase-user-id request)]
(binding [*current-user-id* current-user-id
*current-user* (delay (sel-current-user current-user-id))]
*current-user* (delay (sel :one `[User ~@(:metabase.models.interface/default-fields User) :is_active :is_staff], :id current-user-id))]
(handler request))
(handler request))))
(defn wrap-api-key
"Middleware that sets the :metabase-api-key keyword on the request if a valid API Key can be found.
"Middleware that sets the `:metabase-api-key` keyword on the request if a valid API Key can be found.
We check the request headers for `X-METABASE-APIKEY` and if it's not found then then no keyword is bound to the request."
[handler]
(fn [{:keys [headers] :as request}]
......@@ -88,10 +101,10 @@
(defn enforce-api-key
"Middleware that enforces validation of the client via API Key, cancelling the request processing if the check fails.
Validation is handled by first checking for the presence of the :metabase-api-key on the request. If the api key
is available then we validate it by checking it against the configured :mb-api-key value set in our global config.
Validation is handled by first checking for the presence of the `:metabase-api-key` on the request. If the api key
is available then we validate it by checking it against the configured `:mb-api-key` value set in our global config.
If the request :metabase-api-key matches the configured :mb-api-key value then the request continues, otherwise we
If the request `:metabase-api-key` matches the configured `:mb-api-key` value then the request continues, otherwise we
reject the request and return a 403 Forbidden response."
[handler]
(fn [{:keys [metabase-api-key] :as request}]
......@@ -99,3 +112,128 @@
(handler request)
;; default response is 403
response-forbidden)))
;;; # ------------------------------------------------------------ SECURITY HEADERS ------------------------------------------------------------
(defn add-security-headers
"Add HTTP headers to tell browsers not to cache API responses."
[handler]
(fn [request]
(let [response (handler request)]
(update response :headers merge (when (api-call? request)
{"Cache-Control" "max-age=0, no-cache, must-revalidate, proxy-revalidate"
"Expires" "Tue, 03 Jul 2001 06:00:00 GMT" ; rando date in the past
"Last-Modified" "{now} GMT"})))))
;;; # ------------------------------------------------------------ JSON SERIALIZATION CONFIG ------------------------------------------------------------
;; Tell the JSON middleware to use a date format that includes milliseconds
(intern 'cheshire.factory 'default-date-format "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
;; ## Custom JSON encoders
;; stringify JDBC clobs
(add-encoder org.h2.jdbc.JdbcClob (fn [clob ^com.fasterxml.jackson.core.JsonGenerator json-generator]
(.writeString json-generator (u/jdbc-clob->str clob))))
;; stringify Postgres binary objects (e.g. PostGIS geometries)
(add-encoder org.postgresql.util.PGobject encode-str)
;; Do the same for PG arrays
(add-encoder org.postgresql.jdbc4.Jdbc4Array encode-str)
;; Encode BSON IDs like strings
(add-encoder org.bson.types.ObjectId encode-str)
;; serialize sql dates (i.e., QueryProcessor results) like YYYY-MM-DD instead of as a full-blown timestamp
(add-encoder java.sql.Date (fn [^java.sql.Date date ^com.fasterxml.jackson.core.JsonGenerator json-generator]
(.writeString json-generator (.toString date))))
(defn- remove-fns-and-delays
"Remove values that are fns or delays from map M."
[m]
(filter-vals #(not (or (delay? %)
(fn? %)))
;; Convert typed maps such as metabase.models.database/DatabaseInstance to plain maps because empty, which is used internally by filter-vals,
;; will fail otherwise
(into {} m)))
(defn format-response
"Middleware that recurses over Clojure object before it gets converted to JSON and makes adjustments neccessary so the formatter doesn't barf.
e.g. functions and delays are stripped and H2 Clobs are converted to strings."
[handler]
(let [-format-response (fn -format-response [obj]
(cond
(map? obj) (->> (api-serialize obj)
remove-fns-and-delays
(map-vals -format-response)) ; recurse over all vals in the map
(coll? obj) (map -format-response obj) ; recurse over all items in the collection
:else obj))]
(fn [request]
(-format-response (handler request)))))
;;; # ------------------------------------------------------------ LOGGING ------------------------------------------------------------
(def ^:private ^:const sensitive-fields
"Fields that we should censor before logging."
#{:password})
(defn- scrub-sensitive-fields
"Replace values of fields in `sensitive-fields` with `\"**********\"` before logging."
[request]
(walk/prewalk (fn [form]
(if-not (and (vector? form)
(= (count form) 2)
(keyword? (first form))
(contains? sensitive-fields (first form)))
form
[(first form) "**********"]))
request))
(defn- log-request [{:keys [uri request-method body query-string]}]
(log/debug (u/format-color 'blue "%s %s "
(.toUpperCase (name request-method)) (str uri
(when-not (empty? query-string)
(str "?" query-string)))
(when (or (string? body) (coll? body))
(str "\n" (u/pprint-to-str (scrub-sensitive-fields body)))))))
(defn- log-response [{:keys [uri request-method]} {:keys [status body]} elapsed-time]
(let [log-error #(log/error %) ; these are macros so we can't pass by value :sad:
log-debug #(log/debug %)
log-warn #(log/warn %)
[error? color log-fn] (cond
(>= status 500) [true 'red log-error]
(= status 403) [true 'red log-warn]
(>= status 400) [true 'red log-debug]
:else [false 'green log-debug])]
(log-fn (str (u/format-color color "%s %s %d (%d ms)" (.toUpperCase (name request-method)) uri status elapsed-time)
;; only print body on error so we don't pollute our environment by over-logging
(when (and error?
(or (string? body) (coll? body)))
(str "\n" (u/pprint-to-str body)))))))
(defn log-api-call
"Middleware to log `:request` and/or `:response` by passing corresponding OPTIONS."
[handler & options]
(let [{:keys [request response]} (set options)
log-request? request
log-response? response]
(fn [request]
(if-not (api-call? request) (handler request)
(do
(when log-request?
(log-request request))
(let [start-time (System/nanoTime)
response (handler request)
elapsed-time (-> (- (System/nanoTime) start-time)
double
(/ 1000000.0)
math/round)]
(when log-response?
(log-response request response elapsed-time))
response))))))
(ns metabase.middleware.format
(:require [clojure.core.match :refer [match]]
(cheshire factory
[generate :refer [add-encoder encode-str]])
[medley.core :refer [filter-vals map-vals]]
[metabase.middleware.log-api-call :refer [api-call?]]
[metabase.models.interface :refer [api-serialize]]
[metabase.util :as util]))
(declare -format-response)
;; ## SHADY HACK
;; Tell the JSON middleware to use a date format that includes milliseconds
(intern 'cheshire.factory 'default-date-format "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
;; ## Custom JSON encoders
;; stringify JDBC clobs
(add-encoder org.h2.jdbc.JdbcClob (fn [clob ^com.fasterxml.jackson.core.JsonGenerator json-generator]
(.writeString json-generator (util/jdbc-clob->str clob))))
;; stringify Postgres binary objects (e.g. PostGIS geometries)
(add-encoder org.postgresql.util.PGobject encode-str)
;; Do the same for PG arrays
(add-encoder org.postgresql.jdbc4.Jdbc4Array encode-str)
;; Encode BSON IDs like strings
(add-encoder org.bson.types.ObjectId encode-str)
;; serialize sql dates (i.e., QueryProcessor results) like YYYY-MM-DD instead of as a full-blown timestamp
(add-encoder java.sql.Date (fn [^java.sql.Date date ^com.fasterxml.jackson.core.JsonGenerator json-generator]
(.writeString json-generator (.toString date))))
(defn add-security-headers
"Add HTTP headers to tell browsers not to cache API responses."
[handler]
(fn [request]
(let [response (handler request)]
(update response :headers merge (when (api-call? request)
{"Cache-Control" "max-age=0, no-cache, must-revalidate, proxy-revalidate"
"Expires" "Tue, 03 Jul 2001 06:00:00 GMT" ; rando date in the past
"Last-Modified" "{now} GMT"})))))
;; ## FORMAT RESPONSE MIDDLEWARE
(defn format-response
"Middleware that recurses over Clojure object before it gets converted to JSON and makes adjustments neccessary so the formatter doesn't barf.
e.g. functions and delays are stripped and H2 Clobs are converted to strings."
[handler]
(fn [request]
(-format-response (handler request))))
(defn- remove-fns-and-delays
"Remove values that are fns or delays from map M."
[m]
(filter-vals #(not (or (delay? %)
(fn? %)))
;; Convert typed maps such as metabase.models.database/DatabaseInstance to plain maps because empty, which is used internally by filter-vals,
;; will fail otherwise
(into {} m)))
(defn- -format-response [obj]
(cond
(map? obj) (->> (api-serialize obj)
remove-fns-and-delays
(map-vals -format-response)) ; recurse over all vals in the map
(coll? obj) (map -format-response obj) ; recurse over all items in the collection
:else obj))
(ns metabase.middleware.log-api-call
"Middleware to log API calls. Primarily for debugging purposes."
(:require [clojure.math.numeric-tower :as math]
[clojure.pprint :refer [pprint]]
[clojure.tools.logging :as log]
[colorize.core :as color]))
(declare api-call?
log-request
log-response)
(def ^:private sensitive-fields
"Fields that we should censor before logging."
#{:password})
(defn- scrub-sensitive-fields
"Replace values of fields in `sensitive-fields` with `\"**********\"` before logging."
[request]
(clojure.walk/prewalk (fn [form]
(if-not (and (vector? form)
(= (count form) 2)
(keyword? (first form))
(contains? sensitive-fields (first form)))
form
[(first form) "**********"]))
request))
(def ^:private only-display-output-on-error
"Set this to `false` to see all API responses."
true)
(defn log-api-call
"Middleware to log `:request` and/or `:response` by passing corresponding OPTIONS."
[handler & options]
(let [{:keys [request response]} (set options)
log-request? request
log-response? response]
(fn [request]
(if-not (api-call? request) (handler request)
(do
(when log-request?
(log-request request))
(let [start-time (System/nanoTime)
response (handler request)
elapsed-time (-> (- (System/nanoTime) start-time)
double
(/ 1000000.0)
math/round)]
(when log-response?
(log-response request response elapsed-time))
response))))))
(defn api-call?
"Is this ring request an API call (does path start with `/api`)?"
[{:keys [^String uri]}]
(and (>= (count uri) 4)
(= (.substring uri 0 4) "/api")))
(defn- log-request [{:keys [uri request-method body query-string]}]
(log/debug (color/blue (format "%s %s " (.toUpperCase (name request-method)) (str uri
(when-not (empty? query-string)
(str "?" query-string))))
(when (or (string? body) (coll? body))
(str "\n" (with-out-str (pprint (scrub-sensitive-fields body))))))))
(defn- log-response [{:keys [uri request-method]} {:keys [status body]} elapsed-time]
(let [log-error (fn [& args] (log/error (apply str args))) ; inconveniently these are not macros
log-debug (fn [& args] (log/debug (apply str args)))
log-warn (fn [& args] (log/warn (apply str args)))
[error? color-fn log-fn] (cond
(>= status 500) [true color/red log-error]
(= status 403) [true color/red log-warn]
(>= status 400) [true color/red log-debug]
:else [false color/green log-debug])]
(log-fn (color-fn (format "%s %s %d (%d ms)" (.toUpperCase (name request-method)) uri status elapsed-time)
(when (or error? (not only-display-output-on-error))
(when (or (string? body) (coll? body))
(str "\n" (with-out-str (pprint body)))))))))
......@@ -53,13 +53,6 @@
(cascade-delete 'ViewLog :user_id id)))
(def ^:const current-user-fields
"The fields we should return for `*current-user*` (used by `metabase.middleware.current-user`)"
(concat (:metabase.models.interface/default-fields User)
[:is_active
:is_staff])) ; but not `password` !
;; ## Related Functions
(declare create-user
......
(ns metabase.api.notify-test
(:require [clj-http.lite.client :as client]
[expectations :refer :all]
[metabase.http-client :as http]
[metabase.middleware.auth :as auth]))
(metabase [http-client :as http]
[middleware :as middleware])))
;; ## /api/notify/* AUTHENTICATION Tests
;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same
;; authentication test on every single individual endpoint
(expect (get auth/response-forbidden :body) (http/client :post 403 "notify/db/100"))
(expect (get middleware/response-forbidden :body) (http/client :post 403 "notify/db/100"))
;; ## POST /api/notify/db/:id
......
......@@ -3,8 +3,8 @@
(:require [expectations :refer :all]
[metabase.db :refer :all]
[metabase.driver.mongo.test-data :as mongo-data :refer [mongo-test-db-id]]
[metabase.http-client :as http]
[metabase.middleware.auth :as auth]
(metabase [http-client :as http]
[middleware :as middleware])
(metabase.models [field :refer [Field]]
[foreign-key :refer [ForeignKey]]
[table :refer [Table]])
......@@ -19,8 +19,8 @@
;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same
;; authentication test on every single individual endpoint
(expect (get auth/response-unauthentic :body) (http/client :get 401 "table"))
(expect (get auth/response-unauthentic :body) (http/client :get 401 (format "table/%d" (id :users))))
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "table"))
(expect (get middleware/response-unauthentic :body) (http/client :get 401 (format "table/%d" (id :users))))
;; ## GET /api/table?org
......
......@@ -3,8 +3,8 @@
(:require [expectations :refer :all]
[korma.core :as k]
[metabase.db :refer :all]
[metabase.http-client :as http]
[metabase.middleware.auth :as auth]
(metabase [http-client :as http]
[middleware :as middleware])
(metabase.models [session :refer [Session]]
[user :refer [User]])
[metabase.test.data :refer :all]
......@@ -15,8 +15,8 @@
;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same
;; authentication test on every single individual endpoint
(expect (get auth/response-unauthentic :body) (http/client :get 401 "user"))
(expect (get auth/response-unauthentic :body) (http/client :get 401 "user/current"))
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "user"))
(expect (get middleware/response-unauthentic :body) (http/client :get 401 "user/current"))
;; ## Helper Fns
......
(ns metabase.middleware.format-test
(:require [expectations :refer :all]
[metabase.middleware.format :refer :all]))
;; `format`, being a middleware function, expects a `handler`
;; and returns a function that actually affects the response.
;; Since we're just interested in testing the returned function pass it `identity` as a handler
;; so whatever we pass it is unaffected
(def fmt (format-response identity))
;; check basic stripping
(expect {:a 1}
(fmt {:a 1
:b (fn [] 2)}))
;; check recursive stripping w/ map
(expect {:response {:a 1}}
(fmt {:response {:a 1
:b (fn [] 2)}}))
;; check recursive stripping w/ array
(expect [{:a 1}]
(fmt [{:a 1
:b (fn [] 2)}]))
;; check combined recursive stripping
(expect [{:a [{:b 1}]}]
(fmt [{:a [{:b 1
:c (fn [] 2)} ]}]))
(ns metabase.middleware.auth-test
(ns metabase.middleware-test
(:require [expectations :refer :all]
[korma.core :as k]
[ring.mock.request :as mock]
[metabase.api.common :refer [*current-user-id* *current-user*]]
[metabase.middleware.auth :refer :all]
[metabase.middleware :refer :all]
[metabase.models.session :refer [Session]]
[metabase.test.data :refer :all]
[metabase.test.data.users :refer :all]
......@@ -170,3 +170,33 @@
;; invalid apikey, expect 403
(expect response-forbidden
(api-key-enforced-handler (request-with-api-key "foobar")))
;;; # ------------------------------------------------------------ FORMATTING TESTS ------------------------------------------------------------
;; `format`, being a middleware function, expects a `handler`
;; and returns a function that actually affects the response.
;; Since we're just interested in testing the returned function pass it `identity` as a handler
;; so whatever we pass it is unaffected
(def fmt (format-response identity))
;; check basic stripping
(expect {:a 1}
(fmt {:a 1
:b (fn [] 2)}))
;; check recursive stripping w/ map
(expect {:response {:a 1}}
(fmt {:response {:a 1
:b (fn [] 2)}}))
;; check recursive stripping w/ array
(expect [{:a 1}]
(fmt [{:a 1
:b (fn [] 2)}]))
;; check combined recursive stripping
(expect [{:a [{:b 1}]}]
(fmt [{:a [{:b 1
:c (fn [] 2)} ]}]))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment