diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 1e303093..0564015e 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -28,6 +28,7 @@ * [Dynamic Extensions](ring/dynamic_extensions.md) * [Data-driven Middleware](ring/data_driven_middleware.md) * [Middleware Registry](ring/middleware_registry.md) + * [Default Middleware](ring/default_middleware.md) * [Pluggable Coercion](ring/coercion.md) * [Route Data Validation](ring/route_data_validation.md) * [Compiling Middleware](ring/compiling_middleware.md) diff --git a/doc/ring/README.md b/doc/ring/README.md index 87356e08..f215c0f7 100644 --- a/doc/ring/README.md +++ b/doc/ring/README.md @@ -7,6 +7,7 @@ * [Dynamic Extensions](dynamic_extensions.md) * [Data-driven Middleware](data_driven_middleware.md) * [Middleware Registry](middleware_registry.md) +* [Default Middleware](default_middleware.md) * [Pluggable Coercion](coercion.md) * [Route Data Validation](route_data_validation.md) * [Compiling Middleware](compiling_middleware.md) diff --git a/doc/ring/default_middleware.md b/doc/ring/default_middleware.md new file mode 100644 index 00000000..f6470b30 --- /dev/null +++ b/doc/ring/default_middleware.md @@ -0,0 +1,131 @@ +# Default Middleware + +```clj +[metosin/reitit-middleware "0.2.0-SNAPSHOT"] +``` + +Any Ring middleware can be used with `reitit-ring`, but using data-driven middleware is preferred as they are easier to manage and in many cases, yield better performance. `reitit-middleware` contains a set of common ring middleware, lifted into data-driven middleware. + +* [Exception handling](#exception-handling) +* [Content negotiation](#content-negotiation) +* [Multipart request handling](#multipart-request-handling) + +## Exception handling + +A polished version of [compojure-api](https://github.com/metosin/compojure-api) exception handling. Catches all exceptions and invokes configured exception handler. + +```clj +(require '[reitit.ring.middleware.exception :as exception]) +``` + +### `exception/exception-middleware` + +A preconfigured middleware using `exception/default-handlers`. Catches: + +* Request & response [Coercion](coercion.md) exceptions +* [Muuntaja](https://github.com/metosin/muuntaja) decode exceptions +* Exceptions with `:type` of `:reitit.ring/response`, returning `:response` key from `ex-data`. +* Safely all other exceptions + +```clj +(require '[reitit.ring :as ring]) + +(def app + (ring/ring-handler + (ring/router + ["/fail" (fn [_] (throw (Exception. "fail")))] + {:data {:middleware [exception/exception-middleware]}}))) + +(app {:request-method :get, :uri "/fail"}) +;{:status 500 +; :body {:type "exception" +; :class "java.lang.Exception"}} +``` + +### `exception/create-exception-middleware` + +Creates the exception-middleware with custom options. Takes a map of `identifier => exception request => response` that is used to select the exception handler for the thown/raised exception identifier. Exception idenfier is either a `Keyword` or a Exception Class. + +The following handlers special keys are available: + +| key | description +|--------------|------------- +| `::default` | a default exception handler if nothing else mathced (default `exception/default-handler`). +| `::wrap` | a 3-arity handler to wrap the actual handler `handler exception request => response` (no default). + +The handler is selected from the options map by exception idenfitifier in the following lookup order: + +1) `:type` of exception ex-data +2) Class of exception +3) `:type` ancestors of exception ex-data +4) Super Classes of exception +5) The ::default handler + +```clj +;; type hierarchy +(derive ::error ::exception) +(derive ::failure ::exception) +(derive ::horror ::exception) + +(defn handler [message exception request] + {:status 500 + :body {:message message + :exception (.getClass exception) + :data (ex-data exception) + :uri (:uri request)}}) + +(def exception-middleware + (exception/create-exception-middleware + (merge + exception/default-handlers + {;; ex-data with :type ::error + ::error (partial handler "error") + + ;; ex-data with ::exception or ::failure + ::exception (partial handler "exception") + + ;; SQLException and all it's child classes + java.sql.SQLException (partial handler "sql-exception") + + ;; override the default handler + ::exception/default (partial handler "default") + + ;; print stack-traces for all exceptions + ::exception/wrap (fn [handler e request] + (println "ERROR" (pr-str (:uri request))) + (handler e request))}))) + +(def app + (ring/ring-handler + (ring/router + ["/fail" (fn [_] (throw (ex-info "fail" {:type ::failue})))] + {:data {:middleware [exception-middleware]}}))) + +(app {:request-method :get, :uri "/fail"}) +; ERROR "/fail" +; => {:status 500, +; :body {:message "default" +; :exception clojure.lang.ExceptionInfo +; :data {:type :user/failue} +; :uri "/fail"}} +``` + +## Content Negotiation + +Wrapper for [Muuntaja](https://github.com/metosin/muuntaja) middleware for content-negotiation, request decoding and response encoding. Reads configuration from route data and emit's [swagger](swagger.md) `:produces` and `:consumes` definitions automatically. + +```clj +(require '[reitit.ring.middleware.muuntaja :as muuntaja]) +``` + +## Multipart request handling + +Wrapper for [Ring Multipart Middleware](https://github.com/ring-clojure/ring/blob/master/ring-core/src/ring/middleware/multipart_params.clj). Conditionally mounts to an endpoint only if it has `:multipart` params defined. Emits swagger `:consumes` definitions automatically. + +```clj +(require '[reitit.ring.middleware.multipart :as multipart]) +``` + +## Example app + +See an example app with the default middleware in action: https://github.com/metosin/reitit/blob/master/examples/ring-swagger/src/example/server.clj. diff --git a/examples/ring-example/src/example/server.clj b/examples/ring-example/src/example/server.clj index 86fa8bf9..0181c606 100644 --- a/examples/ring-example/src/example/server.clj +++ b/examples/ring-example/src/example/server.clj @@ -3,7 +3,7 @@ [ring.middleware.params] [muuntaja.middleware] [reitit.ring :as ring] - [reitit.ring.coercion :as rrc] + [reitit.ring.coercion :as coercion] [example.dspec] [example.schema] [example.spec])) @@ -18,9 +18,9 @@ example.spec/routes] {:data {:middleware [ring.middleware.params/wrap-params muuntaja.middleware/wrap-format - rrc/coerce-exceptions-middleware - rrc/coerce-request-middleware - rrc/coerce-response-middleware]}}))) + coercion/coerce-exceptions-middleware + coercion/coerce-request-middleware + coercion/coerce-response-middleware]}}))) (defn restart [] (swap! server (fn [x] diff --git a/examples/ring-swagger/project.clj b/examples/ring-swagger/project.clj index c4fd112d..60f6406d 100644 --- a/examples/ring-swagger/project.clj +++ b/examples/ring-swagger/project.clj @@ -2,6 +2,5 @@ :description "Reitit Ring App with Swagger" :dependencies [[org.clojure/clojure "1.9.0"] [ring "1.6.3"] - [metosin/muuntaja "0.5.0"] [metosin/reitit "0.2.0-SNAPSHOT"]] :repl-options {:init-ns example.server}) diff --git a/examples/ring-swagger/resources/reitit.png b/examples/ring-swagger/resources/reitit.png new file mode 100644 index 00000000..c89c3654 Binary files /dev/null and b/examples/ring-swagger/resources/reitit.png differ diff --git a/examples/ring-swagger/src/example/server.clj b/examples/ring-swagger/src/example/server.clj index adb86200..ff5b57a2 100644 --- a/examples/ring-swagger/src/example/server.clj +++ b/examples/ring-swagger/src/example/server.clj @@ -2,28 +2,45 @@ (:require [reitit.ring :as ring] [reitit.swagger :as swagger] [reitit.swagger-ui :as swagger-ui] - [reitit.ring.coercion :as rrc] - [reitit.coercion.spec :as spec] - [reitit.coercion.schema :as schema] - [schema.core :refer [Int]] - + [reitit.ring.coercion :as coercion] + [reitit.coercion.spec] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.exception :as exception] + [reitit.ring.middleware.multipart :as multipart] + [ring.middleware.params :as params] [ring.adapter.jetty :as jetty] - [ring.middleware.params] - [muuntaja.middleware])) + [muuntaja.core :as m] + [clojure.java.io :as io])) (def app (ring/ring-handler (ring/router - ["/api" - - ["/swagger.json" + [["/swagger.json" {:get {:no-doc true :swagger {:info {:title "my-api"}} :handler (swagger/create-swagger-handler)}}] - ["/spec" - {:coercion spec/coercion - :swagger {:tags ["spec"]}} + ["/files" + {:swagger {:tags ["files"]}} + + ["/upload" + {:post {:summary "upload a file" + :parameters {:multipart {:file multipart/temp-file-part}} + :responses {200 {:body {:file multipart/temp-file-part}}} + :handler (fn [{{{:keys [file]} :multipart} :parameters}] + {:status 200 + :body {:file file}})}}] + + ["/download" + {:get {:summary "downloads a file" + :swagger {:produces ["image/png"]} + :handler (fn [_] + {:status 200 + :headers {"Content-Type" "image/png"} + :body (io/input-stream (io/resource "reitit.png"))})}}]] + + ["/math" + {:swagger {:tags ["math"]}} ["/plus" {:get {:summary "plus with spec query parameters" @@ -35,43 +52,30 @@ :post {:summary "plus with spec body parameters" :parameters {:body {:x int?, :y int?}} :responses {200 {:body {:total int?}}} - :handler (fn [{{{:keys [x y]} :body} :parameters}] - {:status 200 - :body {:total (+ x y)}})}}]] - - ["/schema" - {:coercion schema/coercion - :swagger {:tags ["schema"]}} - - ["/plus" - {:get {:summary "plus with schema query parameters" - :parameters {:query {:x Int, :y Int}} - :responses {200 {:body {:total Int}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200 - :body {:total (+ x y)}})} - :post {:summary "plus with schema body parameters" - :parameters {:body {:x Int, :y Int}} - :responses {200 {:body {:total Int}}} :handler (fn [{{{:keys [x y]} :body} :parameters}] {:status 200 :body {:total (+ x y)}})}}]]] - {:data {:middleware [ring.middleware.params/wrap-params - muuntaja.middleware/wrap-format - swagger/swagger-feature - rrc/coerce-exceptions-middleware - rrc/coerce-request-middleware - rrc/coerce-response-middleware] - :swagger {:produces #{"application/json" - "application/edn" - "application/transit+json"} - :consumes #{"application/json" - "application/edn" - "application/transit+json"}}}}) + {:data {:coercion reitit.coercion.spec/coercion + :muuntaja m/instance + :middleware [;; query-params & form-params + params/wrap-params + ;; content-negotiation + muuntaja/format-negotiate-middleware + ;; encoding response body + muuntaja/format-response-middleware + ;; exception handling + exception/exception-middleware + ;; decoding request body + muuntaja/format-request-middleware + ;; coercing response bodys + coercion/coerce-response-middleware + ;; coercing request parameters + coercion/coerce-request-middleware + ;; multipart + multipart/multipart-middleware]}}) (ring/routes - (swagger-ui/create-swagger-ui-handler - {:path "/", :url "/api/swagger.json"}) + (swagger-ui/create-swagger-ui-handler {:path "/"}) (ring/create-default-handler)))) (defn start [] diff --git a/modules/reitit-core/src/reitit/coercion.cljc b/modules/reitit-core/src/reitit/coercion.cljc index 04515d51..c5e9c07c 100644 --- a/modules/reitit-core/src/reitit/coercion.cljc +++ b/modules/reitit-core/src/reitit/coercion.cljc @@ -74,19 +74,19 @@ :or {extract-request-format extract-request-format-default parameter-coercion default-parameter-coercion}}] (if coercion - (let [{:keys [keywordize? open? in style]} (parameter-coercion type) - transform (comp (if keywordize? walk/keywordize-keys identity) in) - model (if open? (-open-model coercion model) model) - coercer (-request-coercer coercion style model)] - (fn [request] - (let [value (transform request) - format (extract-request-format request) - result (coercer value format)] - (if (error? result) - (request-coercion-failed! result coercion value in request) - result)))))) + (if-let [{:keys [keywordize? open? in style]} (parameter-coercion type)] + (let [transform (comp (if keywordize? walk/keywordize-keys identity) in) + model (if open? (-open-model coercion model) model) + coercer (-request-coercer coercion style model)] + (fn [request] + (let [value (transform request) + format (extract-request-format request) + result (coercer value format)] + (if (error? result) + (request-coercion-failed! result coercion value in request) + result))))))) -(defn extract-response-format-default [request response] +(defn extract-response-format-default [request _] (-> request :muuntaja/response :format)) (defn response-coercer [coercion body {:keys [extract-response-format] @@ -124,6 +124,7 @@ (->> (for [[k v] parameters :when v] [k (request-coercer coercion k v opts)]) + (filter second) (into {}))) (defn response-coercers [coercion responses opts] @@ -140,6 +141,28 @@ "{:compile reitit.coercion/compile-request-coercers}\n") {:match match}))) +;; +;; api-docs +;; + +(defn get-apidocs [this spesification data] + (let [swagger-parameter {:query :query + :body :body + :form :formData + :header :header + :path :path + :multipart :formData}] + (case spesification + :swagger (->> (update + data + :parameters + (fn [parameters] + (->> parameters + (map (fn [[k v]] [(swagger-parameter k) v])) + (filter first) + (into {})))) + (-get-apidocs this spesification))))) + ;; ;; integration ;; diff --git a/modules/reitit-middleware/project.clj b/modules/reitit-middleware/project.clj new file mode 100644 index 00000000..8212153a --- /dev/null +++ b/modules/reitit-middleware/project.clj @@ -0,0 +1,10 @@ +(defproject metosin/reitit-middleware "0.2.0-SNAPSHOT" + :description "Reitit, common middleware bundled" + :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/muuntaja]]) diff --git a/modules/reitit-middleware/src/reitit/ring/middleware/exception.clj b/modules/reitit-middleware/src/reitit/ring/middleware/exception.clj new file mode 100644 index 00000000..1db6e7e4 --- /dev/null +++ b/modules/reitit-middleware/src/reitit/ring/middleware/exception.clj @@ -0,0 +1,177 @@ +(ns reitit.ring.middleware.exception + (:require [reitit.coercion :as coercion] + [reitit.ring :as ring] + [clojure.spec.alpha :as s] + [clojure.string :as str]) + (:import (java.time Instant) + (java.io PrintWriter))) + +(s/def ::handlers (s/map-of any? fn?)) +(s/def ::spec (s/keys :opt-un [::handlers])) + +;; +;; helpers +;; + +(defn- super-classes [^Class k] + (loop [sk (.getSuperclass k), ks []] + (if-not (= sk Object) + (recur (.getSuperclass sk) (conj ks sk)) + ks))) + +(defn- call-error-handler [handlers error request] + (let [type (:type (ex-data error)) + ex-class (class error) + error-handler (or (get handlers type) + (get handlers ex-class) + (some + (partial get handlers) + (descendants type)) + (some + (partial get handlers) + (super-classes ex-class)) + (get handlers ::default))] + (if-let [wrap (get handlers ::wrap)] + (wrap error-handler error request) + (error-handler error request)))) + +(defn- on-exception [handlers e request respond raise] + (try + (respond (call-error-handler handlers e request)) + (catch Exception e + (raise e)))) + +(defn- wrap [handlers] + (fn [handler] + (fn + ([request] + (try + (handler request) + (catch Throwable e + (on-exception handlers e request identity #(throw %))))) + ([request respond raise] + (try + (handler request respond (fn [e] (on-exception handlers e request respond raise))) + (catch Throwable e + (on-exception handlers e request respond raise))))))) + +(defn print! [^PrintWriter writer & more] + (.write writer (str (str/join " " more) "\n"))) + +;; +;; handlers +;; + +(defn default-handler + "Default safe handler for any exception." + [^Exception e _] + {:status 500 + :body {:type "exception" + :class (.getName (.getClass e))}}) + +(defn create-coercion-handler + "Creates a coercion exception handler." + [status] + (fn [e _] + {:status status + :body (coercion/encode-error (ex-data e))})) + +(defn http-response-handler + "Reads response from Exception ex-data :response" + [e _] + (-> e ex-data :response)) + +(defn request-parsing-handler [e _] + {:status 400 + :headers {"Content-Type" "text/plain"} + :body (str "Malformed " (-> e ex-data :format pr-str) " request.")}) + +(defn wrap-log-to-console [handler e {:keys [uri request-method] :as req}] + (print! *out* (Instant/now) request-method (pr-str uri) "=>" (.getMessage e)) + (.printStackTrace e *out*) + (handler e req)) + +;; +;; public api +;; + +(def default-handlers + {::default default-handler + ::ring/response http-response-handler + :muuntaja/decode request-parsing-handler + ::coercion/request-coercion (create-coercion-handler 400) + ::coercion/response-coercion (create-coercion-handler 500)}) + +(defn wrap-exception + ([handler] + (handler default-handlers)) + ([handler options] + (-> options wrap handler))) + +(def exception-middleware + {:name ::exception + :spec ::spec + :wrap (wrap default-handlers)}) + +(defn create-exception-middleware + "Creates a reitit middleware that catches all exceptions. Takes a map + of `identifier => exception request => response` that is used to select + the exception handler for the thown/raised exception identifier. Exception + idenfier is either a `Keyword` or a Exception Class. + + The following handlers special handlers are available: + + | key | description + |--------------|------------- + | `::default` | a default exception handler if nothing else mathced (default [[default-handler]]). + | `::wrap` | a 3-arity handler to wrap the actual handler `handler exception request => response` + + The handler is selected from the options map by exception idenfiter + in the following lookup order: + + 1) `:type` of exception ex-data + 2) Class of exception + 3) `:type` ancestors of exception ex-data + 4) Super Classes of exception + 5) The ::default handler + + Example: + + (require '[reitit.ring.middleware.exception :as exception]) + + ;; type hierarchy + (derive ::error ::exception) + (derive ::failure ::exception) + (derive ::horror ::exception) + + (defn handler [message exception request] + {:status 500 + :body {:message message + :exception (str exception) + :uri (:uri request)}}) + + (exception/create-exception-middleware + (merge + exception/default-handlers + {;; ex-data with :type ::error + ::error (partial handler \"error\") + + ;; ex-data with ::exception or ::failure + ::exception (partial handler \"exception\") + + ;; SQLException and all it's child classes + java.sql.SQLException (partial handler \"sql-exception\") + + ;; override the default handler + ::exception/default (partial handler \"default\") + + ;; print stack-traces for all exceptions + ::exception/wrap (fn [handler e request] + (.printStackTrace e) + (handler e request))}))" + ([] + (create-exception-middleware default-handlers)) + ([handlers] + {:name ::exception + :spec ::spec + :wrap (wrap handlers)})) diff --git a/modules/reitit-middleware/src/reitit/ring/middleware/multipart.clj b/modules/reitit-middleware/src/reitit/ring/middleware/multipart.clj new file mode 100644 index 00000000..e6152e02 --- /dev/null +++ b/modules/reitit-middleware/src/reitit/ring/middleware/multipart.clj @@ -0,0 +1,77 @@ +(ns ^:no-doc reitit.ring.middleware.multipart + (:refer-clojure :exclude [compile]) + (:require [reitit.coercion :as coercion] + [ring.middleware.multipart-params :as multipart-params] + [clojure.spec.alpha :as s] + [spec-tools.core :as st]) + (:import (java.io File))) + +(s/def ::filename string?) +(s/def ::content-type string?) +(s/def ::tempfile (partial instance? File)) +(s/def ::bytes bytes?) +(s/def ::size int?) + +(def temp-file-part + "Spec for file param created by ring.middleware.multipart-params.temp-file store." + (st/spec + {:spec (s/keys :req-un [::filename ::content-type ::tempfile ::size]) + :swagger/type "file"})) + +(def bytes-part + "Spec for file param created by ring.middleware.multipart-params.byte-array store." + (st/spec + {:spec (s/keys :req-un [::filename ::content-type ::bytes]) + :swagger/type "file"})) + +(defn- coerced-request [request coercers] + (if-let [coerced (if coercers (coercion/coerce-request coercers request))] + (update request :parameters merge coerced) + request)) + +(defn- compile [options] + (fn [{:keys [parameters coercion]} opts] + (if-let [multipart (:multipart parameters)] + (let [parameter-coercion {:multipart (coercion/->ParameterCoercion + :multipart-params :string true true)} + opts (assoc opts ::coercion/parameter-coercion parameter-coercion) + coercers (if multipart (coercion/request-coercers coercion parameters opts))] + {:data {:swagger {:consumes ^:replace #{"multipart/form-data"}}} + :wrap (fn [handler] + (fn + ([request] + (try + (-> request + (multipart-params/multipart-params-request options) + (coerced-request coercers) + (handler)) + (catch Exception e + (.printStackTrace e) + (throw e)))) + ([request respond raise] + (-> request + (multipart-params/multipart-params-request options) + (coerced-request coercers) + (handler respond raise)))))})))) + +;; +;; public api +;; + +(defn create-multipart-middleware + "Creates a Middleware to handle the multipart params, based on + ring.middleware.multipart-params, taking same options. Mounts only + if endpoint has `[:parameters :multipart]` defined. Publishes coerced + parameters into `[:parameters :multipart]` under request." + ([] + (create-multipart-middleware nil)) + ([options] + {:name ::multipart + :compile (compile options)})) + +(def multipart-middleware + "Middleware to handle the multipart params, based on + ring.middleware.multipart-params, taking same options. Mounts only + if endpoint has `[:parameters :multipart]` defined. Publishes coerced + parameters into `[:parameters :multipart]` under request." + (create-multipart-middleware)) diff --git a/modules/reitit-middleware/src/reitit/ring/middleware/muuntaja.clj b/modules/reitit-middleware/src/reitit/ring/middleware/muuntaja.clj new file mode 100644 index 00000000..081d0762 --- /dev/null +++ b/modules/reitit-middleware/src/reitit/ring/middleware/muuntaja.clj @@ -0,0 +1,41 @@ +(ns reitit.ring.middleware.muuntaja + (:require [muuntaja.core :as m] + [muuntaja.middleware] + [clojure.spec.alpha :as s])) + +(s/def ::muuntaja (partial instance? m/Muuntaja)) +(s/def ::spec (s/keys :opt-un [::muuntaja])) + +(defn- displace [x] (with-meta x {:displace true})) + +(def format-middleware + {:name ::format + :spec ::spec + :compile (fn [{:keys [muuntaja]} _] + (if muuntaja + {:data {:swagger {:produces (displace (m/encodes muuntaja)) + :consumes (displace (m/decodes muuntaja))}} + :wrap #(muuntaja.middleware/wrap-format % muuntaja)}))}) + +(def format-negotiate-middleware + {:name ::format-negotiate + :spec ::spec + :compile (fn [{:keys [muuntaja]} _] + (if muuntaja + {:wrap #(muuntaja.middleware/wrap-format-negotiate % muuntaja)}))}) + +(def format-request-middleware + {:name ::format-request + :spec ::spec + :compile (fn [{:keys [muuntaja]} _] + (if muuntaja + {:data {:swagger {:consumes (displace (m/decodes muuntaja))}} + :wrap #(muuntaja.middleware/wrap-format-request % muuntaja)}))}) + +(def format-response-middleware + {:name ::format-response + :spec ::spec + :compile (fn [{:keys [muuntaja]} _] + (if muuntaja + {:data {:swagger {:produces (displace (m/encodes muuntaja))}} + :wrap #(muuntaja.middleware/wrap-format-response % muuntaja)}))}) diff --git a/modules/reitit-schema/src/reitit/coercion/schema.cljc b/modules/reitit-schema/src/reitit/coercion/schema.cljc index c7bbc7e6..9bd512f6 100644 --- a/modules/reitit-schema/src/reitit/coercion/schema.cljc +++ b/modules/reitit-schema/src/reitit/coercion/schema.cljc @@ -48,7 +48,7 @@ (-get-options [_] opts) (-get-apidocs [this spesification {:keys [parameters responses]}] ;; TODO: this looks identical to spec, refactor when schema is done. - (condp = spesification + (case spesification :swagger (swagger/swagger-spec (merge (if parameters diff --git a/modules/reitit-schema/src/reitit/ring/schema.cljc b/modules/reitit-schema/src/reitit/ring/schema.cljc new file mode 100644 index 00000000..094b23b4 --- /dev/null +++ b/modules/reitit-schema/src/reitit/ring/schema.cljc @@ -0,0 +1,30 @@ +(ns reitit.ring.schema + (:require [schema.core :as s] + [schema-tools.swagger.core :as swagger]) + #?(:clj (:import (java.io File)))) + +(defrecord Upload [m] + s/Schema + (spec [_] + (s/spec m)) + (explain [_] + (cons 'file m)) + + swagger/SwaggerSchema + (-transform [_ _] + {:type "file"})) + +#?(:clj + (def TempFilePart + "Schema for file param created by ring.middleware.multipart-params.temp-file store." + (->Upload {:filename s/Str + :content-type s/Str + :size s/Int + :tempfile File}))) + +#?(:clj + (def BytesPart + "Schema for file param created by ring.middleware.multipart-params.byte-array store." + (->Upload {:filename s/Str + :content-type s/Str + :bytes s/Any}))) diff --git a/modules/reitit-spec/src/reitit/coercion/spec.cljc b/modules/reitit-spec/src/reitit/coercion/spec.cljc index db3e6206..7de3f423 100644 --- a/modules/reitit-spec/src/reitit/coercion/spec.cljc +++ b/modules/reitit-spec/src/reitit/coercion/spec.cljc @@ -87,7 +87,7 @@ (-get-name [_] :spec) (-get-options [_] opts) (-get-apidocs [this spesification {:keys [parameters responses]}] - (condp = spesification + (case spesification :swagger (swagger/swagger-spec (merge (if parameters diff --git a/modules/reitit-swagger/src/reitit/swagger.cljc b/modules/reitit-swagger/src/reitit/swagger.cljc index a5e6511d..c17f4fb5 100644 --- a/modules/reitit-swagger/src/reitit/swagger.cljc +++ b/modules/reitit-swagger/src/reitit/swagger.cljc @@ -77,18 +77,22 @@ (let [{:keys [id] :or {id ::default} :as swagger} (-> match :result request-method :data :swagger) ->set (fn [x] (if (or (set? x) (sequential? x)) (set x) (conj #{} x))) ids (->set id) - swagger (->> (dissoc swagger :id) + strip-top-level-keys #(dissoc % :id :info :host :basePath :definitions :securityDefinitions) + strip-endpoint-keys #(dissoc % :id :parameters :responses :summary :description) + swagger (->> (strip-endpoint-keys swagger) (merge {:swagger "2.0" :x-id ids})) - accept-route #(-> % second :swagger :id (or ::default) ->set (set/intersection ids) seq) - transform-endpoint (fn [[method {{:keys [coercion no-doc swagger] :as data} :data}]] + accept-route (fn [route] + (-> route second :swagger :id (or ::default) ->set (set/intersection ids) seq)) + transform-endpoint (fn [[method {{:keys [coercion no-doc swagger] :as data} :data middleware :middleware}]] (if (and data (not no-doc)) [method (meta-merge + (apply meta-merge (keep (comp :swagger :data) middleware)) (if coercion - (coercion/-get-apidocs coercion :swagger data)) + (coercion/get-apidocs coercion :swagger data)) (select-keys data [:tags :summary :description]) - (dissoc swagger :id))])) + (strip-top-level-keys swagger))])) transform-path (fn [[p _ c]] (if-let [endpoint (some->> c (keep transform-endpoint) (seq) (into {}))] [(path->template p) endpoint]))] diff --git a/modules/reitit/project.clj b/modules/reitit/project.clj index b0b8bcfa..0dba44f6 100644 --- a/modules/reitit/project.clj +++ b/modules/reitit/project.clj @@ -8,6 +8,7 @@ :inherit [:deploy-repositories :managed-dependencies]} :dependencies [[metosin/reitit-core] [metosin/reitit-ring] + [metosin/reitit-middleware] [metosin/reitit-spec] [metosin/reitit-schema] [metosin/reitit-swagger] diff --git a/project.clj b/project.clj index c67701a3..6a425083 100644 --- a/project.clj +++ b/project.clj @@ -12,17 +12,18 @@ :managed-dependencies [[metosin/reitit "0.2.0-SNAPSHOT"] [metosin/reitit-core "0.2.0-SNAPSHOT"] [metosin/reitit-ring "0.2.0-SNAPSHOT"] + [metosin/reitit-middleware "0.2.0-SNAPSHOT"] [metosin/reitit-spec "0.2.0-SNAPSHOT"] [metosin/reitit-schema "0.2.0-SNAPSHOT"] [metosin/reitit-swagger "0.2.0-SNAPSHOT"] [metosin/reitit-swagger-ui "0.2.0-SNAPSHOT"] [metosin/reitit-frontend "0.2.0-SNAPSHOT"] - [meta-merge "1.0.0"] [ring/ring-core "1.6.3"] [metosin/spec-tools "0.7.1"] [metosin/schema-tools "0.10.3"] [metosin/ring-swagger-ui "2.2.10"] + [metosin/muuntaja "0.6.0-alpha1"] [metosin/jsonista "0.2.1"]] :plugins [[jonase/eastwood "0.2.6"] @@ -38,6 +39,7 @@ :source-paths ["modules/reitit/src" "modules/reitit-core/src" "modules/reitit-ring/src" + "modules/reitit-middleware/src" "modules/reitit-spec/src" "modules/reitit-schema/src" "modules/reitit-swagger/src" @@ -55,7 +57,7 @@ [ring "1.6.3"] [ikitommi/immutant-web "3.0.0-alpha1"] - [metosin/muuntaja "0.6.0-SNAPSHOT"] + [metosin/muuntaja "0.6.0-alpha1"] [metosin/ring-swagger-ui "2.2.10"] [metosin/jsonista "0.2.1"] diff --git a/scripts/lein-modules b/scripts/lein-modules index fc3870d8..17eefffc 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-schema reitit-swagger reitit-swagger-ui reitit-frontend reitit; do +for ext in reitit-core reitit-ring reitit-middleware reitit-spec reitit-schema reitit-swagger reitit-swagger-ui reitit-frontend reitit; do cd modules/$ext; lein "$@"; cd ../..; done diff --git a/test/clj/reitit/ring/middleware/exception_test.clj b/test/clj/reitit/ring/middleware/exception_test.clj new file mode 100644 index 00000000..7e13f7c4 --- /dev/null +++ b/test/clj/reitit/ring/middleware/exception_test.clj @@ -0,0 +1,116 @@ +(ns reitit.ring.middleware.exception-test + (:require [clojure.test :refer [deftest testing is]] + [reitit.ring :as ring] + [reitit.ring.middleware.exception :as exception] + [reitit.coercion.spec] + [reitit.ring.coercion] + [muuntaja.core :as m]) + (:import (java.sql SQLException SQLWarning))) + +(derive ::kikka ::kukka) + +(deftest exception-test + (letfn [(create + ([f] + (create f nil)) + ([f wrap] + (ring/ring-handler + (ring/router + [["/defaults" + {:handler f}] + ["/coercion" + {:middleware [reitit.ring.coercion/coerce-request-middleware + reitit.ring.coercion/coerce-response-middleware] + :coercion reitit.coercion.spec/coercion + :parameters {:query {:x int?, :y int?}} + :responses {200 {:body {:total pos-int?}}} + :handler f}]] + {:data {:middleware [(exception/create-exception-middleware + (merge + exception/default-handlers + {::kikka (constantly {:status 400, :body "kikka"}) + SQLException (constantly {:status 400, :body "sql"}) + ::exception/wrap wrap}))]}}))))] + + (testing "normal calls work ok" + (let [response {:status 200, :body "ok"} + app (create (fn [_] response))] + (is (= response (app {:request-method :get, :uri "/defaults"}))))) + + (testing "unknown exception" + (let [app (create (fn [_] (throw (NullPointerException.))))] + (is (= {:status 500 + :body {:type "exception" + :class "java.lang.NullPointerException"}} + (app {:request-method :get, :uri "/defaults"})))) + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::invalid}))))] + (is (= {:status 500 + :body {:type "exception" + :class "clojure.lang.ExceptionInfo"}} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "::ring/response" + (let [response {:status 200, :body "ok"} + app (create (fn [_] (throw (ex-info "fail" {:type ::ring/response, :response response}))))] + (is (= response (app {:request-method :get, :uri "/defaults"}))))) + + (testing ":muuntaja/decode" + (let [app (create (fn [_] (m/decode m/instance "application/json" "{:so \"invalid\"}")))] + (is (= {:body "Malformed \"application/json\" request." + :headers {"Content-Type" "text/plain"} + :status 400} + (app {:request-method :get, :uri "/defaults"})))) + + (testing "::coercion/request-coercion" + (let [app (create (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200, :body {:total (+ x y)}}))] + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "1", "y" "2"}})] + (is (= 200 status)) + (is (= {:total 3} body))) + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "abba", "y" "2"}})] + (is (= 400 status)) + (is (= :reitit.coercion/request-coercion (:type body)))) + + (let [{:keys [status body]} (app {:request-method :get + :uri "/coercion" + :query-params {"x" "-10", "y" "2"}})] + (is (= 500 status)) + (is (= :reitit.coercion/response-coercion (:type body))))))) + + (testing "exact :type" + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::kikka}))))] + (is (= {:status 400, :body "kikka"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "parent :type" + (let [app (create (fn [_] (throw (ex-info "fail" {:type ::kukka}))))] + (is (= {:status 400, :body "kikka"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "exact Exception" + (let [app (create (fn [_] (throw (SQLException.))))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "Exception SuperClass" + (let [app (create (fn [_] (throw (SQLWarning.))))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))))) + + (testing "::exception/wrap" + (let [calls (atom 0) + app (create (fn [_] (throw (SQLWarning.))) + (fn [handler exception request] + (if (< (swap! calls inc) 2) + (handler exception request) + {:status 500, :body "too many tries"})))] + (is (= {:status 400, :body "sql"} + (app {:request-method :get, :uri "/defaults"}))) + (is (= {:status 500, :body "too many tries"} + (app {:request-method :get, :uri "/defaults"}))))))) diff --git a/test/clj/reitit/ring/middleware/muuntaja_test.clj b/test/clj/reitit/ring/middleware/muuntaja_test.clj new file mode 100644 index 00000000..f3d7fa31 --- /dev/null +++ b/test/clj/reitit/ring/middleware/muuntaja_test.clj @@ -0,0 +1,143 @@ +(ns reitit.ring.middleware.muuntaja-test + (:require [clojure.test :refer [deftest testing is]] + [reitit.ring :as ring] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.swagger :as swagger] + [muuntaja.core :as m])) + +(deftest muuntaja-test + (let [data {:kikka "kukka"} + app (ring/ring-handler + (ring/router + ["/ping" {:get (constantly {:status 200, :body data})}] + {:data {:muuntaja m/instance + :middleware [muuntaja/format-middleware]}}))] + (is (= data (->> {:request-method :get, :uri "/ping"} + (app) + :body + (m/decode m/instance "application/json")))))) + +(deftest muuntaja-swagger-test + (let [with-defaults m/instance + no-edn-decode (m/create (-> m/default-options (update-in [:formats "application/edn"] dissoc :decoder))) + just-edn (m/create (-> m/default-options (m/select-formats ["application/edn"]))) + app (ring/ring-handler + (ring/router + [["/defaults" + {:get identity}] + ["/explicit-defaults" + {:muuntaja with-defaults + :get identity}] + ["/no-edn-decode" + {:muuntaja no-edn-decode + :get identity}] + ["/just-edn" + {:muuntaja just-edn + :get identity}] + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]] + {:data {:muuntaja m/instance + :middleware [muuntaja/format-middleware]}})) + spec (fn [path] + (let [path (keyword path)] + (-> {:request-method :get :uri "/swagger.json"} + (app) :body + (->> (m/decode m/instance "application/json")) + :paths path :get))) + produces (comp set :produces spec) + consumes (comp set :consumes spec)] + + (testing "with defaults" + (let [path "/defaults"] + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (produces path) + (consumes path))))) + + (testing "with explicit muuntaja defaults" + (let [path "/explicit-defaults"] + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (produces path) + (consumes path))))) + + (testing "without edn decode" + (let [path "/no-edn-decode"] + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (produces path))) + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json"} + (consumes path))))) + + (testing "just edn" + (let [path "/just-edn"] + (is (= #{"application/edn"} + (produces path) + (consumes path))))))) + +(deftest muuntaja-swagger-parts-test + (let [app (ring/ring-handler + (ring/router + [["/request" + {:middleware [muuntaja/format-negotiate-middleware + muuntaja/format-request-middleware] + :get identity}] + ["/response" + {:middleware [muuntaja/format-negotiate-middleware + muuntaja/format-response-middleware] + :get identity}] + ["/both" + {:middleware [muuntaja/format-negotiate-middleware + muuntaja/format-response-middleware + muuntaja/format-request-middleware] + :get identity}] + + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]] + {:data {:muuntaja m/instance}})) + spec (fn [path] + (-> {:request-method :get :uri "/swagger.json"} + (app) :body :paths (get path) :get)) + produces (comp :produces spec) + consumes (comp :consumes spec)] + + (testing "just request formatting" + (let [path "/request"] + (is (nil? (produces path))) + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (consumes path))))) + + (testing "just response formatting" + (let [path "/response"] + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (produces path))) + (is (nil? (consumes path))))) + + (testing "just response formatting" + (let [path "/both"] + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (produces path))) + (is (= #{"application/json" + "application/transit+msgpack" + "application/transit+json" + "application/edn"} + (consumes path))))))) diff --git a/test/cljc/reitit/swagger_test.clj b/test/cljc/reitit/swagger_test.clj index 7c563ec2..9ac8fda8 100644 --- a/test/cljc/reitit/swagger_test.clj +++ b/test/cljc/reitit/swagger_test.clj @@ -180,5 +180,23 @@ :handler (swagger/create-swagger-handler)}}]]))] (is (= ["/ping"] (spec-paths app "/swagger.json"))) (is (= #{::swagger/default} - (-> {:request-method :get :uri "/swagger.json"} - (app) :body :x-id))))) + (-> {:request-method :get :uri "/swagger.json"} + (app) :body :x-id))))) + +(deftest all-parameter-types-test + (let [app (ring/ring-handler + (ring/router + [["/parameters" + {:post {:coercion spec/coercion + :parameters {:query {:q string?} + :body {:b string?} + :form {:f string?} + :header {:h string?} + :path {:p string?}} + :handler identity}}] + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]])) + spec (:body (app {:request-method :get, :uri "/swagger.json"}))] + (is (= ["query" "body" "formData" "header" "path"] + (map :in (get-in spec [:paths "/parameters" :post :parameters]))))))