mirror of
https://github.com/metosin/reitit.git
synced 2025-12-17 08:21:11 +00:00
Merge pull request #114 from metosin/reitit-middleware
Reitit default middleware
This commit is contained in:
commit
b7302a236a
22 changed files with 852 additions and 74 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
131
doc/ring/default_middleware.md
Normal file
131
doc/ring/default_middleware.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
BIN
examples/ring-swagger/resources/reitit.png
Normal file
BIN
examples/ring-swagger/resources/reitit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 494 KiB |
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@
|
|||
: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)
|
||||
(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]
|
||||
|
|
@ -84,9 +84,9 @@
|
|||
result (coercer value format)]
|
||||
(if (error? result)
|
||||
(request-coercion-failed! result coercion value in request)
|
||||
result))))))
|
||||
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
|
||||
;;
|
||||
|
|
|
|||
10
modules/reitit-middleware/project.clj
Normal file
10
modules/reitit-middleware/project.clj
Normal file
|
|
@ -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]])
|
||||
|
|
@ -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)}))
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)}))})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
30
modules/reitit-schema/src/reitit/ring/schema.cljc
Normal file
30
modules/reitit-schema/src/reitit/ring/schema.cljc
Normal file
|
|
@ -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})))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]))]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
test/clj/reitit/ring/middleware/exception_test.clj
Normal file
116
test/clj/reitit/ring/middleware/exception_test.clj
Normal file
|
|
@ -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"})))))))
|
||||
143
test/clj/reitit/ring/middleware/muuntaja_test.clj
Normal file
143
test/clj/reitit/ring/middleware/muuntaja_test.clj
Normal file
|
|
@ -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)))))))
|
||||
|
|
@ -182,3 +182,21 @@
|
|||
(is (= #{::swagger/default}
|
||||
(-> {: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]))))))
|
||||
|
|
|
|||
Loading…
Reference in a new issue