/fschema

Work in Progress. Elegant functional schemas for Clojure and Clojurescript

Primary LanguageClojure

fschema

Elegant functional data validation and mutation for Clojure and Clojurescript.

fschema has the following design goals:

  • The API should be so simple and intuitive that you mostly just remember it.
  • Validators and mutators should be simple functions. It should be possible to create more complex validators and mutators through function composition.
  • Error messages should be detailed and easy to transform into localized error messages.
  • Error message should include information on property paths.
  • It should be possible to validate individual properties within a schema.

An example:

(ns example
    (:require [fschema.core :refer [schema-fn each]])
    (:require [fschema.constraints :as c])

(def my-schema
    (schema-fn
        {:a [c/not-nil (c/>= 0)]
         :b [c/string?]
         :c [c/not-nil c/keyword?]
         :d [c/vector? (each c/integer?)]}))

(my-schema {:a -1})
;; [{:path [:a], :value -1, :error-id :fschema.constraints/>=, :params [0]}
;;  {:path [:c], :value nil, :error-id :fschema.constraints/not-nil}]
    
(my-schema {:a 5 :b 6 :c :xyz :d [1 "abc" 3]})
;; [{:path [:b], :value 6, :error-id :fschema.constraints/string?}
;   {:path [:d 1], :value "abc", :error-id :fschema.constraints/integer?}]

(my-schema {:a 5 :b "6" :c :xyz :d [1 2 3]})
;; {:a 5 :b "6" :c :xyz :d [1 2 3]}
;; Validators return the input data upon successful validation

Installation

Add the following dependency to your project.clj:

[fschema "0.2.0-SNAPSHOT"]

Basics

All validators and mutators are composed of functions taking a signal argument. A validator is a function that always returns the value it was passed or an error value. A mutator may return the value it was passed, a "mutated" value, or an error value. error values control the flow of execution in fschema.

Errors

An error value is created using the error function. A new error value may be created by passing a map describing the error to the error function. The result will be a vector containing this error marked with {:error true} in its metadata map.

user> (error {:error-id ::my-error})
[{:error-id :user/my-error}]

user> (meta *1)
{:error true}

Errors can also be combined using the error function.

user> (error {:error-id ::my-error} {:error-id ::my-error2})
[{:error-id :user/my-error} {:error-id :user/my-error2}]

user> (error *1 {:error-id ::my-error3})
[{:error-id :user/my-error} {:error-id :user/my-error2} {:error-id :user/my-error3}]

To check for error values, the error? function is used. If it is passed an error value it will return that value. If it is passed a non-error value, it will return nil.

user> (error? (error {:error-id ::my-error}))
[{:error-id :user/my-error}]

user> (error? {:error-id ::my-error})
nil

This gives us a simple way to collect errors that may occur at different places within execution (for instance on different branches of the same map) and to easily differentiate them from other valid values (error values are marked using Clojure metadata facilities).

Constraints

Constraints are the simplest validators. Constraints can be created using the constraint function or the defconstraint macro (see Creating Constraints). fschema includes a number of built-in constraints.

Constraints return error maps that include the :error-id and failing :value as well as possibly :params specific to this constraint instance and a default error :message. This enables error maps to be used to generate specific localizable error messages. fschema's builtin constraints intentionally mirror Clojure's basic functions so they are easy to remember. Because of this the fschema.constraints namespace must always be require'd using an :as alias.

user> (require '[fschema.constraints :as c])
nil

user> (c/string? 5)
[{:value 5, :error-id :fschema.constraints/string?}]

user> (c/string? "abc")
"abc" ;; Constraints always return the value they were passed upon success

user> ((c/> 5) 3)

Constraints and nil values

For every constraint other than the not-nil constraint, the constraint will not return an error when passed a nil value, but instead return nil. This is to facilitate the functional composability of constraints. To return an error when nil is passed in, the not-nil constraint must be used.

user> (c/string? nil)
nil

user> (c/not-nil nil)
[{:value nil, :error-id :fschema.constraints/not-nil}]

Composing validators and mutators

To make things more interesting we must can validators and mutators using the schema-fn and eachfunctions.

schema-fn

schema-fn is the main function used for composing validators and mutators. It creates "schema functions", thus the name schema-fn.

Function Chaining with Error Checking

If schema-fn is passed multiple functions or a vector of functions it will chain these functions together and create a composite function which threads the value passed to it through each function. In some ways it is similar to Clojure's -> macro. schema-fn's special feature is that it will stop threading if any function in the chain return an error value and it will instead return that error.

Here is an example of creating a composite validator using constraints and schema-fn:

user> (def v1 (schema-fn c/not-nil c/string?)
#'user/v1

user> (v1 "5")
"5" ;; successful validation returns the same value

user> (v1 5)
[{:value 5, :error-id :fschema.constraints/string?}]

user> (v1 nil)
[{:value nil, :error-id :fschema.constraints/not-nil}]

Mutators can also be composed using schema-fn:

user> (def f1 (schema-fn inc str))
#'user/f1

user> (f1 5)
"6"

;; This is similar to:
user> (-> 5 inc str)
"6"

A vector of functions can also be used to get the same result:

user> (def f2 (schema-fn [inc str]))
#'user/f2

user> (f2 5)
"6"

Validating maps

schema-fn can also be used to create map validators and mutators. If a map is passed as an argument to schema-fn, schema-fn is applied to each value in that map and the resulting functions at each key are applied to each value with the corresponding keys in an input map. This gives us a simple model to compose map validators and nested map validators.

(schema-fn {:a [c/not-nil c/integer?]) is equivalent to (schema-fn {:a (schema-fn c/not-nil c/integer?)}). Both functions will require the input map to contain an integer value for the key :a.

One nice feature about map validators is that they will return the path of the failing property in error messages (even through multiple levels of nesting).

user> ((schema-fn {:a c/not-nil}) {})
[{:path [:a], :value nil, :error-id :fschema.constraints/not-nil}]

user> ((schema-fn {:a c/not-nil}) {:a 1})
{:a 1}

;; Nested maps
user> ((schema-fn {:a [c/not-nil {:b c/not-nil}]}) {:a {}})
[{:path [:a :b], :value nil, :error-id :fschema.constraints/not-nil}]

user> ((schema-fn {:a [c/not-nil {:b c/not-nil}]}) {:a {:b 5}})
{:a {:b 5}}

*Map validators will handle nil values like constraints - that is a nil value passed in will return nil as opposed to an error. This may seem counter-intuitive but it helps with composability and consistency. Please use (schema-fn c/not-nil {...}) to ensure that the map is not *nil.

each

The each function is used to compose a schema-fn that will be executed upon each member of a sequence and return a sequence of the same type.

user> ((each c/not-nil c/integer?) [1 2.0 nil])
[{:path [1], :value 2.0, :error-id :fschema.constraints/integer?}
 {:path [2], :value nil, :error-id :fschema.constraints/not-nil}]
;; each functions return the index in the sequence as part of the path

user> ((each c/not-nil c/integer?) [1 2 3])
[1 2 3]

where

The where function is used to conditionally apply a chain of functions. Its first parameter is the test function that will be used to test if the remaining arguments should be applied (as a schema-fn chain). If the test-fn returns a nil, false, or error value, the function(s) won't be applied - test-fn may therefore be either a regular function or a validator.

user> ((where number? (c/> 0)) -1)
[{:value -1, :error-id :fschema.constraints/>, :params [0]}]

user> ((where number? (c/> 0)) "abc")
"abc"

defschema

The defschema macro is a combination of schema-fn and not-nil.

(defschema my-schema
  {:a [c/not-nil c/integer]
   :b [c/string?]})

is equivalent to:

(def my-schema
 (schema-fn
   c/not-nil
   {:a [c/not-nil c/integer]
    :b [c/string?]}))

Syntatic sugar and nothing else...

Creating Constraints

constraint

TODO

defconstraint

TODO

Built-in Constraints

not-nil

The not-nil constraint is to be used whenever it is necessary to ensure that a value is not nil. *All other constraints will return nil when passed a nil value.

string?, number?, integer?, map?, vector?, seq?, keyword?, symbol?, set?, coll?, list?, instance?, boolean?

These constraints are available to validate the type of arguments.

Note: these functions intentionally mirror Clojure's type checking functions. The fschema.constraints namespace must never be loaded with :use (which is bad practice anyway).

user> (require '[fschema.constraints :as c])
nil

user> (c/string? 7)
[{:value 7, :error-id :fschema.constraints/string?}]

user> (c/map? {:a 1})
{:a 1}

user> ((c/instance? String) [\a])
[{:value [\a], :error-id :fschema.constraints/instance?, :params [java.lang.String]}]

re-matches

The re-matches constraint factory function can be used to match strings against regular expressions. (Note: the string? constraint is invoked implicity when re-matches is used.)

Note: this function intentionally mirrors Clojure's re-matches function. The fschema.constraints namespace must never be loaded with :use (which is bad practice anyway).

user> (require '[fschema.constraints :as c])
nil

user> ((c/re-matches #"a.\*z") "abx")
[{:value "abx", :error-id :re-matches, :params [#"a.\*z"]}]

user> ((c/re-matches #"a.\*z") 4)
[{:value 4, :error-id :fschema.constraints/string?}]

user> ((c/re-matches #"a.\*z") "abcz")
"abcz"

<, >, <=, =>, not=, =

The <, >, <=, >=, not= and = constraint constructors are available to validate the range of numeric values. (Note: the number? constraint is invoked implicitly when any of the numeric range functions are used.)

Note: these functions intentionally mirror Clojure's comparison operators. The fschema.constraints namespace must never be loaded with :use (which is bad practice anyway).

user> (require '[fschema.constraints :as c])
nil

user> ((c/> 3) 2)
[{:value 2, :error-id :fschema.constraints/>, :params [3]}]

user> ((c/> 3) "abc")
[{:value "abc", :error-id :fschema.constraints/number?}]

user> ((c/> 3) 4)
4

count=, count<, count>, count<=, and count>=

The count=, count<, count>, count<=, and count>= constraint constructors are available to validate the length of strings and sequences.

user> ((c/count> 5) "abc")
[{:value "abc", :error-id :fschema.constraints/count>, :params [5]}]

user> ((c/count> 5) "abcefg")
"abcefg"

user> ((c/count= 2) [1 2 3])
[{:value [1 2 3], :error-id :fschema.constraints/count=, :params [2]}]

user> ((c/count= 2) [1 2])
[1 2]

any

The any validator always validates successfully. It is essentially the identity function marked as a validator.

user> (c/any 2362)
2362

user> (c/any nil)
nil

Validating Properties

for-path

Inspecting schemas

inspect

Error API

error

error?

License

Distributed under the Eclipse Public License, the same as Clojure.