├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── VERSION ├── bin └── kaocha ├── deps.edn ├── src └── exoscale │ ├── coax.cljc │ └── coax │ ├── coercer.cljc │ ├── inspect.cljc │ ├── utils.clj │ └── utils.cljs ├── test └── exoscale │ └── coax_test.cljc └── tests.edn /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Prepare java 13 | uses: actions/setup-java@v3 14 | with: 15 | java-version: '11' 16 | distribution: 'zulu' 17 | architecture: 'x64' 18 | 19 | - name: Install clojure tools 20 | uses: DeLaGuardo/setup-clojure@10.0 21 | with: 22 | cli: 1.11.1.1182 23 | 24 | - name: Run clj tests 25 | run: ./bin/kaocha clj 26 | 27 | - name: Run cljs tests 28 | run: ./bin/kaocha cljs 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | .cpcache 3 | **/.clj-kondo/.cache 4 | **/.lsp 5 | **/.DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Exoscale 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coax 2 | 3 | Clojure.spec coercion library for clj(s) 4 | 5 | Most Coax "public" functions take a spec and a value and try to return a new 6 | value that conforms to that spec by altering the input value when possible and 7 | most importantly when it makes sense. 8 | 9 | Coax is centered around its own registry for coercion rules, when a coercion is 10 | not registered it can infer in most cases what to do to coerce a value into 11 | something that conforms to a spec. It also supports "coerce time" options to 12 | enable custom coercion from any spec type, including spec `forms` (like 13 | s/coll-of & co) or just `idents` (predicates, registered specs). 14 | 15 | Coax initially started as a fork of spec-coerce, but nowadays the internals and 16 | the API are totally different. Wilker Lúcio's approach gave us a nice outline of 17 | how such library could expose its functionality. 18 | 19 | ## What 20 | 21 | The typical (inferred) example would be : 22 | 23 | ```clj 24 | (s/def ::foo keyword?) 25 | (c/coerce ::foo "bar") -> :bar 26 | ``` 27 | 28 | ### registering coercers 29 | 30 | You can register a coercer per spec if needed 31 | 32 | ```clj 33 | (s/def ::foo string?) 34 | (c/def ::foo (fn [x opts] (str "from-registry: " x))) 35 | (c/coerce ::foo "bar") -> "from-registry: bar" 36 | 37 | ``` 38 | 39 | ### Overrides 40 | 41 | Overrides allow to change the defaults, essentially all the internal 42 | conversion rules are open via the options to `coerce`, they will be 43 | merged with the internal registry at coerce time. 44 | 45 | ```clj 46 | (s/def ::foo keyword?) 47 | (c/coerce ::foo "bar" {:idents {::foo (fn [x opts] (str "keyword:" x))}}) -> "keyword:bar" 48 | ``` 49 | 50 | Coercers are functions of 2 args, the value, and the options coerce 51 | received. They return either a coerced value or 52 | `:exoscale.coax/invalid`, which indicates we didn't know how to coerce 53 | that value, in which case s/coerce will set the value to the input. 54 | 55 | Overrides also works on any qualified-ident (registered specs or 56 | symbols/fns), which is something spec-coerce cannot do currently. 57 | 58 | The typical example would be : 59 | 60 | ```clj 61 | (s/def ::foo (s/coll-of keyword?)) 62 | ;; we'll namespace all keywords in that coll-of 63 | (c/coerce ::foo ["a" "b"] {:idents {`keyword? (fn [x opts] (keyword "foo" x))}}) -> [foo/a foo/b] 64 | ``` 65 | 66 | You can specify multiple overrides per coerce call. 67 | 68 | Another thing we added is the ability to reach and change the 69 | behavior of coercer generators via :forms, essentially allowing 70 | you to support any spec form like inst-in, coll-of, .... You could 71 | easily for instance generate open-api definitions using these. 72 | 73 | ```clj 74 | (c/coerce ::foo (s/coll-of keyword?) 75 | {:forms {`s/coll-of (fn [[_ spec]] (fn [x opts] (do-something-crazy-with-spec+the-value spec x opts)))}}) 76 | ``` 77 | 78 | ### Closed maps 79 | 80 | `coax` also allows to *close* maps specced with `s/keys`. 81 | 82 | If you call `coerce` using the option `{:closed true ...}` if a value 83 | corresponding to a `s/keys` spec is encountered it will effectively remove all 84 | unknown keys from the returned value. 85 | 86 | ``` clj 87 | (s/def ::foo string?) 88 | (s/def ::bar string?) 89 | (s/def ::z string?) 90 | 91 | (s/def ::m (s/keys :req-un [::foo ::bar])) 92 | 93 | (c/coerce ::m {:foo "f" :bar "b" :baz "z"} {:closed true}) ; baz is not on the spec 94 | -> {:foo "f" :bar "b"} ; it gets removed 95 | 96 | ;; this works in any s/keys matching spec in the data passed: 97 | (coerce `(s/coll-of ::m) [{:foo "f" :bar "b" :baz "x"}] {:closed true}) 98 | -> [{:foo "f" :bar "b"}] 99 | 100 | ;; also plays nice with s/merge, multi-spec & co 101 | (coerce `(s/merge ::m (s/keys :req-un [::z])) 102 | {:foo "f" :bar "b" :baz "x" :z "z"} {:closed true}) 103 | 104 | -> {:foo "f" :bar "b" :z "z"} 105 | ``` 106 | 107 | ## Documentation 108 | 109 | [![cljdocbadge](https://cljdoc.xyz/badge/exoscale/coax)](https://cljdoc.org/d/exoscale/coax/CURRENT/api/exoscale.coax) 110 | 111 | ## Installation 112 | 113 | coax is [available on Clojars](https://clojars.org/exoscale/coax). 114 | 115 | Add this to your dependencies: 116 | 117 | [![Clojars Project](https://img.shields.io/clojars/v/exoscale/coax.svg)](https://clojars.org/exoscale/coax) 118 | 119 | 120 | ## More Usage examples (taken directly from spec-coerce) 121 | 122 | Learn by example: 123 | 124 | ```clojure 125 | (ns exoscale.coax.example 126 | (:require 127 | [clojure.spec.alpha :as s] 128 | [exoscale.coax :as c])) 129 | 130 | ; Define a spec as usual 131 | (s/def ::number int?) 132 | 133 | ; Call the coerce method passing the spec and the value to be coerced 134 | (c/coerce ::number "42") ; => 42 135 | 136 | ; Like spec generators, when using `and` it will use the first item as the inference source 137 | (s/def ::odd-number (s/and int? odd?)) 138 | (c/coerce ::odd-number "5") ; => 5 139 | 140 | ; When inferring the coercion, it tries to resolve the upmost spec in the definition 141 | (s/def ::extended (s/and ::odd-number #(> % 10))) 142 | (c/coerce ::extended "11") ; => 11 143 | 144 | ; Nilables are considered 145 | (s/def ::nilable (s/nilable ::number)) 146 | (c/coerce ::nilable "42") ; => 42 147 | (c/coerce ::nilable "foo") ; => "foo" 148 | 149 | ; The coercion can even be automatically inferred from specs given explicitly as sets of a homogeneous type 150 | (s/def ::enum #{:a :b :c}) 151 | (c/coerce ::enum ":a") ; => :a 152 | 153 | ; If you wanna play around or use a specific coercion, you can pass the predicate symbol directly 154 | (c/coerce `int? "40") ; => 40 155 | 156 | ; Parsers are written to be safe to call, when unable to coerce they will return the original value 157 | (c/coerce `int? "40.2") ; => "40.2" 158 | (c/coerce `inst? "date") ; => "date" 159 | 160 | ; To leverage map keys and coerce a composed structure, use coerce-structure 161 | (c/coerce-structure {::number "42" 162 | ::not-defined "bla" 163 | :sub {::odd-number "45"}}) 164 | ; => {::number 42 165 | ; ::not-defined "bla" 166 | ; :sub {::odd-number 45}} 167 | 168 | ; coerce-structure supports overrides, so you can set a custom coercer for a specific context 169 | (c/coerce-structure {::number "42" 170 | ::not-defined "bla" 171 | :sub {::odd-number "45"}} 172 | {:idents {::not-defined `keyword? 173 | ; => {::number 42 174 | ; ::not-defined :bla 175 | ; :sub {::odd-number 45}} 176 | 177 | ; If you want to set a custom coercer for a given spec, use the exoscale.coax registry 178 | (defrecord SomeClass [x]) 179 | (s/def ::my-custom-attr #(instance? SomeClass %)) 180 | (c/def ::my-custom-attr #(map->SomeClass {:x %})) 181 | 182 | ; Custom registered keywords always takes precedence over inference 183 | (c/coerce ::my-custom-attr "Z") ; => #user.SomeClass{:x "Z"} 184 | 185 | (c/coerce ::my-custom-attr "Z") {:idents {::my-custom-attr keyword}}) ; => :Z 186 | ``` 187 | 188 | Examples from predicate to coerced value: 189 | 190 | ```clojure 191 | ; Numbers 192 | (c/coerce `number? "42") ; => 42.0 193 | (c/coerce `integer? "42") ; => 42 194 | (c/coerce `int? "42") ; => 42 195 | (c/coerce `pos-int? "42") ; => 42 196 | (c/coerce `neg-int? "-42") ; => -42 197 | (c/coerce `nat-int? "10") ; => 10 198 | (c/coerce `even? "10") ; => 10 199 | (c/coerce `odd? "9") ; => 9 200 | (c/coerce `float? "42.42") ; => 42.42 201 | (c/coerce `double? "42.42") ; => 42.42 202 | (c/coerce `zero? "0") ; => 0 203 | 204 | ; Numbers on CLJS 205 | (c/coerce `int? "NaN") ; => js/NaN 206 | (c/coerce `double? "NaN") ; => js/NaN 207 | 208 | ; Booleans 209 | (c/coerce `boolean? "true") ; => true 210 | (c/coerce `boolean? "false") ; => false 211 | (c/coerce `true? "true") ; => true 212 | (c/coerce `false? "false") ; => false 213 | 214 | ; Idents 215 | (c/coerce `ident? ":foo/bar") ; => :foo/bar 216 | (c/coerce `ident? "foo/bar") ; => 'foo/bar 217 | (c/coerce `simple-ident? ":foo") ; => :foo 218 | (c/coerce `qualified-ident? ":foo/baz") ; => :foo/baz 219 | (c/coerce `keyword? "keyword") ; => :keyword 220 | (c/coerce `keyword? ":keyword") ; => :keyword 221 | (c/coerce `simple-keyword? ":simple-keyword") ; => :simple-keyword 222 | (c/coerce `qualified-keyword? ":qualified/keyword") ; => :qualified/keyword 223 | (c/coerce `symbol? "sym") ; => 'sym 224 | (c/coerce `simple-symbol? "simple-sym") ; => 'simple-sym 225 | (c/coerce `qualified-symbol? "qualified/sym") ; => 'qualified/sym 226 | 227 | ; Collections 228 | (c/coerce `(s/coll-of int?) ["5" "11" "42"]) ; => [5 11 42] 229 | (c/coerce `(s/coll-of int?) ["5" "11.3" "42"]) ; => [5 "11.3" 42] 230 | (c/coerce `(s/map-of keyword? int?) {"foo" "42" "bar" "31"}) 231 | ; => {:foo 42 :bar 31} 232 | 233 | ; Branching 234 | ; tests are realized in order 235 | (c/coerce `(s/or :int int? :bool boolean?) "40") ; 40 236 | (c/coerce `(s/or :int int? :bool boolean?) "true") ; true 237 | ; returns original value when no options can handle 238 | (c/coerce `(s/or :int int? :bool boolean?) "foo") ; "foo" 239 | 240 | ; Tuple 241 | (c/coerce `(s/tuple int? string?) ["0" 1]) ; => [0 "1"] 242 | 243 | ; Others 244 | (c/coerce `uuid? "d6e73cc5-95bc-496a-951c-87f11af0d839") ; => #uuid "d6e73cc5-95bc-496a-951c-87f11af0d839" 245 | (c/coerce `inst? "2017-07-21") ; => #inst "2017-07-21T00:00:00.000000000-00:00" 246 | (c/coerce `nil? "foo") ; => "foo" 247 | (c/coerce `nil? nil) ; => nil 248 | 249 | ;; Clojure only: 250 | (c/coerce `uri? "http://site.com") ; => (URI. "http://site.com") 251 | (c/coerce `decimal? "42.42") ; => 42.42M 252 | (c/coerce `decimal? "42.42M") ; => 42.42M 253 | 254 | ;; Throw exception when coercion fails 255 | (c/coerce! ::number "abc") ; => throws (ex-info "Failed to coerce value" {:spec ::number :val "abc" ...}) 256 | (c/coerce! :simple-keyword "abc") ; => "abc", coerce! doesn't do anything on simple keywords 257 | 258 | ;; Conform the result after coerce 259 | (c/conform ::number "40") ; 40 260 | 261 | ;; Throw on coerce structure 262 | (c/coerce-structure {::number "42"} {:op c/coerce!}) 263 | 264 | ;; Conform on coerce structure 265 | (c/coerce-structure {::number "42"} {:op c/conform}) 266 | ``` 267 | 268 | ## Caching 269 | 270 | Coax applies caching of coercers function to cut the cost of walking 271 | specs and generating coercers per call, it makes the coercion process 272 | orders of magnitude faster once cached (depends on what the coercion 273 | does of course). It is `on` by default. The cache is under 274 | `exoscale.coax/coercer-cache`, it's just an atom holding a map of 275 | `[spec, options] -> coercer`. In most cases you shouldn't have to care 276 | about this, for instance when you define static coercers via 277 | `coax/def` we'll make sure the cache is updated accordingly. But 278 | during development you might need to be aware of the existence of that 279 | cache (ex if you defined a bugged coercer, or while doing REPL dev). 280 | 281 | In any case you can turn off the cache by passing `:cache false` to the options 282 | of coerce/conform/coerce-structure, alternatively you can manually fiddle with 283 | the cache under `exoscale.coax/coercer-cache`, for instance via `(reset! 284 | exoscale.coax/coercer-cache {})`. 285 | 286 | ## Options 287 | 288 | * `:coerce-or-match-first`: By default coercion of s/or will either the first 289 | value that **matches the input** and is not considered *coercion invalid*. You 290 | can however change this behavior by setting this option to true by making 291 | coerce-or return the **first** coerced value instead. This allows you to handle 292 | cases where there is ambiguity (ex s/or with string? and uuid? with a valid uuid 293 | string). 294 | 295 | * `:cache`: See Caching 296 | 297 | * `:idents`: See Overrides 298 | 299 | * `:forms`: See Overrides 300 | 301 | * `:closed`: See Closed maps 302 | 303 | ## License 304 | 305 | * License Copyright © 2020 Exoscale - Distributed under ISC License 306 | 307 | * [spec-coerce original license](https://github.com/wilkerlucio/spec-coerce/blob/master/LICENSE) 308 | Copyright © 2017 Wilker Lúcio - Distributed under the MIT License. 309 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.0.3-SNAPSHOT -------------------------------------------------------------------------------- /bin/kaocha: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | [ -d "node_modules/ws" ] || npm install ws 3 | clojure -M:test "$@" 4 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:exoscale.project/lib exoscale/coax 2 | :exoscale.project/version-file "VERSION" 3 | :exoscale.project/deploy? true 4 | :exoscale.project/pom-data 5 | [[:licenses 6 | [:license 7 | [:name "MIT"] 8 | [:url "https://opensource.org/license/mit/"] 9 | [:distribution "repo"]] 10 | [:license 11 | [:name "ISC"] 12 | [:url "https://opensource.org/license/isc-license-txt/"] 13 | [:distribution "repo"]]]] 14 | 15 | :slipset.deps-deploy/exec-args {:installer :remote 16 | :sign-releases? false 17 | :repository "clojars"} 18 | 19 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 20 | net.cgrand/macrovich {:mvn/version "0.2.1"}} 21 | 22 | :aliases 23 | {:project {:deps {io.github.exoscale/tools.project {:git/sha "99e6b7aaccd9b97079341625c807b9fa0352e36d"}} 24 | :ns-default exoscale.tools.project 25 | :jvm-opts ["-Dclojure.main.report=stderr"]} 26 | 27 | :test 28 | {:extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} 29 | lambdaisland/kaocha {:mvn/version "1.71.1119"} 30 | com.lambdaisland/kaocha-cljs {:mvn/version "1.4.130"}} 31 | :extra-paths ["test"] 32 | :main-opts ["-m" "kaocha.runner"]}}} 33 | -------------------------------------------------------------------------------- /src/exoscale/coax.cljc: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax 2 | (:refer-clojure :exclude [def]) 3 | (:require #?(:clj [net.cgrand.macrovich :as macros]) 4 | [clojure.spec.alpha :as s] 5 | [clojure.walk :as walk] 6 | [exoscale.coax.coercer :as c] 7 | [exoscale.coax.inspect :as si]) 8 | #?(:clj 9 | (:import (clojure.lang Keyword) 10 | (java.util Date UUID) 11 | (java.time Instant) 12 | (java.net URI))) 13 | #?(:cljs 14 | (:require-macros [net.cgrand.macrovich :as macros] 15 | [exoscale.coax :refer [def]]))) 16 | 17 | (declare coerce coerce*) 18 | 19 | (defn- empty-rec 20 | [r] 21 | (reduce-kv (fn [r k _] (assoc r k nil)) r r)) 22 | 23 | (defn gen-coerce-or [[_ & pairs]] 24 | (fn [x {:as opts :keys [coerce-or-match-first]}] 25 | (let [xs (sequence 26 | (comp (partition-all 2) 27 | (map #(coerce* (second %) x opts)) 28 | (remove #{:exoscale.coax/invalid})) 29 | pairs)] 30 | (if coerce-or-match-first 31 | ;; match first value that was coerced 32 | (first xs) 33 | ;; return first val that's either matching input or not invalid 34 | (or (reduce (fn [_ x'] 35 | (when (= x x') 36 | (reduced x))) 37 | nil 38 | xs) 39 | (first xs)))))) 40 | 41 | (defn gen-coerce-and [[_ & [spec]]] 42 | (fn [x opts] 43 | (coerce spec x opts))) 44 | 45 | (defn gen-coerce-keys 46 | [[_ & {:as _opts :keys [req-un opt-un req opt]}]] 47 | (let [keys-mapping-unns (into {} 48 | (keep #(when (keyword? %) 49 | [(keyword (name %)) %])) 50 | (flatten (concat req-un opt-un))) 51 | keys-mapping-ns (into {} 52 | (map (juxt identity identity)) 53 | (flatten (concat req opt))) 54 | keys-mapping (merge keys-mapping-unns keys-mapping-ns)] 55 | (fn [x {:as opts :keys [closed]}] 56 | (if (map? x) 57 | (reduce-kv (fn [m k v] 58 | (let [s-from-mapping (keys-mapping k) 59 | s (or s-from-mapping k)] 60 | (cond 61 | ;; if closed and not in mapping dissoc 62 | (and closed (not s-from-mapping)) 63 | (dissoc m k) 64 | ;; registered spec -> coerce 65 | (qualified-ident? s) 66 | (assoc m k 67 | (coerce s 68 | v 69 | opts)) 70 | ;; passthrough 71 | :else m))) 72 | x 73 | x) 74 | :exoscale.coax/invalid)))) 75 | 76 | (defn gen-coerce-coll-of [[_ spec & {:as _opts :keys [kind]}]] 77 | (fn [x opts] 78 | (if (coll? x) 79 | ;; either we have a `:kind` and coerce to that, or we just `empty` the 80 | ;; original 81 | (let [xs (into (condp = kind 82 | `vector? [] 83 | `set? #{} 84 | `coll? '() 85 | `list? '() 86 | ;; else 87 | (empty x)) 88 | (map #(coerce spec % opts)) 89 | x)] 90 | (cond-> xs (list? xs) 91 | reverse)) 92 | :exoscale.coax/invalid))) 93 | 94 | (defn gen-coerce-map-of [[_ kspec vspec & _]] 95 | (fn [x opts] 96 | (if (map? x) 97 | (into (if (record? x) 98 | (empty-rec x) 99 | (empty x)) 100 | (map (fn [[k v]] 101 | [(coerce kspec k opts) 102 | (coerce vspec v opts)])) 103 | x) 104 | :exoscale.coax/invalid))) 105 | 106 | (defn gen-coerce-tuple [[_ & specs]] 107 | (fn [x opts] 108 | (if (sequential? x) 109 | (mapv #(coerce %1 %2 opts) 110 | specs 111 | x) 112 | :exoscale.coax/invalid))) 113 | 114 | (defn gen-coerce-multi-spec 115 | [[_ f retag & _ :as spec-expr]] 116 | (let [f #?(:clj (resolve f) 117 | ;; wall-hack, inspired by spec-tools internals until we 118 | ;; get a better way to do it 119 | :cljs (->> (s/registry) 120 | vals 121 | (filter #(= spec-expr (s/form %))) 122 | first 123 | .-mmvar))] 124 | (fn [x opts] 125 | (if (map? x) 126 | (coerce (s/form (f x)) 127 | x 128 | opts) 129 | :exoscale.coax/invalid)))) 130 | 131 | (defn gen-coerce-merge 132 | [[_ & spec-forms]] 133 | (fn [x {:as opts :keys [closed]}] 134 | (if (map? x) 135 | (if closed 136 | (into {} 137 | (map (fn [spec-form] 138 | (coerce spec-form x (assoc opts :closed true)))) 139 | spec-forms) 140 | ;; not closed, we also have to ensure we don't overwrite values with 141 | ;; more loose specs towards the end of the args (ex `any?` 142 | (reduce (fn [m spec-form] 143 | (into m 144 | (remove (fn [[k v]] 145 | (= (get x k) v))) 146 | (coerce spec-form x (assoc opts :closed true)))) 147 | x 148 | spec-forms)) 149 | 150 | :exoscale.coax/invalid))) 151 | 152 | (defn gen-coerce-nilable 153 | [[_ spec]] 154 | (fn [x opts] 155 | (when (some? x) 156 | (coerce spec x opts)))) 157 | 158 | (defprotocol EnumKey 159 | (enum-key [x] 160 | "takes enum value `x` and returns matching predicate to resolve 161 | coercer from registry")) 162 | 163 | (defonce ^:private registry-ref 164 | (atom {:exoscale.coax/forms 165 | {`s/or gen-coerce-or 166 | `s/and gen-coerce-and 167 | `s/nilable gen-coerce-nilable 168 | `s/coll-of gen-coerce-coll-of 169 | `s/every gen-coerce-coll-of 170 | `s/map-of gen-coerce-map-of 171 | `s/every-kv gen-coerce-map-of 172 | `s/tuple gen-coerce-tuple 173 | `s/multi-spec gen-coerce-multi-spec 174 | `s/keys gen-coerce-keys 175 | `s/merge gen-coerce-merge 176 | `s/inst-in (constantly c/to-inst) 177 | `s/int-in (constantly c/to-long) 178 | `s/double-in (constantly c/to-double)} 179 | :exoscale.coax/idents 180 | {`string? c/to-string 181 | `number? c/to-number 182 | `integer? c/to-long 183 | `int? c/to-long 184 | `pos-int? c/to-long 185 | `neg-int? c/to-long 186 | `nat-int? c/to-long 187 | `even? c/to-long 188 | `odd? c/to-long 189 | `float? c/to-double 190 | `double? c/to-double 191 | `boolean? c/to-boolean 192 | `ident? c/to-ident 193 | `simple-ident? c/to-ident 194 | `qualified-ident? c/to-ident 195 | `keyword? c/to-keyword 196 | `simple-keyword? c/to-keyword 197 | `qualified-keyword? c/to-keyword 198 | `symbol? c/to-symbol 199 | `simple-symbol? c/to-symbol 200 | `qualified-symbol? c/to-symbol 201 | `uuid? c/to-uuid 202 | `inst? c/to-inst 203 | `false? c/to-boolean 204 | `true? c/to-boolean 205 | `zero? c/to-long} 206 | :exoscale.coax/enums #'enum-key})) 207 | 208 | (defn registry 209 | "returns the registry map, prefer 'get-spec' to lookup a spec by name" 210 | [] 211 | @registry-ref) 212 | 213 | #?(:clj (swap! registry-ref 214 | update :exoscale.coax/idents 215 | assoc 216 | `uri? c/to-uri 217 | `decimal? c/to-decimal)) 218 | 219 | (extend-protocol EnumKey 220 | #?(:clj Number :cljs number) 221 | (enum-key [x] `number?) 222 | 223 | #?(:clj Double :cljs double) 224 | (enum-key [x] `double?) 225 | 226 | #?(:clj String :cljs string) 227 | (enum-key [x] `string?) 228 | 229 | #?(:clj Boolean :cljs boolean) 230 | (enum-key [x] `boolean?) 231 | 232 | Keyword 233 | (enum-key [x] `keyword?) 234 | 235 | UUID 236 | (enum-key [x] `uuid?) 237 | 238 | nil 239 | (enum-key [x] `nil?) 240 | 241 | #?(:clj Object :cljs default) 242 | (enum-key [x] nil)) 243 | 244 | #?(:clj 245 | (extend-protocol EnumKey 246 | Float 247 | (enum-key [x] `float?) 248 | Long 249 | (enum-key [x] `int?) 250 | Instant 251 | (enum-key [x] `inst?) 252 | Date 253 | (enum-key [x] `inst?) 254 | URI 255 | (enum-key [x] `uri?) 256 | BigDecimal 257 | (enum-key [x] `decimal?))) 258 | 259 | (defn enum? 260 | "If the spec is given as a set, and every member of the set is the same type, 261 | then we can infer a coercion from that shared type." 262 | [x] 263 | (when (set? x) 264 | (let [x0 (first x) 265 | t (type x0)] 266 | (reduce (fn [_ y] 267 | (or (= t (type y)) 268 | (reduced false))) 269 | true 270 | x)))) 271 | 272 | (defn find-coercer 273 | "Tries to find coercer by looking into registry. 274 | First looking at :exoscale.coax/idents if value is a 275 | qualified-keyword or qualified symbol, or checking if the value is 276 | an enum value (homogeneous set) and lastly if it's a s-exp form that 277 | indicates a spec form likely it will return it's generated coercer 278 | from registry :exoscale.coax/forms, otherwise it returns the 279 | identity coercer" 280 | [spec {:as _opts :keys [idents forms enums]}] 281 | (let [spec-exp (si/spec-root spec) 282 | {:as reg :exoscale.coax/keys [idents]} (-> @registry-ref 283 | (update :exoscale.coax/idents merge idents) 284 | (update :exoscale.coax/forms merge forms) 285 | (cond-> enums 286 | (assoc :exoscale.coax/enums enums)))] 287 | (or (cond (qualified-ident? spec-exp) 288 | (get idents spec-exp) 289 | 290 | (enum? spec-exp) 291 | (when-let [f (:exoscale.coax/enums reg)] 292 | (get idents (f (first spec-exp)))) 293 | 294 | (sequential? spec-exp) 295 | (when-let [f (get-in reg [:exoscale.coax/forms (first spec-exp)])] 296 | (f spec-exp))) 297 | c/identity))) 298 | 299 | (defn coerce-fn* 300 | "Get the coercing function from a given key. First it tries to lookup 301 | the coercion on the registry, otherwise try to infer from the 302 | specs. In case nothing is found, identity function is returned." 303 | [spec {:keys [idents] :as opts}] 304 | (or (when (qualified-keyword? spec) 305 | (si/registry-lookup (merge (:exoscale.coax/idents @registry-ref) 306 | idents) 307 | spec)) 308 | (find-coercer spec opts))) 309 | 310 | (def coercer-cache (atom {})) 311 | 312 | (defn update-cache! 313 | [cache k coercer] 314 | (swap! cache assoc k coercer) 315 | coercer) 316 | 317 | (defn cached-coerce-fn 318 | [spec opts] 319 | (let [k [spec opts]] 320 | (if-let [e (find @coercer-cache k)] 321 | (val e) 322 | (update-cache! coercer-cache k (coerce-fn* spec opts))))) 323 | 324 | (defn coerce-fn 325 | [spec opts] 326 | (if (:cache opts true) 327 | (cached-coerce-fn spec opts) 328 | (coerce-fn* spec opts))) 329 | 330 | (defn coerce* 331 | "Like coerce, but if it can't find a way to coerce the original value 332 | will return `:exoscale.coax/invalid`. Mostly useful for 333 | implementation of special forms like s/or." 334 | [spec x opts] 335 | (if-let [coerce-fn (coerce-fn spec opts)] 336 | (coerce-fn x opts) 337 | x)) 338 | 339 | (s/def ::opts map?) 340 | (s/def ::symbolic-spec (s/or :spec-sym symbol? 341 | :spec-sym-form (s/cat :h symbol? 342 | :more (s/* any?)))) 343 | (s/def ::reg-spec qualified-keyword?) 344 | (s/def ::spec (s/or :reg-spec ::reg-spec 345 | :symbolic-spec ::symbolic-spec 346 | :set-spec set?)) 347 | 348 | (s/fdef coerce 349 | :args (s/cat :spec ::spec 350 | :x any? 351 | :opts (s/? (s/nilable ::opts)))) 352 | (defn coerce 353 | "Coerce a value `x` using spec/coercer `spec`. This function will 354 | first try to use a coercer from the registry, otherwise it will try 355 | to infer a coercer from the spec with the same name or matching 356 | symbol. Returns original value in case a coercer can't be found." 357 | ([spec x] (coerce spec x {})) 358 | ([spec x opts] 359 | (let [x' (coerce* spec x opts)] 360 | (if (= :exoscale.coax/invalid x') 361 | x 362 | x')))) 363 | 364 | (s/fdef coerce! 365 | :args (s/cat :spec ::reg-spec 366 | :x any? 367 | :opts (s/? (s/nilable ::opts)))) 368 | (defn coerce! 369 | "Like coerce, but will call s/assert on the result, making it throw an 370 | error if value doesn't comply after coercion. Only works with 371 | registered specs" 372 | ([spec x] (coerce! spec x {})) 373 | ([spec x opts] 374 | (let [coerced (coerce spec x opts)] 375 | (if (s/valid? spec coerced) 376 | coerced 377 | (throw (ex-info "Invalid coerced value" 378 | {:type :exoscale.coax/invalid-coerced-value 379 | :val x 380 | :coerced coerced 381 | :explain-data (s/explain-data spec coerced) 382 | :spec spec})))))) 383 | 384 | (s/fdef conform 385 | :args (s/cat :spec ::reg-spec 386 | :x any? 387 | :opts (s/? (s/nilable ::opts)))) 388 | (defn conform 389 | "Like coerce, and will call s/conform on the result. Only works with 390 | registered specs" 391 | ([spec x] (conform spec x {})) 392 | ([spec x opts] 393 | (s/conform spec (coerce spec x opts)))) 394 | 395 | (defn ^:no-doc def-impl 396 | [k coerce-fn] 397 | (swap! registry-ref assoc-in [:exoscale.coax/idents k] coerce-fn) 398 | ;; ensure all cache entries for that key are cleared 399 | (swap! coercer-cache 400 | (fn [cache] 401 | (reduce-kv (fn [cache [spec _opts :as cache-key] coercer] 402 | (cond-> cache 403 | (not (= k spec)) 404 | (assoc cache-key coercer))) 405 | {} 406 | cache))) 407 | k) 408 | 409 | (s/fdef def 410 | :args (s/cat :k ::reg-spec 411 | :coercion any?) 412 | :ret qualified-keyword?) 413 | (macros/deftime 414 | (defmacro def 415 | "Given a namespace-qualified keyword, and a coerce function, makes an 416 | entry in the registry mapping k to the coerce function." 417 | [k coercion] 418 | `(def-impl '~k ~coercion))) 419 | 420 | (s/fdef coerce-structure 421 | :args (s/cat :x any? 422 | :opts (s/? (s/nilable ::opts)))) 423 | (defn coerce-structure 424 | "Recursively coerce map values on a structure." 425 | ([x] (coerce-structure x {})) 426 | ([x {:keys [idents op] 427 | :or {op coerce} 428 | :as opts}] 429 | (walk/prewalk (fn [x] 430 | (cond->> x 431 | (map? x) 432 | (into x 433 | (map (fn [[k v]] 434 | (if (qualified-keyword? k) 435 | [k (op (get idents k k) v opts)] 436 | [k v])))))) 437 | x))) 438 | -------------------------------------------------------------------------------- /src/exoscale/coax/coercer.cljc: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax.coercer 2 | (:refer-clojure :exclude [identity]) 3 | (:require [clojure.string :as str] 4 | #?@(:cljs [[cljs.reader]] 5 | :clj [[clojure.instant] 6 | [exoscale.coax.utils :refer [invalid-on-throw!]]])) 7 | #?(:cljs (:require-macros [exoscale.coax.utils :refer [invalid-on-throw!]]) 8 | :clj (:import (java.util UUID) 9 | (java.net URI)))) 10 | 11 | (defn to-string 12 | [x _] 13 | ;; we should only try to coerce "scalars" 14 | (cond 15 | (string? x) 16 | x 17 | (or (number? x) 18 | (char? x) 19 | (boolean? x) 20 | (ident? x) 21 | (inst? x) 22 | (nil? x) 23 | (uuid? x) 24 | (uri? x)) 25 | (str x) 26 | :else :exoscale.coax/invalid)) 27 | 28 | (defn to-long 29 | [x _] 30 | (invalid-on-throw! 31 | (cond (string? x) 32 | #?(:clj (Long/parseLong x) 33 | :cljs (if (= "NaN" x) 34 | js/NaN 35 | (let [v (js/parseInt x)] 36 | (if (js/isNaN v) 37 | :exoscale.coax/invalid 38 | v)))) 39 | 40 | (or (double? x) 41 | (float? x)) 42 | (long x) 43 | 44 | #?@(:clj ((ratio? x) (long x))) 45 | 46 | (number? x) x 47 | 48 | :else :exoscale.coax/invalid))) 49 | 50 | (defn to-double 51 | [x _] 52 | (invalid-on-throw! 53 | (cond (string? x) 54 | #?(:clj (case x 55 | "##-Inf" ##-Inf 56 | "##Inf" ##Inf 57 | "##NaN" ##NaN 58 | "NaN" ##NaN 59 | "Infinity" ##Inf 60 | "-Infinity" ##-Inf 61 | (Double/parseDouble x)) 62 | :cljs (if (= "NaN" x) 63 | js/NaN 64 | (let [v (js/parseFloat x)] 65 | (if (js/isNaN v) 66 | :exoscale.coax/invalid 67 | v)))) 68 | (number? x) (double x) 69 | :else :exoscale.coax/invalid))) 70 | 71 | (defn to-number 72 | [x opts] 73 | (if (number? x) 74 | x 75 | (let [l (to-long x opts) 76 | d (to-double x opts)] 77 | (if (and (every? number? [l d]) 78 | (== d l)) 79 | l 80 | d)))) 81 | 82 | (defn to-uuid 83 | [x _] 84 | (cond 85 | (uuid? x) 86 | x 87 | (string? x) 88 | (invalid-on-throw! 89 | #?(:clj (UUID/fromString x) 90 | :cljs (uuid x))) 91 | :else :exoscale.coax/invalid)) 92 | 93 | (defn to-inst 94 | [x _] 95 | (cond 96 | (inst? x) 97 | x 98 | (string? x) 99 | (invalid-on-throw! 100 | #?(:clj (clojure.instant/read-instant-date x) 101 | :cljs (cljs.reader/parse-timestamp x))) 102 | :else :exoscale.coax/invalid)) 103 | 104 | (defn to-boolean 105 | [x _] 106 | (case x 107 | (true "true") true 108 | (false "false") false 109 | :exoscale.coax/invalid)) 110 | 111 | (defn to-keyword 112 | [x _] 113 | (cond 114 | (keyword? x) 115 | x 116 | 117 | (string? x) 118 | (keyword (cond-> x 119 | (str/starts-with? x ":") 120 | (subs 1))) 121 | 122 | (symbol? x) 123 | (keyword x) 124 | 125 | :else :exoscale.coax/invalid)) 126 | 127 | (defn to-symbol 128 | [x _] 129 | (cond 130 | (symbol? x) 131 | x 132 | (string? x) 133 | (symbol x) 134 | :else :exoscale.coax/invalid)) 135 | 136 | (defn to-ident 137 | [x opts] 138 | (cond 139 | (string? x) 140 | (if (str/starts-with? x ":") 141 | (to-keyword x opts) 142 | (symbol x)) 143 | (ident? x) 144 | x 145 | :else :exoscale.coax/invalid)) 146 | 147 | #?(:clj 148 | (defn to-decimal 149 | [x _] 150 | (invalid-on-throw! 151 | (if (and (string? x) 152 | (str/ends-with? x "M")) 153 | (bigdec (subs x 0 (dec (count x)))) 154 | (bigdec x))))) 155 | 156 | #?(:clj 157 | (defn to-uri 158 | [x _] 159 | (cond 160 | (uri? x) x 161 | (string? x) 162 | (URI. x) 163 | :else :exoscale.coax/invalid))) 164 | 165 | (defn identity 166 | [x _] 167 | x) 168 | -------------------------------------------------------------------------------- /src/exoscale/coax/inspect.cljc: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax.inspect 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (defn- accept-keyword [x] 5 | (when (qualified-keyword? x) 6 | x)) 7 | 8 | (defn- accept-symbol [x] 9 | (when (qualified-symbol? x) 10 | x)) 11 | 12 | (defn- accept-set [x] 13 | (when (set? x) 14 | x)) 15 | 16 | (defn- accept-symbol-call [spec] 17 | (when (and (seq? spec) 18 | (symbol? (first spec))) 19 | spec)) 20 | 21 | (defn spec-form 22 | "Return the spec form or nil." 23 | [spec] 24 | (some-> spec s/get-spec s/form)) 25 | 26 | (defn spec-root 27 | "Determine the main spec root from a spec form." 28 | [spec] 29 | (let [spec-def (or (spec-form spec) 30 | (accept-symbol spec) 31 | (accept-symbol-call spec) 32 | (accept-set spec))] 33 | (cond-> spec-def 34 | (qualified-keyword? spec-def) 35 | recur))) 36 | 37 | (defn parent-spec 38 | "Look up for the parent coercer using the spec hierarchy." 39 | [k] 40 | (or (accept-keyword (s/get-spec k)) 41 | (accept-keyword (spec-form k)))) 42 | 43 | (s/fdef registry-lookup 44 | :args (s/cat :registry map? :k qualified-keyword?) 45 | :ret any?) 46 | (defn registry-lookup 47 | "Look for the key in registry, if not found try key spec parent recursively." 48 | [registry k] 49 | (if-let [c (get registry k)] 50 | c 51 | (when-let [parent (-> (parent-spec k) accept-keyword)] 52 | (recur registry parent)))) 53 | -------------------------------------------------------------------------------- /src/exoscale/coax/utils.clj: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax.utils) 2 | 3 | (defmacro invalid-on-throw! 4 | [& body] 5 | `(try 6 | ~@body 7 | (catch Exception _# 8 | :exoscale.coax/invalid))) 9 | -------------------------------------------------------------------------------- /src/exoscale/coax/utils.cljs: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax.utils) 2 | 3 | (defmacro invalid-on-throw! 4 | [& body] 5 | `(try 6 | ~@body 7 | (catch :default _# 8 | :exoscale.coax/invalid))) 9 | -------------------------------------------------------------------------------- /test/exoscale/coax_test.cljc: -------------------------------------------------------------------------------- 1 | (ns exoscale.coax-test 2 | #?(:cljs (:require-macros [cljs.test :refer [deftest testing is are]] 3 | [exoscale.coax :as sc])) 4 | (:require 5 | #?(:clj [clojure.test :refer [deftest testing is are]]) 6 | #?(:clj [clojure.test.check.clojure-test :refer [defspec]]) 7 | #?(:cljs [clojure.test.check.clojure-test :refer-macros [defspec]]) 8 | [clojure.spec.alpha :as s] 9 | [clojure.spec.test.alpha :as st] 10 | [clojure.string :as str] 11 | [clojure.test.check :as tc] 12 | [clojure.test.check.generators] 13 | [clojure.test.check.properties :as prop] 14 | [exoscale.coax :as sc] 15 | [exoscale.coax.coercer :as c]) 16 | #?(:clj (:import (java.net URI)))) 17 | 18 | #?(:clj (st/instrument)) 19 | 20 | (defrecord Foo []) 21 | 22 | (s/def ::infer-int int?) 23 | (s/def ::infer-and-spec (s/and int? #(> % 10))) 24 | (s/def ::infer-and-spec-indirect (s/and ::infer-int #(> % 10))) 25 | (s/def ::infer-form (s/coll-of int?)) 26 | (s/def ::infer-nilable (s/nilable int?)) 27 | 28 | #?(:clj (s/def ::infer-decimal? decimal?)) 29 | 30 | (sc/def ::some-coercion c/to-long) 31 | 32 | (s/def ::first-layer int?) 33 | (sc/def ::first-layer (fn [x _] (inc (c/to-long x nil)))) 34 | 35 | (s/def ::second-layer ::first-layer) 36 | (s/def ::second-layer-and (s/and ::first-layer #(> % 10))) 37 | 38 | (s/def ::or-example (s/or :int int? :double double? :bool boolean?)) 39 | 40 | (s/def ::nilable-int (s/nilable ::infer-int)) 41 | (s/def ::nilable-pos-int (s/nilable (s/and ::infer-int pos?))) 42 | (s/def ::nilable-string (s/nilable string?)) 43 | 44 | (s/def ::nilable-set #{nil}) 45 | (s/def ::int-set #{1 2}) 46 | (s/def ::float-set #{1.2 2.1}) 47 | (s/def ::boolean-set #{true}) 48 | (s/def ::symbol-set #{'foo/bar 'bar/foo}) 49 | (s/def ::ident-set #{'foo/bar :bar/foo}) 50 | (s/def ::string-set #{"hey" "there"}) 51 | (s/def ::keyword-set #{:a :b}) 52 | (s/def ::uuid-set #{#uuid "d6e73cc5-95bc-496a-951c-87f11af0d839" 53 | #uuid "a6e73cc5-95bc-496a-951c-87f11af0d839"}) 54 | (s/def ::nil-set #{nil}) 55 | #?(:clj (s/def ::uri-set #{(URI. "http://site.com") 56 | (URI. "http://site.org")})) 57 | #?(:clj (s/def ::decimal-set #{42.42M 1.1M})) 58 | 59 | (def enum-set #{:a :b}) 60 | (s/def ::referenced-set enum-set) 61 | 62 | (def enum-map {:foo "bar" 63 | :baz "qux"}) 64 | (s/def ::calculated-set (->> enum-map keys (into #{}))) 65 | 66 | (s/def ::nilable-referenced-set (s/nilable enum-set)) 67 | (s/def ::nilable-calculated-set (s/nilable (->> enum-map keys (into #{})))) 68 | 69 | (s/def ::nilable-referenced-set-kw (s/nilable ::referenced-set)) 70 | (s/def ::nilable-calculated-set-kw (s/nilable ::calculated-set)) 71 | 72 | (s/def ::unevaluatable-spec (letfn [(pred [x] (string? x))] 73 | (s/spec pred))) 74 | 75 | (sc/def ::some-coercion c/to-long) 76 | 77 | (deftest test-coerce-from-registry 78 | (testing "it uses the registry to coerce a key" 79 | (is (= (sc/coerce ::some-coercion "123") 123))) 80 | 81 | (testing "it returns original value when it a coercion can't be found" 82 | (is (= (sc/coerce ::not-defined "123") "123"))) 83 | 84 | (testing "go over nilables" 85 | (is (= (sc/coerce ::infer-nilable "123") 123)) 86 | (is (= (sc/coerce ::infer-nilable nil) nil)) 87 | (is (= (sc/coerce ::infer-nilable "") "")) 88 | (is (= (sc/coerce ::nilable-int "10") 10)) 89 | (is (= (sc/coerce ::nilable-int "10" {:idents {`int? (fn [x _] (keyword x))}}) :10)) 90 | (is (= (sc/coerce ::nilable-pos-int "10") 10)) 91 | 92 | (is (= (sc/coerce ::nilable-string nil) nil)) 93 | (is (= (sc/coerce ::nilable-string 1) "1")) 94 | (is (= (sc/coerce ::nilable-string "") "")) 95 | (is (= (sc/coerce ::nilable-string "asdf") "asdf"))) 96 | 97 | (testing "specs given as sets" 98 | (is (= (sc/coerce ::nilable-set nil) nil)) 99 | (is (= (sc/coerce ::int-set "1") 1)) 100 | (is (= (sc/coerce ::float-set "1.2") 1.2)) 101 | (is (= (sc/coerce ::boolean-set "true") true)) 102 | ;;(is (= (sc/coerce ::symbol-set "foo/bar") 'foo/bar)) 103 | (is (= (sc/coerce ::string-set "hey") "hey")) 104 | (is (= (sc/coerce ::keyword-set ":b") :b)) 105 | (is (= (sc/coerce ::uuid-set "d6e73cc5-95bc-496a-951c-87f11af0d839") #uuid "d6e73cc5-95bc-496a-951c-87f11af0d839")) 106 | ;;#?(:clj (is (= (sc/coerce ::uri-set "http://site.com") (URI. "http://site.com")))) 107 | #?(:clj (is (= (sc/coerce ::decimal-set "42.42M") 42.42M))) 108 | 109 | ;; The following tests can't work without using `eval`. We will avoid this 110 | ;; and hope that spec2 will give us a better way. 111 | ;;(is (= (sc/coerce ::referenced-set ":a") :a)) 112 | ;;(is (= (sc/coerce ::calculated-set ":foo") :foo)) 113 | ;;(is (= (sc/coerce ::nilable-referenced-set ":a") :a)) 114 | ;;(is (= (sc/coerce ::nilable-calculated-set ":foo") :foo)) 115 | ;;(is (= (sc/coerce ::nilable-referenced-set-kw ":a") :a)) 116 | ;;(is (= (sc/coerce ::nilable-calculated-set-kw ":foo") :foo)) 117 | 118 | (is (= (sc/coerce ::unevaluatable-spec "just a string") "just a string")))) 119 | 120 | (deftest test-coerce! 121 | (is (= (sc/coerce! ::infer-int "123") 123)) 122 | (is (thrown-with-msg? #?(:clj clojure.lang.ExceptionInfo :cljs js/Error) 123 | #"Invalid coerced value" (sc/coerce! ::infer-int "abc")))) 124 | 125 | (deftest test-conform 126 | (is (= (sc/conform ::or-example "true") [:bool true]))) 127 | 128 | (deftest test-coerce-from-predicates 129 | (are [predicate input output] (= (sc/coerce predicate input) output) 130 | `number? "42" 42 131 | `number? "foo" "foo" 132 | `integer? "42" 42 133 | `int? "42" 42 134 | `int? 42.0 42 135 | `int? 42.5 42 136 | `(s/int-in 0 100) "42" 42 137 | `pos-int? "42" 42 138 | `neg-int? "-42" -42 139 | `nat-int? "10" 10 140 | `even? "10" 10 141 | `odd? "9" 9 142 | `float? "42.42" 42.42 143 | `double? "42.42" 42.42 144 | `double? 42.42 42.42 145 | `double? 42 42.0 146 | 147 | `number? "42.42" 42.42 148 | `number? 42.42 42.42 149 | `number? 42 42 150 | 151 | `(s/double-in 0 100) "42.42" 42.42 152 | `string? 42 "42" 153 | `string? :a ":a" 154 | `string? :foo/bar ":foo/bar" 155 | `string? [] [] 156 | `string? {} {} 157 | `string? #{} #{} 158 | `boolean? "true" true 159 | `boolean? "false" false 160 | `ident? ":foo/bar" :foo/bar 161 | `ident? "foo/bar" 'foo/bar 162 | `simple-ident? ":foo" :foo 163 | `qualified-ident? ":foo/baz" :foo/baz 164 | `keyword? "keyword" :keyword 165 | `keyword? ":keyword" :keyword 166 | `keyword? 'symbol :symbol 167 | `simple-keyword? ":simple-keyword" :simple-keyword 168 | `qualified-keyword? ":qualified/keyword" :qualified/keyword 169 | `symbol? "sym" 'sym 170 | `simple-symbol? "simple-sym" 'simple-sym 171 | `qualified-symbol? "qualified/sym" 'qualified/sym 172 | `uuid? "d6e73cc5-95bc-496a-951c-87f11af0d839" #uuid "d6e73cc5-95bc-496a-951c-87f11af0d839" 173 | `nil? nil nil 174 | `false? "false" false 175 | `true? "true" true 176 | `zero? "0" 0 177 | 178 | `(s/coll-of int?) ["11" "31" "42"] [11 31 42] 179 | `(s/coll-of int?) ["11" "foo" "42"] [11 "foo" 42] 180 | `(s/coll-of int? :kind list?) ["11" "foo" "42"] '(11 "foo" 42) 181 | `(s/coll-of int? :kind set?) ["11" "foo" "42"] #{11 "foo" 42} 182 | `(s/coll-of int? :kind set?) #{"11" "foo" "42"} #{11 "foo" 42} 183 | `(s/coll-of int? :kind vector?) '("11" "foo" "42") [11 "foo" 42] 184 | `(s/every int?) ["11" "31" "42"] [11 31 42] 185 | `(s/keys :req [::keyword-set]) (map->Foo {::keyword-set "a"}) (map->Foo {::keyword-set :a}) 186 | 187 | `(s/map-of keyword? int?) {"foo" "42" "bar" "31"} {:foo 42 :bar 31} 188 | `(s/map-of keyword? int?) "foo" "foo" 189 | `(s/every-kv keyword? int?) {"foo" "42" "bar" "31"} {:foo 42 :bar 31} 190 | 191 | `(s/or :int int? :double double? :bool boolean?) "42" 42 192 | `(s/or :double double? :bool boolean?) "42.3" 42.3 193 | 194 | `(s/or :int int? :double double? :bool boolean?) "true" true 195 | 196 | `(s/or :b keyword? :a string?) "abc" "abc" 197 | `(s/or :a string? :b keyword?) "abc" "abc" 198 | `(s/or :b keyword? :a string?) :abc :abc 199 | 200 | `(s/or :str string? :kw keyword? :number? number?) :asdf :asdf 201 | `(s/or :str string? :kw keyword? :number? number?) "asdf" "asdf" 202 | `(s/or :kw keyword? :str string? :number? number?) "asdf" "asdf" 203 | `(s/or :number? number? :kw keyword?) "1" 1 204 | `(s/or :number? number?) "1" 1 205 | `(s/or :number? number? :kw keyword? :str string?) "1" "1" 206 | `(s/or :number? number? :kw keyword? :str string?) 1 1 207 | #{:a :b} "a" :a 208 | #{1 2} "1" 1 209 | 210 | #?@(:clj [`uri? "http://site.com" (URI. "http://site.com")]) 211 | #?@(:clj [`decimal? "42.42" 42.42M 212 | `decimal? "42.42M" 42.42M]))) 213 | 214 | (def test-gens 215 | {`inst? (s/gen (s/inst-in #inst "1980" #inst "9999"))}) 216 | 217 | #?(:cljs 218 | (defn ->js [var-name] 219 | (-> (str var-name) 220 | (str/replace #"/" ".") 221 | (str/replace #"-" "_") 222 | (str/replace #"\?" "_QMARK_") 223 | (js/eval)))) 224 | 225 | (defn safe-gen [s sp] 226 | (try 227 | (or (test-gens s) 228 | (s/gen sp)) 229 | (catch #?(:clj Exception :cljs :default) _ nil))) 230 | 231 | #?(:clj 232 | ;; FIXME won't run on cljs 233 | (deftest test-coerce-generative 234 | (doseq [s (->> (sc/registry) 235 | ::sc/idents 236 | (keys) 237 | (filter symbol?)) 238 | :let [sp #?(:clj @(resolve s) :cljs (->js s)) 239 | gen (safe-gen s sp)] 240 | :when gen] 241 | (let [res (tc/quick-check 100 242 | (prop/for-all [v gen] 243 | (s/valid? sp (sc/coerce s (-> (pr-str v) 244 | (str/replace #"^#[^\"]+\"|\"]?$" 245 | ""))))))] 246 | (is (:result res) 247 | (str "Error coercing " {:symbol s 248 | :spec sp 249 | :result res})))))) 250 | 251 | #?(:clj (deftest test-coerce-inst 252 | (are [input output] (= (sc/coerce `inst? input) 253 | output) 254 | "2020-05-17T21:37:57.830-00:00" #inst "2020-05-17T21:37:57.830-00:00" 255 | "2018-09-28" #inst "2018-09-28"))) 256 | 257 | (deftest test-records-coerce 258 | (is (instance? Foo (sc/coerce-structure (map->Foo {::keyword-set "a"})))) 259 | (is (instance? Foo (sc/coerce `(s/keys :req [::keyword-set]) (map->Foo {::keyword-set :a})))) 260 | (is (instance? Foo (sc/coerce `(s/map-of keyword? any?) (map->Foo {::keyword-set :a}))))) 261 | 262 | (deftest test-coerce-inference-test 263 | (are [keyword input output] (= (sc/coerce keyword input) output) 264 | ::infer-int "123" 123 265 | ::infer-and-spec "42" 42 266 | ::infer-and-spec-indirect "43" 43 267 | ::infer-form ["20" "43"] [20 43] 268 | ::infer-form '("20" "43") '(20 43) 269 | ::infer-form (map str (range 2)) '(0 1) 270 | ::second-layer "41" 42 271 | ::second-layer-and "41" 42 272 | 273 | #?@(:clj [::infer-decimal? "123.4" 123.4M]) 274 | #?@(:clj [::infer-decimal? 123.4 123.4M]) 275 | #?@(:clj [::infer-decimal? 123.4M 123.4M]) 276 | #?@(:clj [::infer-decimal? "" ""]) 277 | #?@(:clj [::infer-decimal? [] []]))) 278 | 279 | (deftest test-coerce-structure 280 | (is (= (sc/coerce-structure {::some-coercion "321" 281 | ::not-defined "bla" 282 | :sub {::infer-int "42"}}) 283 | {::some-coercion 321 284 | ::not-defined "bla" 285 | :sub {::infer-int 42}})) 286 | (is (= (sc/coerce-structure {::some-coercion "321" 287 | ::not-defined "bla" 288 | :unqualified 12 289 | :sub {::infer-int "42"}} 290 | {:idents {::not-defined `keyword?}}) 291 | {::some-coercion 321 292 | ::not-defined :bla 293 | :unqualified 12 294 | :sub {::infer-int 42}})) 295 | (is (= (sc/coerce-structure {::or-example "321"} 296 | {:op sc/conform}) 297 | {::or-example [:int 321]})) 298 | 299 | (defrecord SomeRec [a]) 300 | (is (= (->SomeRec 1) 301 | (sc/coerce-structure (->SomeRec 1))))) 302 | 303 | (s/def ::bool boolean?) 304 | (s/def ::simple-keys (s/keys :req [::infer-int] 305 | :opt [::bool])) 306 | (s/def ::nested-keys (s/keys :req [::infer-form ::simple-keys] 307 | :req-un [::bool])) 308 | 309 | (deftest test-coerce-keys 310 | (is (= {::infer-int 123} 311 | (sc/coerce ::simple-keys {::infer-int "123"}))) 312 | (is (= {::infer-form [1 2 3] 313 | ::simple-keys {::infer-int 456 314 | ::bool true} 315 | :bool true} 316 | (sc/coerce ::nested-keys {::infer-form ["1" "2" "3"] 317 | ::simple-keys {::infer-int "456" 318 | ::bool "true"} 319 | :bool "true"}))) 320 | (is (= "garbage" (sc/coerce ::simple-keys "garbage")))) 321 | 322 | (s/def ::head double?) 323 | (s/def ::body int?) 324 | (s/def ::arm int?) 325 | (s/def ::leg double?) 326 | (s/def ::arms (s/coll-of ::arm)) 327 | (s/def ::legs (s/coll-of ::leg)) 328 | (s/def ::name string?) 329 | (s/def ::animal (s/keys :req [::head ::body ::arms ::legs] 330 | :opt-un [::name ::id])) 331 | 332 | (deftest test-coerce-with-registry-overrides 333 | (testing "it uses overrides when provided" 334 | (is (= {::head 1 335 | ::body 16 336 | ::arms [4 4] 337 | ::legs [7 7] 338 | :foo "bar" 339 | :name :john} 340 | (sc/coerce ::animal 341 | {::head "1" 342 | ::body "16" 343 | ::arms ["4" "4"] 344 | ::legs ["7" "7"] 345 | :foo "bar" 346 | :name "john"} 347 | {:idents 348 | {::head c/to-long 349 | ::leg c/to-long 350 | ::name c/to-keyword}})) 351 | "Coerce with option form") 352 | (is (= 1 (sc/coerce `string? "1" {:idents {`string? c/to-long}})) 353 | "overrides works on qualified-idents") 354 | 355 | (is (= [1] (sc/coerce `(s/coll-of string?) ["1"] 356 | {:idents {`string? c/to-long}})) 357 | "overrides works on qualified-idents, also with composites") 358 | 359 | (is (= ["foo" "bar" "baz"] 360 | (sc/coerce `vector? 361 | "foo,bar,baz" 362 | {:idents {`vector? (fn [x _] (str/split x #"[,]"))}})) 363 | "override on real world use case with vector?"))) 364 | 365 | (s/def ::foo int?) 366 | (s/def ::bar string?) 367 | (s/def ::qualified (s/keys :req [(or ::foo ::bar)])) 368 | (s/def ::unqualified (s/keys :req-un [(or ::foo ::bar)])) 369 | 370 | (deftest test-or-conditions-in-qualified-keys 371 | (is (= (sc/coerce ::qualified {::foo "1" ::bar "hi"}) 372 | {::foo 1 ::bar "hi"}))) 373 | 374 | (deftest test-or-conditions-in-unqualified-keys 375 | (is (= (sc/coerce ::unqualified {:foo "1" :bar "hi"}) 376 | {:foo 1 :bar "hi"})) 377 | 378 | (is (= (sc/coerce ::unqualified {:foo "1" :bar "hi"} {:closed true}) 379 | {:foo 1 :bar "hi"}))) 380 | 381 | (deftest test-or-match-first 382 | (s/def ::test-or-match-first (s/or :uuid uuid? :str string? :char (s/coll-of char?))) 383 | (is (= (sc/coerce ::test-or-match-first "00000000-0000-0000-0000-000000000000") 384 | "00000000-0000-0000-0000-000000000000")) 385 | (is (= (sc/coerce ::test-or-match-first "00000000-0000-0000-0000-000000000000" {:coerce-or-match-first true}) 386 | #uuid "00000000-0000-0000-0000-000000000000"))) 387 | 388 | (deftest test-closed-keys 389 | (s/def ::zzz string?) 390 | (s/def ::test-closed-keys (s/keys :req [::bar ::foo])) 391 | (is (= (sc/coerce ::test-closed-keys {::foo 1 ::bar 2 ::zzz 3}) 392 | {::foo 1 ::bar "2" ::zzz "3"})) 393 | (is (= (sc/coerce ::test-closed-keys {::foo 1 ::bar 2 ::baz 3} {:closed true}) 394 | {::foo 1 ::bar "2"})) 395 | 396 | (s/def ::test-closed-keys2 (s/keys :req-un [::bar ::foo])) 397 | (is (= (sc/coerce ::test-closed-keys2 {:foo 1 :bar 2 :zzz 3}) 398 | {:foo 1 :bar "2" :zzz 3})) 399 | (is (= (sc/coerce ::test-closed-keys2 {:foo 1 :bar 2 :baz 3} {:closed true}) 400 | {:foo 1 :bar "2"}))) 401 | 402 | (s/def ::tuple (s/tuple ::foo ::bar int?)) 403 | 404 | (deftest test-tuple 405 | (is (= [0 "" 1] (sc/coerce ::tuple ["0" nil "1"]))) 406 | (is (= "garbage" (sc/coerce ::tuple "garbage")))) 407 | 408 | (deftest test-merge 409 | (s/def ::merge (s/merge (s/keys :req-un [::foo]) 410 | ::unqualified 411 | ;; TODO: add s/multi-spec test 412 | any?)) 413 | (is (= {:foo 1 :bar "1" :c {:a 2}} 414 | (sc/coerce ::merge {:foo "1" :bar 1 :c {:a 2}})) 415 | "Coerce new vals appropriately 1") 416 | 417 | (is (= {:foo 1 :bar "1" :c {:a 2}} 418 | (sc/coerce ::merge {:foo 1 :bar "1" :c {:a 2}})) 419 | "Leave out ok vals") 420 | 421 | (is (= {:foo 1 :bar "1" :c {:a 2}} 422 | (sc/coerce ::merge {:foo "1" :bar 1 :c {:a 2}})) 423 | "Coerce new vals appropriately 2") 424 | 425 | (s/def ::merge2 (s/merge (s/keys :req [::foo]) 426 | ::unqualified)) 427 | 428 | (is (= {::foo 1 :bar "1" :c {:a 2} :foo 1} 429 | (sc/coerce ::merge2 {::foo "1" :foo "1" :bar "1" :c {:a 2}})) 430 | "Leave out ok vals") 431 | 432 | (is (= {::foo 1 :bar "1" :foo 1} 433 | (sc/coerce ::merge2 {::foo "1" :foo "1" :bar "1" :c {:a 2}} 434 | {:closed true})) 435 | "Remove extras") 436 | 437 | (is (= "garbage" (sc/coerce ::merge "garbage")) 438 | "garbage is passthrough") 439 | 440 | (s/def ::x qualified-keyword?) 441 | (sc/def ::x (fn [x _] (keyword "y" x))) 442 | (s/def ::m1 (s/keys :opt [::x])) 443 | (s/def ::mm (s/merge ::m1 ::m1)) 444 | (is (= {::x :y/quux} 445 | (sc/coerce ::mm 446 | {::x "quux"} 447 | {:cache? false})))) 448 | 449 | (def d :kw) 450 | ;; no vars in cljs 451 | #?(:clj (defmulti multi #'d) :cljs (defmulti multi :kw)) 452 | (defmethod multi :default [_] (s/keys :req-un [::foo])) 453 | (defmethod multi :kw [_] ::unqualified) 454 | (s/def ::multi (s/multi-spec multi :hit)) 455 | 456 | (deftest test-multi-spec 457 | (is (= {:not "foo"} (sc/coerce ::multi {:not "foo"}))) 458 | (is (= {:foo 1} (sc/coerce ::multi {:foo 1}))) 459 | (is (= {:foo 1} (sc/coerce ::multi {:foo "1"}))) 460 | (is (= {:foo 1 :d :kw} (sc/coerce ::multi {:d :kw :foo "1"}))) 461 | (is (= "garbage" (sc/coerce ::multi "garbage")))) 462 | 463 | (deftest test-gigo 464 | (is (= (sc/coerce `(some-unknown-form string?) 1) 1)) 465 | (is (= (sc/coerce `(some-unknown-form) 1) 1))) 466 | 467 | (deftest invalidity-test 468 | (is (= :exoscale.coax/invalid (sc/coerce* `int? [] {}))) 469 | (is (= :exoscale.coax/invalid (sc/coerce* `(s/coll-of int?) 1 {}))) 470 | (is (= :exoscale.coax/invalid (sc/coerce* ::int-set "" {})))) 471 | 472 | (deftest test-caching 473 | (s/def ::bs (s/keys :req [::bool])) 474 | (is (= false (sc/coerce ::bool "false"))) 475 | (is (= false (::bool (sc/coerce ::bs {::bool "false"})))) 476 | (is (= false (sc/coerce ::bool 477 | "false" 478 | {:exoscale.coax/cache? false}))) 479 | (is (= false (::bool (sc/coerce ::bs 480 | {::bool "false"} 481 | {:exoscale.coax/cache? false}))))) 482 | 483 | #?(:clj 484 | (deftest numerics-test 485 | (testing "ensure we preserve numeric types" 486 | (is (instance? Integer (sc/coerce `int? (int 1))))))) 487 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:tests [{:id :clj 3 | :type :kaocha.type/clojure.test} 4 | {:id :cljs 5 | :type :kaocha.type/cljs}]} 6 | --------------------------------------------------------------------------------