diff --git a/doc/ring/coercion.md b/doc/ring/coercion.md index 930c10df..8ca8fb68 100644 --- a/doc/ring/coercion.md +++ b/doc/ring/coercion.md @@ -4,14 +4,15 @@ Basic coercion is explained in detail [in the Coercion Guide](../coercion/coerci The following request parameters are currently supported: -| type | request source | -|------------|--------------------------------------------------| -| `:query` | `:query-params` | -| `:body` | `:body-params` | -| `:request` | `:body-params`, allows per-content-type coercion | -| `:form` | `:form-params` | -| `:header` | `:header-params` | -| `:path` | `:path-params` | +| type | request source | +|--------------|--------------------------------------------------| +| `:query` | `:query-params` | +| `:body` | `:body-params` | +| `:request` | `:body-params`, allows per-content-type coercion | +| `:form` | `:form-params` | +| `:header` | `:header-params` | +| `:path` | `:path-params` | +| `:multipart` | `:multipart-params`, see [Default Middleware](default_middleware.md) | To enable coercion, the following things need to be done: diff --git a/doc/ring/openapi.md b/doc/ring/openapi.md index 3a1f5fcf..d3e127cf 100644 --- a/doc/ring/openapi.md +++ b/doc/ring/openapi.md @@ -5,7 +5,9 @@ Reitit can generate [OpenAPI 3.1.0](https://spec.openapis.org/oas/v3.1.0) documentation. The feature works similarly to [Swagger documentation](swagger.md). -The [http-swagger example](../../examples/http-swagger) also has OpenAPI documentation. +The [http-swagger](../../examples/http-swagger) and +[ring-malli-swagger](../../examples/ring-malli-swagger) examples also +have OpenAPI documentation. ## OpenAPI data diff --git a/examples/http-swagger/src/example/server.clj b/examples/http-swagger/src/example/server.clj index c7251d9c..d0a9164e 100644 --- a/examples/http-swagger/src/example/server.clj +++ b/examples/http-swagger/src/example/server.clj @@ -76,6 +76,8 @@ ["/download" {:get {:summary "downloads a file" :swagger {:produces ["image/png"]} + :responses {200 {:description "an image" + :content {"image/png" any?}}} :handler (fn [_] {:status 200 :headers {"Content-Type" "image/png"} diff --git a/examples/ring-malli-swagger/README.md b/examples/ring-malli-swagger/README.md index 3a2144c2..6162591b 100644 --- a/examples/ring-malli-swagger/README.md +++ b/examples/ring-malli-swagger/README.md @@ -7,6 +7,10 @@ (start) ``` +- Swagger spec served at +- Openapi spec served at +- Swagger UI served at + To test the endpoints using [httpie](https://httpie.org/): ```bash @@ -20,4 +24,4 @@ http GET :3000/swagger.json ## License -Copyright © 2017-2019 Metosin Oy +Copyright © 2017-2023 Metosin Oy diff --git a/examples/ring-malli-swagger/project.clj b/examples/ring-malli-swagger/project.clj index 406e05c6..a2991dae 100644 --- a/examples/ring-malli-swagger/project.clj +++ b/examples/ring-malli-swagger/project.clj @@ -3,6 +3,7 @@ :dependencies [[org.clojure/clojure "1.10.0"] [metosin/jsonista "0.2.6"] [ring/ring-jetty-adapter "1.7.1"] - [metosin/reitit "0.6.0"]] + [metosin/reitit "0.6.0"] + [metosin/ring-swagger-ui "5.0.0-alpha.0"]] :repl-options {:init-ns example.server} :profiles {:dev {:dependencies [[ring/ring-mock "0.3.2"]]}}) diff --git a/examples/ring-malli-swagger/src/example/server.clj b/examples/ring-malli-swagger/src/example/server.clj index bc9c3861..b7ab91b4 100644 --- a/examples/ring-malli-swagger/src/example/server.clj +++ b/examples/ring-malli-swagger/src/example/server.clj @@ -1,6 +1,7 @@ (ns example.server (:require [reitit.ring :as ring] [reitit.coercion.malli] + [reitit.openapi :as openapi] [reitit.ring.malli] [reitit.swagger :as swagger] [reitit.swagger-ui :as swagger-ui] @@ -24,13 +25,20 @@ [["/swagger.json" {:get {:no-doc true :swagger {:info {:title "my-api" - :description "with [malli](https://github.com/metosin/malli) and reitit-ring"} + :description "swagger docs with [malli](https://github.com/metosin/malli) and reitit-ring" + :version "0.0.1"} :tags [{:name "files", :description "file api"} {:name "math", :description "math api"}]} :handler (swagger/create-swagger-handler)}}] + ["/openapi.json" + {:get {:no-doc true + :openapi {:info {:title "my-api" + :description "openapi3 docs with [malli](https://github.com/metosin/malli) and reitit-ring" + :version "0.0.1"}} + :handler (openapi/create-openapi-handler)}}] ["/files" - {:swagger {:tags ["files"]}} + {:tags ["files"]} ["/upload" {:post {:summary "upload a file" @@ -44,6 +52,8 @@ ["/download" {:get {:summary "downloads a file" :swagger {:produces ["image/png"]} + :responses {200 {:description "an image" + :content {"image/png" any?}}} :handler (fn [_] {:status 200 :headers {"Content-Type" "image/png"} @@ -52,7 +62,7 @@ (io/input-stream))})}}]] ["/math" - {:swagger {:tags ["math"]}} + {:tags ["math"]} ["/plus" {:get {:summary "plus with malli query parameters" @@ -96,8 +106,9 @@ ;; malli options :options nil}) :muuntaja m/instance - :middleware [;; swagger feature + :middleware [;; swagger & openapi swagger/swagger-feature + openapi/openapi-feature ;; query-params & form-params parameters/parameters-middleware ;; content-negotiation @@ -118,6 +129,9 @@ (swagger-ui/create-swagger-ui-handler {:path "/" :config {:validatorUrl nil + :urls [{:name "swagger", :url "swagger.json"} + {:name "openapi", :url "openapi.json"}] + :urls.primaryName "openapi" :operationsSorter "alpha"}}) (ring/create-default-handler)))) diff --git a/modules/reitit-interceptors/src/reitit/http/interceptors/multipart.clj b/modules/reitit-interceptors/src/reitit/http/interceptors/multipart.clj index d616c689..b4bd02e5 100644 --- a/modules/reitit-interceptors/src/reitit/http/interceptors/multipart.clj +++ b/modules/reitit-interceptors/src/reitit/http/interceptors/multipart.clj @@ -19,13 +19,17 @@ "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"})) + :swagger {:type "file"} + :openapi {:type "string" + :format "binary"}})) (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"})) + :swagger {:type "file"} + :openapi {:type "string" + :format "binary"}})) (defn- coerced-request [request coercers] (if-let [coerced (if coercers (coercion/coerce-request coercers request))] diff --git a/modules/reitit-malli/src/reitit/coercion/malli.cljc b/modules/reitit-malli/src/reitit/coercion/malli.cljc index 9b66f8fa..99226230 100644 --- a/modules/reitit-malli/src/reitit/coercion/malli.cljc +++ b/modules/reitit-malli/src/reitit/coercion/malli.cljc @@ -135,8 +135,8 @@ (defn -get-apidocs-openapi [coercion {:keys [parameters responses content-types] :or {content-types ["application/json"]}} options] - (let [{:keys [body request]} parameters - parameters (dissoc parameters :request :body) + (let [{:keys [body request multipart]} parameters + parameters (dissoc parameters :request :body :multipart) ->schema-object (fn [schema opts] (let [current-opts (merge options opts)] (json-schema/transform (coercion/-compile-model coercion schema current-opts) @@ -184,6 +184,14 @@ :content-type content-type})] [content-type {:schema schema}]))) (:content request)))}}) + (when multipart + {:requestBody + {:content + {"multipart/form-data" + {:schema + (->schema-object multipart {:in :requestBody + :type :schema + :content-type "multipart/form-data"})}}}}) (when responses {:responses (into {} diff --git a/modules/reitit-malli/src/reitit/ring/malli.cljc b/modules/reitit-malli/src/reitit/ring/malli.cljc index a7987625..9815fef5 100644 --- a/modules/reitit-malli/src/reitit/ring/malli.cljc +++ b/modules/reitit-malli/src/reitit/ring/malli.cljc @@ -4,7 +4,9 @@ #?(:clj (def temp-file-part "Schema for file param created by ring.middleware.multipart-params.temp-file store." - [:map {:json-schema {:type "file"}} + [:map {:swagger {:type "file"} + :json-schema {:type "string" + :format "binary"}} [:filename string?] [:content-type string?] [:size int?] @@ -13,7 +15,9 @@ #?(:clj (def bytes-part "Schema for file param created by ring.middleware.multipart-params.byte-array store." - [:map {:json-schema {:type "file"}} + [:map {:swagger {:type "file"} + :json-schema {:type "string" + :format "binary"}} [:filename string?] [:content-type string?] [:bytes bytes?]])) diff --git a/modules/reitit-schema/src/reitit/coercion/schema.cljc b/modules/reitit-schema/src/reitit/coercion/schema.cljc index 07dc5ce6..ce66e153 100644 --- a/modules/reitit-schema/src/reitit/coercion/schema.cljc +++ b/modules/reitit-schema/src/reitit/coercion/schema.cljc @@ -70,7 +70,7 @@ (update $ :schema #(coercion/-compile-model this % nil)) $))]))}))) :openapi (merge - (when (seq (dissoc parameters :body :request)) + (when (seq (dissoc parameters :body :request :multipart)) (openapi/openapi-spec {::openapi/parameters (into (empty parameters) @@ -85,6 +85,10 @@ (when-let [default (get-in parameters [:request :body])] (zipmap content-types (repeat default))) (:content (:request parameters)))})}) + (when (:multipart parameters) + {:requestBody + (openapi/openapi-spec + {::openapi/content {"multipart/form-data" (:multipart parameters)}})}) (when responses {:responses (into diff --git a/modules/reitit-spec/src/reitit/coercion/spec.cljc b/modules/reitit-spec/src/reitit/coercion/spec.cljc index d453dd61..d5edd78b 100644 --- a/modules/reitit-spec/src/reitit/coercion/spec.cljc +++ b/modules/reitit-spec/src/reitit/coercion/spec.cljc @@ -108,7 +108,7 @@ (update $ :schema #(coercion/-compile-model this % nil)) $))]))}))) :openapi (merge - (when (seq (dissoc parameters :body :request)) + (when (seq (dissoc parameters :body :request :multipart)) (openapi/openapi-spec {::openapi/parameters (into (empty parameters) (for [[k v] (dissoc parameters :body :request)] @@ -124,6 +124,12 @@ (into {} (for [[format model] (:content (:request parameters))] [format (coercion/-compile-model this model nil)])))})}) + (when (:multipart parameters) + {:requestBody + (openapi/openapi-spec + {::openapi/content + {"multipart/form-data" + (coercion/-compile-model this (:multipart parameters) nil)}})}) (when responses {:responses (into diff --git a/project.clj b/project.clj index 72936964..eebcf7d9 100644 --- a/project.clj +++ b/project.clj @@ -33,7 +33,7 @@ [metosin/reitit-pedestal "0.6.0"] [metosin/ring-swagger-ui "4.15.5"] [metosin/spec-tools "0.10.5"] - [metosin/schema-tools "0.12.3"] + [metosin/schema-tools "0.13.0"] [metosin/muuntaja "0.6.8"] [metosin/jsonista "0.3.7"] [metosin/sieppari "0.0.0-alpha13"] @@ -86,7 +86,7 @@ [org.clojure/clojurescript "1.10.773"] ;; modules dependencies - [metosin/schema-tools "0.12.3"] + [metosin/schema-tools "0.13.0"] [metosin/spec-tools "0.10.5"] [metosin/muuntaja "0.6.8"] [metosin/sieppari "0.0.0-alpha13"] diff --git a/test/cljc/reitit/openapi_test.clj b/test/cljc/reitit/openapi_test.clj index 3db5533f..563125ed 100644 --- a/test/cljc/reitit/openapi_test.clj +++ b/test/cljc/reitit/openapi_test.clj @@ -8,12 +8,15 @@ [reitit.coercion.malli :as malli] [reitit.coercion.schema :as schema] [reitit.coercion.spec :as spec] + [reitit.http.interceptors.multipart] [reitit.openapi :as openapi] [reitit.ring :as ring] + [reitit.ring.malli] [reitit.ring.spec] [reitit.ring.coercion :as rrc] [reitit.swagger-ui :as swagger-ui] [schema.core :as s] + [schema-tools.core] [spec-tools.data-spec :as ds])) (defn validate @@ -429,6 +432,54 @@ (testing "spec is valid" (is (nil? (validate spec)))))))) +(deftest multipart-test + (doseq [[coercion file-schema string-schema] + [[#'malli/coercion + reitit.ring.malli/bytes-part + :string] + [#'schema/coercion + (schema-tools.core/schema {:filename s/Str + :content-type s/Str + :bytes s/Num} + {:openapi {:type "string" + :format "binary"}}) + s/Str] + [#'spec/coercion + reitit.http.interceptors.multipart/bytes-part + string?]]] + (testing coercion + (let [app (ring/ring-handler + (ring/router + [["/upload" + {:post {:decription "upload" + :coercion @coercion + :parameters {:multipart {:file file-schema + :more string-schema}} + :handler identity}}] + ["/openapi.json" + {:get {:handler (openapi/create-openapi-handler) + :openapi {:info {:title "" :version "0.0.1"}} + :no-doc true}}]] + {:data {:middleware [openapi/openapi-feature]}})) + spec (-> {:request-method :get + :uri "/openapi.json"} + app + :body)] + (testing "multipart body" + (is (nil? (get-in spec [:paths "/upload" :post :parameters]))) + (is (= (merge {:type "object" + :properties {:file {:type "string" + :format "binary"} + :more {:type "string"}} + :required ["file" "more"]} + (when-not (= #'spec/coercion coercion) + {:additionalProperties false})) + (-> spec + (get-in [:paths "/upload" :post :requestBody :content "multipart/form-data" :schema]) + normalize)))) + (testing "spec is valid" + (is (nil? (validate spec)))))))) + (deftest per-content-type-test (doseq [[coercion ->schema] [[#'malli/coercion (fn [nom] [:map [nom :string]])] diff --git a/test/cljc/reitit/swagger_test.clj b/test/cljc/reitit/swagger_test.clj index 542b7ebd..d2d18c31 100644 --- a/test/cljc/reitit/swagger_test.clj +++ b/test/cljc/reitit/swagger_test.clj @@ -1,16 +1,27 @@ (ns reitit.swagger-test (:require [clojure.test :refer [deftest is testing]] + [jsonista.core :as j] [muuntaja.core :as m] [reitit.coercion.malli :as malli] [reitit.coercion.schema :as schema] [reitit.coercion.spec :as spec] + [reitit.http.interceptors.multipart] [reitit.ring :as ring] + [reitit.ring.malli] [reitit.ring.coercion :as rrc] [reitit.swagger :as swagger] [reitit.swagger-ui :as swagger-ui] [schema.core :as s] [spec-tools.data-spec :as ds])) +(defn- normalize + "Normalize format of swagger spec by converting it to json and back. + Handles differences like :q vs \"q\" in swagger generation." + [data] + (-> data + j/write-value-as-string + (j/read-value j/keyword-keys-object-mapper))) + (def app (ring/ring-handler (ring/router @@ -410,3 +421,41 @@ :handler (swagger/create-swagger-handler)}}]])) output (with-out-str (app {:request-method :get, :uri "/swagger.json"}))] (is (.contains output "WARN"))))) + +(deftest multipart-test + (doseq [[coercion file-schema string-schema] + [[#'malli/coercion + reitit.ring.malli/bytes-part + :string] + [#'spec/coercion + reitit.http.interceptors.multipart/bytes-part + string?]]] + (testing coercion + (let [app (ring/ring-handler + (ring/router + [["/upload" + {:post {:decription "upload" + :coercion @coercion + :parameters {:multipart {:file file-schema + :more string-schema}} + :handler identity}}] + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]] + {:data {:middleware [swagger/swagger-feature]}})) + spec (-> {:request-method :get + :uri "/swagger.json"} + app + :body)] + (is (= [{:description "" + :in "formData" + :name "file" + :required true + :type "file"} + {:description "" + :in "formData" + :name "more" + :required true + :type "string"}] + (normalize + (get-in spec [:paths "/upload" :post :parameters]))))))))