metosin/spec-tools

Name: spec-tools

Owner: Metosin

Description: Clojure(Script) tools for clojure.spec

Created: 2016-06-22 12:23:25.0

Updated: 2018-05-22 00:44:05.0

Pushed: 2018-05-14 10:16:59.0

Homepage:

Size: 572

Language: Clojure

GitHub Committers

UserMost Recent Commit# Commits

Other Committers

UserEmailMost Recent Commit# Commits

README

spec-tools Build Status

Clojure/Script tools for clojure.spec.

Status: Alpha (as spec is still alpha too).

Blogs:

Latest version

Clojars Project

Requires Java 1.8 & Clojure 1.9.0 and/or ClojureScript 1.9.908+.

Spec Records

To enable spec metadata and features like Spec driven transformations, Spec-tools introduces extendable Spec Records, Specs. They wrap specs and act like specs or 1-arity functions. Specs are created with spec-tools.core/spec macro or with the underlying spec-tools.core/create-spec function.

The following Spec keys having a special meaning:

| Key | Description | | ——————-|—————————————————————————–| | :spec | The wrapped spec (predicate). | | :form | The wrapped spec form. | | :type | Type hint of the Spec, mostly auto-resolved. Used in runtime conformation. | | :name | Name of the spec. Maps to title in JSON Schema. | | :description | Description of the spec. Maps to description in JSON Schema. | | :gen | Generator function for the Spec (set via s/with-gen) | | :keys           | Set of all map keys that the spec defines. Extracted from s/keys Specs.   | | :keys/req       | Set of required map keys that the spec defines. Extracted from s/keys Specs.| | :keys/opt       | Set of optional map keys that the spec defines. Extracted from s/keys Specs.| | :reason | Value is added to s/explain-data problems under key :reason | | :reason | Value is added to s/explain-data problems under key :reason | | :decode/... | 2-arity function to transform a value from an external format. | | :encode/... | 2-arity function to transform a value into external format. | | :json-schema/... | Extra data that is merged with unqualifed keys into json-schema |

Creating Specs

The following are all equivalent:

uire '[spec-tools.core :as st])

sing type inference
spec integer?)

ith explicit type
spec integer? {:type :long})

ap form
spec {:spec integer?})
spec {:spec integer?, :type :long})

