-
Notifications
You must be signed in to change notification settings - Fork 149
Coercion
[metosin/compojure-api "2.0.0-alpha5"]
Example project: https://github.com/metosin/compojure-api/tree/master/examples/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 predefinedCoercion
.
(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"}}
-
:schema
(default) resolves tocompojure.api.coercion.schema/SchemaCoercion
. -
:spec
resolves tocompojure.api.coercion.spec/SpecCoercion
- supports both vanillaclojure.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
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]))
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}
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}
...}
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"]
(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
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}
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}
...}