DEV Community

Cover image for The hard way or the easy way?
Daniel Fitzpatrick
Daniel Fitzpatrick

Posted on

The hard way or the easy way?

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

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

["my/route"
 {:get
  {:middleware []
   :handler my/handler
   :test-schemas {200 schemas/ResponseSchema}}}]
Enter fullscreen mode Exit fullscreen mode

test-schemas expresses our validation schema to the middleware.

What middleware?

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

(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))))))
Enter fullscreen mode Exit fullscreen mode

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?

(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)))
Enter fullscreen mode Exit fullscreen mode

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

(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]}}]]
Enter fullscreen mode Exit fullscreen mode

Let's see if it works!

((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 {}}
Enter fullscreen mode Exit fullscreen mode

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.

@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})}}]
Enter fullscreen mode Exit fullscreen mode

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

(defn run-request [router request]
  (router (assoc request :original-request request)))
Enter fullscreen mode Exit fullscreen mode

Let's try again.

(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})}}]
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)