(ns schema.coerce "Extension of schema for input coercion (coercing an input to match a schema)" (:require #?(:cljs [cljs.reader :as reader]) #?(:clj [clojure.edn :as edn]) #?(:clj [schema.macros :as macros]) #?(:clj [schema.core :as s] :cljs [schema.core :as s :include-macros true]) [schema.spec.core :as spec] [schema.utils :as utils] [clojure.string :as str]) #?(:cljs (:require-macros [schema.macros :as macros]))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Generic input coercion (def Schema "A Schema for Schemas" (s/protocol s/Schema)) (def CoercionMatcher "A function from schema to coercion function, or nil if no special coercion is needed. The returned function is applied to the corresponding data before validation (or walking/ coercion of its sub-schemas, if applicable)" (s/=> (s/maybe (s/=> s/Any s/Any)) Schema)) (s/defn coercer "Produce a function that simultaneously coerces and validates a datum. Returns a coerced value, or a schema.utils.ErrorContainer describing the error." [schema coercion-matcher :- CoercionMatcher] (spec/run-checker (fn [s params] (let [c (spec/checker (s/spec s) params)] (if-let [coercer (coercion-matcher s)] (fn [x] (macros/try-catchall (let [v (coercer x)] (if (utils/error? v) v (c v))) (catch t (macros/validation-error s x t)))) c))) true schema)) (s/defn coercer! "Like `coercer`, but is guaranteed to return a value that satisfies schema (or throw)." [schema coercion-matcher :- CoercionMatcher] (let [c (coercer schema coercion-matcher)] (fn [value] (let [coerced (c value)] (when-let [error (utils/error-val coerced)] (macros/error! (utils/format* "Value cannot be coerced to match schema: %s" (pr-str error)) {:schema schema :value value :error error})) coerced)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Coercion helpers (s/defn first-matcher :- CoercionMatcher "A matcher that takes the first match from matchers." [matchers :- [CoercionMatcher]] (fn [schema] (first (keep #(% schema) matchers)))) (defn string->keyword [s] (if (string? s) (keyword s) s)) (defn string->boolean "returns true for strings that are equal, ignoring case, to the string 'true' (following java.lang.Boolean/parseBoolean semantics)" [s] (if (string? s) (= "true" (str/lower-case s)) s)) (defn keyword-enum-matcher [schema] (when (or (and (instance? #?(:clj schema.core.EnumSchema :cljs s/EnumSchema) schema) (every? keyword? (.-vs ^schema.core.EnumSchema schema))) (and (instance? #?(:clj schema.core.EqSchema :cljs s/EqSchema) schema) (keyword? (.-v ^schema.core.EqSchema schema)))) string->keyword)) (defn set-matcher [schema] (if (instance? #?(:clj clojure.lang.APersistentSet :cljs cljs.core.PersistentHashSet) schema) (fn [x] (if (sequential? x) (set x) x)))) (defn safe "Take a single-arg function f, and return a single-arg function that acts as identity if f throws an exception, and like f otherwise. Useful because coercers are not explicitly guarded for exceptions, and failing to coerce will generally produce a more useful error in this case." [f] (fn [x] (macros/try-catchall (f x) (catch e x)))) #?(:clj (def safe-long-cast "Coerce x to a long if this can be done without losing precision, otherwise return x." (safe (fn [x] (let [l (long x)] (if (== l x) l x)))))) (def string->uuid "Returns instance of UUID if input is a string. Note: in CLJS, this does not guarantee a specific UUID string representation, similar to #uuid reader" #?(:clj (safe #(java.util.UUID/fromString ^String %)) :cljs #(if (string? %) (uuid %) %))) (def ^:no-doc +json-coercions+ (merge {s/Keyword string->keyword s/Bool string->boolean s/Uuid string->uuid} #?(:clj {clojure.lang.Keyword string->keyword s/Int safe-long-cast Long safe-long-cast Double (safe double) Float (safe float) Boolean string->boolean}))) (defn json-coercion-matcher "A matcher that coerces keywords and keyword eq/enums from strings, and longs and doubles from numbers on the JVM (without losing precision)" [schema] (or (+json-coercions+ schema) (keyword-enum-matcher schema) (set-matcher schema))) (def edn-read-string "Reads one object from a string. Returns nil when string is nil or empty" #?(:clj edn/read-string :cljs reader/read-string)) (def ^:no-doc +string-coercions+ (merge +json-coercions+ {s/Num (safe edn-read-string) s/Int (safe edn-read-string)} #?(:clj {s/Int (safe #(safe-long-cast (edn-read-string %))) Long (safe #(safe-long-cast (edn-read-string %))) Double (safe #(Double/parseDouble %))}))) (defn string-coercion-matcher "A matcher that coerces keywords, keyword eq/enums, s/Num and s/Int, and long and doubles (JVM only) from strings." [schema] (or (+string-coercions+ schema) (keyword-enum-matcher schema) (set-matcher schema)))