hodur/spec-schema

Hodur is a domain modeling approach and collection of libraries to Clojure. By using Hodur you can define your domain model as data, parse and validate it, and then either consume your model via an API or use one of the many plugins to help you achieve mechanical results faster and in a purely functional manner.


Keywords
clojure, data, modeling, schema, spec, types, validation
License
MIT

Documentation

Hodur Spec Schema

https://circleci.com/gh/luchiniatwork/hodur-spec-schema.svg?style=shield&circle-token=4efa55e1e836d3613c886708b4246b488090b263 https://img.shields.io/clojars/v/hodur/engine.svg https://img.shields.io/clojars/v/hodur/spec-schema.svg https://img.shields.io/badge/license-MIT-blue.svg https://img.shields.io/badge/project%20status-alpha-brightgreen.svg

./docs/logo-tag-line.png

Hodur is a descriptive domain modeling approach and related collection of libraries for Clojure.

By using Hodur you can define your domain model as data, parse and validate it, and then either consume your model via an API making your apps respond to the defined model or use one of the many plugins to help you achieve mechanical, repetitive results faster and in a purely functional manner.

This Hodur plugin provides the ability to generate Clojure Spec schemas out of your Hodur model. You can then validate your data structures, generate random payloads, extend yours tests… you name it.

Motivation

For a deeper insight into the motivations behind Hodur, check the motivation doc.

Getting Started

Hodur has a highly modular architecture. Hodur Engine is always required as it provides the meta-database functions and APIs consumed by plugins.

Therefore, refer the Hodur Engine’s Getting Started first and then return here for Spec-specific setup.

After having set up hodur-engine as described above, we also need to add hodur/spec-schema, a plugin that creates Spec Schemas out of your model to the deps.edn file:

{:deps {hodur/engine      {:mvn/version "0.1.5"}
        hodur/spec-schema {:mvn/version "0.1.1"}}}

You should require it any way you see fit:

(require '[hodur-engine.core :as hodur])
(require '[hodur-spec-schema.core :as hodur-spec])

Let’s expand our Person model from the original getting started by “tagging” the Person entity for Spec. You can read more about the concept of tagging for plugins in the sessions below but, in short, this is the way we, model designers, use to specify which entities we want to be exposed to which plugins.

(def meta-db (hodur/init-schema
              '[^{:spec/tag-recursive true}
                Person
                [^String first-name
                 ^String last-name]]))

The hodur-spec-schema plugin exposes a function called schema that returns a vector with all the spec definitions your model needs:

(def spec-schema (hodur-spec/schema meta-db))

When you inspect spec-schema, this is what you have:

[(clojure.spec.alpha/def
   :my-app.core.person/last-name
   clojure.core/string?)
 (clojure.spec.alpha/def
   :my-app.core.person/first-name
   clojure.core/string?)
 (clojure.spec.alpha/def
   :my-app.core/person
   (clojure.spec.alpha/keys
    :req-un
    [:my-app.core.person/first-name
     :my-app.core.person/last-name]
    :opt-un
    []))]

As a convenience, hodur-spec-schema also provides a macro called defspecs that already defines all your specs onto your registry:

(hodur-specs/defspecs meta-db)

Once defspecs is run, you’ll have three specs to use:

  • :my-app.core.person/last-name
  • :my-app.core.person/first-name
  • :my-app.core/person

Therefore, we can use spec normally like:

(require '[clojure.spec.alpha :as s])

(s/valid? :my-app.core/person {:first-name "Jane"
                               :last-name "Janet"}) ;; => true

(s/valid? :my-app.core/person {:firs-name "Jane"
                               :last-name "Janet"}) ;; => false

Model Definition

All Hodur plugins follow the Model Definition as described on Hodur Engine’s documentation.

Naming Conventions

For the sake of composability each of your entities, fields, and parameters will have their own bespoke specs defined.

The convention is that each spec will have a fully-qualified name in the namespace where defspecs is called pretty much as if a :: was used. Example:

(ns my-app.core
  (:require [clojure.spec.alpha :as s]
            [hodur-engine.core :as hodur]
            [hodur-spec-schema.core :as hodur-spec]))

(def meta-db (engine/init-schema
              '[^{:spec/tag-recursive true}
                Person
                [^String first-name
                 ^String last-name]]))

(hodur-spec/defspecs meta-db)
;; => [:my-app.core.person/last-name
;;     :my-app.core.person/first-name
;;     :my-app.core/person]

(s/valid? :my-app.core/person {:first-name "Jane"
                               :last-name "Janet"}) ;; => true

(s/valid? :my-app.core/person {:firs-name "Jane"
                               :last-name "Janet"});; => false

(s/explain :my-app.core/person {:firs-name "Jane"
                                :last-name "Janet"})
;; prints out:
;; val: {:firs-name "Jane", :last-name "Janet"} fails spec: :hodur-spec-schema.core/person predicate: (contains? % :first-name)

Influencing Names with Aliases and Prefix

Sometimes the default behavior of the naming convention above might not suit you. There are two ways to affect the names.

The first one is to use :prefix on defspecs. It will override the default namespace altogether. Example:

(ns my-app.core
  (:require [clojure.spec.alpha :as s]
            [hodur-engine.core :as hodur]
            [hodur-spec-schema.core :as hodur-spec]))

(def meta-db (engine/init-schema
              '[^{:spec/tag-recursive true}
                Person
                [^String first-name
                 ^String last-name]]))

