Merge pull request #639 from metosin/fix-openapi-examples

fix, test and document openapi named examples
This commit is contained in:
Joel Kaasinen 2023-09-11 11:04:16 +03:00 committed by GitHub
commit 6360fa8ba0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 287 additions and 103 deletions

View file

@ -15,6 +15,8 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
## UNRELEASED
* **BREAKING**: require Clojure 1.11, drop support for Clojure 1.10
* **BREAKING**: new syntax for `:request` and `:response` per-content-type coercions. See [coercion.md](doc/ring/coercion.md). [#627](https://github.com/metosin/reitit/issues/627)
* **BREAKING**: replace the openapi `:content-types` keyword with separate `:openapi/request-content-types` and `:openapi/response-content-types`. See [openapi.md](doc/ring/openapi.md)
## 0.7.0-alpha5 (2023-06-14)

View file

@ -152,7 +152,10 @@ Invalid response:
## Per-content-type coercion
You can also specify request and response body schemas per content-type. The syntax for this is:
You can also specify request and response body schemas per
content-type. These are also read by the [OpenAPI
feature](./openapi.md) when generating api docs. The syntax for this
is:
```clj
(def app
@ -161,13 +164,12 @@ You can also specify request and response body schemas per content-type. The syn
["/api"
["/example" {:post {:coercion reitit.coercion.schema/coercion
:request {:content {"application/json" {:schema {:y s/Int}}
"application/edn" {:schema {:z s/Int}}}
"application/edn" {:schema {:z s/Int}}
;; default if no content-type matches:
:body {:yy s/Int}}
:default {:schema {:yy s/Int}}}}
:responses {200 {:content {"application/json" {:schema {:w s/Int}}
"application/edn" {:schema {:x s/Int}}}
;; default if no content-type matches:
:body {:ww s/Int}}}
"application/edn" {:schema {:x s/Int}}
:default {:schema {:ww s/Int}}}}}
:handler ...}}]]
{:data {:middleware [rrc/coerce-exceptions-middleware
rrc/coerce-request-middleware

View file

@ -19,7 +19,8 @@ The following route data keys contribute to the generated swagger specification:
| key | description |
| ---------------|-------------|
| :openapi | map of any openapi data. Can contain keys like `:deprecated`.
| :content-types | vector of supported content types. Defaults to `["application/json"]`
| :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
| :tags | optional set of string or keyword tags for an endpoint api docs
| :summary | optional short string summary of an endpoint
@ -30,10 +31,9 @@ Coercion keys also contribute to the docs:
| key | description |
| --------------|-------------|
| :parameters | optional input parameters for a route, in a format defined by the coercion
| :request | optional description of body parameters, possibly per content-type
| :responses | optional descriptions of responses, in a format defined by coercion
Use `:request` parameter coercion (instead of `:body`) to unlock per-content-type coercions. See [Coercion](coercion.md).
## Annotating schemas
You can use malli properties, schema-tools data or spec-tools data to
@ -81,28 +81,45 @@ Spec:
:y int?}}}}}]
```
## Per-content-type coercions
Use `:request` coercion (instead of `:body`) to unlock
per-content-type coercions. This also lets you specify multiple named
examples. See [Coercion](coercion.md) for more info. See also [the
openapi example](../../examples/openapi).
```clj
["/pizza"
{:get {:summary "Fetch a pizza | Multiple content-types, multiple examples"
:responses {200 {:content {"application/json" {:description "Fetch a pizza as json"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:white {:description "White pizza with pineapple"
:value {:color :white
:pineapple true}}
:red {:description "Red pizza"
:value {:color :red
:pineapple false}}}}
"application/edn" {:description "Fetch a pizza as edn"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:red {:description "Red pizza with pineapple"
:value (pr-str {:color :red :pineapple true})}}}}}}
```
## Custom OpenAPI data
The `:openapi` route data key can be used to add top-level or
route-level information to the generated OpenAPI spec. This is useful
for providing `"securitySchemes"`, `"examples"` or other OpenAPI keys
that are not generated automatically by reitit.
for providing `"securitySchemes"` or other OpenAPI keys that are not
generated automatically by reitit.
```clj
["/foo"
{:post {:parameters {:body {:name string? :age int?}}
:openapi {:requestBody
{:content
{"application/json"
{:examples {"Pyry" {:summary "Pyry, 45y"
:value {:name "Pyry" :age 45}}
"Cat" {:summary "Cat, 8y"
:value {:name "Cat" :age 8}}}}}}}
...}}]
```
See [the ring-malli-swagger example](../../examples/ring-malli-swagger) for
working examples of `"securitySchemes"` and `"examples"`.
See [the openapi example](../../examples/openapi) for a working
example of `"securitySchemes"`.
## OpenAPI spec

View file

@ -62,7 +62,7 @@
:handler (openapi/create-openapi-handler)}}]
["/files"
{:tags ["files"]}
{:tags #{"files"}}
["/upload"
{:post {:summary "upload a file"
@ -85,7 +85,7 @@
(io/resource "reitit.png"))})}}]]
["/async"
{:get {:tags ["async"]
{:get {:tags #{"async"}
:summary "fetches random users asynchronously over the internet"
:parameters {:query (s/keys :req-un [::results] :opt-un [::seed])}
:responses {200 {:body any?}}
@ -102,7 +102,7 @@
:body results})))}}]
["/math"
{:tags ["math"]}
{:tags #{"math"}}
["/plus"
{:get {:summary "plus with data-spec query parameters"
@ -112,22 +112,6 @@
{:status 200
:body {:total (+ x y)}})}
:post {:summary "plus with data-spec body parameters"
;; OpenAPI3 named examples for request & response
:openapi {:requestBody
{:content
{"application/json"
{:examples {"add-one-one" {:summary "1+1"
:value {:x 1 :y 1}}
"add-one-two" {:summary "1+2"
:value {:x 1 :y 2}}}}}}
:responses
{200
{:content
{"application/json"
{:examples {"two" {:summary "2"
:value {:total 2}}
"three" {:summary "3"
:value {:total 3}}}}}}}}
:parameters {:body {:x int?, :y int?}}
:responses {200 {:body {:total int?}}}
:handler (fn [{{{:keys [x y]} :body} :parameters}]
@ -148,7 +132,7 @@
{:status 200
:body {:total (- x y)}})}}]]
["/secure"
{:tags ["secure"]
{:tags #{"secure"}
:openapi {:security [{"auth" []}]}
:swagger {:security [{"auth" []}]}}
["/get"

View file

@ -0,0 +1,18 @@
# OpenAPI 3 feature showcase
## Usage
```clj
> lein repl
(start)
```
- Swagger UI served at <http://localhost:3000/>
- Openapi spec served at <http://localhost:3000/openapi.json>
- See [src/example/server.clj](src/example/server.clj) for details
<img src="https://raw.githubusercontent.com/metosin/reitit/master/examples/openapi/openapi.png" />
## License
Copyright © 2023 Metosin Oy

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View file

@ -0,0 +1,9 @@
(defproject openapi "0.1.0-SNAPSHOT"
:description "Reitit OpenAPI example"
:dependencies [[org.clojure/clojure "1.10.0"]
[metosin/jsonista "0.2.6"]
[ring/ring-jetty-adapter "1.7.1"]
[metosin/reitit "0.7.0-alpha5"]
[metosin/ring-swagger-ui "5.0.0-alpha.0"]]
:repl-options {:init-ns example.server}
:profiles {:dev {:dependencies [[ring/ring-mock "0.3.2"]]}})

View file

@ -0,0 +1,148 @@
(ns example.server
(:require [reitit.ring :as ring]
[reitit.ring.spec]
[reitit.coercion.malli]
[reitit.openapi :as openapi]
[reitit.ring.malli]
[reitit.swagger-ui :as swagger-ui]
[reitit.ring.coercion :as coercion]
[reitit.dev.pretty :as pretty]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.exception :as exception]
[reitit.ring.middleware.multipart :as multipart]
[reitit.ring.middleware.parameters :as parameters]
[ring.adapter.jetty :as jetty]
[muuntaja.core :as m]))
(def app
(ring/ring-handler
(ring/router
[["/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"}
;; used in /secure APIs below
:components {:securitySchemes {"auth" {:type :apiKey
:in :header
:name "Example-Api-Key"}}}}
:handler (openapi/create-openapi-handler)}}]
["/pizza"
{:get {:summary "Fetch a pizza | Multiple content-types, multiple examples"
:responses {200 {:content {"application/json" {:description "Fetch a pizza as json"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:white {:description "White pizza with pineapple"
:value {:color :white
:pineapple true}}
:red {:description "Red pizza"
:value {:color :red
:pineapple false}}}}
"application/edn" {:description "Fetch a pizza as edn"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:red {:description "Red pizza with pineapple"
:value (pr-str {:color :red :pineapple true})}}}}}}
:handler (fn [_request]
{:status 200
:body {:color :red
:pineapple true}})}
:post {:summary "Create a pizza | Multiple content-types, multiple examples"
:request {:content {"application/json" {:description "Create a pizza using json"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:purple {:value {:color :purple
:pineapple false}}}}
"application/edn" {:description "Create a pizza using EDN"
:schema [:map
[:color :keyword]
[:pineapple :boolean]]
:examples {:purple {:value (pr-str {:color :purple
: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"
:schema [:map [:success :boolean]]
:example {:success true}}}}}
:handler (fn [_request]
{:status 200
:body {:success true}})}}]
["/contact"
{:get {:summary "Search for a contact | Customizing via malli properties"
:parameters {:query [:map
[:limit {:title "How many results to return? Optional."
:optional true
:json-schema/default 30
:json-schema/example 10}
int?]
[:email {:title "Email address to search for"
:json-schema/format "email"}
string?]]}
:responses {200 {:content {:default {:schema [:vector
[:map
[:name {:json-schema/example "Heidi"}
string?]
[:email {:json-schema/example "heidi@alps.ch"}
string?]]]}}}}
:handler (fn [_request]
[{:name "Heidi"
:email "heidi@alps.ch"}])}}]
["/secure"
{:tags #{"secure"}
:openapi {:security [{"auth" []}]}}
["/get"
{:get {:summary "endpoint authenticated with a header"
:responses {200 {:body [:map [:secret :string]]}
401 {:body [:map [:error :string]]}}
:handler (fn [request]
;; In a real app authentication would be handled by middleware
(if (= "secret" (get-in request [:headers "example-api-key"]))
{:status 200
:body {:secret "I am a marmot"}}
{:status 401
:body {:error "unauthorized"}}))}}]]]
{;;:reitit.middleware/transform dev/print-request-diffs ;; pretty diffs
:validate reitit.ring.spec/validate
:exception pretty/exception
:data {:coercion reitit.coercion.malli/coercion
:muuntaja m/instance
:middleware [openapi/openapi-feature
;; query-params & form-params
parameters/parameters-middleware
;; 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 "/"
:config {:validatorUrl nil
:urls [{:name "openapi", :url "openapi.json"}]
:urls.primaryName "openapi"
:operationsSorter "alpha"}})
(ring/create-default-handler))))
(defn start []
(jetty/run-jetty #'app {:port 3000, :join? false})
(println "server running in port 3000"))
(comment
(start))

View file

@ -12,7 +12,7 @@
[reitit.ring.middleware.multipart :as multipart]
[reitit.ring.middleware.parameters :as parameters]
; [reitit.ring.middleware.dev :as dev]
; [reitit.ring.spec :as spec]
[reitit.ring.spec :as spec]
; [spec-tools.spell :as spell]
[ring.adapter.jetty :as jetty]
[muuntaja.core :as m]
@ -46,7 +46,7 @@
:handler (openapi/create-openapi-handler)}}]
["/files"
{:tags ["files"]}
{:tags #{"files"}}
["/upload"
{:post {:summary "upload a file"
@ -70,7 +70,7 @@
(io/input-stream))})}}]]
["/math"
{:tags ["math"]}
{:tags #{"math"}}
["/plus"
{:get {:summary "plus with malli query parameters"
@ -93,29 +93,13 @@
:json-schema/default 42}
int?]
[:y int?]]}
;; OpenAPI3 named examples for request & response
:openapi {:requestBody
{:content
{"application/json"
{:examples {"add-one-one" {:summary "1+1"
:value {:x 1 :y 1}}
"add-one-two" {:summary "1+2"
:value {:x 1 :y 2}}}}}}
:responses
{200
{:content
{"application/json"
{:examples {"two" {:summary "2"
:value {:total 2}}
"three" {:summary "3"
:value {:total 3}}}}}}}}
:responses {200 {:body [:map [:total int?]]}}
:handler (fn [{{{:keys [x y]} :body} :parameters}]
{:status 200
:body {:total (+ x y)}})}}]]
["/secure"
{:tags ["secure"]
{:tags #{"secure"}
:openapi {:security [{"auth" []}]}
:swagger {:security [{"auth" []}]}}
["/get"
@ -131,7 +115,7 @@
:body {:error "unauthorized"}}))}}]]]
{;;:reitit.middleware/transform dev/print-request-diffs ;; pretty diffs
;;:validate spec/validate ;; enable spec validation for route data
:validate spec/validate ;; enable spec validation for route data
;;:reitit.spec/wrap spell/closed ;; strict top-level validation
:exception pretty/exception
:data {:coercion (reitit.coercion.malli/create

View file

@ -103,10 +103,6 @@
(request-coercion-failed! result coercion value in request serialize-failed-result)
result)))))))))
(defn get-default-schema [request-or-response]
(or (-> request-or-response :content :default :schema)
(:body request-or-response)))
(defn get-default [request-or-response]
(or (-> request-or-response :content :default)
(some->> request-or-response :body (assoc {} :schema))))

