Increasingly Functional.
by Joshua Miller | on Twitter | on the web | on github

Dynamically Generating clojure.spec Keywords

July 13th 2016

Tagged: clojure

I've been experimenting with clojure.spec on a new web project, as a way of validating data coming in from the client. We're using JSON API as the lingua franca between the server and client, which adds a not-insignificant amount of ceremony and overhead to the JSON payload. So once I got one namespace's endpoints spec'ed correctly, the obvious next step was to generalize it with a function that looked like this (edited for brevity):

(defn request-spec
  [type]
  (let [api-ns (str "my-project.api." type)
        db-ns (str "my-project.db." type)]
    (s/def (keyword api-ns "attributes") (keyword db-ns "attributes"))
    (s/def (keyword api-ns "data") (s/keys :req-un [(keyword api-ns "attributes")]))))

What we're accomplishing here with (keyword api-ns "attributes") is building a keyword that looks like :my-project.api.users/attributes. Or at least, that's what I thought we were doing; here's the error I ran into:

CompilerException java.lang.AssertionError: Assert failed: k must be namespaced keyword or resolvable symbol

That's weird — (keyword api-ns "attributes") on the REPL is building a clojure.lang.Keyword like I expected it to, so why is an assert that it's a keyword failing?

The answer lies in how the macro behind clojure.spec/def is implemented. Macroexpanding (s/def (keyword "spec-test-ns" "name") string?) gives you:

(clojure.spec/def-impl (quote (keyword "spec-test-ns" "name")) (quote clojure.core/string?) string?)

So since it's quoteing our input, it's not resolving to the keyword before the assert. The solution I came to is to build a macro that unquotes my keyword building before s/def is called:

(defmacro request-spec
  [type]
  (let [api-ns (str "my-project.api." type)
        db-ns (str "my-project.db." type)
        attr-k (keyword api-ns "attributes")
        attr-v (keyword db-ns type)
        data-k (keyword api-ns "data")]
    `(do (s/def ~attr-k ~attr-v)
         (s/def ~data-k (s/keys :req-un [~attr-k])))))

I generally prefer non-macro solutions when available, but as of now, this seems like the only way to DRY up this particular pattern.