(hodur-spec/defspecs meta-db {:prefix :app})
;; => [:app.person/last-name
;;     :app.person/first-name
;;     :app/person]

(s/valid? :app/person {:first-name "Jane"
                       :last-name "Janet"}) ;; => true

The second method is to use the marker :spec/alias or :spec/aliases when defining entities, fields or parameters. Example:

(ns my-app.core
  (:require [clojure.spec.alpha :as s]
            [hodur-engine.core :as hodur]
            [hodur-spec-schema.core :as hodur-spec]))

(def meta-db (engine/init-schema
              '[^{:spec/tag-recursive true
                  :spec/alias :la/persona}
                Person
                [^{:spec/aliases [:a-persons/first-name
                                  :el/primo]}
                 ^String first-name
                 ^{:spec/aliases [:el/secondo]}
                 ^String last-name]]))

(hodur-spec/defspecs meta-db)
;; => [:my-app.core.person/last-name
;;     :my-app.core.person/first-name
;;     :my-app.core/person
;;     :la/persona
;;     :a-persons/first-name
;;     :el/primo
;;     :el/secondo]

(s/valid? :la/persona {:first-name "Jane"
                       :last-name "Janet"}) ;; => true

(s/valid? :el/secondo "Janet") ;; => true

Primitive Types

All Hodur primitive types have natural specs as described below:

Other specs can be specified by using the :spec/override or :spec/extend features described in more detail in the respective section below.

Hodur Type Equivalent Spec
String string?
ID string?
Integer integer?
Boolean boolean?
Float float?
DateTime inst?

Cardinality

Multiple cardinalities are dealt with as expected. The following table shows some examples:

Hodur Cardinality Equivalent Spec
nil (none specified) a single <spec>
[0 n] s/coll-of <spec> :min-count 0
[4 n] s/coll-of <spec> :min-count 4
3 s/coll-of <spec> :count 3
[5 9] s/coll-of <spec> :min-count 5 :max-count 9
[n 7] s/coll-of <spec> :max-count 7

Interfaces

Hodur interfaces are supported. The approach taken is that the resulting spec for the child entity is an `s/and` of itself and all of its interfaces.

Take the following example:

'[^:interface
  Animal
  [^String race]

  ^{:implements Animal}
  Person
  [^String first-name
   ^String last-name]]

The resulting high level specs would be :app/animal and :app/person where :app/person needs to validate the keys in the Person entity and also the keys on Animal.

Enums and Unions

Hodur enums are spec’d as exact strings. Therefore the hodur model below:

'[^:enum
  Gender
  [FEMALE MALE]]

Will create two specs where one of them would be along the lines of :app.core.gender/female where #(= "FEMALE" %) (one for female and one for male).

The enum per se is an s/or between all of the enum’s options.

If you need a different behavior, you can use :spec/override described in the section below.

