diff --git a/README.md b/README.md index a7757fe2..d5b5e9d6 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ A friendly data-driven router for Clojure(Script). * Route [conflict resolution](https://metosin.github.io/reitit/basics/route_conflicts.html) * First-class [route data](https://metosin.github.io/reitit/basics/route_data.html) * Bi-directional routing -* [Ring-router](https://metosin.github.io/reitit/ring.html) with data-driven [middleware](https://metosin.github.io/reitit/ring/compiling_middleware.html) -* [Pluggable coercion](https://metosin.github.io/reitit/ring/parameter_coercion.html) ([clojure.spec](https://clojure.org/about/spec)) +* [Ring-router](https://metosin.github.io/reitit/ring/ring.html) with [data-driven middleware](https://metosin.github.io/reitit/ring/data_driven_middleware.html) +* [Pluggable coercion](https://metosin.github.io/reitit/ring/coercion.html) ([schema](https://github.com/plumatic/schema) & [clojure.spec](https://clojure.org/about/spec)) * Extendable +* Modular * [Fast](https://metosin.github.io/reitit/performance.html) See the [full documentation](https://metosin.github.io/reitit/) for details. @@ -26,7 +27,8 @@ Optionally, the parts can be required separately: ```clj [metosin/reitit-core "0.1.0-SNAPSHOT"] ; just the router [metosin/reitit-ring "0.1.0-SNAPSHOT"] ; ring-router -[metosin/reitit-spec "0.1.0-SNAPSHOT"] ; spec-coercion +[metosin/reitit-spec "0.1.0-SNAPSHOT"] ; spec coercion +[metosin/reitit-schema "0.1.0-SNAPSHOT"] ; schema coercion ``` ## Quick start diff --git a/doc/README.md b/doc/README.md index 4dfd2318..bde4bab0 100644 --- a/doc/README.md +++ b/doc/README.md @@ -3,12 +3,13 @@ [Reitit](https://github.com/metosin/reitit) is a small Clojure(Script) library for data-driven routing. * Simple data-driven [route syntax](./basics/route_syntax.md) -* [Route conflict resolution](./advanced/route_conflicts.md) +* [Route conflict resolution](./basics/route_conflicts.md) * First-class [route data](./basics/route_data.md) * Bi-directional routing -* [Pluggable coercion](./ring/parameter_coercion.md) ([clojure.spec](https://clojure.org/about/spec)) -* supports both [Middleware](./ring/compiling_middleware.md) & Interceptors +* [Ring-router](./ring/ring.html) with [data-driven middleware](./ring/data_driven_middleware.html) +* [Pluggable coercion](./ring/coercion.html) ([schema](https://github.com/plumatic/schema) & [clojure.spec](https://clojure.org/about/spec)) * Extendable +* Modular * [Fast](performance.md) To use Reitit, add the following dependecy to your project: @@ -23,6 +24,7 @@ Optionally, the parts can be required separately: [metosin/reitit-core "0.1.0-SNAPSHOT"] ; just the router [metosin/reitit-ring "0.1.0-SNAPSHOT"] ; ring-router [metosin/reitit-spec "0.1.0-SNAPSHOT"] ; spec-coercion +[metosin/reitit-schema "0.1.0-SNAPSHOT"] ; schema coercion ``` For discussions, there is a [#reitit](https://clojurians.slack.com/messages/reitit/) channel in [Clojurians slack](http://clojurians.net/). diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index f221c216..8930d756 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -16,8 +16,9 @@ * [Ring-router](ring/ring.md) * [Dynamic extensions](ring/dynamic_extensions.md) * [Data-driven Middleware](ring/data_driven_middleware.md) - * [Parameter coercion](ring/parameter_coercion.md) + * [Pluggable Coercion](ring/coercion.md) * [Compiling middleware](ring/compiling_middleware.md) * [Performance](performance.md) +* [FAQ](faq.md) * TODO: Swagger & OpenAPI * TODO: Interceptors diff --git a/doc/faq.md b/doc/faq.md new file mode 100644 index 00000000..318b08dc --- /dev/null +++ b/doc/faq.md @@ -0,0 +1 @@ +# Frequently Asked Questions diff --git a/doc/ring/README.md b/doc/ring/README.md index 9bbe9b59..7db18acf 100644 --- a/doc/ring/README.md +++ b/doc/ring/README.md @@ -3,5 +3,5 @@ * [Ring-router](ring.md) * [Dynamic extensions](dynamic_extensions.md) * [Data-driven Middleware](data_driven_middleware.md) -* [Parameter coercion](parameter_coercion.md) +* [Pluggable Coercion](coercion.md) * [Compiling middleware](compiling_middleware.md) diff --git a/doc/ring/coercion.md b/doc/ring/coercion.md new file mode 100644 index 00000000..a2a730a0 --- /dev/null +++ b/doc/ring/coercion.md @@ -0,0 +1,209 @@ +# Pluggable Coercion + +Reitit provides pluggable parameter coercion via `reitit.ring.coercion.protocol/Coercion` protocol, originally introduced in [compojure-api](https://clojars.org/metosin/compojure-api). + +Reitit ships with the following coercion modules: + +* `reitit.ring.coercion.schema/SchemaCoercion` for [plumatic schema](https://github.com/plumatic/schema). +* `reitit.ring.coercion.spec/SpecCoercion` for both [clojure.spec](https://clojure.org/about/spec) and [data-specs](https://github.com/metosin/spec-tools#data-specs). + +### Ring request and response coercion + +To use `Coercion` with Ring, one needs to do the following: + +1. Define parameters and responses as data into route data, in format adopted from [ring-swagger](https://github.com/metosin/ring-swagger#more-complete-example): + * `:parameters` map, with submaps for different parameters: `:query`, `:body`, `:form`, `:header` and `:path`. Parameters are defined in the format understood by the `Coercion`. + * `:responses` map, with response status codes as keys (or `:default` for "everything else") with maps with `:schema` and optionally `:description` as values. +2. Set a `Coercion` implementation to route data under `:coercion` +3. Mount request & response coercion middleware to the routes (can be done for all routes as the middleware are only mounted to routes which have the parameters &/ responses defined): + * `reitit.ring.coercion/gen-wrap-coerce-parameters` + * `reitit.ring.coercion/gen-wrap-coerce-response` + +If the request coercion succeeds, the coerced parameters are injected into request under `:parameters`. + +If either request or response coercion fails, an descriptive error is thrown. To turn the exceptions into http responses, one can also mount the `reitit.ring.coercion/gen-wrap-coerce-exceptions` middleware + +### Example with Schema + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.ring.coercion :as coercion]) +(require '[reitit.ring.coercion.schema :as schema]) +(require '[schema.core :as s]) + +(def app + (ring/ring-handler + (ring/router + ["/api" + ["/ping" {:parameters {:body {:x s/Int, :y s/Int}} + :responses {200 {:schema {:total (s/constrained s/Int pos?}}} + :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + {:data {:middleware [coercion/gen-wrap-coerce-exceptions + coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response] + :coercion schema/coercion}}))) +``` + +Valid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y 2}}) +; {:status 200 +; :body {:total 3}} +``` + +Invalid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y "2"}}) +; {:status 400, +; :body {:type :reitit.ring.coercion/request-coercion +; :coercion :schema +; :in [:request :body-params] +; :value {:x 1, :y "2"} +; :schema {:x "Int", :y "Int"} +; :errors {:y "(not (integer? \"2\"))"}}} +``` + +### Example with data-specs + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.ring.coercion :as coercion]) +(require '[reitit.ring.coercion.spec :as spec]) + +(def app + (ring/ring-handler + (ring/router + ["/api" + ["/ping" {:parameters {:body {:x int?, :y int?}} + :responses {200 {:schema {:total pos-int?}}} + :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + {:data {:middleware [coercion/gen-wrap-coerce-exceptions + coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response] + :coercion spec/coercion}}))) +``` + +Valid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y 2}}) +; {:status 200 +; :body {:total 3}} +``` + +Invalid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y "2"}}) +; {:status 400, +; :body {:type ::coercion/request-coercion +; :coercion :spec +; :in [:request :body-params] +; :value {:x 1, :y "2"} +; :spec "(spec-tools.core/spec {:spec (clojure.spec.alpha/keys :req-un [:$spec37747/x :$spec37747/y]), :type :map, :keys #{:y :x}, :keys/req #{:y :x}})" +; :problems [{:path [:y] +; :pred "clojure.core/int?" +; :val "2" +; :via [:$spec37747/y] +; :in [:y]}]}} +``` + +### Example with clojure.spec + +Currently, `clojure.spec` [doesn't support runtime transformations via conforming](https://dev.clojure.org/jira/browse/CLJ-2116), so one needs to wrap all specs with `spec-tools.core/spec`. + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.ring.coercion :as coercion]) +(require '[reitit.ring.coercion.spec :as spec]) +(require '[clojure.spec.alpha :as s]) +(require '[spec-tools.core :as st]) + +(s/def ::x (st/spec int?)) +(s/def ::y (st/spec int?)) +(s/def ::total int?) +(s/def ::request (s/keys :req-un [::x ::y])) +(s/def ::response (s/keys :req-un [::total])) + +(def app + (ring/ring-handler + (ring/router + ["/api" + ["/ping" {:parameters {:body ::request} + :responses {200 {:schema ::response}} + :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + {:data {:middleware [coercion/gen-wrap-coerce-exceptions + coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response] + :coercion spec/coercion}}))) +``` + +Valid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y 2}}) +; {:status 200 +; :body {:total 3}} +``` + +Invalid request: + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y "2"}}) +; {:status 400, +; :body {:type ::coercion/request-coercion +; :coercion :spec +; :in [:request :body-params] +; :value {:x 1, :y "2"} +; :spec "(spec-tools.core/spec {:spec (clojure.spec.alpha/keys :req-un [:reitit.coercion-test/x :reitit.coercion-test/y]), :type :map, :keys #{:y :x}, :keys/req #{:y :x}})" +; :problems [{:path [:y] +; :pred "clojure.core/int?" +; :val "2" +; :via [::request ::y] +; :in [:y]}]}} +``` + +### Custom coercion + +Both Schema and Spec Coercion can be configured via options, see the source code for details. + +To plug in new validation engine, see the +`reitit.ring.coercion.protocol/Coercion` protocol. + +```clj +(defprotocol Coercion + "Pluggable coercion protocol" + (get-name [this] "Keyword name for the coercion") + (compile [this model name] "Compiles a coercion model") + (get-apidocs [this model data] "???") + (make-open [this model] "Returns a new map model which doesn't fail on extra keys") + (encode-error [this error] "Converts error in to a serializable format") + (request-coercer [this type model] "Returns a `value format => value` request coercion function") + (response-coercer [this model] "Returns a `value format => value` response coercion function")) +``` diff --git a/doc/ring/compiling_middleware.md b/doc/ring/compiling_middleware.md index 19afd272..c522e8e8 100644 --- a/doc/ring/compiling_middleware.md +++ b/doc/ring/compiling_middleware.md @@ -2,13 +2,13 @@ The [dynamic extensions](dynamic_extensions.md) is a easy way to extend the system. To enable fast lookups into route data, we can compile them into any shape (records, functions etc.) we want, enabling fast access at request-time. -Still, we can do much better. As we know the exact route that middleware/interceptor is linked to, we can pass the (compiled) route information into the middleware/interceptor at creation-time. It can do local reasoning: extract and transform relevant data just for it and pass it into the actual request-handler via a closure - yielding much faster runtime processing. It can also decide not to mount itself by returning `nil`. Why mount a `wrap-enforce-roles` middleware for a route if there are no roles required for it? +But, we can do much better. As we know the exact route that middleware/interceptor is linked to, we can pass the (compiled) route information into the middleware/interceptor at creation-time. It can do local reasoning: extract and transform relevant data just for it and pass it into the actual request-handler via a closure - yielding much faster runtime processing. It can also decide not to mount itself by returning `nil`. Why mount a `wrap-enforce-roles` middleware for a route if there are no roles required for it? To enable this we use [middleware records](data_driven_middleware.md) `:gen-wrap` key instead of the normal `:wrap`. `:gen-wrap` expects a function of `route-data router-opts => ?wrap`. -To demonstrate the two approaches, below are response coercion middleware written as normal ring middleware function and as middleware record with `:gen-wrap`. Actual codes can be found in [`reitit.ring.coercion`](https://github.com/metosin/reitit/blob/master/src/reitit/ring/coercion.cljc): +To demonstrate the two approaches, below are response coercion middleware written as normal ring middleware function and as middleware record with `:gen-wrap`. -## Naive +## Normal Middleware * Reads the compiled route information on every request. @@ -42,7 +42,7 @@ To demonstrate the two approaches, below are response coercion middleware writte (handler request respond raise)))))) ``` -## Compiled +## Compiled Middleware * Route information is provided via a closure * Pre-compiled coercers @@ -52,20 +52,20 @@ To demonstrate the two approaches, below are response coercion middleware writte (require '[reitit.ring.middleware :as middleware]) (def gen-wrap-coerce-response - "Generator for pluggable response coercion middleware. + "Middleware for pluggable response coercion. Expects a :coercion of type `reitit.coercion.protocol/Coercion` and :responses from route data, otherwise does not mount." (middleware/create {:name ::coerce-response - :gen-wrap (fn [{:keys [responses coercion opts]} _] - (if (and coercion responses) - (let [coercers (response-coercers coercion responses opts)] - (fn [handler] - (fn - ([request] - (coerce-response coercers request (handler request))) - ([request respond raise] - (handler request #(respond (coerce-response coercers request %)) raise)))))))})) + :gen-wrap (fn [{:keys [coercion responses opts]} _] + (if (and coercion responses) + (let [coercers (response-coercers coercion responses opts)] + (fn [handler] + (fn + ([request] + (coerce-response coercers request (handler request))) + ([request respond raise] + (handler request #(respond (coerce-response coercers request %)) raise)))))))})) ``` The latter has 50% less code, is easier to reason about and is much faster. diff --git a/doc/ring/parameter_coercion.md b/doc/ring/parameter_coercion.md deleted file mode 100644 index 10b20d6b..00000000 --- a/doc/ring/parameter_coercion.md +++ /dev/null @@ -1,96 +0,0 @@ -# Parameter coercion - -Reitit provides pluggable parameter coercion via `reitit.ring.coercion.protocol/Coercion` protocol, originally introduced in [compojure-api](https://clojars.org/metosin/compojure-api). Reitit ships with `reitit.ring.coercion.spec/SpecCoercion` providing implemenation for [clojure.spec](https://clojure.org/about/spec) and [data-specs](https://github.com/metosin/spec-tools#data-specs). - -**NOTE**: Before Clojure 1.9.0 is shipped, to use the spec-coercion, one needs to add the following dependencies manually to the project: - -```clj -[org.clojure/clojure "1.9.0-beta2"] -[org.clojure/spec.alpha "0.1.123"] -[metosin/spec-tools "0.4.0"] -``` - -### Ring request and response coercion - -To use `Coercion` with Ring, one needs to do the following: - -1. Define parameters and responses as data into route data, in format adopted from [ring-swagger](https://github.com/metosin/ring-swagger#more-complete-example): - * `:parameters` map, with submaps for different parameters: `:query`, `:body`, `:form`, `:header` and `:path`. Parameters are defined in the format understood by the `Coercion`. - * `:responses` map, with response status codes as keys (or `:default` for "everything else") with maps with `:schema` and optionally `:description` as values. -2. Define a `Coercion` to route data under `:coercion` -3. Mount request & response coercion middleware to the routes (recommended to mount to all routes under router as they mounted only to routes which have the parameters / responses defined): - * `reitit.ring.coercion/gen-wrap-coerce-parameters` - * `gen-wrap-coerce-parameters/gen-wrap-coerce-responses` - -If the request coercion succeeds, the coerced parameters are injected into request under `:parameters`. - -If either request or response coercion fails, an descriptive error is thrown. - -#### Example with data-specs - -```clj -(require '[reitit.ring :as ring]) -(require '[reitit.ring.coercion :as coercion]) -(require '[reitit.ring.coercion.spec :as spec]) - -(def app - (ring/ring-handler - (ring/router - ["/api" - ["/ping" {:parameters {:body {:x int?, :y int?}} - :responses {200 {:schema {:total pos-int?}}} - :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] - {:status 200 - :body {:total (+ x y)}})}}]] - {:data {:middleware [coercion/gen-wrap-coerce-parameters - coercion/gen-wrap-coerce-response] - :coercion spec/coercion}}))) -``` - - -```clj -(app - {:request-method :get - :uri "/api/ping" - :body-params {:x 1, :y 2}}) -; {:status 200, :body {:total 3}} -``` - -#### Example with specs - -Currently, `clojure.spec` [doesn't support runtime transformations via conforming](https://dev.clojure.org/jira/browse/CLJ-2116), so one needs to wrap all specs with `spec-tools.core/spec`. - -```clj -(require '[reitit.ring :as ring]) -(require '[reitit.ring.coercion :as coercion]) -(require '[reitit.ring.coercion.spec :as spec]) -(require '[clojure.spec.alpha :as s]) -(require '[spec-tools.core :as st]) - -(s/def ::x (st/spec int?)) -(s/def ::y (st/spec int?)) -(s/def ::total int?) -(s/def ::request (s/keys :req-un [::x ::y])) -(s/def ::response (s/keys :req-un [::total])) - -(def app - (ring/ring-handler - (ring/router - ["/api" - ["/ping" {:parameters {:body ::request} - :responses {200 {:schema ::response}} - :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] - {:status 200 - :body {:total (+ x y)}})}}]] - {:data {:middleware [coercion/gen-wrap-coerce-parameters - coercion/gen-wrap-coerce-response] - :coercion spec/coercion}}))) -``` - -```clj -(app - {:request-method :get - :uri "/api/ping" - :body-params {:x 1, :y 2}}) -; {:status 200, :body {:total 3}} -``` diff --git a/modules/reitit-ring/src/reitit/ring/coercion.cljc b/modules/reitit-ring/src/reitit/ring/coercion.cljc index 1a394558..3dda55dc 100644 --- a/modules/reitit-ring/src/reitit/ring/coercion.cljc +++ b/modules/reitit-ring/src/reitit/ring/coercion.cljc @@ -15,9 +15,11 @@ (defrecord ParameterCoercion [in style keywordize? open?]) +(def valid-type? #{::request-coercion ::response-coercion}) + (def ring-parameter-coercion {:query (->ParameterCoercion :query-params :string true true) - :body (->ParameterCoercion :body-params :string false true) + :body (->ParameterCoercion :body-params :body false false) :form (->ParameterCoercion :form-params :string true true) :header (->ParameterCoercion :header-params :string true true) :path (->ParameterCoercion :path-params :string true true)}) @@ -47,6 +49,8 @@ :request request :response response})))) +;; TODO: support faster key walking, walk/keywordize-keys is quite slow... + (defn request-coercer [coercion type model] (if coercion (let [{:keys [keywordize? open? in style]} (ring-parameter-coercion type) @@ -77,11 +81,13 @@ (response-coercion-failed! result coercion value request response) result)))))) -;; -;; middleware -;; +(defn encode-error [data] + (-> data + (dissoc :request :response) + (update :coercion protocol/get-name) + (->> (protocol/encode-error (:coercion data))))) -(defn- coerce-parameters [coercers request] +(defn- coerce-request [coercers request] (reduce-kv (fn [acc k coercer] (impl/fast-assoc acc k (coercer request))) @@ -104,85 +110,46 @@ [status (response-coercer coercion schema opts)]) (into {}))) -(defn wrap-coerce-parameters - "Pluggable request coercion middleware. - Expects a :coercion of type `reitit.coercion.protocol/Coercion` - and :parameters from route data, otherwise will do nothing." - [handler] - (fn - ([request] - (let [method (:request-method request) - match (ring/get-match request) - parameters (-> match :result method :data :parameters) - coercion (-> match :data :coercion)] - (if (and coercion parameters) - (let [coercers (request-coercers coercion parameters) - coerced (coerce-parameters coercers request)] - (handler (impl/fast-assoc request :parameters coerced))) - (handler request)))) - ([request respond raise] - (let [method (:request-method request) - match (ring/get-match request) - parameters (-> match :result method :data :parameters) - coercion (-> match :data :coercion)] - (if (and coercion parameters) - (let [coercers (request-coercers coercion parameters) - coerced (coerce-parameters coercers request)] - (handler (impl/fast-assoc request :parameters coerced) respond raise))))))) +(defn handle-coercion-exception [e respond raise] + (let [data (ex-data e)] + (if-let [status (condp = (:type data) + ::request-coercion 400 + ::response-coercion 500 + nil)] + (respond + {:status status + :body (encode-error data)}) + (raise e)))) + +;; +;; middleware +;; (def gen-wrap-coerce-parameters - "Generator for pluggable request coercion middleware. + "Middleware for pluggable request coercion. Expects a :coercion of type `reitit.coercion.protocol/Coercion` and :parameters from route data, otherwise does not mount." (middleware/create {:name ::coerce-parameters - :gen-wrap (fn [{:keys [parameters coercion]} _] + :gen-wrap (fn [{:keys [coercion parameters]} _] (if (and coercion parameters) (let [coercers (request-coercers coercion parameters)] (fn [handler] (fn ([request] - (let [coerced (coerce-parameters coercers request)] + (let [coerced (coerce-request coercers request)] (handler (impl/fast-assoc request :parameters coerced)))) ([request respond raise] - (let [coerced (coerce-parameters coercers request)] + (let [coerced (coerce-request coercers request)] (handler (impl/fast-assoc request :parameters coerced) respond raise))))))))})) -(defn wrap-coerce-response - "Pluggable response coercion middleware. - Expects a :coercion of type `reitit.coercion.protocol/Coercion` - and :responses from route data, otherwise will do nothing." - [handler] - (fn - ([request] - (let [response (handler request) - method (:request-method request) - match (ring/get-match request) - responses (-> match :result method :data :responses) - coercion (-> match :data :coercion) - opts (-> match :data :opts)] - (if (and coercion responses) - (let [coercers (response-coercers coercion responses opts)] - (coerce-response coercers request response)) - response))) - ([request respond raise] - (let [method (:request-method request) - match (ring/get-match request) - responses (-> match :result method :data :responses) - coercion (-> match :data :coercion) - opts (-> match :data :opts)] - (if (and coercion responses) - (let [coercers (response-coercers coercion responses opts)] - (handler request #(respond (coerce-response coercers request %)))) - (handler request respond raise)))))) - (def gen-wrap-coerce-response - "Generator for pluggable response coercion middleware. + "Middleware for pluggable response coercion. Expects a :coercion of type `reitit.coercion.protocol/Coercion` and :responses from route data, otherwise does not mount." (middleware/create {:name ::coerce-response - :gen-wrap (fn [{:keys [responses coercion opts]} _] + :gen-wrap (fn [{:keys [coercion responses opts]} _] (if (and coercion responses) (let [coercers (response-coercers coercion responses opts)] (fn [handler] @@ -191,3 +158,24 @@ (coerce-response coercers request (handler request))) ([request respond raise] (handler request #(respond (coerce-response coercers request %)) raise)))))))})) + +(def gen-wrap-coerce-exceptions + "Middleware for handling coercion exceptions. + Expects a :coercion of type `reitit.coercion.protocol/Coercion` + and :parameters or :responses from route data, otherwise does not mount." + (middleware/create + {:name ::coerce-exceptions + :gen-wrap (fn [{:keys [coercion parameters responses]} _] + (if (and coercion (or parameters responses)) + (fn [handler] + (fn + ([request] + (try + (handler request) + (catch #?(:clj Exception :cljs js/Error) e + (handle-coercion-exception e identity #(throw %))))) + ([request respond raise] + (try + (handler request respond #(handle-coercion-exception % respond raise)) + (catch #?(:clj Exception :cljs js/Error) e + (handle-coercion-exception e respond raise))))))))})) diff --git a/modules/reitit-schema/project.clj b/modules/reitit-schema/project.clj new file mode 100644 index 00000000..e405cfdb --- /dev/null +++ b/modules/reitit-schema/project.clj @@ -0,0 +1,10 @@ +(defproject metosin/reitit-schema "0.1.0-SNAPSHOT" + :description "Reitit: Plumatic Schema coercion" + :url "https://github.com/metosin/reitit" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :plugins [[lein-parent "0.3.2"]] + :parent-project {:path "../../project.clj" + :inherit [:deploy-repositories :managed-dependencies]} + :dependencies [[metosin/reitit-ring] + [metosin/schema-tools]]) diff --git a/modules/reitit-schema/src/reitit/ring/coercion/schema.cljc b/modules/reitit-schema/src/reitit/ring/coercion/schema.cljc new file mode 100644 index 00000000..5029f6ba --- /dev/null +++ b/modules/reitit-schema/src/reitit/ring/coercion/schema.cljc @@ -0,0 +1,84 @@ +(ns reitit.ring.coercion.schema + (:require [schema.core :as s] + [schema-tools.core :as st] + [schema.coerce :as sc] + [schema.utils :as su] + [schema-tools.coerce :as stc] + [spec-tools.swagger.core :as swagger] + [clojure.walk :as walk] + [reitit.ring.coercion.protocol :as protocol])) + +(def string-coercion-matcher + stc/string-coercion-matcher) + +(def json-coercion-matcher + stc/json-coercion-matcher) + +(def default-coercion-matcher + (constantly nil)) + +(defmulti coerce-response? identity :default ::default) +(defmethod coerce-response? ::default [_] true) + +(defn stringify [schema] + (walk/prewalk + (fn [x] + (cond + #?@(:clj [(class? x) (.getName ^Class x)]) + (instance? schema.core.OptionalKey x) (pr-str (list 'opt (:k x))) + (instance? schema.core.RequiredKey x) (pr-str (list 'req (:k x))) + (and (satisfies? s/Schema x) (record? x)) (try (pr-str (s/explain x)) (catch #?(:clj Exception :cljs js/Error) _ x)) + (instance? schema.utils.ValidationError x) (str (su/validation-error-explain x)) + (instance? schema.utils.NamedError x) (str (su/named-error-explain x)) + :else x)) + schema)) + +(defrecord SchemaCoercion [name matchers coerce-response?] + + protocol/Coercion + (get-name [_] name) + + (compile [_ model _] + model) + + (get-apidocs [_ _ {:keys [parameters responses] :as info}] + (cond-> (dissoc info :parameters :responses) + parameters (assoc ::swagger/parameters parameters) + responses (assoc ::swagger/responses responses))) + + (make-open [_ schema] (st/open-schema schema)) + + (encode-error [_ error] + (-> error + (update :schema stringify) + (update :errors stringify))) + + ;; TODO: create all possible coercers ahead of time + (request-coercer [_ type schema] + (let [{:keys [formats default]} (matchers type)] + (fn [value format] + (if-let [matcher (or (get formats format) default)] + (let [coercer (sc/coercer schema matcher) + coerced (coercer value)] + (if-let [error (su/error-val coerced)] + (protocol/map->CoercionError + {:schema schema + :errors error}) + coerced)) + value)))) + + (response-coercer [this schema] + (if (coerce-response? schema) + (protocol/request-coercer this :response schema)))) + +(def default-options + {:coerce-response? coerce-response? + :matchers {:body {:default default-coercion-matcher + :formats {"application/json" json-coercion-matcher}} + :string {:default string-coercion-matcher} + :response {:default default-coercion-matcher}}}) + +(defn create [{:keys [matchers coerce-response?]}] + (->SchemaCoercion :schema matchers coerce-response?)) + +(def coercion (create default-options)) diff --git a/modules/reitit-spec/src/reitit/ring/coercion/spec.cljc b/modules/reitit-spec/src/reitit/ring/coercion/spec.cljc index a2afa3c8..cd2aba4f 100644 --- a/modules/reitit-spec/src/reitit/ring/coercion/spec.cljc +++ b/modules/reitit-spec/src/reitit/ring/coercion/spec.cljc @@ -50,6 +50,9 @@ (def memoized-into-spec (memoize #(into-spec %1 (gensym "spec")))) +(defn stringify-pred [pred] + (str (if (seq? pred) (seq pred) pred))) + (defmulti coerce-response? identity :default ::default) (defmethod coerce-response? ::default [_] true) @@ -79,7 +82,9 @@ (make-open [_ spec] spec) (encode-error [_ error] - (update error :spec (comp str s/form))) + (-> error + (update :spec (comp str s/form)) + (update :problems (partial mapv #(update % :pred stringify-pred))))) (request-coercer [_ type spec] (let [spec (memoized-into-spec spec) diff --git a/modules/reitit/project.clj b/modules/reitit/project.clj index b0f4404b..6ac2562b 100644 --- a/modules/reitit/project.clj +++ b/modules/reitit/project.clj @@ -8,4 +8,5 @@ :inherit [:deploy-repositories :managed-dependencies]} :dependencies [[metosin/reitit-core] [metosin/reitit-ring] - [metosin/reitit-spec]]) + [metosin/reitit-spec] + [metosin/reitit-schema]]) diff --git a/project.clj b/project.clj index e7872068..8b5fcfb7 100644 --- a/project.clj +++ b/project.clj @@ -13,9 +13,11 @@ [metosin/reitit-core "0.1.0-SNAPSHOT"] [metosin/reitit-ring "0.1.0-SNAPSHOT"] [metosin/reitit-spec "0.1.0-SNAPSHOT"] + [metosin/reitit-schema "0.1.0-SNAPSHOT"] [meta-merge "1.0.0"] - [metosin/spec-tools "0.5.1"]] + [metosin/spec-tools "0.5.1"] + [metosin/schema-tools "0.10.0-SNAPSHOT"]] :plugins [[jonase/eastwood "0.2.5"] [lein-doo "0.1.8"] @@ -30,13 +32,15 @@ :source-paths ["modules/reitit/src" "modules/reitit-core/src" "modules/reitit-ring/src" - "modules/reitit-spec/src"] + "modules/reitit-spec/src" + "modules/reitit-schema/src"] :dependencies [[org.clojure/clojure "1.9.0-RC1"] [org.clojure/clojurescript "1.9.946"] - ;; all modules dependencies + ;; modules dependencies [metosin/reitit] + [metosin/schema-tools "0.10.0-SNAPSHOT"] [expound "0.3.2"] [orchestra "2017.08.13"] diff --git a/scripts/lein-modules b/scripts/lein-modules index feab8f2e..8ae825fd 100755 --- a/scripts/lein-modules +++ b/scripts/lein-modules @@ -3,6 +3,6 @@ set -e # Modules -for ext in reitit-core reitit-ring reitit-spec reitit; do +for ext in reitit-core reitit-ring reitit-spec reitit-schema reitit; do cd modules/$ext; lein "$@"; cd ../..; done diff --git a/test/cljc/reitit/coercion_test.cljc b/test/cljc/reitit/coercion_test.cljc index ea4dfd19..c9d5ec71 100644 --- a/test/cljc/reitit/coercion_test.cljc +++ b/test/cljc/reitit/coercion_test.cljc @@ -1,63 +1,147 @@ (ns reitit.coercion-test (:require [clojure.test :refer [deftest testing is]] + [schema.core :as s] [reitit.ring :as ring] [reitit.ring.coercion :as coercion] - [reitit.ring.coercion.spec :as spec]) + [reitit.ring.coercion.spec :as spec] + [reitit.ring.coercion.schema :as schema]) #?(:clj (:import (clojure.lang ExceptionInfo)))) -(defn handler - ([{:keys [::mw]}] - {:status 200 :body (conj mw :ok)}) - ([request respond raise] - (respond (handler request)))) +(defn handler [{{{:keys [a]} :query + {:keys [b]} :body + {:keys [c]} :form + {:keys [d]} :header + {:keys [e]} :path} :parameters}] + {:status 200 + :body {:total (+ a b c d e)}}) -(deftest coercion-test - (let [app (ring/ring-handler - (ring/router - ["/api" - ["/plus/:e" - {:get {:parameters {:query {:a int?} - :body {:b int?} - :form {:c int?} - :header {:d int?} - :path {:e int?}} - :responses {200 {:schema {:total pos-int?}}} - :handler (fn [{{{:keys [a]} :query - {:keys [b]} :body - {:keys [c]} :form - {:keys [d]} :header - {:keys [e]} :path} :parameters}] - {:status 200 - :body {:total (+ a b c d e)}})}}]] - {:data {:middleware [coercion/gen-wrap-coerce-parameters - coercion/gen-wrap-coerce-response] - :coercion spec/coercion}}))] +(def valid-request + {:uri "/api/plus/5" + :request-method :get + :query-params {"a" "1"} + :body-params {:b 2} + :form-params {:c 3} + :header-params {:d 4}}) - (testing "all good" - (is (= {:status 200 - :body {:total 15}} - (app {:uri "/api/plus/5" - :request-method :get - :query-params {"a" "1"} - :body-params {:b 2} - :form-params {:c 3} - :header-params {:d 4}})))) +(def invalid-request + {:uri "/api/plus/5" + :request-method :get}) - (testing "invalid request" - (is (thrown-with-msg? - ExceptionInfo - #"Request coercion failed" - (app {:uri "/api/plus/5" - :request-method :get})))) +(def invalid-request2 + {:uri "/api/plus/5" + :request-method :get + :query-params {"a" "1"} + :body-params {:b 2} + :form-params {:c 3} + :header-params {:d -40}}) - (testing "invalid response" - (is (thrown-with-msg? - ExceptionInfo - #"Response coercion failed" - (app {:uri "/api/plus/5" - :request-method :get - :query-params {"a" "1"} - :body-params {:b 2} - :form-params {:c 3} - :header-params {:d -40}})))))) +(deftest spec-coercion-test + (let [create (fn [middleware] + (ring/ring-handler + (ring/router + ["/api" + ["/plus/:e" + {:get {:parameters {:query {:a int?} + :body {:b int?} + :form {:c int?} + :header {:d int?} + :path {:e int?}} + :responses {200 {:schema {:total pos-int?}}} + :handler handler}}]] + {:data {:middleware middleware + :coercion spec/coercion}})))] + + (testing "withut exception handling" + (let [app (create [coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request)))) + + (testing "invalid response" + (is (thrown-with-msg? + ExceptionInfo + #"Response coercion failed" + (app invalid-request2)))))) + + (testing "with exception handling" + (let [app (create [coercion/gen-wrap-coerce-exceptions + coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (let [{:keys [status body]} (app invalid-request)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status body]} (app invalid-request2)] + (is (= 500 status)))))))) + +(deftest schema-coercion-test + (let [create (fn [middleware] + (ring/ring-handler + (ring/router + ["/api" + ["/plus/:e" + {:get {:parameters {:query {:a s/Int} + :body {:b s/Int} + :form {:c s/Int} + :header {:d s/Int} + :path {:e s/Int}} + :responses {200 {:schema {:total (s/constrained s/Int pos? 'positive)}}} + :handler handler}}]] + {:data {:middleware middleware + :coercion schema/coercion}})))] + + (testing "withut exception handling" + (let [app (create [coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (is (thrown-with-msg? + ExceptionInfo + #"Request coercion failed" + (app invalid-request)))) + + (testing "invalid response" + (is (thrown-with-msg? + ExceptionInfo + #"Response coercion failed" + (app invalid-request2)))) + + (testing "with exception handling" + (let [app (create [coercion/gen-wrap-coerce-exceptions + coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response])] + + (testing "all good" + (is (= {:status 200 + :body {:total 15}} + (app valid-request)))) + + (testing "invalid request" + (let [{:keys [status body]} (app invalid-request)] + (is (= 400 status)))) + + (testing "invalid response" + (let [{:keys [status body]} (app invalid-request2)] + (is (= 500 status))))))))))