Function instrumention for Clojure(Script) using malli schemas and a custom defn wrapper.
Inspired by Guardrails and malli-instrument.
I wanted a way to use malli schemas to check the validity of the inputs and outputs of functions. Instrumentation is a conventient way to spot errors using real-world data and it does not require writing tests upfront. malli-instrument and aave had limitations that made them unsuitable for my needs.
I attempted to modify malli-instrument to be ClojureScript-compatible. However,
I found that clojure.spec
-like instrumentation (which works on regular defn
s)
can be inconvenient with hot code reloading and evaluating functions on the fly.
Thus, I took the approach of using a >defn
macro, which has the following benefits:
- Makes it more convenient to specify the schema
- You do not have to define the function symbol twice (once for the function,
again for the schema using
m/=>
) - In multi-arity functions, schemas can be colocated with each arity
- You do not have to define the function symbol twice (once for the function,
again for the schema using
- Easy to quickly disable instrumentation on individual functions
- No special linter required (can be linted as
defn
)
deps.edn:
com.crypticbutter/snoop {:mvn/version "21-353-alpha"}
metosin/malli {:mvn/version "LATEST"}
(Also see the changelog)
Then either:
- Create a
snoop.edn
file with a map in it. Specify the-Dsnoop.enabled
JVM option when launching a REPL. E.gclj -J-Dsnoop.enabled
or:jvm-opts ["-Dsnoop.enabled"]
in deps.edn - [Only available with ClojureScript] Provide the compiler options:
{:external-config {:crypticbutter.snoop {}}}
- Example for a shadow-cljs build:
:your-build {:dev {:compiler-options {:external-config {:crypticbutter.snoop {}}}} ...}
- Example for a shadow-cljs build:
Snoop is disabled by default and will throw an exception if enabled in a CLJS production build.
If there are any problems installing & using, please let me know.
Prerequisite: | understand malli's function schemas |
---|
(require '[crypticbutter.snoop :refer [>defn]])
The >defn
macro is optionally backwards compatible with defn
(you can swap out one
symbol with the other without breaking any code). This makes it more feasible to
combine multiple defn wrappers (also see the :defn-sym option).
Enclosed, you will find a clj-kondo config export, for your linting convenience. See (exporting and importing clj-kondo configs).
There are multiple ways of specifying your schema(s).
(require '[malli.core :as m])
(m/=> add [:=> [:cat int? int?] int?])
(>defn add [x y] ...)
The schema specified with m/=>
will be ignored if any schema is specified within the function body.
(>defn add [x [y z]] ;; You can still use destructuring
[:=> [:cat int? [:tuple int? int?]] int?]
...)
;; Require `=>` solely to prevent unresolved symbol linting errors
(require '[crypticbutter.snoop :refer [>defn =>]])
(>defn add [x y]
;; Either:
[[:cat int? int?] int?]
;; Or:
[int? int? => int?]
...)
The second schema above uses a similar notation to ghostwheel.
The =>
can be substituted with :=>
, '=>
or :ret
To outstrument a 0-parameter function, you could use [=> int?]
β this means
there will be no input validation.
(>defn add [x y]
{:=> [[:cat int? int?] int?]}
...)
The main motivation for this option is that it could make combining defn wrappers
easier by allowing you to forward the schema via the prepost map. Requires that you
are able to set the defn
symbol used by the top-level macro.
You can mix and match notations.
(>defn add
([x]
[int? => int?]
...)
([x y]
{:=> [:=> [:cat int? int?] int?]}
...)
([x y & zs]
;; Either
[int? int? [:+ int?] => int?]
;; Or
[[:cat int? int? [:+ int?]] int?]
...))
You could also use m/=>
.
(m/=> add [:function
[:=> [:cat int?] int?]
[:=> [:cat int? int?] int?]
[:=> [:cat int? int? [:+ int?]] int?]])
(>defn add
([x] ...)
([x y] ...)
([x y & zs] ...))
Schemas are optional. >defn
works fine without the schema (acts as a regular
defn
without the instrumentation):
(>defn add [x y]
;; advanced maths
...)
You can choose to depart from the standard defn
pattern and specify your schemas
right alongside your function parameters. You will need a custom linter, so you may
find the included clj-kondo config export useful (exporting and importing clj-kondo configs).
(>defn wow
[(mickey string?)
(mouse) ;; this argument is not validated
({:keys [fun]} MySchema) ;; Destructuring still available
house ;; list brackets are optional for schemaless params
& (melon [:* int?])]
...)
The disadvantage of this syntax is that you are limited to considering each argument individually; You cannot validate the relationship between arguments.
Note that the inline schemas are used for validation in addition to any schema specified
before the function body (or any schema defined with m/=>
). For example, this will always throw an error:
(>defn doom
[(x int?)]
[string? => :any]
;; x cannot be int and string at the same time
...)
However, this also means that you can use an additional schema vector to specify the return schema:
(>defn melon [(x int?) (y int?) (z melon?)]
[=> string?]
...)
As with the other methods, this works with multiple arities:
(>defn add
([(x int?)]
[=> int?]
...)
([(x int?) (y int?)]
{:=> [=> int?]} ;; return value is int, specified in prepost map
...)
([(x int?) (y int?) & (zs [:* int?])]
;; with no output schema
...))
Treat the keyword arguments as a single map argument (as if it were a fixed-arity function).
If no keyword arguments are passed, an empty map (instead of nil) will be used for validation
(so you do not have to wrap :map
with :maybe
).
(>defn f [a & {:keys [b c]}]
[int? [:map
[:b int?]
[:c {:optional true} int?]]
=> int?]
...)
There are two main global configurations and they can be overrided for individual functions:
At runtime, you are able to modify the crypticbutter.snoop/*config
atom,
which affects the behaviour of instrumented functions.
Key | Default | Description |
---|---|---|
:on-instrument-fail |
Function to call when the input is not valid. Receives single argument. | |
:on-outstrument-fail |
Function to call when the output is not valid. Receives single argument. | |
:log-error-fn |
#?(:clj println :cljs js/console.error) | Used to log errors at runtime. Must be variadic. |
:malli-opts |
{} | Given to m/explain which is used for validation. |
:instrument? |
true | Whether to enable validation on a function's arguments. |
:outstrument? |
true | Whether to enable validation on a function's return value. |
:whitelist-by-default |
true | Determines whether validation is allowed on functions by default. If set to false, functions must be whitelisted in order for validation to occur. |
:blacklist-ns |
#{} | Set of namespace symbols for which in/outstrumentation should be disallowed. |
:whitelist-ns |
#{} | Similar to above but allows validation in the namespaces. Only useful if :whitelist-by-default is false. |
:whitelist-fn |
{} | Maps namespace symbols to sets of function symbols whose validation should be allowed. Overrides the namespace rules above. |
:blacklist-fn |
{} | Similar to above but disallows validation. |
You can also modify the config used by the macros. This can be done in snoop.edn
or via the CLJS compiler options (see Installation).
Key | Default | Description |
---|---|---|
:enabled? | true (only if config provided) |
Whether to augment the function body with instrumentation features. This is the master switch, and should not be true in a production build. |
:defn-sym | clojure.core/defn |
The symbol to use for defn . This allows you to combine defn wrappers as long as their structures are compatible with the core defn macro (you can forward data via metadata or prepost maps). |
:log-fn-sym | clojure.core/println |
The symbol used to resolve the function used for logging messages during compile-time. |
You can provide config overrides as metadata (including via an attr-map
).
-
::snoop/macro-config
gets merged on top of the compile-time config. Whatever you provide here, it must be possible toeval
it as compile-time (so all the appropriate vars must be bound and you cannot pass in locals). -
::snoop/config-atom
will be used within the function instead ofsnoop/*config
. In ClojureScript, this will be attached to the metadata of the function object because var metadata does not get evaluated.
(require '[crypticbutter.snoop :as snoop :refer [>defn]])
(def special-compiletime-config {:enabled? true
:defn-sym 'some.magic/>defn})
(def special-runtime-config (atom {:malli-opts {...} :on-instrument-fail ...}))
(>defn fun-function
{::snoop/macro-config special-compiletime-config
::snoop/config-atom special-runtime-config}
[]
['=> string?]
"π")
- In
>defn
, combine schemas for each arity into a single schema and callm/=>
. Would be useful for malli.dev static schema checking facilities. at runtime to register a schema passed via the prepost map or body. - Provide facilities to allow valiation to be done in a different thread in CLJS.
- Option for asynchronous checking in Clojure JVM
I will probably only work on new features as I need them. That said, please report any issues you run into whilst using this library.
See development
And ensure the tests pass: testing
I'll publish more details in the future.
Copyright Β© 2021 Luis Thiam-Nye and contributors.
Distributed under Eclipse Public License 2.0, see LICENSE.