Hodur unions work similarly but the s/or is between the entities the union refers to.

Overriding and Extending

Specs can get very elaborate and Hodur models do not capture - nor even try to capture - all the possibilities. Instead there are two concepts in place: you can either override the spec that Hodur would use or extend it.

Overriding is as simple as providing a marker :spec/override that points to the function you want to use:

'[MyEntity
  [{:spec/override keyword?}
   a-keyword-field]]

In the example above the spec for a-keyword-field will be simply keyword?. You can also specify your own validation functions. Simply make them fully qualified and make sure they have been required in the correct context:

'[User
  [{:spec/override my-app.user/email?}
   email]]

Then, just make sure you have something along these lines for your email validation (or any other in fact):

(ns my-app.user
  (:require [clojure.test.check.generators :as gen]))

(defn email? [s]
  (let [email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"]
    (re-matches email-regex s)))

Sometimes you are happy with the default spec used by Hodur but want to extend it a bit. For instance, in the email example above you might want to still make it a string? but also an email. By using the marker :spec/extend you can automatically wrap the basic spec with an s/and:

'[User
  [{:type String
    :spec/extend my-app.user/email?}
   email]]

The resulting spec will be a string? s/and a my-app.validations/email?.

Custom Generators

Custom generators can be provided with the marker :spec/gen. Example:

'[User
  [{:type String
    :spec/extend my-app.user/email?
    :spec/gen my-app.user/gen-email}
   email]]

Then the hypothetical code below could validate and genarate out of a set of possible emails:

(ns my-app.user
  (:require [clojure.test.check.generators :as gen]))

(defn email? [s]
  (let [email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"]
    (re-matches email-regex s)))

(defn email-gen []
  (gen/elements #{"asd@qwe.com" "qwe@asd.com" "foo@bar.edu" "bar@edu.br"}))

Once you have these in place, you can easily generate users like:

(require '[clojure.spec.gen.alpha :as gen])
(require '[clojure.spec.alpha :as s])

(gen/sample (s/gen :app/user))
;; => [{:email "qwe@asd.com"}
;;     {:email "foo@bar.edu"}
;;     {:email "qwe@asd.com"}
;;     {:email "asd@qwe.com"}
;;     {:email "bar@edu.br"}]

(s/valid? :app/user (gen/generate (s/gen :app/user))) ;; => true

Parameters and Parameter Groups’ Specs

Hodur parameters are each individually spec’d so that you are able to run validations against specific entries in your functions.

In some situations though, it is also possible that you want to validate the whole set of parameters as a group. This is particularly useful if your parameters are set as a kind of argument map or ordered tuple/vector.

Hodur’s spec plugin will always create two specs for the parameter group, one as a map and one as a tuple. What this means in practice is that in the following example the specs :app.core.user/avatar-url% and :app.core.user/avatar-url-ordered% are created.

:app.core.user/avatar-url% will represent a map that will include the required entries :max-width and :max-height.

:app.core.user/avatar-url-ordered% will represent a tuple of two integers (the first representing :max-width and the second representing :max-height). As you can see, in this spec entry the names of the parameters get lost. Another feature to notice is that optional parameters are not supported in such case. This is as per tuple spec.

'[User
  [^String email
   ^String avatar-url [^Integer max-width
                       ^Integer max-height]]]

Special attention must be given to the naming convention here. A % is added as a postfix to the name of the field the parameters refer. For the ordered spec, a -ordered% is added. In the above example, :app.core.user/avatar-url is the spec to the avatar-url field (which happens to be a String - or string?), :app.core.user/avatar-url% refers to the parameter group as a map, and :app.core.user/avatar-url-ordered% refers to the parameter group as a tuple..

You can also choose a different postfix when calling the defspecs macro if % doesn’t work for you. In the following example, instead of %, -params will be used (for ordered specs, this will mean -ordered-params will be used).

(defspecs meta-db {:params-postfix "-params"})

Bugs

If you find a bug, submit a GitHub issue.

Help!

This project is looking for team members who can help this project succeed! If you are interested in becoming a team member please open an issue.

License

Copyright © 2018 Tiago Luchini

Distributed under the MIT License (see LICENSE).