├── .gitignore ├── README.md ├── doc ├── content-docinfo.html ├── Makefile └── content.adoc ├── CONTRIBUTING.md ├── deps.edn ├── CHANGELOG.md ├── UNLICENSE ├── tools.clj ├── test └── struct │ └── tests.cljc └── src └── struct └── core.cljc /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | /*-init.clj 11 | /doc/dist 12 | /out 13 | /repl 14 | /node_modules 15 | /settings.xml 16 | /.cpcache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # struct # 2 | 3 | A structural validation library for Clojure(Script). 4 | 5 | [![Clojars Project](http://clojars.org/funcool/struct/latest-version.svg)](http://clojars.org/funcool/struct) 6 | 7 | Documentation: http://funcool.github.io/struct/latest/ 8 | -------------------------------------------------------------------------------- /doc/content-docinfo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | all: doc 2 | 3 | api: 4 | cd .. 5 | lein doc 6 | 7 | doc: 8 | mkdir -p dist/latest/ 9 | asciidoctor -a docinfo -a stylesheet! -o dist/latest/index.html content.adoc 10 | 11 | github: doc api 12 | ghp-import -m "Generate documentation" -b gh-pages dist/ 13 | git push origin gh-pages 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributed Code # 2 | 3 | In order to keep *struct* completely free and unencumbered by copyright, all new 4 | contributors to the *struct* code base are asked to dedicate their contributions to 5 | the public domain. If you want to send a patch or enhancement for possible inclusion 6 | in the *struct* source tree, please accompany the patch with the following 7 | statement: 8 | 9 | The author or authors of this code dedicate any and all copyright interest 10 | in this code to the public domain. We make this dedication for the benefit of 11 | the public at large and to the detriment of our heirs and successors. We 12 | intend this dedication to be an overt act of relinquishment in perpetuity of 13 | all present and future rights to this code under copyright law. 14 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {funcool/cuerdas {:mvn/version "2.2.0"}} 2 | :paths ["src"] 3 | :aliases 4 | {:dev 5 | {:extra-deps {org.clojure/clojurescript {:mvn/version "1.10.516"} 6 | ;; org.clojure/clojure {:mvn/version "1.10.0"} 7 | com.bhauman/rebel-readline-cljs {:mvn/version "0.1.4"} 8 | com.bhauman/rebel-readline {:mvn/version "0.1.4"} 9 | com.bhauman/figwheel-main {:mvn/version "0.2.0"} 10 | eftest/eftest {:mvn/version "0.5.7"}} 11 | :extra-paths ["test"]} 12 | 13 | :ancient {:main-opts ["-m" "deps-ancient.deps-ancient"] 14 | :extra-deps {deps-ancient {:mvn/version "RELEASE"}}} 15 | 16 | :jar {:extra-deps {seancorfield/depstar {:mvn/version "RELEASE"}} 17 | :main-opts ["-m" "hf.depstar.jar"]} 18 | 19 | :repl {:main-opts ["-m" "rebel-readline.main"]} 20 | }} 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog # 2 | 3 | ## Version 1.3.0 ## 4 | 5 | Date: 2018-06-02 6 | 7 | - Fix message formatting. 8 | 9 | 10 | ## Version 1.2.0 ## 11 | 12 | Date: 2017-01-11 13 | 14 | - Allow `number-str` and `integer-str` receive already coerced values. 15 | - Minor code cleaning. 16 | - Update dependencies. 17 | 18 | ## Version 1.1.0 ## 19 | 20 | Date: 2017-08-16 21 | 22 | - Add count validators. 23 | - Update cuerdas to 2.0.3 24 | 25 | 26 | ## Version 1.0.0 ## 27 | 28 | Date: 2016-06-24 29 | 30 | - Add support for neested data structures. 31 | - Add fast skip already validated and failed paths (performance improvement). 32 | - BREAKING CHANGE: the errors are now simple strings. No additional list 33 | wrapping is done anymore. Because the design of the library is just fail 34 | fast and only one error is allowed. 35 | 36 | 37 | ## Version 0.1.0 ## 38 | 39 | Date: 2016-04-19 40 | 41 | Initial version. 42 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /tools.clj: -------------------------------------------------------------------------------- 1 | (require '[clojure.java.shell :as shell] 2 | '[clojure.main]) 3 | (require '[rebel-readline.core] 4 | '[rebel-readline.clojure.main] 5 | '[rebel-readline.clojure.line-reader] 6 | '[rebel-readline.clojure.service.local] 7 | '[rebel-readline.cljs.service.local] 8 | '[rebel-readline.cljs.repl] 9 | '[eftest.runner :as ef]) 10 | (require '[cljs.build.api :as api] 11 | '[cljs.repl :as repl] 12 | '[cljs.repl.node :as node]) 13 | 14 | (defmulti task first) 15 | 16 | (defmethod task :default 17 | [args] 18 | (let [all-tasks (-> task methods (dissoc :default) keys sort) 19 | interposed (->> all-tasks (interpose ", ") (apply str))] 20 | (println "Unknown or missing task. Choose one of:" interposed) 21 | (System/exit 1))) 22 | 23 | (defmethod task "repl" 24 | [[_ type]] 25 | (case type 26 | (nil "clj") 27 | (rebel-readline.core/with-line-reader 28 | (rebel-readline.clojure.line-reader/create 29 | (rebel-readline.clojure.service.local/create)) 30 | (clojure.main/repl 31 | :prompt (fn []) ;; prompt is handled by line-reader 32 | :read (rebel-readline.clojure.main/create-repl-read))) 33 | 34 | "node" 35 | (rebel-readline.core/with-line-reader 36 | (rebel-readline.clojure.line-reader/create (rebel-readline.cljs.service.local/create)) 37 | (cljs.repl/repl 38 | (node/repl-env) 39 | :prompt (fn []) ;; prompt is handled by line-reader 40 | :read (rebel-readline.cljs.repl/create-repl-read) 41 | :output-dir "out" 42 | :cache-analysis false)) 43 | (println "Unknown repl: " type) 44 | (System/exit 1))) 45 | 46 | (def options 47 | {:main 'struct.tests 48 | :output-to "out/tests.js" 49 | :output-dir "out/tests" 50 | :target :nodejs 51 | :pretty-print true 52 | :optimizations :advanced 53 | :language-in :ecmascript5 54 | :language-out :ecmascript5 55 | :verbose true}) 56 | 57 | (defmethod task "test" 58 | [[_ exclude]] 59 | (let [tests (ef/find-tests "test") 60 | tests (if (string? exclude) 61 | (ef/find-tests (symbol exclude)) 62 | tests)] 63 | (ef/run-tests tests 64 | {:fail-fast? true 65 | :capture-output? false 66 | :multithread? false}) 67 | (System/exit 1))) 68 | 69 | 70 | (defmethod task "test-cljs" 71 | [[_ type]] 72 | (letfn [(build [optimizations] 73 | (api/build (api/inputs "src" "test") 74 | (cond-> (assoc options :optimizations optimizations) 75 | (= optimizations :none) (assoc :source-map true)))) 76 | 77 | (run-tests [] 78 | (let [{:keys [out err]} (shell/sh "node" "out/tests.js")] 79 | (println out err))) 80 | 81 | (test-once [] 82 | (build :none) 83 | (run-tests) 84 | (shutdown-agents)) 85 | 86 | (test-watch [] 87 | (println "Start watch loop...") 88 | (try 89 | (api/watch (api/inputs "src", "test") 90 | (assoc options 91 | :parallel-build false 92 | :watch-fn run-tests 93 | :cache-analysis false 94 | :optimizations :none 95 | :source-map true)) 96 | (catch Exception e 97 | (println "ERROR:" e) 98 | (Thread/sleep 2000) 99 | (test-watch))))] 100 | 101 | (case type 102 | (nil "once") (test-once) 103 | "watch" (test-watch) 104 | "build-none" (build :none) 105 | "build-simple" (build :simple) 106 | "build-advanced" (build :advanced) 107 | (do (println "Unknown argument to test task:" type) 108 | (System/exit 1))))) 109 | 110 | ;;; Build script entrypoint. This should be the last expression. 111 | 112 | (task *command-line-args*) 113 | -------------------------------------------------------------------------------- /test/struct/tests.cljc: -------------------------------------------------------------------------------- 1 | (ns struct.tests 2 | (:require #?(:cljs [cljs.test :as t] 3 | :clj [clojure.test :as t]) 4 | [struct.core :as st])) 5 | 6 | ;; --- Tests 7 | 8 | (t/deftest test-optional-validators 9 | (let [scheme {:max st/number 10 | :scope st/string} 11 | input {:scope "foobar"} 12 | result (st/validate input scheme)] 13 | (t/is (= nil (first result))) 14 | (t/is (= input (second result))))) 15 | 16 | (t/deftest test-simple-validators 17 | (let [scheme {:max st/number 18 | :scope st/string} 19 | input {:scope "foobar" :max "d"} 20 | errors {:max "must be a number"} 21 | result (st/validate input scheme)] 22 | (t/is (= errors (first result))) 23 | (t/is (= {:scope "foobar"} (second result))))) 24 | 25 | (t/deftest test-neested-validators 26 | (let [scheme {[:a :b] st/number 27 | [:c :d :e] st/string} 28 | input {:a {:b "foo"} :c {:d {:e "bar"}}} 29 | errors {:a {:b "must be a number"}} 30 | result (st/validate input scheme)] 31 | (t/is (= errors (first result))) 32 | (t/is (= {:c {:d {:e "bar"}}} (second result))))) 33 | 34 | (t/deftest test-single-validators 35 | (let [result1 (st/validate-single 2 st/number) 36 | result2 (st/validate-single nil st/number) 37 | result3 (st/validate-single nil [st/required st/number])] 38 | (t/is (= [nil 2] result1)) 39 | (t/is (= [nil nil] result2)) 40 | (t/is (= ["this field is mandatory" nil] result3)))) 41 | 42 | (t/deftest test-parametric-validators 43 | (let [result1 (st/validate 44 | {:name "foo"} 45 | {:name [[st/min-count 4]]}) 46 | result2 (st/validate 47 | {:name "bar"} 48 | {:name [[st/max-count 2]]})] 49 | (t/is (= {:name "less than the minimum 4"} (first result1))) 50 | (t/is (= {:name "longer than the maximum 2"} (first result2))))) 51 | 52 | (t/deftest test-simple-validators-with-vector-schema 53 | (let [scheme [[:max st/number] 54 | [:scope st/string]] 55 | input {:scope "foobar" :max "d"} 56 | errors {:max "must be a number"} 57 | result (st/validate input scheme)] 58 | (t/is (= errors (first result))) 59 | (t/is (= {:scope "foobar"} (second result))))) 60 | 61 | (t/deftest test-simple-validators-with-translate 62 | (let [scheme [[:max st/number] 63 | [:scope st/string]] 64 | input {:scope "foobar" :max "d"} 65 | errors {:max "a"} 66 | result (st/validate input scheme {:translate (constantly "a")})] 67 | (t/is (= errors (first result))) 68 | (t/is (= {:scope "foobar"} (second result))))) 69 | 70 | (t/deftest test-dependent-validators-1 71 | (let [scheme [[:password1 st/string] 72 | [:password2 [st/identical-to :password1]]] 73 | input {:password1 "foobar" 74 | :password2 "foobar."} 75 | errors {:password2 "does not match"} 76 | result (st/validate input scheme)] 77 | (t/is (= errors (first result))) 78 | (t/is (= {:password1 "foobar"} (second result))))) 79 | 80 | (t/deftest test-dependent-validators-2 81 | (let [scheme [[:password1 st/string] 82 | [:password2 [st/identical-to :password1]]] 83 | input {:password1 "foobar" 84 | :password2 "foobar"} 85 | result (st/validate input scheme)] 86 | (t/is (= nil (first result))) 87 | (t/is (= {:password1 "foobar" 88 | :password2 "foobar"} (second result))))) 89 | 90 | (t/deftest test-multiple-validators 91 | (let [scheme {:max [st/required st/number] 92 | :scope st/string} 93 | input {:scope "foobar"} 94 | errors {:max "this field is mandatory"} 95 | result (st/validate input scheme)] 96 | (t/is (= errors (first result))) 97 | (t/is (= {:scope "foobar"} (second result))))) 98 | 99 | (t/deftest test-validation-with-coersion 100 | (let [scheme {:max st/integer-str 101 | :scope st/string} 102 | input {:max "2" :scope "foobar"} 103 | result (st/validate input scheme)] 104 | (t/is (= nil (first result))) 105 | (t/is (= {:max 2 :scope "foobar"} (second result))))) 106 | 107 | (t/deftest test-validation-with-custom-coersion 108 | (let [scheme {:max [[st/number-str :coerce (constantly :foo)]] 109 | :scope st/string} 110 | input {:max "2" :scope "foobar"} 111 | result (st/validate input scheme)] 112 | (t/is (= nil (first result))) 113 | (t/is (= {:max :foo :scope "foobar"} (second result))))) 114 | 115 | (t/deftest test-validation-with-custom-message 116 | (let [scheme {:max [[st/number-str :message "custom msg"]] 117 | :scope st/string} 118 | input {:max "g" :scope "foobar"} 119 | errors {:max "custom msg"} 120 | result (st/validate input scheme)] 121 | (t/is (= errors (first result))) 122 | (t/is (= {:scope "foobar"} (second result))))) 123 | 124 | (t/deftest test-coersion-with-valid-values 125 | (let [scheme {:a st/number-str 126 | :b st/integer-str} 127 | input {:a 2.3 :b 3.3} 128 | [errors data] (st/validate input scheme)] 129 | (t/is (= {:a 2.3 :b 3} data)))) 130 | 131 | (t/deftest test-validation-nested-data-in-a-vector 132 | (let [scheme {:a [st/vector [st/every number?]]} 133 | input1 {:a [1 2 3 4]} 134 | input2 {:a [1 2 3 4 "a"]} 135 | [errors1 data1] (st/validate input1 scheme) 136 | [errors2 data2] (st/validate input2 scheme)] 137 | (t/is (= data1 input1)) 138 | (t/is (= errors1 nil)) 139 | (t/is (= data2 {})) 140 | (t/is (= errors2 {:a "must match the predicate"})))) 141 | 142 | (t/deftest test-in-range-validator 143 | (t/is (= {:age "not in range 18 and 26"} 144 | (-> {:age 17} 145 | (st/validate {:age [[st/in-range 18 26]]}) 146 | first)))) 147 | 148 | (t/deftest test-honor-nested-data 149 | (let [scheme {[:a :b] [st/required 150 | st/string 151 | [st/min-count 2 :message "foobar"] 152 | [st/max-count 5]]} 153 | input1 {:a {:b "abcd"}} 154 | input2 {:a {:b "abcdefgh"}} 155 | input3 {:a {:b "a"}} 156 | [errors1 data1] (st/validate input1 scheme) 157 | [errors2 data2] (st/validate input2 scheme) 158 | [errors3 data3] (st/validate input3 scheme)] 159 | (t/is (= data1 input1)) 160 | (t/is (= errors1 nil)) 161 | (t/is (= data2 {})) 162 | (t/is (= errors2 {:a {:b "longer than the maximum 5"}})) 163 | (t/is (= data3 {})) 164 | (t/is (= errors3 {:a {:b "foobar"}})))) 165 | 166 | ;; --- Entry point 167 | 168 | #?(:cljs 169 | (do 170 | (enable-console-print!) 171 | (set! *main-cli-fn* #(t/run-tests)))) 172 | 173 | #?(:cljs 174 | (defmethod t/report [:cljs.test/default :end-run-tests] 175 | [m] 176 | (if (t/successful? m) 177 | (set! (.-exitCode js/process) 0) 178 | (set! (.-exitCode js/process) 1)))) 179 | -------------------------------------------------------------------------------- /src/struct/core.cljc: -------------------------------------------------------------------------------- 1 | (ns struct.core 2 | (:refer-clojure :exclude [keyword uuid vector boolean long map set]) 3 | (:require [cuerdas.core :as str])) 4 | 5 | ;; --- Impl details 6 | 7 | (def ^:private map' #?(:cljs cljs.core/map 8 | :clj clojure.core/map)) 9 | 10 | (defn- apply-validation 11 | [step data value] 12 | (if-let [validate (:validate step nil)] 13 | (let [args (:args step [])] 14 | (if (:state step) 15 | (apply validate data value args) 16 | (apply validate value args))) 17 | true)) 18 | 19 | (defn- dissoc-in 20 | [m [k & ks :as keys]] 21 | (if ks 22 | (if-let [nextmap (get m k)] 23 | (let [newmap (dissoc-in nextmap ks)] 24 | (if (seq newmap) 25 | (assoc m k newmap) 26 | (dissoc m k))) 27 | m) 28 | (dissoc m k))) 29 | 30 | (defn- prepare-message 31 | [opts step] 32 | (if (::nomsg opts) 33 | ::nomsg 34 | (let [msg (:message step "errors.invalid") 35 | tr (:translate opts identity)] 36 | (apply str/format (tr msg) (vec (:args step)))))) 37 | 38 | (def ^:const ^:private opts-params 39 | #{:coerce :message :optional}) 40 | 41 | (def ^:private notopts? 42 | (complement opts-params)) 43 | 44 | (defn- build-step 45 | [key item] 46 | (letfn [(coerce-key [key] (if (vector? key) key [key]))] 47 | (if (vector? item) 48 | (let [validator (first item) 49 | result (split-with notopts? (rest item)) 50 | args (first result) 51 | opts (apply hash-map (second result))] 52 | (merge (assoc validator :args args :path (coerce-key key)) 53 | (select-keys opts [:coerce :message :optional]))) 54 | (assoc item :args [] :path (coerce-key key))))) 55 | 56 | (defn- normalize-step-map-entry 57 | [acc key value] 58 | (if (vector? value) 59 | (reduce #(conj! %1 (build-step key %2)) acc value) 60 | (conj! acc (build-step key value)))) 61 | 62 | (defn- normalize-step-entry 63 | [acc [key & values]] 64 | (reduce #(conj! %1 (build-step key %2)) acc values)) 65 | 66 | (defn- build-steps 67 | [schema] 68 | (cond 69 | (vector? schema) 70 | (persistent! 71 | (reduce normalize-step-entry (transient []) schema)) 72 | 73 | (map? schema) 74 | (persistent! 75 | (reduce-kv normalize-step-map-entry (transient []) schema)) 76 | 77 | :else 78 | (throw (ex-info "Invalid schema." {})))) 79 | 80 | (defn- strip-values 81 | [data steps] 82 | (reduce (fn [acc path] 83 | (let [value (get-in data path ::notexists)] 84 | (if (not= value ::notexists) 85 | (assoc-in acc path value) 86 | acc))) 87 | {} 88 | (into #{} (map' :path steps)))) 89 | 90 | (defn- validate-internal 91 | [data steps opts] 92 | (loop [skip #{} 93 | errors nil 94 | data data 95 | steps steps] 96 | (if-let [step (first steps)] 97 | (let [path (:path step) 98 | value (get-in data path)] 99 | (cond 100 | (contains? skip path) 101 | (recur skip errors data (rest steps)) 102 | 103 | (and (nil? value) (:optional step)) 104 | (recur skip errors data (rest steps)) 105 | 106 | (apply-validation step data value) 107 | (let [value ((:coerce step identity) value)] 108 | (recur skip errors (assoc-in data path value) (rest steps))) 109 | 110 | :else 111 | (let [message (prepare-message opts step)] 112 | (recur (conj skip path) 113 | (assoc-in errors path message) 114 | (dissoc-in data path) 115 | (rest steps))))) 116 | [errors data]))) 117 | 118 | ;; --- Public Api 119 | 120 | (defn validate 121 | "Validate data with specified schema. 122 | 123 | This function by default strips all data that are not defined in 124 | schema, but this behavior can be changed by passing `{:strip false}` 125 | as third argument." 126 | ([data schema] 127 | (validate data schema nil)) 128 | ([data schema {:keys [strip] 129 | :or {strip false} 130 | :as opts}] 131 | (let [steps (build-steps schema) 132 | data (if strip (strip-values data steps) data)] 133 | (validate-internal data steps opts)))) 134 | 135 | (defn validate-single 136 | "A helper that used just for validate one value." 137 | ([data schema] (validate-single data schema nil)) 138 | ([data schema opts] 139 | (let [data {:field data} 140 | steps (build-steps {:field schema})] 141 | (mapv :field (validate-internal data steps opts))))) 142 | 143 | (defn validate! 144 | "Analogous function to the `validate` that instead of return 145 | the errors, just raise a ex-info exception with errors in case 146 | them are or just return the validated data. 147 | 148 | This function accepts the same parameters as `validate` with 149 | an additional `:message` that serves for customize the exception 150 | message." 151 | ([data schema] 152 | (validate! data schema nil)) 153 | ([data schema {:keys [message] :or {message "Schema validation error"} :as opts}] 154 | (let [[errors data] (validate data schema opts)] 155 | (if (seq errors) 156 | (throw (ex-info message errors)) 157 | data)))) 158 | 159 | (defn valid? 160 | "Return true if the data matches the schema, otherwise 161 | return false." 162 | [data schema] 163 | (nil? (first (validate data schema {::nomsg true})))) 164 | 165 | (defn valid-single? 166 | "Analogous function to `valid?` that just validates single value." 167 | [data schema] 168 | (nil? (first (validate-single data schema {::nomsg true})))) 169 | 170 | ;; --- Validators 171 | 172 | (def keyword 173 | {:message "must be a keyword" 174 | :optional true 175 | :validate keyword? 176 | :coerce identity}) 177 | 178 | (def uuid 179 | {:message "must be an uuid" 180 | :optional true 181 | :validate #?(:clj #(instance? java.util.UUID %) 182 | :cljs #(instance? cljs.core.UUID %))}) 183 | 184 | (def ^:const ^:private +uuid-re+ 185 | #"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") 186 | 187 | (def uuid-str 188 | {:message "must be an uuid" 189 | :optional true 190 | :validate #(and (string? %) 191 | (re-seq +uuid-re+ %)) 192 | :coerce #?(:clj #(java.util.UUID/fromString %) 193 | :cljs #(uuid %))}) 194 | 195 | (def email 196 | (let [rx #"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"] 197 | {:message "must be a valid email" 198 | :optional true 199 | :validate #(and (string? %) 200 | (re-seq rx %))})) 201 | 202 | (def required 203 | {:message "this field is mandatory" 204 | :optional false 205 | :validate #(if (string? %) 206 | (not (empty? %)) 207 | (not (nil? %)))}) 208 | 209 | (def number 210 | {:message "must be a number" 211 | :optional true 212 | :validate number?}) 213 | 214 | (def number-str 215 | {:message "must be a number" 216 | :optional true 217 | :validate #(or (number? %) (and (string? %) (str/numeric? %))) 218 | :coerce #(if (number? %) % (str/parse-number %))}) 219 | 220 | (def integer 221 | {:message "must be a integer" 222 | :optional true 223 | :validate #?(:cljs #(js/Number.isInteger %) 224 | :clj #(integer? %))}) 225 | 226 | (def integer-str 227 | {:message "must be a long" 228 | :optional true 229 | :validate #(or (number? %) (and (string? %) (str/numeric? %))) 230 | :coerce #(if (number? %) (int %) (str/parse-int %))}) 231 | 232 | (def boolean 233 | {:message "must be a boolean" 234 | :optional true 235 | :validate #(or (= false %) (= true %))}) 236 | 237 | (def boolean-str 238 | {:message "must be a boolean" 239 | :optional true 240 | :validate #(and (string? %) 241 | (re-seq #"^(?:t|true|false|f|0|1)$" %)) 242 | :coerce #(contains? #{"t" "true" "1"} %)}) 243 | 244 | (def string 245 | {:message "must be a string" 246 | :optional true 247 | :validate string?}) 248 | 249 | (def string-like 250 | {:message "must be a string" 251 | :optional true 252 | :coerce str}) 253 | 254 | (def in-range 255 | {:message "not in range %s and %s" 256 | :optional true 257 | :validate #(and (number? %1) 258 | (number? %2) 259 | (number? %3) 260 | (<= %2 %1 %3))}) 261 | 262 | (def positive 263 | {:message "must be positive" 264 | :optional true 265 | :validate pos?}) 266 | 267 | (def negative 268 | {:message "must be negative" 269 | :optional true 270 | :validate neg?}) 271 | 272 | (def map 273 | {:message "must be a map" 274 | :optional true 275 | :validate map?}) 276 | 277 | (def set 278 | {:message "must be a set" 279 | :optional true 280 | :validate set?}) 281 | 282 | (def coll 283 | {:message "must be a collection" 284 | :optional true 285 | :validate coll?}) 286 | 287 | (def vector 288 | {:message "must be a vector instance" 289 | :optional true 290 | :validate vector?}) 291 | 292 | (def every 293 | {:message "must match the predicate" 294 | :optional true 295 | :validate #(every? %2 %1)}) 296 | 297 | (def member 298 | {:message "not in coll" 299 | :optional true 300 | :validate #(some #{%1} %2)}) 301 | 302 | (def function 303 | {:message "must be a function" 304 | :optional true 305 | :validate ifn?}) 306 | 307 | (def identical-to 308 | {:message "does not match" 309 | :optional true 310 | :state true 311 | :validate (fn [state v ref] 312 | (let [prev (get state ref)] 313 | (= prev v)))}) 314 | 315 | (def min-count 316 | (letfn [(validate [v minimum] 317 | {:pre [(number? minimum)]} 318 | (>= (count v) minimum))] 319 | {:message "less than the minimum %s" 320 | :optional true 321 | :validate validate})) 322 | 323 | (def max-count 324 | (letfn [(validate [v maximum] 325 | {:pre [(number? maximum)]} 326 | (<= (count v) maximum))] 327 | {:message "longer than the maximum %s" 328 | :optional true 329 | :validate validate})) 330 | -------------------------------------------------------------------------------- /doc/content.adoc: -------------------------------------------------------------------------------- 1 | = struct - validation library for Clojure(Script) 2 | :toc: left 3 | :!numbered: 4 | :idseparator: - 5 | :idprefix: 6 | :sectlinks: 7 | :source-highlighter: pygments 8 | :pygments-style: friendly 9 | 10 | 11 | == Introduction 12 | 13 | A structural validation library for Clojure and ClojureScript. 14 | 15 | Highlights: 16 | 17 | - *No macros*: validators are defined using plain data. 18 | - *Dependent validators*: the ability to access to already validated data. 19 | - *Coercion*: the ability to coerce incoming values to other types. 20 | - *No exceptions*: no exceptions used in the validation process. 21 | 22 | Based on similar ideas of 23 | link:https://github.com/leonardoborges/bouncer[bouncer]. 24 | 25 | 26 | === Project Maturity 27 | 28 | Since _struct_ is a young project there may be some API breakage. 29 | 30 | 31 | === Install 32 | 33 | Just include that in your dependency vector on *_project.clj_*: 34 | 35 | [source,clojure] 36 | ---- 37 | [funcool/struct "1.3.0"] 38 | ---- 39 | 40 | 41 | == User guide 42 | 43 | === Quick Start 44 | 45 | Let's require the main _struct_ namespace: 46 | 47 | [source, clojure] 48 | ---- 49 | (require '[struct.core :as st]) 50 | ---- 51 | 52 | Define a small schema for the example purpose: 53 | 54 | [source, clojure] 55 | ---- 56 | (def +scheme+ 57 | {:name [st/required st/string] 58 | :year [st/required st/number]}) 59 | ---- 60 | 61 | You can observe that it consists in a simple map when you declare keys and 62 | corresponding validators for that key. A vector as value allows us to put 63 | more than one validator for the same key. If you have only one validator for the 64 | key, you can omit the vector and put it as single value. 65 | 66 | The same schema can be defined using vectors, if the order of validation 67 | matters: 68 | 69 | [source, clojure] 70 | ---- 71 | (def +scheme+ 72 | [[:name st/required st/string] 73 | [:year st/required st/number]]) 74 | ---- 75 | 76 | By default, all validators are optional so if the value is missing, no error 77 | will reported. If you want make the value mandatory, you should use a specific 78 | `required` validator. 79 | 80 | And finally, start validating your data: 81 | 82 | [source, clojure] 83 | ---- 84 | (-> {:name "Blood of Elves" :year 1994} 85 | (st/validate +scheme+)) 86 | ;; => [nil {:name "Blood of Elves" :year 1994}] 87 | 88 | (-> {:name "Blood of Elves" :year "1994"} 89 | (st/validate +scheme+)) 90 | ;; => [{:year "must be a number"} {:name "Blood of Elves", :year "1994"}] 91 | 92 | (-> {:year "1994"} 93 | (st/validate +scheme+)) 94 | ;; => [{:name "this field is mandatory", :year "must be a number"} {}] 95 | ---- 96 | 97 | If only want to know if some data is valid or not, you can use the `valid?` predicate 98 | for that purpose: 99 | 100 | [source, clojure] 101 | ---- 102 | (st/valid? {:year "1994"} +scheme+) 103 | ;; => false 104 | ---- 105 | 106 | The additional entries in the map are not stripped by default, but this behavior 107 | can be changed passing an additional flag as the third argument: 108 | 109 | [source, clojure] 110 | ---- 111 | (-> {:name "Blood of Elves" :year 1994 :foo "bar"} 112 | (st/validate +scheme+)) 113 | ;; => [nil {:name "Blood of Elves" :year 1994 :foo "bar"}] 114 | 115 | (-> {:name "Blood of Elves" :year 1994 :foo "bar"} 116 | (st/validate +scheme+ {:strip true})) 117 | ;; => [nil {:name "Blood of Elves" :year 1994}] 118 | 119 | ---- 120 | 121 | With similar syntax you can validate neested data structures, specifying in the 122 | key part the proper path to the neested data structure: 123 | 124 | [source, clojure] 125 | ---- 126 | (def +scheme+ 127 | {[:a :b] st/integer 128 | [:c :d] st/string}) 129 | 130 | (-> {:a {:b "foo"} {:c {:d "bar"}}} 131 | (st/validate +scheme+)) 132 | ;; => [{:a {:b "must be a number"}} {:c {:d "bar"}}] 133 | ---- 134 | 135 | 136 | === Parametrized validators 137 | 138 | In addition to simple validators, one may use additional contraints 139 | (e.g. `in-range`). This is how they can be passed to the validator: 140 | 141 | [source, clojure] 142 | ---- 143 | (def schema {:num [[st/in-range 10 20]]}) 144 | 145 | (st/validate {:num 21} schema) 146 | ;; => [{:num "not in range"} {}] 147 | 148 | (st/validate {:num 19} schema) 149 | ;; => [nil {:num 19}] 150 | ---- 151 | 152 | Note the double vector; the outer denotes a list of validatiors and the inner 153 | denotes a validator with patameters. 154 | 155 | 156 | === Custom messages 157 | 158 | The builtin validators comes with default messages in human readable format, but 159 | sometimes you may want to change them (e.g. for i18n purposes). This is how you 160 | can do it: 161 | 162 | [source, clojure] 163 | ---- 164 | (def schema 165 | {:num [[st/in-range 10 20 :message "errors.not-in-range"]]}) 166 | 167 | (st/validate {:num 21} schema) 168 | ;; => [{:num "errors.not-in-range"} {}] 169 | ---- 170 | 171 | A message can contains format wildcards `%s`, these wildcards will be replaced by `args` of validator, e.g.: 172 | 173 | [source, clojure] 174 | ---- 175 | (def schema 176 | {:age [[st/in-range 18 26 :message "The age must be between %s and %s"]]}) 177 | 178 | (st/validate {:age 30} schema) 179 | ;; => [{:age "The age must be between 18 and 26"} {}] 180 | 181 | ---- 182 | 183 | 184 | === Data coercions 185 | 186 | In addition to simple validations, this library includes the ability 187 | to coerce values, and a collection of validators that matches over strings. Let's 188 | see some code: 189 | 190 | .Example attaching custom coercions 191 | [source, clojure] 192 | ---- 193 | (def schema 194 | {:year [[st/integer :coerce str]]}) 195 | 196 | (st/validate {:year 1994} schema)) 197 | ;; => [nil {:year "1994"}] 198 | ---- 199 | 200 | Looking at the data returned from the validation 201 | process, one can see that the value is properly coerced with the specified coercion function. 202 | 203 | This library comes with a collection of validators that already 204 | have attached coercion functions. These serve to validate parameters 205 | that arrive as strings but need to be converted to the appropriate type: 206 | 207 | [source, clojure] 208 | ---- 209 | (def schema {:year [st/required st/integer-str] 210 | :id [st/required st/uuid-str]}) 211 | 212 | (st/validate {:year "1994" 213 | :id "543e7472-6624-4cb5-b65e-f3c341843d0f"} 214 | schema) 215 | ;; => [nil {:year 1994, :id #uuid "543e7472-6624-4cb5-b65e-f3c341843d0f"}] 216 | ---- 217 | 218 | To facilitate this operation, the `validate!` function receives the 219 | data and schema, then returns the resulting data. If data not matches the schema 220 | an exception will be raised using `ex-info` clojure facility: 221 | 222 | [source, clojure] 223 | ---- 224 | (st/validate! {:year "1994" :id "543e7472-6624-4cb5-b65e-f3c341843d0f"} schema) 225 | ;; => {:year 1994, :id #uuid "543e7472-6624-4cb5-b65e-f3c341843d0f"} 226 | ---- 227 | 228 | === Builtin Validators 229 | 230 | This is the table with available builtin validators: 231 | 232 | .Builtin Validators 233 | [options="header", cols="2,1,4"] 234 | |=========================================================================== 235 | | Identifier | Coercion | Description 236 | | `struct.core/keyword` | no | Validator for clojure's keyword 237 | | `struct.core/uuid` | no | Validator for UUID's 238 | | `struct.core/uuid-str` | yes | Validator for uuid strings with coercion to UUID 239 | | `struct.core/email` | no | Validator for email string. 240 | | `struct.core/required` | no | Marks field as required. 241 | | `struct.core/number` | no | Validator for Number. 242 | | `struct.core/number-str` | yes | Validator for number string. 243 | | `struct.core/integer` | no | Validator for integer. 244 | | `struct.core/integer-str` | yes | Validator for integer string. 245 | | `struct.core/boolean` | no | Validator for boolean. 246 | | `struct.core/boolean-str` | yes | Validator for boolean string. 247 | | `struct.core/string` | no | Validator for string. 248 | | `struct.core/string-str` | yes | Validator for string like. 249 | | `struct.core/in-range` | no | Validator for a number range. 250 | | `struct.core/member` | no | Validator for check if a value is member of coll. 251 | | `struct.core/positive` | no | Validator for positive number. 252 | | `struct.core/negative` | no | Validator for negative number. 253 | | `struct.core/function` | no | Validator for IFn interface. 254 | | `struct.core/vector` | no | Validator for clojure vector. 255 | | `struct.core/map` | no | Validator for clojure map. 256 | | `struct.core/set` | no | Validator for clojure set. 257 | | `struct.core/coll` | no | Validator for clojure coll. 258 | | `struct.core/every` | no | Validator to check if pred match for every item in coll. 259 | | `struct.core/identical-to` | no | Validator to check that value is identical to other field. 260 | | `struct.core/min-count` | no | Validator to check that value is has at least a minimum number of characters. 261 | | `struct.core/max-count` | no | Validator to check that value is not larger than a maximum number of characters. 262 | |=========================================================================== 263 | 264 | Additional notes: 265 | 266 | * `number-str` coerces to `java.lang.Double` or `float` (cljs) 267 | * `boolean-str` coerces to `true` (`"t"`, `"true"`, `"1"`) or `false` (`"f"`, `"false"`, `"0"`). 268 | * `string-str` coerces anything to string using `str` function. 269 | 270 | 271 | === Define your own validator 272 | 273 | As mentioned previously, the validators in _struct_ library are defined using plain 274 | hash-maps. For example, this is how the builtin `integer` validator is defined: 275 | 276 | [source, clojure] 277 | ---- 278 | (def integer 279 | {:message "must be a integer" 280 | :optional true 281 | :validate integer?})) 282 | ---- 283 | 284 | If the validator needs access to previously validated data, the `:state` key 285 | should be present with the value `true`. Let see the `identical-to` validator as example: 286 | 287 | [source,clojure] 288 | ---- 289 | (def identical-to 290 | {:message "does not match" 291 | :optional true 292 | :state true 293 | :validate (fn [state v ref] 294 | (let [prev (get state ref)] 295 | (= prev v)))}) 296 | ---- 297 | 298 | Validators that access the state receive an additional argument with the state for validator 299 | function. 300 | 301 | === Translating validation messages 302 | 303 | `struct.core/validate` accepts a third options argument where a function can be passed in with the `:translate` key like the following: 304 | 305 | [source,clojure] 306 | ---- 307 | (st/validate {:year "1994" 308 | :id "543e7472-6624-4cb5-b65e-f3c341843d0f"} 309 | schema 310 | {:translate (fn [message] (clojure.string/uppercase message))) 311 | ---- 312 | 313 | The translation function accepts the `:message` of the schma upon validation failure. 314 | This allows easy integration with an i18n library such as tempura if you privide a keyword for the schema's `:message` that in turn maps to the localised message in the dictionary. 315 | 316 | == Developers Guide 317 | 318 | === Contributing 319 | 320 | Unlike Clojure and other Clojure contrib libs, there aren't many restrictions for 321 | contributions. Just open an issue or pull request. 322 | 323 | 324 | === Get the Code 325 | 326 | _struct_ is open source and can be found on 327 | link:https://github.com/funcool/struct[github]. 328 | 329 | You can clone the public repository with this command: 330 | 331 | [source,text] 332 | ---- 333 | git clone https://github.com/funcool/struct 334 | ---- 335 | 336 | 337 | === Run tests 338 | 339 | To run the tests execute the following: 340 | 341 | For the JVM platform: 342 | 343 | [source, text] 344 | ---- 345 | lein test 346 | ---- 347 | 348 | And for JS platform: 349 | 350 | [source, text] 351 | ---- 352 | ./scripts/build 353 | node out/tests.js 354 | ---- 355 | 356 | You will need to have nodejs installed on your system. 357 | 358 | === License 359 | 360 | _struct_ is under public domain: 361 | 362 | ---- 363 | This is free and unencumbered software released into the public domain. 364 | 365 | Anyone is free to copy, modify, publish, use, compile, sell, or 366 | distribute this software, either in source code form or as a compiled 367 | binary, for any purpose, commercial or non-commercial, and by any 368 | means. 369 | 370 | In jurisdictions that recognize copyright laws, the author or authors 371 | of this software dedicate any and all copyright interest in the 372 | software to the public domain. We make this dedication for the benefit 373 | of the public at large and to the detriment of our heirs and 374 | successors. We intend this dedication to be an overt act of 375 | relinquishment in perpetuity of all present and future rights to this 376 | software under copyright law. 377 | 378 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 379 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 380 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 381 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 382 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 383 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 384 | OTHER DEALINGS IN THE SOFTWARE. 385 | 386 | For more information, please refer to 387 | ---- 388 | --------------------------------------------------------------------------------