Browse Source

moving to compojure; api done

master
Christian Mueller 12 years ago
parent
commit
5a1626a2f4
  1. 2
      Makefile
  2. 21
      project.clj
  3. 19
      src/NoteHub/api.clj
  4. 137
      src/NoteHub/handler.clj
  5. 10
      src/NoteHub/server.clj
  6. 2
      src/NoteHub/settings.clj
  7. 20
      src/NoteHub/storage.clj
  8. 53
      test/NoteHub/test/api.clj
  9. 24
      test/NoteHub/test/handler.clj
  10. 7
      test/NoteHub/test/storage.clj

2
Makefile

@ -1,6 +1,6 @@
# starts the app in :dev mode # starts the app in :dev mode
run: run:
@DEVMODE=1 lein run @DEVMODE=1 lein ring server
server: server:
redis-server & redis-server &

21
project.clj

@ -1,10 +1,13 @@
(defproject NoteHub "2.0.0" (defproject NoteHub "2.0.0"
:description "A free and anonymous hosting for markdown pages." :description "A free and anonymous hosting for markdown pages."
:dependencies [[org.clojure/clojure "1.5.1"] :dependencies [[org.clojure/clojure "1.5.1"]
[hiccup "1.0.0"] [hiccup "1.0.0"]
[cheshire "5.3.1"] [cheshire "5.3.1"]
[ring/ring-core "1.1.0"] [ring/ring-core "1.1.0"]
[com.taoensso/carmine "2.4.4"] [com.taoensso/carmine "2.4.4"]
[noir "1.3.0-beta1"]] [compojure "1.1.6"]]
:jvm-opts ["-Dfile.encoding=utf-8"] :plugins [[lein-ring "0.8.10"]]
:main NoteHub.server) :ring {:handler notehub.handler/app}
:profiles {:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
[ring-mock "0.1.5"]]}}
:jvm-opts ["-Dfile.encoding=utf-8"])

19
src/NoteHub/api.clj

