The hard way or the easy way?
Tags: clojure, testing, webdev
Published: 2023-02-13 16:59:08

This story is about doing _the wrong thing_™ for selfish reasons, although there is an adequate amount of how-to, so it qualifies as a tech article.

# The background

Where I last worked, the backend developers agreed that we had a problem with HTTP response schemas.  We agreed that we needed them.  We agreed that they were a nuisance.

_Have you ever seen a Malli invalid schema report?_

![A taste](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wfd02jvvqxn352ozwzpl.png)

Nobody wanted to deal with these after each commit, so something had to be done.

# Doing something!

Removing the schemas was not an option because they provided too much benefit to the front-end developers.

Likewise, if we stopped maintaining the HTTP response schemas, a front-end developer would eventually discover our negligence.

We had to be clever.

# How we did it

A significant feature of [reitit](https://github.com/metosin/reitit) is that routes are composed of simple vectors and maps. That means you can pre-process that data structure however you want before building the router.

That means we can jerry-rig our router to include, for instance, different middleware for use only in our tests.

I warned you. 😈

We'll start by copying our response schemas to a new key-value pair called `test-schemas`.

```clojure
["my/route"
 {:get
  {:middleware []
   :handler my/handler
   :test-schemas {200 schemas/ResponseSchema}}}]
```

`test-schemas` expresses our validation schema to the middleware.

What middleware?

Let's make a new middleware function in our testing layer.

```clojure
(def schema-warnings
  "aggregates a vector of {:request ..., :explanation ...} maps"
  (atom []))

(defn build-test-middleware
  [schemas]
  (fn test-middleware
    ([handler]
     (test-middleware handler {}))
    ([handler _]
     (fn [request]
       (let [response (handler request)
             status (:status response)
             body (:body response)]
         (if-let [schema (get schemas status)]
           (if (not (malli/validate schema body))
             (do
               (swap! schema-warnings conj
                      {:request (:original-request request)
                       :explanation (malli/explain schema body)})
               response)
             response)
           response))))))
```

`build-test-middleware` returns a standard ring middleware function.  Whenever there is a request, it captures the response and compares it to the schema corresponding to the HTTP response status using `malli/validate`.  If the response is invalid, we save it to an atom for later review.

How do we wedge the testing middleware into place?

```clojure
(defn test-routes [routes]
  (letfn [(add-test-middleware
            [[request-method
              {:keys [test-schemas] :as details}]]
            (if (some? test-schemas)
              [request-method
               (-> details
                   (update :middleware (comp #(conj % (build-test-middleware test-schemas)) #(or % [])))
                   (dissoc :test-schemas))]
              [request-method details]))]
    (mapv
     (fn [[url request-methods]]
       [url
        (->>
         request-methods
         (mapv add-test-middleware)
         (into {}))])
     routes)))
```

`test-routes` modifies routes by adding the `test-middleware` when a `test-schemas` key-value pair exists i.e.

```clojure
(test-routes
 [["my/route"
   {:get
    {:middleware []
     :handler (fn [& args] {:status 200 :body {}})
     :test-schemas {200 [:map [:foo number?]]}}}]])

;; result
[["my/route"
  {:get
   {:middleware
    [#function[my-ns/build-test-middleware/test-middleware--14034]],
    :handler #function[my-ns/fn--14169]}}]]
```

Let's see if it works!

```clojure
((reitit.ring/ring-handler
  (reitit.ring/router
   (test-routes
    [["my/route"
      {:get
       {:middleware []
        :handler (fn [& args] {:status 200 :body {}})
        :test-schemas {200 [:map [:foo number?]]}}}]])))
 {:request-method :get :uri "my/route"})

;; result
{:status 200, :body {}}
```

We were able to make a request. However, you can see that the response body doesn't match the schema.  Did we record a problem?

Let's check.

```clojure
@schema-warnings

;; result
[{:request nil,
  :explanation
  {:schema [:map [:foo number?]],
   :value {},
   :errors
   ({:path [:foo],
     :in [:foo],
     :schema [:map [:foo number?]],
     :value nil,
     :type :malli.core/missing-key,
     :message nil})}}]
```

There wasn't any request to save because the `original-request` key-value pair was missing from the request.  Adding that is easy.

```clojure
(defn run-request [router request]
  (router (assoc request :original-request request)))
```

Let's try again.

```clojure
(run-request
 (reitit.ring/ring-handler
  (reitit.ring/router
   (test-routes
    [["my/route"
      {:get
       {:middleware []
        :handler (fn [& args] {:status 200 :body {}})
        :test-schemas {200 [:map [:foo number?]]}}}]])))
 {:request-method :get :uri "my/route"})

;; result
{:status 200, :body {}}

@schema-warnings

;; result
[{:request {:request-method :get, :uri "my/route"},
  :explanation
  {:schema [:map [:foo number?]],
   :value {},
   :errors
   ({:path [:foo],
     :in [:foo],
     :schema [:map [:foo number?]],
     :value nil,
     :type :malli.core/missing-key,
     :message nil})}}]
```

Now we have the request paired with the invalid schema report.

# Outcome

Despite being thoroughly _the wrong thing to do_, this generally led to a happier backend team.  But what about the front-end developers?  Did they ever call out our negligence?  Did it cause problems in production?

Surprisingly no.  We continued to handle problems with the response schema inside feature branches.  Mostly. 😈