Merge pull request #663 from metosin/openapi-exp

#636 Adds level-1 Muuntaja support for OpenAPI3
This commit is contained in:
Martín Varela 2024-02-09 12:19:15 +02:00 committed by GitHub
commit ca434f9c05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 140 additions and 55 deletions

View file

@ -14,6 +14,8 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
## UNRELEASED ## UNRELEASED
* Fetch OpenAPI content types from Muuntaja [#636](https://github.com/metosin/reitit/issues/636)
* **BREAKING** OpenAPI support is now clj only
* Updated dependencies: * Updated dependencies:
```clojure ```clojure

View file

@ -19,12 +19,12 @@ The following route data keys contribute to the generated swagger specification:
| key | description | | key | description |
| ---------------|-------------| | ---------------|-------------|
| :openapi | map of any openapi data. Can contain keys like `:deprecated`. | :openapi | map of any openapi data. Can contain keys like `:deprecated`.
| :openapi/request-content-types | vector of supported request content types. Defaults to `["application/json"]`. Only needed if you use the [:request :content :default] coercion.
| :openapi/response-content-types | vector of supported response content types. Defaults to `["application/json"]`. Only needed if you use the [:response nnn :content :default] coercion.
| :no-doc | optional boolean to exclude endpoint from api docs | :no-doc | optional boolean to exclude endpoint from api docs
| :tags | optional set of string or keyword tags for an endpoint api docs | :tags | optional set of string or keyword tags for an endpoint api docs
| :summary | optional short string summary of an endpoint | :summary | optional short string summary of an endpoint
| :description | optional long description of an endpoint. Supports http://spec.commonmark.org/ | :description | optional long description of an endpoint. Supports http://spec.commonmark.org/
| :openapi/request-content-types | See the Per-content-type-coercions section below.
| :openapi/response-content-types |See the Per-content-type-coercions section below. vector of supported response content types. Defaults to `["application/json"]`. Only needed if you use the [:response nnn :content :default] coercion.
Coercion keys also contribute to the docs: Coercion keys also contribute to the docs:
@ -109,7 +109,11 @@ openapi example](../../examples/openapi).
:value (pr-str {:color :red :pineapple true})}}}}}} :value (pr-str {:color :red :pineapple true})}}}}}}
``` ```
The special `:default` content types map to the content types supported by the Muuntaja
instance. You can override these by using the `:openapi/request-content-types`
and `:openapi/response-content-types` keys, which must contain vector of
supported content types. If there is no Muuntaja instance, and these keys are
not defined, the content types will default to `["application/json"]`.
## Custom OpenAPI data ## Custom OpenAPI data
@ -123,9 +127,13 @@ example of `"securitySchemes"`.
## OpenAPI spec ## OpenAPI spec
Serving the OpenAPI specification is handled by `reitit.openapi/create-openapi-handler`. It takes no arguments and returns a ring handler which collects at request-time data from all routes and returns an OpenAPI specification as Clojure data, to be encoded by a response formatter. Serving the OpenAPI specification is handled by
`reitit.openapi/create-openapi-handler`. It takes no arguments and returns a
ring handler which collects at request-time data from all routes and returns an
OpenAPI specification as Clojure data, to be encoded by a response formatter.
You can use the `:openapi` route data key of the `create-openapi-handler` route to populate the top level of the OpenAPI spec. You can use the `:openapi` route data key of the `create-openapi-handler` route
to populate the top level of the OpenAPI spec.
Example: Example:

View file

@ -63,8 +63,6 @@
[:pineapple :boolean]] [:pineapple :boolean]]
:examples {:purple {:value (pr-str {:color :purple :examples {:purple {:value (pr-str {:color :purple
:pineapple false})}}}}} :pineapple false})}}}}}
;; Need to list content types explicitly because we use :default in :responses
:openapi/response-content-types ["application/json" "application/edn"]
:responses {200 {:content {:default {:description "Success" :responses {200 {:content {:default {:description "Success"
:schema [:map [:success :boolean]] :schema [:map [:success :boolean]]
:example {:success true}}}}} :example {:success true}}}}}

View file