View file

@ -12,10 +12,11 @@
(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?)))
(s/def ::summary string?)
(s/def ::description string?)
(s/def ::content-types (s/coll-of string?))
(s/def :openapi/request-content-types (s/coll-of string?))
(s/def :openapi/response-content-types (s/coll-of string?))
(s/def ::openapi (s/keys :opt-un [::id]))
(s/def ::spec (s/keys :opt-un [::openapi ::no-doc ::tags ::summary ::description ::content-types]))
(s/def ::spec (s/keys :opt-un [::openapi ::no-doc ::tags ::summary ::description] :opt [:openapi/request-content-types :openapi/response-content-types]))
(def openapi-feature
"Stability: alpha
@ -31,7 +32,8 @@
| key | description |
| ---------------|-------------|
| :openapi | map of any openapi-data. Can contain keys like `:deprecated`.
| :content-types | vector of supported content types. Defaults to `[\"application/json\"]`
| :openapi/request-content-types | vector of supported request content types. Defaults to `[\"application/json\"]` :response nnn :content :default. 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
| :tags | optional set of string or keyword tags for an endpoint api docs
| :summary | optional short string summary of an endpoint
@ -74,7 +76,9 @@
(-> path (trie/normalize opts) (str/replace #"\{\*" "{")))
(defn -get-apidocs-openapi
[coercion {:keys [request parameters responses content-types] :or {content-types ["application/json"]}}]
[coercion {:keys [request 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
parameters (dissoc parameters :request :body :multipart)
->content (fn [data schema]
@ -105,7 +109,7 @@
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
content-types)}})
request-content-types)}})
(when request
;; request allow to different :requestBody per content-type
@ -119,7 +123,7 @@
:type :schema
:content-type content-type})]
[content-type (->content data schema)])))
content-types))
request-content-types))
(into {}
(map (fn [[content-type {:keys [schema] :as data}]]
(let [schema (->schema-object schema {:in :requestBody
@ -139,16 +143,16 @@
{:responses
(into {}
(map (fn [[status {:keys [content], :as response}]]
(let [default (coercion/get-default-schema response)
(let [default (coercion/get-default response)
content (-> (merge
(when default
(into {}
(map (fn [content-type]
(let [schema (->schema-object default {:in :responses
(let [schema (->schema-object (:schema default) {:in :responses
:type :schema
:content-type content-type})]
[content-type (->content nil schema)])))
content-types))
[content-type (->content default schema)])))
response-content-types))
(when content
(into {}
(map (fn [[content-type {:keys [schema] :as data}]]

View file

@ -9,7 +9,7 @@
(s/def ::id (s/or :keyword keyword? :set (s/coll-of keyword? :into #{})))
(s/def ::no-doc boolean?)
(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?) :kind #{}))
(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?)))
(s/def ::summary string?)
(s/def ::description string?)
(s/def ::operationId string?)

View file

@ -471,23 +471,21 @@
(ring/router
[["/examples"
{:post {:decription "examples"
:openapi/request-content-types ["application/json" "application/edn"]
:openapi/response-content-types ["application/json" "application/edn"]
:coercion @coercion
:request {:body (->schema :b)}
:request {:content {"application/json" {:schema (->schema :b)
:examples {"named-example" {:description "a named example"
:value {:b "named"}}}}
:default {:schema (->schema :b2)
:examples {"default-example" {:description "default example"
:value {:b2 "named"}}}}}}
:parameters {:query (->schema :q)}
:responses {200 {:description "success"
:body (->schema :ok)}}
:openapi {:requestBody
{:content
{"application/json"
{:examples
{"named-example" {:description "a named example"
:value {:b "named"}}}}}}
:responses
{200
{:content
{"application/json"
{:examples
{"response-example" {:value {:ok "response"}}}}}}}}
:content {"application/json" {:schema (->schema :ok)
:examples {"response-example" {:value {:ok "response"}}}}
:default {:schema (->schema :ok)
:examples {"default-response-example" {:value {:ok "default"}}}}}}}
:handler identity}}]
["/openapi.json"
{:get {:handler (openapi/create-openapi-handler)
@ -517,7 +515,18 @@
:value {:b "named"}}}}
(-> spec
(get-in [:paths "/examples" :post :requestBody :content "application/json"])
normalize))))
normalize)))
(testing "default"
(is (match? {:schema {:type "object"
:properties {:b2 {:type "string"
:example "EXAMPLE"}}
:required ["b2"]
:example {:b2 "EXAMPLE2"}}
:examples {:default-example {:description "default example"
:value {:b2 "named"}}}}
(-> spec
(get-in [:paths "/examples" :post :requestBody :content "application/edn"])
normalize)))))
(testing "body response"
(is (match? {:schema {:type "object"
:properties {:ok {:type "string"
@ -527,7 +536,17 @@
:examples {:response-example {:value {:ok "response"}}}}
(-> spec
(get-in [:paths "/examples" :post :responses 200 :content "application/json"])
normalize))))
normalize)))
(testing "default"
(is (match? {:schema {:type "object"
:properties {:ok {:type "string"
:example "EXAMPLE"}}
:required ["ok"]
:example {:ok "EXAMPLE2"}}
:examples {:default-response-example {:value {:ok "default"}}}}
(-> spec
(get-in [:paths "/examples" :post :responses 200 :content "application/edn"])
normalize)))))
(testing "spec is valid"
(is (nil? (validate spec))))))))
@ -678,7 +697,8 @@
[["/parameters"
{:post {:description "parameters"
:coercion coercion
:content-types [content-type] ;; TODO should this be under :openapi ?
:openapi/request-content-types [content-type]
:openapi/response-content-types [content-type "application/response"]
:request {:content {"application/transit" {:schema (->schema :transit)}}
:body (->schema :default)}
:responses {200 {:description "success"
@ -705,7 +725,7 @@
(get-in [:paths "/parameters" :post :requestBody :content])
keys))))
(testing "body response"
(is (match? (matchers/in-any-order [content-type "application/transit"])
(is (match? (matchers/in-any-order [content-type "application/transit" "application/response"])
(-> spec
(get-in [:paths "/parameters" :post :responses 200 :content])
keys))))