Home

Awesome

Snoop (Alpha)

<img width="260" align="right" src="https://user-images.githubusercontent.com/41270840/121264336-e5c9bb80-c8ae-11eb-8466-427b0636b3d0.png">

Clojars Project

Function instrumention for Clojure(Script) using malli schemas and a custom defn wrapper.

Inspired by Guardrails and malli-instrument.

Rationale

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 defns) 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:

example snoop

Installation

Clojars Project

deps.edn:

com.crypticbutter/snoop {:mvn/version "21-353-alpha"}
metosin/malli {:mvn/version "LATEST"}

(Also see the changelog)

Then either:

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.

Using the >defn macro

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).

Using malli's function schema registry:

(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.

Inside the function body:

(>defn add [x [y z]] ;; You can still use destructuring
  [:=> [:cat int? [:tuple int? int?]] int?]
  ...)

More convenient notations that work when using >defn:

;; 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.

Inside the prepost map:

(>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.

Multiple arities and variadic functions

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] ...))

No schema

Schemas are optional. >defn works fine without the schema (acts as a regular defn without the instrumentation):

(>defn add [x y]
   ;; advanced maths
  ...)

Inline Schema

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
    ...))

Support for Keyword Argument Functions

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?]
   ...)

Configuration

There are two main global configurations and they can be overrided for individual functions:

Runtime config

At runtime, you are able to modify the crypticbutter.snoop/*config atom, which affects the behaviour of instrumented functions.

KeyDefaultDescription
:on-instrument-failFunction to call when the input is not valid. Receives single argument.
:on-outstrument-failFunction 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?trueWhether to enable validation on a function's arguments.
:outstrument?trueWhether to enable validation on a function's return value.
:whitelist-by-defaulttrueDetermines 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.

Compile-time config

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).

KeyDefaultDescription
: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-symclojure.core/defnThe 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-symclojure.core/printlnThe symbol used to resolve the function used for logging messages during compile-time.

Per-function config

You can provide config overrides as metadata (including via an attr-map).

(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?]
  "🍉")

Improvements to be made

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.


<img align="right" src="https://user-images.githubusercontent.com/41270840/121725121-bf8b6200-cae0-11eb-8d25-4fd0807f4b8e.png">

Contributing

See development

And ensure the tests pass: testing

I'll publish more details in the future.

License

Copyright © 2021 Luis Thiam-Nye and contributors.

Distributed under Eclipse Public License 2.0, see LICENSE.