@ -9,4 +9,5 @@
:plugins [[lein-parent "0.3.9"]] :plugins [[lein-parent "0.3.9"]]
:parent-project {:path "../../project.clj" :parent-project {:path "../../project.clj"
:inherit [:deploy-repositories :managed-dependencies]} :inherit [:deploy-repositories :managed-dependencies]}
:dependencies [[metosin/reitit-core]]) :dependencies [[metosin/reitit-core]
[metosin/muuntaja]])

View file

@ -3,6 +3,7 @@
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[clojure.string :as str] [clojure.string :as str]
[meta-merge.core :refer [meta-merge]] [meta-merge.core :refer [meta-merge]]
[muuntaja.core :as m]
[reitit.coercion :as coercion] [reitit.coercion :as coercion]
[reitit.core :as r] [reitit.core :as r]
[reitit.trie :as trie])) [reitit.trie :as trie]))
@ -76,9 +77,7 @@
(-> path (trie/normalize opts) (str/replace #"\{\*" "{"))) (-> path (trie/normalize opts) (str/replace #"\{\*" "{")))
(defn -get-apidocs-openapi (defn -get-apidocs-openapi
[coercion {:keys [request parameters responses openapi/request-content-types openapi/response-content-types] [coercion {:keys [request muuntaja parameters responses openapi/request-content-types openapi/response-content-types]}]
:or {request-content-types ["application/json"]
response-content-types ["application/json"]}}]
(let [{:keys [body multipart]} parameters (let [{:keys [body multipart]} parameters
parameters (dissoc parameters :request :body :multipart) parameters (dissoc parameters :request :body :multipart)
->content (fn [data schema] ->content (fn [data schema]
@ -86,7 +85,13 @@
{:schema schema} {:schema schema}
(select-keys data [:description :examples]) (select-keys data [:description :examples])
(:openapi data))) (:openapi data)))
->schema-object #(coercion/-get-model-apidocs coercion :openapi %1 %2)] ->schema-object #(coercion/-get-model-apidocs coercion :openapi %1 %2)
request-content-types (or request-content-types
(when muuntaja (m/decodes muuntaja))
["application/json"])
response-content-types (or response-content-types
(when muuntaja (m/encodes muuntaja))
["application/json"])]
(merge (merge
(when (seq parameters) (when (seq parameters)
{:parameters {:parameters
@ -130,7 +135,7 @@
:type :schema :type :schema
:content-type content-type})] :content-type content-type})]
[content-type (->content data schema)]))) [content-type (->content data schema)])))
(:content request)))}}) (dissoc (:content request) :default)))}})
(when multipart (when multipart
{:requestBody {:requestBody
{:content {:content
@ -206,5 +211,5 @@
([req res raise] ([req res raise]
(try (try
(res (create-openapi req)) (res (create-openapi req))
(catch #?(:clj Exception :cljs :default) e (catch Exception e
(raise e)))))) (raise e))))))

View file

@ -685,25 +685,56 @@
(testing "spec is valid" (testing "spec is valid"
(is (nil? (validate spec)))))))) (is (nil? (validate spec))))))))
(deftest default-content-type-test (deftest default-content-type-test
(doseq [[coercion ->schema] [[malli/coercion (fn [nom] [:map [nom :string]])] (doseq [[coercion ->schema] [[malli/coercion (fn [nom] [:map [nom :string]])]
[schema/coercion (fn [nom] {nom s/Str})] [schema/coercion (fn [nom] {nom s/Str})]
[spec/coercion (fn [nom] {nom string?})]]] [spec/coercion (fn [nom] {nom string?})]]]
(testing (str coercion) (testing (str coercion)
(doseq [content-type ["application/json" "application/edn"]]
(testing (str "default content type " content-type)
(let [app (ring/ring-handler (let [app (ring/ring-handler
(ring/router (ring/router
[["/parameters" [["/explicit-content-type"
{:post {:description "parameters" {:post {:description "parameters"
:coercion coercion :coercion coercion
:openapi/request-content-types [content-type] :request {:content {"application/json" {:schema (->schema :b)}
:openapi/response-content-types [content-type "application/response"] "application/edn" {:schema (->schema :c)}}}
:request {:content {"application/transit" {:schema (->schema :transit)}}
:body (->schema :default)}
:responses {200 {:description "success" :responses {200 {:description "success"
:content {"application/transit" {:schema (->schema :transit)}} :content {"application/json" {:schema (->schema :ok)}
:body (->schema :default)}} "application/edn" {:schema (->schema :edn)}}}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :request)})}}]
["/muuntaja"
{:post {:description "default content types from muuntaja"
:coercion coercion
;;; TODO: test the :parameters syntax
:request {:content {:default {:schema (->schema :b)}
"application/reitit-request" {:schema (->schema :ok)}}}
:responses {200 {:description "success"
:content {:default {:schema (->schema :ok)}
"application/reitit-response" {:schema (->schema :ok)}}}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :request)})}}]
["/override-default-content-type"
{:post {:description "override default content types from muuntaja"
:coercion coercion
:openapi/request-content-types ["application/request"]
:openapi/response-content-types ["application/response"]
;;; TODO: test the :parameters syntax
:request {:content {:default {:schema (->schema :b)}}}
:responses {200 {:description "success"
:content {:default {:schema (->schema :ok)}}}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :request)})}}]
["/legacy"
{:post {:description "default content types from muuntaja, legacy syntax"
:coercion coercion
;;; TODO: test the :parameters syntax
:request {:body {:schema (->schema :b)}}
:responses {200 {:description "success"
:body {:schema (->schema :ok)}}}
:handler (fn [req] :handler (fn [req]
{:status 200 {:status 200
:body (-> req :parameters :request)})}}] :body (-> req :parameters :request)})}}]
@ -712,25 +743,65 @@
:openapi {:info {:title "" :version "0.0.1"}} :openapi {:info {:title "" :version "0.0.1"}}
:no-doc true}}]] :no-doc true}}]]
{:validate reitit.ring.spec/validate {:validate reitit.ring.spec/validate
:data {:middleware [openapi/openapi-feature :data {:muuntaja (m/create (-> m/default-options
(update-in [:formats] select-keys ["application/transit+json"])
(assoc :default-format "application/transit+json")))
:middleware [openapi/openapi-feature
rrc/coerce-request-middleware rrc/coerce-request-middleware
rrc/coerce-response-middleware]}})) rrc/coerce-response-middleware]}}))
spec (-> {:request-method :get spec (-> {:request-method :get
:uri "/openapi.json"} :uri "/openapi.json"}
app app
:body)] :body)
spec-coercion (= coercion spec/coercion)]
(testing "explicit content types"
(testing "body parameter" (testing "body parameter"
(is (match? (matchers/in-any-order [content-type "application/transit"]) (is (= ["application/edn" "application/json"]
(-> spec (-> spec
(get-in [:paths "/parameters" :post :requestBody :content]) (get-in [:paths "/explicit-content-type" :post :requestBody :content])
keys
sort))))
(testing "body response"
(is (= ["application/edn" "application/json"]
(-> spec
(get-in [:paths "/explicit-content-type" :post :responses 200 :content])
keys
sort)))))
(testing "muuntaja content types"
(testing "body parameter"
(is (= ["application/transit+json" "application/reitit-request"]
(-> spec
(get-in [:paths "/muuntaja" :post :requestBody :content])
keys)))) keys))))
(testing "body response" (testing "body response"
(is (match? (matchers/in-any-order [content-type "application/transit" "application/response"]) (is (= ["application/transit+json" "application/reitit-response"]
(-> spec (-> spec
(get-in [:paths "/parameters" :post :responses 200 :content]) (get-in [:paths "/muuntaja" :post :responses 200 :content])
keys)))))
(testing "overridden muuntaja content types"
(testing "body parameter"
(is (= ["application/request"]
(-> spec
(get-in [:paths "/override-default-content-type" :post :requestBody :content])
keys)))) keys))))
(testing "body response"
(is (= ["application/response"]
(-> spec
(get-in [:paths "/override-default-content-type" :post :responses 200 :content])
keys)))))
(testing "legacy syntax muuntaja content types"
(testing "body parameter"
(is (= ["application/transit+json"]
(-> spec
(get-in [:paths "/legacy" :post :requestBody :content])
keys))))
(testing "body response"
(is (= ["application/transit+json"]
(-> spec
(get-in [:paths "/legacy" :post :responses 200 :content])
keys)))))
(testing "spec is valid" (testing "spec is valid"
(is (nil? (validate spec)))))))))) (is (nil? (validate spec))))))))
(deftest recursive-test (deftest recursive-test
;; Recursive schemas only properly supported for malli ;; Recursive schemas only properly supported for malli