unction
create-spec
spec integer?
form `integer?
type :long})

unction, with type and form inference
create-spec
spec integer?})

.. resulting in:
pec{:type :long,
    :form clojure.core/integer?}
Example usage
uire '[clojure.spec.alpha :as s])

 my-integer? (st/spec integer?))

nteger?
pec{:type :long
    :form clojure.core/integer?}

integer? 1)
ue

alid? my-integer? 1)
ue

oc my-integer? :description "It's a int")
pec{:type :long
    :form clojure.core/integer?
    :description "It's a int"}

l (s/form (st/spec integer? {:description "It's a int"})))
pec{:type :long
    :form clojure.core/integer?
    :description "It's a int"}

For most core predicates, :type can be resolved automatically using the spec-tools.parse/parse-form multimethod.

For most core predicates, :form can be resolved automatically using the spec-tools.form/resolve-form multimethod.

Predefined Spec Records

Most clojure.core predicates have a predefined Spec Record instance in spec-tools.spec.

uire '[spec-tools.spec :as spec])

/boolean?
pec{:type :boolean
    :form clojure.core/boolean?}

c/boolean? true)
ue

alid? spec/boolean? false)
ue

oc spec/boolean? :description "it's an bool")
pec{:type :boolean
    :form clojure.core/boolean?
    :description "It's a bool"}
Custom errors

Can be added to a Spec via the key :reason

xplain (st/spec pos-int? {:reason "positive"}) -1)
l: -1 fails predicate: pos-int?,  positive

xplain-data (st/spec pos-int? {:reason "positive"}) -1)
clojure.spec.alpha{:problems [{:path [], :pred pos-int?, :val -1, :via [], :in [], :reason "positive"}]}
Spec Driven Transformations

Like Plumatic Schema, Spec-tools differentiates specs (what) and transformers (how). This enables spec values to be transformed between different formats like JSON and EDN. Core concept is the Transformer protocol:

protocol Transformer
name [this])
encoder [this spec value])
decoder [this spec value]))

Spec-tools ships with following transformer implementations:

| Name | Description | |———————————-|————————————————————————————————————————| | string-transformer | String-formats like properties files, query- & path-parameters. | | json-transformer | JSON format, like string, but numbers and booleans are supported | | strip-extra-keys-transformer | Decoding strips out extra keys of s/keys specs. | | fail-on-extra-keys-transformer | Decoding fails if s/keys specs have extra keys. | | nil | No transformations, EDN & Transit. |

Functions encode, decode, explain, explain-data, conform and conform! take the transformer an optional third argument and pass it into Specs via dynamic binding. Spec Records apply either the encoder or decoder in it's conforming stage. Both encode & decode also unform the data.

Spec-driven transformations
uire '[clojure.string :as str])

ef ::spec
t/spec
{:spec #(and (simple-keyword? %) (-> % name str/lower-case keyword (= %)))
 :description "a lowercase keyword, encoded in uppercase in string-mode"
 :decode/string #(-> %2 name str/lower-case keyword)
 :encode/string #(-> %2 name str/upper-case)}))

decode ::spec :kikka)
ikka

> "KiKka" $
  (st/decode ::spec $))
lojure.spec.alpha/invalid

> "KiKka" $
  (st/decode ::spec $ st/string-transformer))
ikka

> "KiKka" $
  (st/decode ::spec $ st/string-transformer)
  (st/encode ::spec $ st/string-transformer))
IKKA"
Spec Bijections?

no, as there can be multiple valid representations for a encoded value. But it can be quaranteed that a decoded values X is always encoded into Y, which can be decoded back into X: y -> X -> Y -> X

> "KikKa" $
  (doto $ prn)
  (st/encode ::spec $ st/string-transformer)
  (doto $ prn)
  (st/decode ::spec $ st/string-transformer)
  (doto $ prn)
  (st/encode ::spec $ st/string-transformer)
  (prn $))
ikKa"
IKKA"
ikka
IKKA"
Type-driven transformations
> "2014-02-18T18:25:37Z" $
  (st/decode inst? $))
lojure.spec.alpha/invalid

ecode using string-transformer
> "2014-02-18T18:25:37Z" $
  (st/decode inst? $ st/string-transformer))
nst"2014-02-18T18:25:37.000-00:00"

ncode using string-transformer
> "2014-02-18T18:25:37Z" $
  (st/decode inst? $ st/string-transformer)
  (st/encode inst? $ st/string-transformer))
014-02-18T18:25:37.000+0000"

When creating custom specs, :type gives you encoders & decoders (and docs!) for free, like with Data.Unjson.

ef ::kw
t/spec
{:spec #(keyword %) ;; anonymous function
 :type :keyword}))  ;; encode & decode like a keyword

decode ::kw "kikka" st/string-transformer)
kikka

decode ::kw "kikka" st/json-transformer)
kikka
Transforming nested specs

Because of current design of clojure.spec, we need to wrap all non top-level specs into Spec Records manually to enable transformations.

ef ::name string?)
ef ::birthdate spec/inst?)

ef ::languages
/coll-of
(s/and spec/keyword? #{:clj :cljs})
:into #{}))

ef ::user
/keys
:req-un [::name ::languages ::age]
:opt-un [::birthdate]))

 data
name "Ilona"
age "48"
languages ["clj" "cljs"]
birthdate "1968-01-02T15:04:05Z"})

o transformer
decode ::user data)
s/invalid

son-transformer doesn't transform numbers
decode ::user data st/json-tranformer)
s/invalid

tring-transformer for the rescue
decode ::user data st/string-transformer)
name "Ilona"
age 48
languages #{:clj :cljs}
birthdate #inst"1968-01-02T15:04:05.000-00:00"}
Transforming Map Specs

To strip out extra keys from a keyset:

ef ::name string?)
ef ::street string?)
ef ::address (st/spec (s/keys :req-un [::street])))
ef ::user (st/spec (s/keys :req-un [::name ::address])))

 inkeri
name "Inkeri"
age 102
address {:street "Satamakatu"
         :city "Tampere"}})

decode ::user inkeri st/strip-extra-keys-transformer)
name "Inkeri"
address {:street "Satamakatu"}}

There are also a shortcut for this, select-spec:

select-spec ::user inkeri)
name "Inkeri"
address {:street "Satamakatu"}}
Custom Transformers

Transformers should have a simple keyword name and optionally type-based decoders, encoders, default decoder and -encoder set. Currently there is no utility to verify that y -> X -> Y -> X holds for custom transformers.

uire '[clojure.string :as str])
uire '[spec-tools.transform :as stt])

n transform [_ value]
> value
  str/upper-case
  str/reverse
  keyword))

tring-decoding + special keywords
ncoding writes strings by default
 my-string-transformer
ype-transformer
{:name :custom
 :decoders (merge
             stt/string-type-decoders
             {:keyword transform})
 :default-encoder stt/any->string}))

ode keyword? "kikka")
lojure.spec.alpha/invalid

ode keyword? "kikka" my-string-transformer)
KKIK

ec-driven transforming
ode
pec
{:spec #(keyword? %)
 :decode/custom transform})
ikka"
-string-transformer)
KKIK

efaut encoding to strings
ode int? 1 my-string-transformer)
"

Type-based transformer encoding & decoding mappings are defined as data, so they are easy to compose:

 strict-json-transformer
ype-transformer
{:name :custom
 :decoders (merge
             stt/json-type-decoders
             stt/strip-extra-keys-type-decoders)
 :encoders stt/json-type-encoders}))
Data Macros
Data Specs
uire '[spec-tools.data-spec :as ds])

Data Specs offers an alternative, Schema-like data-driven syntax to define simple nested collection specs. Rules:

NOTE: to avoid macros, current implementation uses the undocumented functional core of clojure.spec.alpha: every-impl, tuple-impl, map-spec-impl, nilable-impl and or-spec-impl.

NOTE: To use enums with data-specs, you need to wrap them: (s/spec #{:S :M :L})

ef ::age spec/pos-int?)

 data-spec
 person
:id integer?
:age ::age
boss boolean?
ds/req :name) string?
ds/opt :description) string?
languages #{keyword?}
aliases [(ds/or {:maps {:alias string?}
                 :strings string?})]
orders [{:id int?
         :description string?}]
address (ds/maybe
          {:street string?
           :zip string?})})

t's just data.
 new-person
issoc person ::id))

| Key | Description | | ——————-|———————————————————————————–| | :spec | The wrapped data-spec. | | :name | Qualified root spec name - used to generate unique names for sub-specs. | | :keys-spec | Function to wrap not-wrapped keys, e.g. ds/un to make keys optional by default. | | :keys-default | Function to generate the keys-specs, default ds/keys-specs. |

ptions-syntax
 person-spec
s/spec
{:name ::person
 :spec person}))

egacy syntax
 person-spec
s/spec ::person person))

 new-person-spec
s/spec ::person new-person))
s (st/registry #"user.*"))
user/id
user/age
user$person/boss
user$person/name
user$person/description
user$person/languages
user$person$aliases$maps/alias
user$person/orders
user$person$orders/description
user$person$orders/id
user$person/address
user$person$address/street
user$person$address/zip)
alid?
w-person-spec
:age 63
boss true
name "Liisa"
languages #{:clj :cljs}
aliases [{:alias "Lissu"} "Liisu"]
orders [{:id 1, :description "cola"}
        {:id 2, :description "kebab"}]
description "Liisa is a valid boss"
address {:street "Amurinkatu 2"
         :zip "33210"}})
ue
encode
w-person-spec
:age "63"
boss "true"
name "Liisa"
languages ["clj" "cljs"]
aliases [{:alias "Lissu"} "Liisu"]
orders [{:id "1", :description "cola"}
        {:id "2", :description "kebab"}]
description "Liisa is a valid boss"
address nil}
/string-transformer)
:age 63
boss true
name "Liisa"
aliases [{:alias "Lissu"} "Liisu"]
languages #{:clj :cljs}
orders [{:id 1, :description "cola"}
        {:id 2, :description "kebab"}]
description "Liisa is a valid boss"
address nil}
Spec Visitors

A tool to walk over and transform specs using the Visitor-pattern. Main entry point is the spec-tools.visitor/visit function, extendable via spec-tools.visitor/visit-spec multimethod. There is an example implementation for recursively collecting nested specs. Also, the Spec to JSON Schema -converter is implemented using the visitor.

uire '[spec-tools.visitor :as visitor])

isitor to recursively collect all registered spec forms
 [specs (atom {})]
isitor/visit
person-spec
(fn [_ spec _ _]
  (if-let [s (s/get-spec spec)]
    (swap! specs assoc spec (s/form s))
    @specs))))

user/id ..
user/age ..
user$person/boss ..
user$person/name ..
user$person/aliases ..
user$person/languages ..
user$person/aliases
user$person$aliases$maps/alias
user$person$orders/id ..
user$person$orders/description ..
user$person/orders ..
user$person$address/street ..
user$person$address/zip ..
user$person/address ..
user$person/description ..}

NOTE: due to CLJ-2152, s/& & s/keys* can't be visited.

Generating JSON Schemas

Generating JSON Schemas from arbitrary specs (and Spec Records).

uire '[spec-tools.json-schema :as jsc])

/transform person-spec)
type "object"
properties {"user/id" {:type "integer"}
            "user/age" {:type "integer", :format "int64", :minimum 1}
            "boss" {:type "boolean"}
            "name" {:type "string"}
            "aliases" {:type "array",
                       :items {:anyOf [{:type "string"}
                                       {:type "object",
                                        :properties {"alias" {:type "string"}},
                                        :required ["alias"]}]}},
            "languages" {:type "array", :items {:type "string"}, :uniqueItems true}
            "orders" {:type "array"
                      :items {:type "object"
                              :properties {"id" {:type "integer", :format "int64"}
                                           "description" {:type "string"}}
                              :required ["id" "description"]}}
            "address" {:oneOf [{:type "object"
                                :properties {"street" {:type "string"}
                                             "zip" {:type "string"}}
                                :required ["street" "zip"]}
                               {:type "null"}]}
            "description" {:type "string"}}
required ["user/id" "user/age" "boss" "name" "languages" "orders" "address"]}

Extra data from Spec records is used to populate the data:

/transform
t/spec
{:spec integer?
 :name "integer"
 :description "it's an int"
 :json-schema/default 42}))
type "integer"
title "integer"
description "it's an int"
default 42}
Generating Swagger2 Schemas

A converter from Specs to Swagger2 (JSON) Schemas. Can be used as standalone but will be later available as ring-swagger module. See https://github.com/metosin/ring-swagger/issues/95.

uire '[spec-tools.swagger.core :as swagger])
Spec transformations

swagger/transform converts specs into Swagger2 JSON Schema. Transformation can be customized with the following optional options:

NOTE: As clojure.spec is more powerful than the Swagger2 JSON Schema, we are losing some data in the transformation. We try to retain all the informatin, via vendor extensions.

gger/transform float?)
type "number" :format "float"}

o "null" in swagger2
gger/transform (s/nilable string?))
type "string", :x-nullable true}

wagger2 parameter syntax
gger/transform (s/nilable string?) {:type :parameter})
type "string", :allowEmptyValue true}

o "anyOf" in swagger2
gger/transform (s/cat :int integer? :string string?))
type "array"
items {:type "integer"
       :x-anyOf [{:type "integer"}
                 {:type "string"}]}}
Swagger Spec generation

swagger/swagger-spec function takes an extended swagger2 spec as map and transforms it into a valid Swagger Object. Rules:

Predifined dispatch keys below.

::swagger/parameters

Value should be a map with optional keys :body, :query, :path, :header and :formData. For all but :body, the value should be a s/keys spec (describing the ring parameters). With :body, the value can be any clojure.spec.alpha/Spec.

Returns a map with key :parameters with value of vector of swagger Parameter Objects, merged over the existing :parameters. Duplicate parameters (with identical :in and :name are overridden)

gger/swagger-spec
paths
"echo"
{:post
 {:parameters
  [;; existing parameter, will be overriddden
   {:in "query"
    :name "name"
    :required false}
   ;; unique parameter, will remain
   {:in "query"
    :name "name2"
    :type "string"
    :required true}]
  ;; the spec-parameters
  ::swagger/parameters
  {:query (s/keys :opt-un [::name])
   :body ::user}}}}})
paths
"echo"
{:post
 {:parameters
  [{:in "query"
    :name "name2"
    :description "merged"
    :type "string"
    :required true}
   {:in "query"
    :name ""
    :description ""
    :type "string"
    :required false}
   {:in "body"
    :name ""
    :description ""
    :required true
    :schema {:type "object"
             :title "user/user"
             :properties {"name" {:type "string"}}
             :required ["name"]}}]}}}}
::swagger/responses

Value should a Swagger2 Responses Definition Object with Spec or Spec as the :schema. Returns a map with key :responses with :schemas transformed into Swagger2 Schema Objects, merged over existing :responses.

ef ::name string?)
ef ::user (s/keys :req-un [::name]))

gger/swagger-spec
responses {404 {:description "fail"}
           500 {:description "fail"}}
:swagger/responses
200 {:schema ::user
     :description "Found it!"}
404 {:description "Ohnoes."}}})
responses
200 {:schema
     {:type "object",
      :properties {"name" {:type "string"}},
      :required ["name"],
      :title "user/user"},
     :description "Found it!"}
404 {:description "Ohnoes."
     :schema {}},
500 {:description "fail"}}}
Full example
uire '[spec-tools.swagger.core :as swagger])
uire '[clojure.spec.alpha :as s])

ef ::id string?)
ef ::name string?)
ef ::street string?)
ef ::city #{:tre :hki})
ef ::address (s/keys :req-un [::street ::city]))
ef ::user (s/keys :req-un [::id ::name ::address]))

gger/swagger-spec
swagger "2.0"
info {:version "1.0.0"
      :title "Sausages"
      :description "Sausage description"
      :termsOfService "http://helloreverb.com/terms/"
      :contact {:name "My API Team"
                :email "foo@example.com"
                :url "http://www.metosin.fi"}
      :license {:name "Eclipse Public License"
                :url "http://www.eclipse.org/legal/epl-v10.html"}}
tags [{:name "user"
       :description "User stuff"}]
paths {"/api/ping" {:get {:responses {:default {:description ""}}}}
       "/user/:id" {:post {:summary "User Api"
                           :description "User Api description"
                           :tags ["user"]
                           ::swagger/parameters {:path (s/keys :req [::id])
                                                 :body ::user}
                           ::swagger/responses {200 {:schema ::user
                                                     :description "Found it!"}
                                                404 {:description "Ohnoes."}}}}}})
swagger "2.0",
info {:version "1.0.0",
      :title "Sausages",
      :description "Sausage description",
      :termsOfService "http://helloreverb.com/terms/",
      :contact {:name "My API Team", :email "foo@example.com", :url "http://www.metosin.fi"},
      :license {:name "Eclipse Public License", :url "http://www.eclipse.org/legal/epl-v10.html"}},
tags [{:name "user", :description "User stuff"}],
paths {"/api/ping" {:get {:responses {:default {:description ""}}}},
       "/user/:id" {:post {:summary "User Api",
                           :description "User Api description",
                           :tags ["user"],
                           :responses {200 {:description "Found it!",
                                            :schema {:type "object",
                                                     :properties {"id" {:type "string"},
                                                                  "name" {:type "string"},
                                                                  "address" {:type "object",
                                                                             :properties {"street" {:type "string"},
                                                                                          "city" {:enum [:tre :hki]}},
                                                                             :required ["street" "city"]}},
                                                     :required ["id" "name" "address"]}},
                                       404 {:description "Ohnoes."}},
                           :x-spec-tools.swagger.core-test/kikka 42,
                           :parameters [{:in "path", :name "", :description "", :type "string", :required true}
                                        {:in "body",
                                         :name "",
                                         :description "",
                                         :required true,
                                         :schema {:type "object",
                                                  :properties {"id" {:type "string"},
                                                               "name" {:type "string"},
                                                               "address" {:type "object",
                                                                          :properties {"street" {:type "string"},
                                                                                       "city" {:enum [:tre :hki]}},
                                                                          :required ["street" "city"]}},
                                                  :required ["id" "name" "address"]}}]}}}}
OpenAPI3 Integration

TODO

License

Copyright © 2016-2018 Metosin Oy

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


This work is supported by the National Institutes of Health's National Center for Advancing Translational Sciences, Grant Number U24TR002306. This work is solely the responsibility of the creators and does not necessarily represent the official views of the National Institutes of Health.