Skip to content
Anton Mostovoy edited this page Feb 6, 2020 · 13 revisions
[metosin/compojure-api "2.0.0-alpha5"]

Example project: https://github.com/metosin/compojure-api/tree/master/examples/coercion

Coercion

Compojure-api supports pluggable coercion with out-of-the-box implementations for Schema and clojure.spec. Coercion covers both input (request data) and output (response body) data.

Coercion can be defined to api, context, resource or any other endpoint via the :coercion metadata. It's value should be either:

  • something satisfying compojure.api.coercion.core/Coercion
  • a looking key for compojure.api.coercion.core/named-coercion multimethod to find a predefined Coercion.
(require '[compojure.api.sweet :refer :all])
(require '[ring.util.http-response :refer :all])

(def app
  (api
    ;; no coercion by default
    {:coercion nil}

    ;; explicit schema-coercion
    (context "/schema" []
      :coercion :schema
      (GET "/plus" []
        :query-params [x :- Long, y :- Long]
        :return {:total int?}
        (ok {:total (+ x y)})))

    ;; explicit (data-)spec-coercion
    (context "/spec" []
      (resource
        {:coercion :spec
         :get {:parameters {:query-params {:x int?, :y int?}}
               :responses {200 {:schema {:total int?}}}
               :handler (fn [{{:keys [x y]} :query-params}]
                          (ok {:total (+ x y)}))}}))))

The actual input/output coercion code is generated automatically for all route definitions if the route declares input or output models. At runtime, the Coercion is injected in the request under :compojure.api.request/coercion key.

At runtime, compojure-api calls the Coercion with the route-defined model, value, type of the coercion (:body, :response or :string), request/response content-type and the original request. This enables different wire-formats to coerce differently (e.g. JSON vs Transit).

If the coercion succeeds, coerced values are overwritten over originals. If a coercion fails, an exception is raised with :type either ::ex/request-validation or ::ex/response-validation. To handle these, see Exception handling. Default exception handlers return either 400 or 500 http responses with (machine-readable) details about the coercion failure:

(-> {:uri "/schema/plus"
     :request-method :get
     :query-params {:x "kikka"}}
    app
    :body
    slurp
    (cheshire.core/parse-string true))

; {:type "compojure.api.exception/request-validation"
;  :coercion "schema"
;  :value {:x "kikka"}
;  :in ["request" "query-params"]
;  :schema {:Keyword "Any"
;           :x "java.lang.Long"
;           :y "java.lang.Long"}
;  :errors {:x "(not (instance? java.lang.Long \"kikka\"))"
;           :y "missing-required-key"}}

Predefined coercions

  • :schema (default) resolves to compojure.api.coercion.schema/SchemaCoercion.
  • :spec resolves to compojure.api.coercion.spec/SpecCoercion - supports both vanilla clojure.spec & data-specs.
  • nil for no coercion.

Compojure-api logs about all registered coercions. With Spec enabled, one should see the following lines in the log:

INFO :schema coercion enabled in compojure.api
INFO :spec coercion enabled in compojure.api

Custom coercion

To add support to different validation libs (like Bouncer), one needs to implement the following Protocol:

(defprotocol Coercion
  (get-name [this])
  (get-apidocs [this spec data])
  (make-open [this model])
  (encode-error [this error])
  (coerce-request [this model value type format request])
  (coerce-response [this model value type format request]))

Schema coercion

Uses Ring-Swagger as the coercion backend, supporting both fully data-driven syntax and Plumbing fnk syntax with route macros.

(require '[schema.core :as s])

(def app
  (api
    ;; make the default visible
    {:coercion :schema}

    (context "/math/:a" []
      :path-params [a :- s/Int]

      (POST "/plus" []
        :query-params [b :- s/Int, {c :- s/Int 0}]
        :body [numbers {:d s/Int}]
        :return {:total s/Int}
        (ok {:total (+ a b c (:d numbers))})))

    (context "/data-math" []
      (resource
        ;; to make coercion explicit
        {:coercion :schema
         :get {:parameters {:query-params {:x s/Int, :y s/Int}}
               :responses {200 {:schema {:total s/Int}}}
               :handler (fn [{{:keys [x y]} :query-params}]
                          (ok {:total (+ x y)}))}}))))

(-> {:request-method :get
     :uri "/data-math"
     :query-params {:x "1", :y "2"}}
    (app)
    :body
    (slurp)
    (cheshire.core/parse-string true))
; {:total 3}

Custom Schema coercion

Rules are defined as data, so it's easy to customize them. By default, the following content-type based rules are used:

Format Request Response
application/edn validate validate
application/transit+json validate validate
application/transit+msgpack validate validate
application/json json-coercion-matcher validate
application/msgpack json-coercion-matcher validate
application/x-yaml json-coercion-matcher validate

Defaults as code:

(def default-options
  {:body {:default (constantly nil)
          :formats {"application/json" json-coercion-matcher
                    "application/msgpack" json-coercion-matcher
                    "application/x-yaml" json-coercion-matcher}}
   :string {:default string-coercion-matcher}
   :response {:default (constantly nil)}})

Without response coercion:

(require '[compojure.api.coercion.schema :as schema-coercion])

(def no-response-coercion
  (schema-coercion/create-coercion
    (assoc schema-coercion/default-options :response nil)))

(api
  {:coercion no-response-coercion}
  ...}

With json-coercion for all responses (the default in previous compojure api 1.x):

(require '[compojure.api.coercion.schema :as schema-coercion])

(def my-coercion
  (schema-coercion/create-coercion
    (assoc-in 
      schema-coercion/default-options 
      [:response :default] 
      schema-coercion/json-coercion-matcher)))

(api
  {:coercion my-coercion}
  ...}

Spec coercion

Uses Spec-tools as the coercion backend, supporting both fully data-driven syntax and Plumbing fnk syntax with route macros. Currently, Spec doesn't support selective runtime conforming, so we need to wrap specs into Spec Records to do it. Spec coercion supports both vanilla specs and data-specs.

To enable spec coercion, the following dependencies are needed:

  • Clojure 1.9:
[org.clojure/clojure "1.9.0-beta2"]
[metosin/spec-tools "0.4.0"]
  • Clojure 1.8:
[org.clojure/clojure "1.8.0"]
[metosin/spec-tools "0.2.2" :exlusions [org.clojure/spec.alpha]]
[clojure-future-spec "1.9.0-alpha17"]

Vanilla specs

(require '[clojure.spec.alpha :as s])
(require '[spec-tools.spec :as spec])

(s/def ::a spec/int?)
(s/def ::b spec/int?)
(s/def ::c spec/int?)
(s/def ::d spec/int?)
(s/def ::total spec/int?)
(s/def ::total-body (s/keys ::req-un [::total]))

(s/def ::x spec/int?)
(s/def ::y spec/int?)

(def app
  (api
    {:coercion :spec}

    (context "/math/:a" []
      :path-params [a :- ::a]

      (POST "/plus" []
        :query-params [b :- ::b, {c :- ::c 0}]
        :body [numbers (s/keys :req-un [::d])]
        :return (s/keys :req-un [::total])
        (ok {:total (+ a b c (:d numbers))})))

    (context "/data-math" []
      (resource
        ;; to make coercion explicit
        {:coercion :spec
         :get {:parameters {:query-params (s/keys :req-un [::x ::y])}
               :responses {200 {:schema ::total-body}}
               :handler (fn [{{:keys [x y]} :query-params}]
                          (ok {:total (+ x y)}))}}))))

(-> {:request-method :get
     :uri "/data-math"
     :query-params {:x "1", :y "2"}}
    (app)
    :body
    (slurp)
    (cheshire.core/parse-string true))
; {:total 3}

(println (-> {:request-method :post
              :uri            "/math/3/plus"
              :query-params {:b "4", :c "5"}
              :body-params {:d 6}}
             (app)
             :body
             (slurp)
             (cheshire/parse-string true)))
; => {:total 18}

(println (-> {:request-method :post
              :uri            "/math/3/plus"
              :query-params {:b 4}
              :body-params {:d 6}}
             (app)
             :body
             (slurp)
             (cheshire/parse-string true)))
; => {:total 13}

(println (-> {:request-method :post
              :uri            "/math/3/plus"
              :query-params {:c 5}
              :body-params {:d 6}}
             (app)
             :body
             (slurp)
             (cheshire/parse-string true)))
; => map which contains error description
; look at :pred (clojure.core/fn [%] (clojure.core/contains? % :b)) to understand the error
; we should include :b in query params

(println (-> {:request-method :post
              :uri            "/math/3/plus"
              :query-params {:b 4 :c 5}
              :body-params {:d "6"}}
             (app)
             :body
             (slurp)
             (cheshire/parse-string true)))
; => map which contains error description
; look at :path [d], :pred clojure.core/int? to understand the error
; :d in :body-params should be a number

Data-specs

If you are not fan of the predefined specs & global registry, you can also use data-specs.

(def app
  (api
    {:coercion :spec}

    (context "/math/:a" []
      :path-params [a :- spec/int?]

      (POST "/plus" []
        :query-params [b :- spec/int?, {c :- spec/int? 0}]
        :body [numbers {:d spec/int?}]
        :return {:total ::total}
        (ok {:total (+ a b c (:d numbers))})))

    (context "/data-math" []
      (resource
        ;; to make coercion explicit
        {:coercion :spec
         :get {:parameters {:query-params {:x spec/int?, :y spec/int?}}
               :responses {200 {:schema ::total-body}}
               :handler (fn [{{:keys [x y]} :query-params}]
                          (ok {:total (+ x y)}))}}))))

(-> {:request-method :get
     :uri "/data-math"
     :query-params {:x "1", :y "2"}}
    (app)
    :body
    (slurp)
    (cheshire.core/parse-string true))
; {:total 3}

Custom Spec coercion

Rules are defined as data, so it's easy to customize them. By default, the following content-type based rules are used:

Format Request Response
application/edn validate validate
application/transit+json validate validate
application/transit+msgpack validate validate
application/json json-conforming validate
application/msgpack json-conforming validate
application/x-yaml json-conforming validate

Defaults as code:

(def default-options
  {:body {:default default-conforming
          :formats {"application/json" json-conforming
                    "application/msgpack" json-conforming
                    "application/x-yaml" json-conforming}}
   :string {:default string-conforming}
   :response {:default default-conforming}})

Without response coercion:

(require '[compojure.api.coercion.spec :as spec-coercion])

(def no-response-coercion
  (spec-coercion/create-coercion
    (assoc spec-coercion/default-options :response nil)))

(api
  {:coercion no-response-coercion}
  ...}