@ -1,15 +1,15 @@
(ns NoteHub.api (ns notehub.api
(:import (:import
[java.util Calendar]) [java.util Calendar])
(:use (:use
[NoteHub.settings] [notehub.settings]
[ring.util.codec :only [url-encode]] [ring.util.codec :only [url-encode]]
[clojure.string :rename {replace sreplace} [clojure.string :rename {replace sreplace}
:only [replace blank? lower-case split-lines split]]) :only [replace blank? lower-case split-lines split]])
(:require (:require
[ring.util.codec] [ring.util.codec]
[hiccup.util :as util] [hiccup.util :as util]
[NoteHub.storage :as storage])) [notehub.storage :as storage]))
(def version "1.1") (def version "1.1")
@ -60,15 +60,6 @@
(str domain "/" (storage/create-short-url token {:year year :month month :day day :title title})) (str domain "/" (storage/create-short-url token {:year year :month month :day day :title title}))
(str domain (url year month day title)))))) (str domain (url year month day title))))))
(let [md5Instance (java.security.MessageDigest/getInstance "MD5")]
(defn get-signature
"Returns the MD5 hash for the concatenation of all passed parameters"
[& args]
(let [input (sreplace (apply str args) #"[\r\n]" "")]
(do (.reset md5Instance)
(.update md5Instance (.getBytes input))
(.toString (new java.math.BigInteger 1 (.digest md5Instance)) 16)))))
(defn get-note [noteID] (defn get-note [noteID]
(if (storage/note-exists? noteID) (if (storage/note-exists? noteID)
(let [note (storage/get-note noteID)] (let [note (storage/get-note noteID)]
@ -87,7 +78,7 @@
;(log "post-note: %s" {:pid pid :signature signature :password password :note note}) ;(log "post-note: %s" {:pid pid :signature signature :password password :note note})
(let [errors (filter identity (let [errors (filter identity
[(when-not (storage/valid-publisher? pid) "pid invalid") [(when-not (storage/valid-publisher? pid) "pid invalid")
(when-not (= signature (get-signature pid (storage/get-psk pid) note)) (when-not (= signature (storage/sign pid (storage/get-psk pid) note))
"signature invalid") "signature invalid")
(when (blank? note) "note is empty")])] (when (blank? note) "note is empty")])]
(if (empty? errors) (if (empty? errors)
@ -121,7 +112,7 @@
;(log "update-note: %s" {:pid pid :noteID noteID :signature signature :password password :note note}) ;(log "update-note: %s" {:pid pid :noteID noteID :signature signature :password password :note note})
(let [errors (filter identity (let [errors (filter identity
[(when-not (storage/valid-publisher? pid) "pid invalid") [(when-not (storage/valid-publisher? pid) "pid invalid")
(when-not (= signature (get-signature pid (storage/get-psk pid) noteID note password)) (when-not (= signature (storage/sign pid (storage/get-psk pid) noteID note password))
"signature invalid") "signature invalid")
(when (blank? note) "note is empty") (when (blank? note) "note is empty")
(when-not (storage/valid-password? noteID password) "password invalid")])] (when-not (storage/valid-password? noteID password) "password invalid")])]

137
src/NoteHub/views/pages.clj → src/NoteHub/handler.clj

@ -1,28 +1,24 @@
(ns NoteHub.views.pages (ns notehub.handler
(:require [hiccup.util :as util] (:use compojure.core
[NoteHub.api :as api] [notehub.settings]
[NoteHub.storage :as storage] [clojure.string :rename {replace sreplace}
[cheshire.core :refer :all]) :only [escape split replace blank? split-lines lower-case]]
(:use [clojure.core.incubator :only [-?>]]
[NoteHub.settings] [hiccup.form]
[clojure.string :rename {replace sreplace} [hiccup.core]
:only [escape split replace blank? split-lines lower-case]] [hiccup.element]
[clojure.core.incubator :only [-?>]] [hiccup.util :only [escape-html]]
[noir.util.crypt :only [encrypt]] [hiccup.page :only [include-js html5]])
[hiccup.form] (:require [compojure.handler :as handler]
[hiccup.core] [compojure.route :as route]
[hiccup.element] [hiccup.util :as util]
[hiccup.util :only [escape-html]] [notehub.api :as api]
[hiccup.page :only [include-js html5]] [notehub.storage :as storage]
[noir.response :only [redirect status content-type]] [cheshire.core :refer :all]))
[noir.core :only [defpage defpartial]]
[noir.statuses]))
(when-not (storage/valid-publisher? "NoteHub")
(storage/register-publisher "NoteHub"))
; Creates the main html layout ; Creates the main html layout
(defpartial layout (defn layout
[title & content] [title & content]
(html5 (html5
[:head [:head
@ -40,6 +36,32 @@
(if-not (get-setting :dev-mode) (include-js "/js/google-analytics.js"))] (if-not (get-setting :dev-mode) (include-js "/js/google-analytics.js"))]
[:body {:onload "onLoad()"} content])) [:body {:onload "onLoad()"} content]))
(defn md-node
"Returns an HTML element with a textarea inside
containing the markdown text (to keep all chars unescaped)"
([cls input] (md-node cls {} input))
([cls opts input]
[(keyword (str (name cls) ".markdown")) opts
[:textarea input]]))
#_ (
; ######## OLD CODE START
(ns NoteHub.views.pages
(:require )
(:use
[noir.response :only [redirect status content-type]]
[noir.core :only [defpage defpartial]]
[noir.statuses]
[noir.util.crypt :only [encrypt]]))
(when-not (storage/valid-publisher? "NoteHub")
(storage/register-publisher "NoteHub"))
(defn sanitize (defn sanitize
"Breakes all usages of <script> & <iframe>" "Breakes all usages of <script> & <iframe>"
[input] [input]
@ -80,13 +102,6 @@
(defn generate-session [] (defn generate-session []
(encrypt (str (rand-int Integer/MAX_VALUE)))) (encrypt (str (rand-int Integer/MAX_VALUE))))
(defn md-node
"Returns an HTML element with a textarea inside
containing the markdown text (to keep all chars unescaped)"
([cls input] (md-node cls {} input))
([cls opts input]
[(keyword (str (name cls) ".markdown")) opts
[:textarea input]]))
; Routes ; Routes
; ====== ; ======
@ -135,13 +150,6 @@
[:tr [:td (str (get-message %) ":")] [:td (% stats)]]) [:tr [:td (str (get-message %) ":")] [:td (% stats)]])
[:published :edited :publisher :views])]))) [:published :edited :publisher :views])])))
(defpage "/:short-url" {:keys [short-url]}
(when-let [params (storage/resolve-url short-url)]
(let [{:keys [year month day title]} params
rest-params (dissoc params :year :month :day :title)
core-url (api/url year month day title)
long-url (if (empty? rest-params) core-url (util/url core-url rest-params))]
(redirect long-url))))
(defpage "/:year/:month/:day/:title/edit" {:keys [year month day title]} (defpage "/:year/:month/:day/:title/edit" {:keys [year month day title]}
(let [noteID (api/build-key [year month day] title)] (let [noteID (api/build-key [year month day] title)]
@ -181,23 +189,48 @@
(response 403))) (response 403)))
(response 500)))) (response 500))))
; Here lives the API ; ###### END OLD CODE
)
(defn redirect [url]
{:status 302
:headers {"Location" (str url)}
:body ""})
(defroutes api-routes
(GET "/" [] (layout (get-message :api-title)
(md-node :article (slurp "API.md"))))
(GET "/note" [version noteID]
(generate-string (api/get-note noteID)))
(POST "/note" {params :params}
(generate-string
(api/post-note
(:note params)
(:pid params)
(:signature params)
{:params (dissoc params :version :note :pid :signature :password)
:password (:password params)})))
(PUT "/note" [version noteID note pid signature password]
(generate-string (api/update-note noteID note pid signature password))))
(defpage "/api" args (defroutes app-routes
(layout (get-message :api-title) (context "/api" [] api-routes)
(md-node :article (slurp "API.md")))) (GET "/" [] "Hello World")
(defpage [:get "/api/note"] {:keys [version noteID]} (GET "/:short-url" [short-url]
(generate-string (api/get-note noteID))) (when-let [params (storage/resolve-url short-url)]
(let [{:keys [year month day title]} params
rest-params (dissoc params :year :month :day :title)
core-url (api/url year month day title)
long-url (if (empty? rest-params) core-url (util/url core-url rest-params))]
(redirect long-url))))
(defpage [:post "/api/note"] {:keys [version note pid signature password] :as params} (route/resources "/resources")
(generate-string (route/not-found "Not Found"))
(api/post-note
note
pid
signature
{:params (dissoc params :version :note :pid :signature :password)
:password password})))
(defpage [:put "/api/note"] {:keys [version noteID note pid signature password]} (def app
(generate-string (api/update-note noteID note pid signature password))) (handler/site app-routes))

