Search code examples
clojurecompojure-api

compojure-api spec coercion on response body


I'm trying to figure out how to do custom coercion with compojure-api and spec. By reading the docs and code I have been able to do coercion on the input (the body) but am unable to do coercion on the response body.

Specifically, I have a custom type, a timestamp, that is represented as a long within my app but for the web API I want to consume and return ISO timestamps (no, I don't want to use Joda internally).

The following is what I have that works for input coercion but I have been unable to properly coerce the response.

(ns foo
  (:require [clj-time.core :as t]
            [clj-time.coerce :as t.c]
            [spec-tools.conform :as conform]
            [spec-tools.core :as st]))


(def timestamp (st/create-spec
            {:spec pos-int?
             :form `pos-int?
             :json-schema/default "2017-10-12T05:04:57.585Z"
             :type :timestamp}))

(defn json-timestamp->long [_ val]
  (t.c/to-long val))

(def custom-json-conforming
  (st/type-conforming
   (merge
    conform/json-type-conforming
    {:timestamp json-timestamp->long}
    conform/strip-extra-keys-type-conforming)))

(def custom-coercion
  (->  compojure.api.coercion.spec/default-options
       (assoc-in [:body :formats "application/json"] custom-json-
conforming)
       compojure.api.coercion.spec/create-coercion))

;; how do I use this for the response coercion?
(defn timestamp->json-string [_ val]
  (t.c/to-string val))
;; I've tried the following but it doesn't work:
#_(def custom-coercion
  (->  compojure.api.coercion.spec/default-options
       (assoc-in [:body :formats "application/json"] custom-json-
conforming)
       (assoc-in [:response :formats "application/json"]
                 (st/type-conforming
                  {:timestamp timestamp->json-string}))
       compojure.api.coercion.spec/create-coercion))

Solution

  • Problem is that Spec Conforming is a one-way transformation pipeline:

    s/conform (and because of that st/conform) does both transform and validate for the result. Your response coercion first converts the integer into date string and the validates it against the original spec predicate, which is pos-int? and it fails on that.

    To support two-way transformations, you need to define the end result as either of the possible formats: e.g. change your predicate to something like #(or (pos-int? %) (string? %)) and it should work.

    Or you can have two different Spec Records, one for input (timestamp-long with pos-int? predicate) and another for outputs (timestamp-string with string? predicate). But one needs to remember to use correct ones for request & responses.

    CLJ-2251 could possible help if there was and extra :transform mode (not written in the issue yet), which would do conforming without validating the end results.

    Normally, return transformations are done by the format encoder, but they usually dispatch on value types. For example Cheshire just sees an Long and has no clue that it should be written as date string.

    Maybe people on the #clojure-spec slack could help. Would also like to know how to build this kind of two-way transformation with spec.