10
src/NoteHub/server.clj

@ -1,10 +0,0 @@
(ns NoteHub.server
(:require [noir.server :as server]))
(server/load-views "src/NoteHub/views/")
(defn -main [& m]
(let [mode (keyword (or (first m) :prod))
port (Integer. (get (System/getenv) "PORT" "8080"))]
(server/start port {:mode mode :ns 'NoteHub})))

2
src/NoteHub/settings.clj

@ -1,4 +1,4 @@
(ns NoteHub.settings (ns notehub.settings
(:refer-clojure :exclude [replace reverse]) (:refer-clojure :exclude [replace reverse])
(:use [clojure.string])) (:use [clojure.string]))

20
src/NoteHub/storage.clj

@ -1,11 +1,19 @@
(ns NoteHub.storage (ns notehub.storage
(:use [NoteHub.settings] (:use [notehub.settings]
[clojure.string :only (blank?)] [clojure.string :only (blank? replace) :rename {replace sreplace}])
[noir.util.crypt :only [encrypt]])
(:require [taoensso.carmine :as car :refer (wcar)])) (:require [taoensso.carmine :as car :refer (wcar)]))
(def conn {:pool {} :spec {:uri (get-setting :db-url)}}) (def conn {:pool {} :spec {:uri (get-setting :db-url)}})
(let [md5Instance (java.security.MessageDigest/getInstance "MD5")]
(defn sign
"Returns the MD5 hash for the concatenation of all passed parameters"
[& args]
(let [input (sreplace (apply str args) #"[\r\n]" "")]
(do (.reset md5Instance)
(.update md5Instance (.getBytes input))
(.toString (new java.math.BigInteger 1 (.digest md5Instance)) 16)))))
(defmacro redis [cmd & body] (defmacro redis [cmd & body]
`(car/wcar conn `(car/wcar conn
(~(symbol "car" (name cmd)) (~(symbol "car" (name cmd))
@ -20,7 +28,7 @@
(defn register-publisher [pid] (defn register-publisher [pid]
"Returns nil if given PID exists or a PSK otherwise" "Returns nil if given PID exists or a PSK otherwise"
(when (not (valid-publisher? pid)) (when (not (valid-publisher? pid))
(let [psk (encrypt (str (rand-int Integer/MAX_VALUE) pid))] (let [psk (sign (str (rand-int Integer/MAX_VALUE) pid))]
(redis :hset :publisher-key pid psk) (redis :hset :publisher-key pid psk)
psk))) psk)))
@ -31,7 +39,7 @@
(redis :hget :publisher-key pid)) (redis :hget :publisher-key pid))
(defn create-session [] (defn create-session []
(let [token (encrypt (str (rand-int Integer/MAX_VALUE)))] (let [token (sign (str (rand-int Integer/MAX_VALUE)))]
(redis :sadd :sessions token) (redis :sadd :sessions token)
token)) token))

53
test/NoteHub/test/api.clj

@ -1,9 +1,9 @@
(ns NoteHub.test.api (ns notehub.test.api
(:require (:require
[cheshire.core :refer :all] [cheshire.core :refer :all]
[NoteHub.storage :as storage]) [notehub.storage :as storage])
(:use [NoteHub.api] (:use [notehub.api]
[noir.util.test] [notehub.handler]
[clojure.test])) [clojure.test]))
(def note "hello world!\nThis is a _test_ note!") (def note "hello world!\nThis is a _test_ note!")
@ -15,6 +15,15 @@
(defmacro isnt [arg] `(is (not ~arg))) (defmacro isnt [arg] `(is (not ~arg)))
(defn send-request
([resource] (send-request resource {}))
([resource params]
(let [[method url] (if (vector? resource) resource [:get resource])]
(app-routes {:request-method method :uri url :params params}))))
(defn has-status [input status]
(= status (:status input)))
(defn register-publisher-fixture [f] (defn register-publisher-fixture [f]
(def psk (storage/register-publisher pid)) (def psk (storage/register-publisher pid))
(f) (f)
@ -37,8 +46,8 @@
(isnt (storage/valid-publisher? pid2)))) (isnt (storage/valid-publisher? pid2))))
(testing "note publishing & retrieval" (testing "note publishing & retrieval"
(isnt (:success (:status (get-note "some note id")))) (isnt (:success (:status (get-note "some note id"))))
(is (= "note is empty" (:message (:status (post-note "" pid (get-signature pid psk "")))))) (is (= "note is empty" (:message (:status (post-note "" pid (storage/sign pid psk ""))))))
(let [post-response (post-note note pid (get-signature pid psk note)) (let [post-response (post-note note pid (storage/sign pid psk note))
get-response (get-note (:noteID post-response))] get-response (get-note (:noteID post-response))]
(is (:success (:status post-response))) (is (:success (:status post-response)))
(is (:success (:status get-response))) (is (:success (:status get-response)))
@ -48,8 +57,10 @@
(is (storage/note-exists? (:noteID post-response))) (is (storage/note-exists? (:noteID post-response)))
(let [su (last (clojure.string/split (:shortURL get-response) #"/"))] (let [su (last (clojure.string/split (:shortURL get-response) #"/"))]
(is (= su (storage/create-short-url (:noteID post-response) (storage/resolve-url su))))) (is (= su (storage/create-short-url (:noteID post-response) (storage/resolve-url su)))))
(let [resp (send-request (let [_ (println "DEBUG I" (clojure.string/replace (:shortURL get-response) domain ""))
resp (send-request
(clojure.string/replace (:shortURL get-response) domain "")) (clojure.string/replace (:shortURL get-response) domain ""))
_ (println "DEBUG II" ((:headers resp) "Location"))
resp (send-request ((:headers resp) "Location"))] resp (send-request ((:headers resp) "Location"))]
(is (substring? "hello world"(:body resp)))) (is (substring? "hello world"(:body resp))))
(is (= (:publisher get-response) pid)) (is (= (:publisher get-response) pid))
@ -58,38 +69,38 @@
(isnt (get-in get-response [:statistics :edited])) (isnt (get-in get-response [:statistics :edited]))
(is (= "3" (get-in (get-note (:noteID post-response)) [:statistics :views]))))) (is (= "3" (get-in (get-note (:noteID post-response)) [:statistics :views])))))
(testing "creation with wrong signature" (testing "creation with wrong signature"
(let [response (post-note note pid (get-signature pid2 psk note))] (let [response (post-note note pid (storage/sign pid2 psk note))]
(isnt (:success (:status response))) (isnt (:success (:status response)))
(is (= "signature invalid" (:message (:status response))))) (is (= "signature invalid" (:message (:status response)))))
(let [response (post-note note pid (get-signature pid2 psk "any note"))] (let [response (post-note note pid (storage/sign pid2 psk "any note"))]
(isnt (:success (:status response))) (isnt (:success (:status response)))
(is (= "signature invalid" (:message (:status response))))) (is (= "signature invalid" (:message (:status response)))))
(isnt (:success (:status (post-note note pid (get-signature pid "random_psk" note))))) (isnt (:success (:status (post-note note pid (storage/sign pid "random_psk" note)))))
(is (:success (:status (post-note note pid (get-signature pid psk note))))) (is (:success (:status (post-note note pid (storage/sign pid psk note)))))
(let [randomPID "randomPID" (let [randomPID "randomPID"
psk2 (storage/register-publisher randomPID) psk2 (storage/register-publisher randomPID)
_ (storage/revoke-publisher randomPID) _ (storage/revoke-publisher randomPID)
response (post-note note randomPID (get-signature randomPID psk2 note))] response (post-note note randomPID (storage/sign randomPID psk2 note))]
(isnt (:success (:status response))) (isnt (:success (:status response)))
(is (= (:message (:status response)) "pid invalid")))) (is (= (:message (:status response)) "pid invalid"))))
(testing "note update" (testing "note update"
(let [post-response (post-note note pid (get-signature pid psk note) {:password "passwd"}) (let [post-response (post-note note pid (storage/sign pid psk note) {:password "passwd"})
note-id (:noteID post-response) note-id (:noteID post-response)
new-note "a new note!"] new-note "a new note!"]
(is (:success (:status post-response))) (is (:success (:status post-response)))
(is (:success (:status (get-note note-id)))) (is (:success (:status (get-note note-id))))
(is (= note (:note (get-note note-id)))) (is (= note (:note (get-note note-id))))
(let [update-response (update-note note-id new-note pid (get-signature pid psk new-note) "passwd")] (let [update-response (update-note note-id new-note pid (storage/sign pid psk new-note) "passwd")]
(isnt (:success (:status update-response))) (isnt (:success (:status update-response)))
(is (= "signature invalid" (:message (:status update-response))))) (is (= "signature invalid" (:message (:status update-response)))))
(is (= note (:note (get-note note-id)))) (is (= note (:note (get-note note-id))))
(let [update-response (update-note note-id new-note pid (let [update-response (update-note note-id new-note pid
(get-signature pid psk note-id new-note "passwd") "passwd")] (storage/sign pid psk note-id new-note "passwd") "passwd")]
(is (= { :success true } (:status update-response))) (is (= { :success true } (:status update-response)))
(isnt (= nil (get-in (get-note note-id) [:statistics :edited]))) (isnt (= nil (get-in (get-note note-id) [:statistics :edited])))
(is (= new-note (:note (get-note note-id))))) (is (= new-note (:note (get-note note-id)))))
(let [update-response (update-note note-id "aaa" pid (let [update-response (update-note note-id "aaa" pid
(get-signature pid psk note-id "aaa" "pass") "pass")] (storage/sign pid psk note-id "aaa" "pass") "pass")]
(isnt (:success (:status update-response))) (isnt (:success (:status update-response)))
(is (= "password invalid" (:message (:status update-response))))) (is (= "password invalid" (:message (:status update-response)))))
(is (= new-note (:note (get-note note-id)))) (is (= new-note (:note (get-note note-id))))
@ -100,7 +111,7 @@
(let [response (send-request [:post "/api/note"] (let [response (send-request [:post "/api/note"]
{:note note {:note note
:pid pid :pid pid
:signature (get-signature pid psk note) :signature (storage/sign pid psk note)
:version "1.0"}) :version "1.0"})
body (parse-string (:body response)) body (parse-string (:body response))
noteID (body "noteID")] noteID (body "noteID")]
@ -118,7 +129,7 @@
(let [response (send-request [:post "/api/note"] (let [response (send-request [:post "/api/note"]
{:note note {:note note
:pid pid :pid pid
:signature (get-signature pid psk note) :signature (storage/sign pid psk note)
:version "1.0" :version "1.0"
:theme "dark" :theme "dark"
:text-font "Helvetica"}) :text-font "Helvetica"})
@ -140,7 +151,7 @@
(let [response (send-request [:post "/api/note"] (let [response (send-request [:post "/api/note"]
{:note note {:note note
:pid pid :pid pid
:signature (get-signature pid psk note) :signature (storage/sign pid psk note)
:version "1.0" :version "1.0"
:password "qwerty"}) :password "qwerty"})
body (parse-string (:body response)) body (parse-string (:body response))
@ -156,7 +167,7 @@
{:noteID noteID {:noteID noteID
:note "WRONG pass" :note "WRONG pass"
:pid pid :pid pid
:signature (get-signature pid psk noteID "WRONG pass" "qwerty1") :signature (storage/sign pid psk noteID "WRONG pass" "qwerty1")
:password "qwerty1" :password "qwerty1"
:version "1.0"}) :version "1.0"})
body (parse-string (:body response))] body (parse-string (:body response))]
@ -172,7 +183,7 @@
{:noteID noteID {:noteID noteID
:note "UPDATED CONTENT" :note "UPDATED CONTENT"
:pid pid :pid pid
:signature (get-signature pid psk noteID "UPDATED CONTENT" "qwerty") :signature (storage/sign pid psk noteID "UPDATED CONTENT" "qwerty")
:password "qwerty" :password "qwerty"
:version "1.0"}))) ["status" "success"])) :version "1.0"}))) ["status" "success"]))
(isnt (= nil (((parse-string (isnt (= nil (((parse-string

24
test/NoteHub/test/views/pages.clj → test/NoteHub/test/handler.clj

@ -1,4 +1,11 @@
(ns NoteHub.test.views.pages (ns notehub.test.handler
(:use clojure.test
ring.mock.request
notehub.handler))
#_(
(ns NoteHub.test.views.pages
(:use [NoteHub.views.pages] (:use [NoteHub.views.pages]
[NoteHub.api :only [build-key get-signature get-date url]] [NoteHub.api :only [build-key get-signature get-date url]]
[noir.util.test] [noir.util.test]
@ -112,7 +119,22 @@
(is (has-status (send-request [:post "/post-note"]) 400))) (is (has-status (send-request [:post "/post-note"]) 400)))
(testing "valid accesses" (testing "valid accesses"
;(is (has-status (send-request "/new") 200) "accessing /new") ;(is (has-status (send-request "/new") 200) "accessing /new")
(is (has-status (send-request "/api") 200) "accessing API")
(is (has-status (send-request (url 2012 6 3 "some-title")) 200) "accessing test note") (is (has-status (send-request (url 2012 6 3 "some-title")) 200) "accessing test note")
(is (has-status (send-request (url 2012 6 3 "some-title" "export")) 200) "accessing test note's export") (is (has-status (send-request (url 2012 6 3 "some-title" "export")) 200) "accessing test note's export")
(is (has-status (send-request (url 2012 6 3 "some-title" "stats")) 200) "accessing test note's stats") (is (has-status (send-request (url 2012 6 3 "some-title" "stats")) 200) "accessing test note's stats")
(is (has-status (send-request "/") 200) "accessing landing page")))) (is (has-status (send-request "/") 200) "accessing landing page"))))
)
(deftest test-app
(testing "main route"
(let [response (app (request :get "/"))]
(is (= (:status response) 200))
(is (= (:body response) "Hello World"))))
(testing "not-found route"
(let [response (app (request :get "/invalid"))]
(is (= (:status response) 404)))))

7
test/NoteHub/test/storage.clj

@ -1,7 +1,6 @@
(ns NoteHub.test.storage (ns notehub.test.storage
(:use [NoteHub.storage] (:use [notehub.storage]
[NoteHub.api :only [build-key]] [notehub.api :only [build-key]]
[NoteHub.views.pages]
[clojure.test]) [clojure.test])
(:require [taoensso.carmine :as car :refer (wcar)])) (:require [taoensso.carmine :as car :refer (wcar)]))

Loading…
Cancel
Save