mirror of
https://github.com/metosin/reitit.git
synced 2025-12-22 02:21:11 +00:00
commit
0c82ce0e4d
25 changed files with 3448 additions and 1557 deletions
2
.github/workflows/testsuite.yml
vendored
2
.github/workflows/testsuite.yml
vendored
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2.1.2
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
* [Route Data Validation](ring/route_data_validation.md)
|
||||
* [Compiling Middleware](ring/compiling_middleware.md)
|
||||
* [Swagger Support](ring/swagger.md)
|
||||
* [OpenAPI Support](ring/openapi.md)
|
||||
* [RESTful form methods](ring/RESTful_form_methods.md)
|
||||
|
||||
## HTTP
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
["Route Data Validation" {:file "doc/ring/route_data_validation.md"}]
|
||||
["Compiling Middleware" {:file "doc/ring/compiling_middleware.md"}]
|
||||
["Swagger Support" {:file "doc/ring/swagger.md"}]
|
||||
["OpenAPI Support" {:file "doc/ring/openapi.md"}]
|
||||
["RESTful form methods" {:file "doc/ring/RESTful_form_methods.md"}]]
|
||||
["HTTP" {}
|
||||
["Interceptors" {:file "doc/http/interceptors.md"}]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ 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` |
|
||||
|
|
@ -148,6 +149,30 @@ Invalid response:
|
|||
; :in [:response :body]}}
|
||||
```
|
||||
|
||||
## Per-content-type coercion
|
||||
|
||||
You can also specify request and response body schemas per content-type. The syntax for this is:
|
||||
|
||||
```clj
|
||||
(def app
|
||||
(ring/ring-handler
|
||||
(ring/router
|
||||
["/api"
|
||||
["/example" {:post {:coercion reitit.coercion.schema/coercion
|
||||
:parameters {:request {:content {"application/json" {:y s/Int}
|
||||
"application/edn" {:z s/Int}}
|
||||
;; default if no content-type matches:
|
||||
:body {:yy s/Int}}}
|
||||
:responses {200 {:content {"application/json" {:w s/Int}
|
||||
"application/edn" {:x s/Int}}
|
||||
;; default if no content-type matches:
|
||||
:body {:ww s/Int}}
|
||||
:handler ...}}]]
|
||||
{:data {:middleware [rrc/coerce-exceptions-middleware
|
||||
rrc/coerce-request-middleware
|
||||
rrc/coerce-response-middleware]}})))
|
||||
```
|
||||
|
||||
## Pretty printing spec errors
|
||||
|
||||
Spec problems are exposed as is in request & response coercion errors. Pretty-printers like [expound](https://github.com/bhb/expound) can be enabled like this:
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ Server: Jetty(9.2.21.v20170120)
|
|||
<kikka>kukka</kikka>
|
||||
```
|
||||
|
||||
You can also specify request and response schemas per content-type. See [Coercion](coercion.md) and [OpenAPI Support](openapi.md).
|
||||
|
||||
|
||||
## Changing default parameters
|
||||
|
||||
|
|
|
|||
53
doc/ring/openapi.md
Normal file
53
doc/ring/openapi.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# OpenAPI Support
|
||||
|
||||
**Stability: alpha**
|
||||
|
||||
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.
|
||||
|
||||
## OpenAPI data
|
||||
|
||||
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"]`
|
||||
| :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
|
||||
| :description | optional long description of an endpoint. Supports http://spec.commonmark.org/
|
||||
|
||||
Coercion keys also contribute to the docs:
|
||||
|
||||
| key | description |
|
||||
| --------------|-------------|
|
||||
| :parameters | optional input parameters for a route, in a format defined by the coercion
|
||||
| :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).
|
||||
|
||||
## 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.
|
||||
|
||||
You can use the `:openapi` route data key of the `create-openapi-handler` route to populate the top level of the OpenAPI spec.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
["/openapi.json"
|
||||
{:get {:handler (openapi/create-openapi-handler)
|
||||
:openapi {:info {:title "my nice api" :version "0.0.1"}}
|
||||
:no-doc true}}]
|
||||
```
|
||||
|
||||
If you need to post-process the generated spec, just wrap the handler with a custom `Middleware` or an `Interceptor`.
|
||||
|
||||
## Swagger-ui
|
||||
|
||||
[Swagger-UI](https://github.com/swagger-api/swagger-ui) is a user interface to visualize and interact with the Swagger specification. To make things easy, there is a pre-integrated version of the swagger-ui as a separate module.
|
||||
|
||||
Note: you need Swagger-UI 5 for OpenAPI 3.1 support. As of 2023-03-10, a v5.0.0-alpha.0 is out.
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
Reitit supports [Swagger2](https://swagger.io/) documentation, thanks to [schema-tools](https://github.com/metosin/schema-tools) and [spec-tools](https://github.com/metosin/spec-tools). Documentation is extracted from route definitions, coercion `:parameters` and `:responses` and from a set of new documentation keys.
|
||||
|
||||
See also: [OpenAPI support](openapi.md).
|
||||
|
||||
To enable swagger-documentation for a Ring router:
|
||||
|
||||
1. annotate your routes with swagger-data
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Http with Swagger example
|
||||
# Http with Swagger/OpenAPI example
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -7,6 +7,10 @@
|
|||
(start)
|
||||
```
|
||||
|
||||
- Swagger spec served at <http://localhost:3000/swagger.json>
|
||||
- Openapi spec served at <http://localhost:3000/openapi.json>
|
||||
- Swagger UI served at <http://localhost:3000/>
|
||||
|
||||
To test the endpoints using [httpie](https://httpie.org/):
|
||||
|
||||
```bash
|
||||
|
|
@ -20,4 +24,4 @@ http GET :3000/async results==1 seed==reitit
|
|||
|
||||
## License
|
||||
|
||||
Copyright © 2018 Metosin Oy
|
||||
Copyright © 2018-2023 Metosin Oy
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@
|
|||
:dependencies [[org.clojure/clojure "1.10.0"]
|
||||
[ring/ring-jetty-adapter "1.7.1"]
|
||||
[aleph "0.4.7-alpha5"]
|
||||
[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})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
[reitit.coercion.spec]
|
||||
[reitit.swagger :as swagger]
|
||||
[reitit.swagger-ui :as swagger-ui]
|
||||
[reitit.openapi :as openapi]
|
||||
[reitit.http.coercion :as coercion]
|
||||
[reitit.dev.pretty :as pretty]
|
||||
[reitit.interceptor.sieppari :as sieppari]
|
||||
|
|
@ -42,11 +43,26 @@
|
|||
[["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:swagger {:info {:title "my-api"
|
||||
:description "with reitit-http"}}
|
||||
:description "swagger-docs with reitit-http"
|
||||
:version "0.0.1"}
|
||||
;; used in /secure APIs below
|
||||
:securityDefinitions {"auth" {:type :apiKey
|
||||
:in :header
|
||||
:name "Example-Api-Key"}}}
|
||||
:handler (swagger/create-swagger-handler)}}]
|
||||
["/openapi.json"
|
||||
{:get {:no-doc true
|
||||
:openapi {:info {:title "my-api"
|
||||
:description "openap-docs with reitit-http"
|
||||
: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)}}]
|
||||
|
||||
["/files"
|
||||
{:swagger {:tags ["files"]}}
|
||||
{:tags ["files"]}
|
||||
|
||||
["/upload"
|
||||
{:post {:summary "upload a file"
|
||||
|
|
@ -67,7 +83,7 @@
|
|||
(io/resource "reitit.png"))})}}]]
|
||||
|
||||
["/async"
|
||||
{:get {:swagger {: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?}}
|
||||
|
|
@ -84,7 +100,7 @@
|
|||
:body results})))}}]
|
||||
|
||||
["/math"
|
||||
{:swagger {:tags ["math"]}}
|
||||
{:tags ["math"]}
|
||||
|
||||
["/plus"
|
||||
{:get {:summary "plus with data-spec query parameters"
|
||||
|
|
@ -112,7 +128,22 @@
|
|||
:responses {200 {:body (s/keys :req-un [::total])}}
|
||||
:handler (fn [{{{:keys [x y]} :body} :parameters}]
|
||||
{:status 200
|
||||
:body {:total (- x y)}})}}]]]
|
||||
:body {:total (- x y)}})}}]]
|
||||
["/secure"
|
||||
{:tags ["secure"]
|
||||
:openapi {:security [{"auth" []}]}
|
||||
:swagger {:security [{"auth" []}]}}
|
||||
["/get"
|
||||
{:get {:summary "endpoint authenticated with a header"
|
||||
:responses {200 {:body {:secret string?}}
|
||||
401 {:body {: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.interceptor/transform dev/print-context-diffs ;; pretty context diffs
|
||||
;;:validate spec/validate ;; enable spec validation for route data
|
||||
|
|
@ -122,6 +153,8 @@
|
|||
:muuntaja m/instance
|
||||
:interceptors [;; swagger feature
|
||||
swagger/swagger-feature
|
||||
;; openapi feature
|
||||
openapi/openapi-feature
|
||||
;; query-params & form-params
|
||||
(parameters/parameters-interceptor)
|
||||
;; content-negotiation
|
||||
|
|
@ -142,6 +175,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))
|
||||
{:executor sieppari/executor}))
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
(def ^:no-doc default-parameter-coercion
|
||||
{:query (->ParameterCoercion :query-params :string true true)
|
||||
:body (->ParameterCoercion :body-params :body false false)
|
||||
:request (->ParameterCoercion :body-params :request false false)
|
||||
:form (->ParameterCoercion :form-params :string true true)
|
||||
:header (->ParameterCoercion :headers :string true true)
|
||||
:path (->ParameterCoercion :path-params :string true true)
|
||||
|
|
@ -78,6 +79,9 @@
|
|||
(defn extract-request-format-default [request]
|
||||
(-> request :muuntaja/request :format))
|
||||
|
||||
(defn -identity-coercer [value _format]
|
||||
value)
|
||||
|
||||
;; TODO: support faster key walking, walk/keywordize-keys is quite slow...
|
||||
(defn request-coercer [coercion type model {::keys [extract-request-format parameter-coercion serialize-failed-result]
|
||||
:or {extract-request-format extract-request-format-default
|
||||
|
|
@ -85,11 +89,23 @@
|
|||
(if coercion
|
||||
(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)]
|
||||
(if-let [coercer (-request-coercer coercion style model)]
|
||||
->open (if open? #(-open-model coercion %) identity)
|
||||
format-schema-pairs (if (= :request style)
|
||||
(conj (:content model) [:default (:body model)])
|
||||
[[:default model]])
|
||||
format->coercer (some->> (for [[format schema] format-schema-pairs
|
||||
:when schema]
|
||||
[format (-request-coercer coercion (case style :request :body style) (->open schema))])
|
||||
(filter second)
|
||||
(seq)
|
||||
(into {}))]
|
||||
(when format->coercer
|
||||
(fn [request]
|
||||
(let [value (transform request)
|
||||
format (extract-request-format request)
|
||||
coercer (or (format->coercer format)
|
||||
(format->coercer :default)
|
||||
-identity-coercer)
|
||||
result (coercer value format)]
|
||||
(if (error? result)
|
||||
(request-coercion-failed! result coercion value in request serialize-failed-result)
|
||||
|
|
@ -98,17 +114,24 @@
|
|||
(defn extract-response-format-default [request _]
|
||||
(-> request :muuntaja/response :format))
|
||||
|
||||
(defn response-coercer [coercion body {:keys [extract-response-format serialize-failed-result]
|
||||
(defn response-coercer [coercion {:keys [content body]} {:keys [extract-response-format serialize-failed-result]
|
||||
:or {extract-response-format extract-response-format-default}}]
|
||||
(if coercion
|
||||
(if-let [coercer (-response-coercer coercion body)]
|
||||
(let [per-format-coercers (some->> (for [[format schema] content]
|
||||
[format (-response-coercer coercion schema)])
|
||||
(filter second)
|
||||
(seq)
|
||||
(into {}))
|
||||
default (when body (-response-coercer coercion body))]
|
||||
(when (or per-format-coercers default)
|
||||
(fn [request response]
|
||||
(let [format (extract-response-format request response)
|
||||
value (:body response)
|
||||
coercer (get per-format-coercers format (or default -identity-coercer))
|
||||
result (coercer value format)]
|
||||
(if (error? result)
|
||||
(response-coercion-failed! result coercion value request response serialize-failed-result)
|
||||
result))))))
|
||||
result)))))))
|
||||
|
||||
(defn encode-error [data]
|
||||
(-> data
|
||||
|
|
@ -137,8 +160,8 @@
|
|||
(into {})))
|
||||
|
||||
(defn response-coercers [coercion responses opts]
|
||||
(some->> (for [[status {:keys [body]}] responses :when body]
|
||||
[status (response-coercer coercion body opts)])
|
||||
(some->> (for [[status model] responses]
|
||||
[status (response-coercer coercion model opts)])
|
||||
(filter second)
|
||||
(seq)
|
||||
(into {})))
|
||||
|
|
@ -147,6 +170,12 @@
|
|||
;; api-docs
|
||||
;;
|
||||
|
||||
(defn -warn-unsupported-coercions [{:keys [parameters responses] :as data}]
|
||||
(when (:request parameters)
|
||||
(println "WARNING [reitit.coercion]: swagger apidocs don't support :request coercion"))
|
||||
(when (some :content (vals responses))
|
||||
(println "WARNING [reitit.coercion]: swagger apidocs don't support :responses :content coercion")))
|
||||
|
||||
(defn get-apidocs [coercion specification data]
|
||||
(let [swagger-parameter {:query :query
|
||||
:body :body
|
||||
|
|
@ -155,7 +184,10 @@
|
|||
:path :path
|
||||
:multipart :formData}]
|
||||
(case specification
|
||||
:swagger (->> (update
|
||||
:openapi (-get-apidocs coercion specification data)
|
||||
:swagger (do
|
||||
(-warn-unsupported-coercions data)
|
||||
(->> (update
|
||||
data
|
||||
:parameters
|
||||
(fn [parameters]
|
||||
|
|
@ -163,7 +195,8 @@
|
|||
(map (fn [[k v]] [(swagger-parameter k) v]))
|
||||
(filter first)
|
||||
(into {}))))
|
||||
(-get-apidocs coercion specification)))))
|
||||
(-get-apidocs coercion specification))))))
|
||||
|
||||
|
||||
;;
|
||||
;; integration
|
||||
|
|
|
|||
|
|
@ -82,14 +82,21 @@
|
|||
|
||||
(s/def :reitit.core.coercion/model any?)
|
||||
|
||||
(s/def :reitit.core.coercion/content
|
||||
(s/map-of string? :reitit.core.coercion/model))
|
||||
|
||||
(s/def :reitit.core.coercion/query :reitit.core.coercion/model)
|
||||
(s/def :reitit.core.coercion/body :reitit.core.coercion/model)
|
||||
(s/def :reitit.core.coercion/request
|
||||
(s/keys :opt-un [:reitit.core.coercion/content
|
||||
:reitit.core.coercion/body]))
|
||||
(s/def :reitit.core.coercion/form :reitit.core.coercion/model)
|
||||
(s/def :reitit.core.coercion/header :reitit.core.coercion/model)
|
||||
(s/def :reitit.core.coercion/path :reitit.core.coercion/model)
|
||||
(s/def :reitit.core.coercion/parameters
|
||||
(s/keys :opt-un [:reitit.core.coercion/query
|
||||
:reitit.core.coercion/body
|
||||
:reitit.core.coercion/request
|
||||
:reitit.core.coercion/form
|
||||
:reitit.core.coercion/header
|
||||
:reitit.core.coercion/path]))
|
||||
|
|
@ -103,7 +110,8 @@
|
|||
(s/def :reitit.core.coercion/body any?)
|
||||
(s/def :reitit.core.coercion/description string?)
|
||||
(s/def :reitit.core.coercion/response
|
||||
(s/keys :opt-un [:reitit.core.coercion/body
|
||||
(s/keys :opt-un [:reitit.core.coercion/content
|
||||
:reitit.core.coercion/body
|
||||
:reitit.core.coercion/description]))
|
||||
(s/def :reitit.core.coercion/responses
|
||||
(s/map-of :reitit.core.coercion/status :reitit.core.coercion/response))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
[malli.edn :as edn]
|
||||
[malli.error :as me]
|
||||
[malli.experimental.lite :as l]
|
||||
[malli.json-schema :as json-schema]
|
||||
[malli.swagger :as swagger]
|
||||
[malli.transform :as mt]
|
||||
[malli.util :as mu]
|
||||
|
|
@ -132,6 +133,84 @@
|
|||
;; malli options
|
||||
:options nil})
|
||||
|
||||
(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)
|
||||
->schema-object (fn [schema opts]
|
||||
(let [current-opts (merge options opts)]
|
||||
(json-schema/transform (coercion/-compile-model coercion schema current-opts)
|
||||
current-opts)))]
|
||||
(merge
|
||||
(when (seq parameters)
|
||||
{:parameters
|
||||
(->> (for [[in schema] parameters
|
||||
:let [{:keys [properties required] :as root} (->schema-object schema {:in in :type :parameter})
|
||||
required? (partial contains? (set required))]
|
||||
[k schema] properties]
|
||||
(merge {:in (name in)
|
||||
:name k
|
||||
:required (required? k)
|
||||
:schema schema}
|
||||
(select-keys root [:description])))
|
||||
(into []))})
|
||||
(when body
|
||||
;; body uses a single schema to describe every :requestBody
|
||||
;; the schema-object transformer should be able to transform into distinct content-types
|
||||
{:requestBody {:content (into {}
|
||||
(map (fn [content-type]
|
||||
(let [schema (->schema-object body {:in :requestBody
|
||||
:type :schema
|
||||
:content-type content-type})]
|
||||
[content-type {:schema schema}])))
|
||||
content-types)}})
|
||||
|
||||
(when request
|
||||
;; request allow to different :requestBody per content-type
|
||||
{:requestBody
|
||||
{:content (merge
|
||||
(when (:body request)
|
||||
(into {}
|
||||
(map (fn [content-type]
|
||||
(let [schema (->schema-object (:body request) {:in :requestBody
|
||||
:type :schema
|
||||
:content-type content-type})]
|
||||
[content-type {:schema schema}])))
|
||||
content-types))
|
||||
(into {}
|
||||
(map (fn [[content-type requestBody]]
|
||||
(let [schema (->schema-object requestBody {:in :requestBody
|
||||
:type :schema
|
||||
:content-type content-type})]
|
||||
[content-type {:schema schema}])))
|
||||
(:content request)))}})
|
||||
(when responses
|
||||
{:responses
|
||||
(into {}
|
||||
(map (fn [[status {:keys [body content]
|
||||
:as response}]]
|
||||
(let [content (merge
|
||||
(when body
|
||||
(into {}
|
||||
(map (fn [content-type]
|
||||
(let [schema (->schema-object body {:in :responses
|
||||
:type :schema
|
||||
:content-type content-type})]
|
||||
[content-type {:schema schema}])))
|
||||
content-types))
|
||||
(when content
|
||||
(into {}
|
||||
(map (fn [[content-type schema]]
|
||||
(let [schema (->schema-object schema {:in :responses
|
||||
:type :schema
|
||||
:content-type content-type})]
|
||||
[content-type {:schema schema}])))
|
||||
content)))]
|
||||
[status (merge (select-keys response [:description])
|
||||
(when content
|
||||
{:content content}))])))
|
||||
responses)}))))
|
||||
|
||||
(defn create
|
||||
([]
|
||||
(create nil))
|
||||
|
|
@ -145,7 +224,7 @@
|
|||
(reify coercion/Coercion
|
||||
(-get-name [_] :malli)
|
||||
(-get-options [_] opts)
|
||||
(-get-apidocs [_ specification {:keys [parameters responses]}]
|
||||
(-get-apidocs [this specification {:keys [parameters responses] :as data}]
|
||||
(case specification
|
||||
:swagger (merge
|
||||
(if parameters
|
||||
|
|
@ -167,6 +246,7 @@
|
|||
(update :schema compile options)
|
||||
(update :schema swagger/transform {:type :schema}))
|
||||
$))]))}))
|
||||
:openapi (-get-apidocs-openapi this data options)
|
||||
(throw
|
||||
(ex-info
|
||||
(str "Can't produce Schema apidocs for " specification)
|
||||
|
|
|
|||
12
modules/reitit-openapi/project.clj
Normal file
12
modules/reitit-openapi/project.clj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
(defproject metosin/reitit-openapi "0.6.0"
|
||||
:description "Reitit: OpenAPI-support"
|
||||
:url "https://github.com/metosin/reitit"
|
||||
:license {:name "Eclipse Public License"
|
||||
:url "http://www.eclipse.org/legal/epl-v10.html"}
|
||||
:scm {:name "git"
|
||||
:url "https://github.com/metosin/reitit"
|
||||
:dir "../.."}
|
||||
:plugins [[lein-parent "0.3.8"]]
|
||||
:parent-project {:path "../../project.clj"
|
||||
:inherit [:deploy-repositories :managed-dependencies]}
|
||||
:dependencies [[metosin/reitit-core]])
|
||||
116
modules/reitit-openapi/src/reitit/openapi.cljc
Normal file
116
modules/reitit-openapi/src/reitit/openapi.cljc
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
(ns reitit.openapi
|
||||
(:require [clojure.set :as set]
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.string :as str]
|
||||
[meta-merge.core :refer [meta-merge]]
|
||||
[reitit.coercion :as coercion]
|
||||
[reitit.core :as r]
|
||||
[reitit.trie :as trie]))
|
||||
|
||||
(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?)))
|
||||
(s/def ::summary string?)
|
||||
(s/def ::description string?)
|
||||
(s/def ::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]))
|
||||
|
||||
(def openapi-feature
|
||||
"Stability: alpha
|
||||
|
||||
Feature for handling openapi-documentation for routes.
|
||||
Works both with Middleware & Interceptors. Does not participate
|
||||
in actual request processing, just provides specs for the new
|
||||
documentation keys for the route data. Should be accompanied by a
|
||||
[[create-openapi-handler]] to expose the openapi spec.
|
||||
|
||||
New route data keys contributing to openapi docs:
|
||||
|
||||
| key | description |
|
||||
| ---------------|-------------|
|
||||
| :openapi | map of any openapi-data. Can contain keys like `:deprecated`.
|
||||
| :content-types | vector of supported content types. Defaults to `[\"application/json\"]`
|
||||
| :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
|
||||
| :description | optional long description of an endpoint. Supports http://spec.commonmark.org/
|
||||
|
||||
Also the coercion keys contribute to openapi spec:
|
||||
|
||||
| key | description |
|
||||
| --------------|-------------|
|
||||
| :parameters | optional input parameters for a route, in a format defined by the coercion
|
||||
| :responses | optional descriptions of responses, in a format defined by coercion
|
||||
|
||||
Use `:request` parameter coercion (instead of `:body`) to unlock per-content-type coercions.
|
||||
|
||||
Example:
|
||||
|
||||
[\"/api\"
|
||||
{:openapi {:id :my-api}
|
||||
:middleware [reitit.openapi/openapi-feature]}
|
||||
|
||||
[\"/openapi.json\"
|
||||
{:get {:no-doc true
|
||||
:openapi {:info {:title \"my-api\"}}
|
||||
:handler (reitit.openapi/create-openapi-handler)}}]
|
||||
|
||||
[\"/plus\"
|
||||
{:get {:openapi {:tags \"math\"}
|
||||
:summary \"adds numbers together\"
|
||||
:description \"takes `x` and `y` query-params and adds them together\"
|
||||
:parameters {:query {:x int?, :y int?}}
|
||||
:responses {200 {:body {:total pos-int?}}}
|
||||
:handler (fn [{:keys [parameters]}]
|
||||
{:status 200
|
||||
:body (+ (-> parameters :query :x)
|
||||
(-> parameters :query :y)})}}]]"
|
||||
{:name ::openapi
|
||||
:spec ::spec})
|
||||
|
||||
(defn- openapi-path [path opts]
|
||||
(-> path (trie/normalize opts) (str/replace #"\{\*" "{")))
|
||||
|
||||
(defn create-openapi-handler
|
||||
"Stability: alpha
|
||||
|
||||
Create a ring handler to emit openapi spec. Collects all routes from router which have
|
||||
an intersecting `[:openapi :id]` and which are not marked with `:no-doc` route data."
|
||||
[]
|
||||
(fn create-openapi
|
||||
([{::r/keys [router match] :keys [request-method]}]
|
||||
(let [{:keys [id] :or {id ::default} :as openapi} (-> match :result request-method :data :openapi)
|
||||
ids (trie/into-set id)
|
||||
strip-top-level-keys #(dissoc % :id :info :host :basePath :definitions :securityDefinitions)
|
||||
strip-endpoint-keys #(dissoc % :id :parameters :responses :summary :description)
|
||||
openapi (->> (strip-endpoint-keys openapi)
|
||||
(merge {:openapi "3.1.0"
|
||||
:x-id ids}))
|
||||
accept-route (fn [route]
|
||||
(-> route second :openapi :id (or ::default) (trie/into-set) (set/intersection ids) seq))
|
||||
transform-endpoint (fn [[method {{:keys [coercion no-doc openapi] :as data} :data
|
||||
middleware :middleware
|
||||
interceptors :interceptors}]]
|
||||
(if (and data (not no-doc))
|
||||
[method
|
||||
(meta-merge
|
||||
(apply meta-merge (keep (comp :openapi :data) middleware))
|
||||
(apply meta-merge (keep (comp :openapi :data) interceptors))
|
||||
(if coercion
|
||||
(coercion/get-apidocs coercion :openapi data))
|
||||
(select-keys data [:tags :summary :description])
|
||||
(strip-top-level-keys openapi))]))
|
||||
transform-path (fn [[p _ c]]
|
||||
(if-let [endpoint (some->> c (keep transform-endpoint) (seq) (into {}))]
|
||||
[(openapi-path p (r/options router)) endpoint]))
|
||||
map-in-order #(->> % (apply concat) (apply array-map))
|
||||
paths (->> router (r/compiled-routes) (filter accept-route) (map transform-path) map-in-order)]
|
||||
{:status 200
|
||||
:body (meta-merge openapi {:paths paths})}))
|
||||
([req res raise]
|
||||
(try
|
||||
(res (create-openapi req))
|
||||
(catch #?(:clj Exception :cljs :default) e
|
||||
(raise e))))))
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
[reitit.coercion :as coercion]
|
||||
[schema-tools.coerce :as stc]
|
||||
[schema-tools.core :as st]
|
||||
[schema-tools.openapi.core :as openapi]
|
||||
[schema-tools.swagger.core :as swagger]
|
||||
[schema.coerce :as sc]
|
||||
[schema.core :as s]
|
||||
|
|
@ -46,7 +47,8 @@
|
|||
(reify coercion/Coercion
|
||||
(-get-name [_] :schema)
|
||||
(-get-options [_] opts)
|
||||
(-get-apidocs [this specification {:keys [parameters responses]}]
|
||||
(-get-apidocs [this specification {:keys [parameters responses content-types]
|
||||
:or {content-types ["application/json"]}}]
|
||||
;; TODO: this looks identical to spec, refactor when schema is done.
|
||||
(case specification
|
||||
:swagger (swagger/swagger-spec
|
||||
|
|
@ -67,6 +69,37 @@
|
|||
(if (:schema $)
|
||||
(update $ :schema #(coercion/-compile-model this % nil))
|
||||
$))]))})))
|
||||
:openapi (merge
|
||||
(when (seq (dissoc parameters :body :request))
|
||||
(openapi/openapi-spec {::openapi/parameters
|
||||
(into
|
||||
(empty parameters)
|
||||
(for [[k v] (dissoc parameters :body :request)]
|
||||
[k (coercion/-compile-model this v nil)]))}))
|
||||
(when (:body parameters)
|
||||
{:requestBody (openapi/openapi-spec
|
||||
{::openapi/content (zipmap content-types (repeat (:body parameters)))})})
|
||||
(when (:request parameters)
|
||||
{:requestBody (openapi/openapi-spec
|
||||
{::openapi/content (merge
|
||||
(when-let [default (get-in parameters [:request :body])]
|
||||
(zipmap content-types (repeat default)))
|
||||
(:content (:request parameters)))})})
|
||||
(when responses
|
||||
{:responses
|
||||
(into
|
||||
(empty responses)
|
||||
(for [[k {:keys [body content] :as response}] responses]
|
||||
[k (merge
|
||||
(select-keys response [:description])
|
||||
(when (or body content)
|
||||
(openapi/openapi-spec
|
||||
{::openapi/content (merge
|
||||
(when body
|
||||
(zipmap content-types (repeat (coercion/-compile-model this body nil))))
|
||||
(when response
|
||||
(:content response)))})))]))}))
|
||||
|
||||
(throw
|
||||
(ex-info
|
||||
(str "Can't produce Schema apidocs for " specification)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
[reitit.coercion :as coercion]
|
||||
[spec-tools.core :as st #?@(:cljs [:refer [Spec]])]
|
||||
[spec-tools.data-spec :as ds #?@(:cljs [:refer [Maybe]])]
|
||||
[spec-tools.openapi.core :as openapi]
|
||||
[spec-tools.swagger.core :as swagger])
|
||||
#?(:clj
|
||||
(:import (spec_tools.core Spec)
|
||||
|
|
@ -85,7 +86,8 @@
|
|||
(reify coercion/Coercion
|
||||
(-get-name [_] :spec)
|
||||
(-get-options [_] opts)
|
||||
(-get-apidocs [this specification {:keys [parameters responses]}]
|
||||
(-get-apidocs [this specification {:keys [parameters responses content-types]
|
||||
:or {content-types ["application/json"]}}]
|
||||
(case specification
|
||||
:swagger (swagger/swagger-spec
|
||||
(merge
|
||||
|
|
@ -105,6 +107,40 @@
|
|||
(if (:schema $)
|
||||
(update $ :schema #(coercion/-compile-model this % nil))
|
||||
$))]))})))
|
||||
:openapi (openapi/openapi-spec
|
||||
(merge
|
||||
(when (seq (dissoc parameters :body :request))
|
||||
{::openapi/parameters
|
||||
(into (empty parameters)
|
||||
(for [[k v] (dissoc parameters :body :request)]
|
||||
[k (coercion/-compile-model this v nil)]))})
|
||||
(when (:body parameters)
|
||||
{:requestBody (openapi/openapi-spec
|
||||
{::openapi/content (zipmap content-types (repeat (coercion/-compile-model this (:body parameters) nil)))})})
|
||||
(when (:request parameters)
|
||||
{:requestBody (openapi/openapi-spec
|
||||
{::openapi/content (merge
|
||||
(when-let [default (get-in parameters [:request :body])]
|
||||
(zipmap content-types (repeat (coercion/-compile-model this default nil))))
|
||||
(into {}
|
||||
(for [[format model] (:content (:request parameters))]
|
||||
[format (coercion/-compile-model this model nil)])))})})
|
||||
(when responses
|
||||
{:responses
|
||||
(into
|
||||
(empty responses)
|
||||
(for [[k {:keys [body content] :as response}] responses]
|
||||
[k (merge
|
||||
(select-keys response [:description])
|
||||
(when (or body content)
|
||||
(openapi/openapi-spec
|
||||
{::openapi/content (merge
|
||||
(when body
|
||||
(zipmap content-types (repeat (coercion/-compile-model this (:body response) nil))))
|
||||
(when response
|
||||
(into {}
|
||||
(for [[format model] (:content response)]
|
||||
[format (coercion/-compile-model this model nil)]))))})))]))})))
|
||||
(throw
|
||||
(ex-info
|
||||
(str "Can't produce Spec apidocs for " specification)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
[metosin/reitit-http]
|
||||
[metosin/reitit-interceptors]
|
||||
[metosin/reitit-swagger]
|
||||
[metosin/reitit-openapi]
|
||||
[metosin/reitit-swagger-ui]
|
||||
[metosin/reitit-frontend]
|
||||
[metosin/reitit-sieppari]
|
||||
|
|
|
|||
2543
package-lock.json
generated
2543
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@
|
|||
"name": "reitit",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@seriousme/openapi-schema-validator": "^2.1.0",
|
||||
"karma": "^4.1.0",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-cli": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
[metosin/reitit-http "0.6.0"]
|
||||
[metosin/reitit-interceptors "0.6.0"]
|
||||
[metosin/reitit-swagger "0.6.0"]
|
||||
[metosin/reitit-openapi "0.6.0"]
|
||||
[metosin/reitit-swagger-ui "0.6.0"]
|
||||
[metosin/reitit-frontend "0.6.0"]
|
||||
[metosin/reitit-sieppari "0.6.0"]
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
"modules/reitit-ring/src"
|
||||
"modules/reitit-http/src"
|
||||
"modules/reitit-middleware/src"
|
||||
"modules/reitit-openapi/src"
|
||||
"modules/reitit-interceptors/src"
|
||||
"modules/reitit-malli/src"
|
||||
"modules/reitit-spec/src"
|
||||
|
|
@ -107,6 +109,7 @@
|
|||
[org.clojure/test.check "1.1.1"]
|
||||
[org.clojure/tools.namespace "1.4.1"]
|
||||
[com.gfredericks/test.chuck "0.2.14"]
|
||||
[nubank/matcher-combinators "3.8.3"]
|
||||
|
||||
[io.pedestal/pedestal.service "0.5.10"]
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ for ext in \
|
|||
reitit-http \
|
||||
reitit-interceptors \
|
||||
reitit-swagger \
|
||||
reitit-openapi \
|
||||
reitit-swagger-ui \
|
||||
reitit-frontend \
|
||||
reitit-sieppari \
|
||||
|
|
|
|||
558
test/cljc/reitit/openapi_test.clj
Normal file
558
test/cljc/reitit/openapi_test.clj
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
(ns reitit.openapi-test
|
||||
(:require [clojure.java.shell :as shell]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[jsonista.core :as j]
|
||||
[matcher-combinators.test :refer [match?]]
|
||||
[matcher-combinators.matchers :as matchers]
|
||||
[muuntaja.core :as m]
|
||||
[reitit.coercion.malli :as malli]
|
||||
[reitit.coercion.schema :as schema]
|
||||
[reitit.coercion.spec :as spec]
|
||||
[reitit.openapi :as openapi]
|
||||
[reitit.ring :as ring]
|
||||
[reitit.ring.spec]
|
||||
[reitit.ring.coercion :as rrc]
|
||||
[reitit.swagger-ui :as swagger-ui]
|
||||
[schema.core :as s]
|
||||
[spec-tools.data-spec :as ds]))
|
||||
|
||||
(defn validate
|
||||
"Returns nil if data is a valid openapi spec, otherwise validation result"
|
||||
[data]
|
||||
(let [file (java.io.File/createTempFile "reitit-openapi" ".json")]
|
||||
(.deleteOnExit file)
|
||||
(spit file (j/write-value-as-string data))
|
||||
(let [result (shell/sh "npx" "-p" "@seriousme/openapi-schema-validator" "validate-api" (.getPath file))]
|
||||
(when-not (zero? (:exit result))
|
||||
(j/read-value (:out result))))))
|
||||
|
||||
(def app
|
||||
(ring/ring-handler
|
||||
(ring/router
|
||||
["/api"
|
||||
{:openapi {:id ::math}}
|
||||
|
||||
["/openapi.json"
|
||||
{:get {:no-doc true
|
||||
:openapi {:info {:title "my-api"
|
||||
:version "0.0.1"}}
|
||||
:handler (openapi/create-openapi-handler)}}]
|
||||
|
||||
["/spec" {:coercion spec/coercion}
|
||||
["/plus/:z"
|
||||
{:get {:summary "plus"
|
||||
:tags [:plus :spec]
|
||||
:parameters {:query {:x int?, :y int?}
|
||||
:path {:z int?}}
|
||||
:openapi {:operationId "spec-plus"
|
||||
:deprecated true
|
||||
:responses {400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}}}
|
||||
:responses {200 {:description "success"
|
||||
:body {:total int?}}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [x y]} :query
|
||||
{:keys [z]} :path} :parameters}]
|
||||
{:status 200, :body {:total (+ x y z)}})}
|
||||
:post {:summary "plus with body"
|
||||
:parameters {:body (ds/maybe [int?])
|
||||
:path {:z int?}}
|
||||
:openapi {:responses {400 {:content {"application/json" {:schema {:type "string"}}}
|
||||
:description "kosh"}}}
|
||||
:responses {200 {:description "success"
|
||||
:body {:total int?}}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [z]} :path
|
||||
xs :body} :parameters}]
|
||||
{:status 200, :body {:total (+ (reduce + xs) z)}})}}]]
|
||||
|
||||
["/malli" {:coercion malli/coercion}
|
||||
["/plus/*z"
|
||||
{:get {:summary "plus"
|
||||
:tags [:plus :malli]
|
||||
:parameters {:query [:map [:x int?] [:y int?]]
|
||||
:path [:map [:z int?]]}
|
||||
:openapi {:responses {400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}}}
|
||||
:responses {200 {:description "success"
|
||||
:body [:map [:total int?]]}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [x y]} :query
|
||||
{:keys [z]} :path} :parameters}]
|
||||
{:status 200, :body {:total (+ x y z)}})}
|
||||
:post {:summary "plus with body"
|
||||
:parameters {:body [:maybe [:vector int?]]
|
||||
:path [:map [:z int?]]}
|
||||
:openapi {:responses {400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}}}
|
||||
:responses {200 {:description "success"
|
||||
:body [:map [:total int?]]}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [z]} :path
|
||||
xs :body} :parameters}]
|
||||
{:status 200, :body {:total (+ (reduce + xs) z)}})}}]]
|
||||
|
||||
["/schema" {:coercion schema/coercion}
|
||||
["/plus/*z"
|
||||
{:get {:summary "plus"
|
||||
:tags [:plus :schema]
|
||||
:parameters {:query {:x s/Int, :y s/Int}
|
||||
:path {:z s/Int}}
|
||||
:openapi {:responses {400 {:content {"application/json" {:schema {:type "string"}}}
|
||||
:description "kosh"}}}
|
||||
:responses {200 {:description "success"
|
||||
:body {:total s/Int}}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [x y]} :query
|
||||
{:keys [z]} :path} :parameters}]
|
||||
{:status 200, :body {:total (+ x y z)}})}
|
||||
:post {:summary "plus with body"
|
||||
:parameters {:body (s/maybe [s/Int])
|
||||
:path {:z s/Int}}
|
||||
:openapi {:responses {400 {:content {"application/json" {:schema {:type "string"}}}
|
||||
:description "kosh"}}}
|
||||
:responses {200 {:description "success"
|
||||
:body {:total s/Int}}
|
||||
500 {:description "fail"}}
|
||||
:handler (fn [{{{:keys [z]} :path
|
||||
xs :body} :parameters}]
|
||||
{:status 200, :body {:total (+ (reduce + xs) z)}})}}]]]
|
||||
|
||||
{:validate reitit.ring.spec/validate
|
||||
:data {:middleware [openapi/openapi-feature
|
||||
rrc/coerce-exceptions-middleware
|
||||
rrc/coerce-request-middleware
|
||||
rrc/coerce-response-middleware]}})))
|
||||
|
||||
(deftest openapi-test
|
||||
(testing "endpoints work"
|
||||
(testing "malli"
|
||||
(is (= {:body {:total 6}, :status 200}
|
||||
(app {:request-method :get
|
||||
:uri "/api/malli/plus/3"
|
||||
:query-params {:x "2", :y "1"}})))
|
||||
(is (= {:body {:total 7}, :status 200}
|
||||
(app {:request-method :post
|
||||
:uri "/api/malli/plus/3"
|
||||
:body-params [1 3]})))))
|
||||
(testing "openapi-spec"
|
||||
(let [spec (:body (app {:request-method :get
|
||||
:uri "/api/openapi.json"}))
|
||||
expected {:x-id #{::math}
|
||||
:openapi "3.1.0"
|
||||
:info {:title "my-api"
|
||||
:version "0.0.1"}
|
||||
:paths {"/api/spec/plus/{z}" {:get {:parameters [{:in "query"
|
||||
:name "x"
|
||||
:description ""
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int64"}}
|
||||
{:in "query"
|
||||
:name "y"
|
||||
:description ""
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int64"}}
|
||||
{:in "path"
|
||||
:name "z"
|
||||
:description ""
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int64"}}]
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:type "object"
|
||||
:properties {"total" {:format "int64"
|
||||
:type "integer"}}
|
||||
:required ["total"]}}}}
|
||||
400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}
|
||||
500 {:description "fail"}}
|
||||
:operationId "spec-plus"
|
||||
:deprecated true
|
||||
:tags [:plus :spec]
|
||||
:summary "plus"}
|
||||
:post {:parameters [{:in "path"
|
||||
:name "z"
|
||||
:required true
|
||||
:description ""
|
||||
:schema {:type "integer"
|
||||
:format "int64"}}]
|
||||
:requestBody {:content {"application/json" {:schema {:oneOf [{:items {:type "integer"
|
||||
:format "int64"}
|
||||
:type "array"}
|
||||
{:type "null"}]}}}}
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:properties {"total" {:format "int64"
|
||||
:type "integer"}}
|
||||
:required ["total"]
|
||||
:type "object"}}}}
|
||||
400 {:content {"application/json" {:schema {:type "string"}}}
|
||||
:description "kosh"}
|
||||
500 {:description "fail"}}
|
||||
:summary "plus with body"}}
|
||||
"/api/malli/plus/{z}" {:get {:parameters [{:in "query"
|
||||
:name :x
|
||||
:required true
|
||||
:schema {:type "integer"}}
|
||||
{:in "query"
|
||||
:name :y
|
||||
:required true
|
||||
:schema {:type "integer"}}
|
||||
{:in "path"
|
||||
:name :z
|
||||
:required true
|
||||
:schema {:type "integer"}}]
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:type "object"
|
||||
:properties {:total {:type "integer"}}
|
||||
:additionalProperties false
|
||||
:required [:total]}}}}
|
||||
400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}
|
||||
500 {:description "fail"}}
|
||||
:tags [:plus :malli]
|
||||
:summary "plus"}
|
||||
:post {:parameters [{:in "path"
|
||||
:name :z
|
||||
:schema {:type "integer"}
|
||||
:required true}]
|
||||
:requestBody {:content {"application/json" {:schema {:oneOf [{:items {:type "integer"}
|
||||
:type "array"}
|
||||
{:type "null"}]}}}}
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:properties {:total {:type "integer"}}
|
||||
:required [:total]
|
||||
:additionalProperties false
|
||||
:type "object"}}}}
|
||||
400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}
|
||||
500 {:description "fail"}}
|
||||
:summary "plus with body"}}
|
||||
"/api/schema/plus/{z}" {:get {:parameters [{:description ""
|
||||
:in "query"
|
||||
:name "x"
|
||||
:required true
|
||||
:schema {:format "int32"
|
||||
:type "integer"}}
|
||||
{:description ""
|
||||
:in "query"
|
||||
:name "y"
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int32"}}
|
||||
{:in "path"
|
||||
:name "z"
|
||||
:description ""
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int32"}}]
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:additionalProperties false
|
||||
:properties {"total" {:format "int32"
|
||||
:type "integer"}}
|
||||
:required ["total"]
|
||||
:type "object"}}}}
|
||||
400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}
|
||||
500 {:description "fail"}}
|
||||
:tags [:plus :schema]
|
||||
:summary "plus"}
|
||||
:post {:parameters [{:in "path"
|
||||
:name "z"
|
||||
:description ""
|
||||
:required true
|
||||
:schema {:type "integer"
|
||||
:format "int32"}}]
|
||||
:requestBody {:content {"application/json" {:schema {:oneOf [{:type "array"
|
||||
:items {:type "integer"
|
||||
:format "int32"}}
|
||||
{:type "null"}]}}}}
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" {:schema {:properties {"total" {:format "int32"
|
||||
:type "integer"}}
|
||||
:additionalProperties false
|
||||
:required ["total"]
|
||||
:type "object"}}}}
|
||||
400 {:description "kosh"
|
||||
:content {"application/json" {:schema {:type "string"}}}}
|
||||
500 {:description "fail"}}
|
||||
:summary "plus with body"}}}}]
|
||||
(is (= expected spec))
|
||||
(is (nil? (validate spec))))))
|
||||
|
||||
(defn spec-paths [app uri]
|
||||
(-> {:request-method :get, :uri uri} app :body :paths keys))
|
||||
|
||||
(deftest multiple-openapi-apis-test
|
||||
(let [ping-route ["/ping" {:get (constantly "ping")}]
|
||||
spec-route ["/openapi.json"
|
||||
{:get {:no-doc true
|
||||
:handler (openapi/create-openapi-handler)}}]
|
||||
app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/common" {:openapi {:id #{::one ::two}}}
|
||||
ping-route]
|
||||
|
||||
["/one" {:openapi {:id ::one}}
|
||||
ping-route
|
||||
spec-route]
|
||||
|
||||
["/two" {:openapi {:id ::two}}
|
||||
ping-route
|
||||
spec-route
|
||||
["/deep" {:openapi {:id ::one}}
|
||||
ping-route]]
|
||||
["/one-two" {:openapi {:id #{::one ::two}}}
|
||||
spec-route]]))]
|
||||
(is (= ["/common/ping" "/one/ping" "/two/deep/ping"]
|
||||
(spec-paths app "/one/openapi.json")))
|
||||
(is (= ["/common/ping" "/two/ping"]
|
||||
(spec-paths app "/two/openapi.json")))
|
||||
(is (= ["/common/ping" "/one/ping" "/two/ping" "/two/deep/ping"]
|
||||
(spec-paths app "/one-two/openapi.json")))))
|
||||
|
||||
(deftest openapi-ui-config-test
|
||||
(let [app (swagger-ui/create-swagger-ui-handler
|
||||
{:path "/"
|
||||
:url "/openapi.json"
|
||||
:config {:jsonEditor true}})]
|
||||
(is (= 302 (:status (app {:request-method :get, :uri "/"}))))
|
||||
(is (= 200 (:status (app {:request-method :get, :uri "/index.html"}))))
|
||||
(is (= {:jsonEditor true, :url "/openapi.json"}
|
||||
(->> {:request-method :get, :uri "/config.json"}
|
||||
(app) :body (m/decode m/instance "application/json"))))))
|
||||
|
||||
(deftest without-openapi-id-test
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/ping"
|
||||
{:get (constantly "ping")}]
|
||||
["/openapi.json"
|
||||
{:get {:no-doc true
|
||||
:handler (openapi/create-openapi-handler)}}]]))]
|
||||
(is (= ["/ping"] (spec-paths app "/openapi.json")))
|
||||
(is (= #{::openapi/default}
|
||||
(-> {:request-method :get :uri "/openapi.json"}
|
||||
(app) :body :x-id)))))
|
||||
|
||||
(deftest with-options-endpoint-test
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/ping"
|
||||
{:options (constantly "options")}]
|
||||
["/pong"
|
||||
(constantly "options")]
|
||||
["/openapi.json"
|
||||
{:get {:no-doc true
|
||||
:handler (openapi/create-openapi-handler)}}]]))]
|
||||
(is (= ["/ping" "/pong"] (spec-paths app "/openapi.json")))
|
||||
(is (= #{::openapi/default}
|
||||
(-> {:request-method :get :uri "/openapi.json"}
|
||||
(app) :body :x-id)))))
|
||||
|
||||
(defn- normalize
|
||||
"Normalize format of openapi spec by converting it to json and back.
|
||||
Handles differences like :q vs \"q\" in openapi generation."
|
||||
[data]
|
||||
(-> data
|
||||
j/write-value-as-string
|
||||
(j/read-value j/keyword-keys-object-mapper)))
|
||||
|
||||
(deftest all-parameter-types-test
|
||||
(doseq [[coercion ->schema]
|
||||
[[#'malli/coercion (fn [nom] [:map [nom :string]])]
|
||||
[#'schema/coercion (fn [nom] {nom s/Str})]
|
||||
[#'spec/coercion (fn [nom] {nom string?})]]]
|
||||
(testing coercion
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/parameters"
|
||||
{:post {:decription "parameters"
|
||||
:coercion @coercion
|
||||
:parameters {:query (->schema :q)
|
||||
:body (->schema :b)
|
||||
:header (->schema :h)
|
||||
:cookie (->schema :c)
|
||||
:path (->schema :p)}
|
||||
:responses {200 {:description "success"
|
||||
:body (->schema :ok)}}
|
||||
: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 "all non-body parameters"
|
||||
(is (match? [{:in "query"
|
||||
:name "q"
|
||||
:required true
|
||||
:schema {:type "string"}}
|
||||
{:in "header"
|
||||
:name "h"
|
||||
:required true
|
||||
:schema {:type "string"}}
|
||||
{:in "cookie"
|
||||
:name "c"
|
||||
:required true
|
||||
:schema {:type "string"}}
|
||||
{:in "path"
|
||||
:name "p"
|
||||
:required true
|
||||
:schema {:type "string"}}]
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :parameters])
|
||||
normalize))))
|
||||
(testing "body parameter"
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:b {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["b"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :requestBody :content "application/json"])
|
||||
normalize))))
|
||||
(testing "body response"
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:ok {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["ok"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :responses 200 :content "application/json"])
|
||||
normalize))))
|
||||
(testing "spec is valid"
|
||||
(is (nil? (validate spec))))))))
|
||||
|
||||
(deftest per-content-type-test
|
||||
(doseq [[coercion ->schema]
|
||||
[[#'malli/coercion (fn [nom] [:map [nom :string]])]
|
||||
[#'schema/coercion (fn [nom] {nom s/Str})]
|
||||
[#'spec/coercion (fn [nom] {nom string?})]]]
|
||||
(testing coercion
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/parameters"
|
||||
{:post {:description "parameters"
|
||||
:coercion @coercion
|
||||
:parameters {:request {:content {"application/json" (->schema :b)
|
||||
"application/edn" (->schema :c)}}}
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/json" (->schema :ok)
|
||||
"application/edn" (->schema :edn)}}}
|
||||
:handler (fn [req]
|
||||
{:status 200
|
||||
:body (-> req :parameters :request)})}}]
|
||||
["/openapi.json"
|
||||
{:get {:handler (openapi/create-openapi-handler)
|
||||
:openapi {:info {:title "" :version "0.0.1"}}
|
||||
:no-doc true}}]]
|
||||
{:validate reitit.ring.spec/validate
|
||||
:data {:middleware [openapi/openapi-feature
|
||||
rrc/coerce-request-middleware
|
||||
rrc/coerce-response-middleware]}}))
|
||||
spec (-> {:request-method :get
|
||||
:uri "/openapi.json"}
|
||||
app
|
||||
:body)]
|
||||
(testing "body parameter"
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:b {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["b"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :requestBody :content "application/json"])
|
||||
normalize)))
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:c {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["c"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :requestBody :content "application/edn"])
|
||||
normalize))))
|
||||
(testing "body response"
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:ok {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["ok"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :responses 200 :content "application/json"])
|
||||
normalize)))
|
||||
(is (match? {:schema {:type "object"
|
||||
:properties {:edn {:type "string"}}
|
||||
#_#_:additionalProperties false ;; not present for spec
|
||||
:required ["edn"]}}
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :responses 200 :content "application/edn"])
|
||||
normalize))))
|
||||
(testing "validation"
|
||||
(let [query {:request-method :post
|
||||
:uri "/parameters"
|
||||
:muuntaja/request {:format "application/json"}
|
||||
:muuntaja/response {:format "application/json"}
|
||||
:body-params {:b "x"}}]
|
||||
(testing "of output"
|
||||
(is (= {:type :reitit.coercion/response-coercion
|
||||
:in [:response :body]}
|
||||
(try
|
||||
(app query)
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(select-keys (ex-data e) [:type :in]))))))
|
||||
(testing "of input"
|
||||
(is (= {:type :reitit.coercion/request-coercion
|
||||
:in [:request :body-params]}
|
||||
(try
|
||||
(app (assoc query :body-params {:z 1}))
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(select-keys (ex-data e) [:type :in]))))))))
|
||||
(testing "spec is valid"
|
||||
(is (nil? (validate spec))))))))
|
||||
|
||||
(deftest default-content-type-test
|
||||
(doseq [[coercion ->schema]
|
||||
[[#'malli/coercion (fn [nom] [:map [nom :string]])]
|
||||
[#'schema/coercion (fn [nom] {nom s/Str})]
|
||||
[#'spec/coercion (fn [nom] {nom string?})]]]
|
||||
(testing coercion
|
||||
(doseq [content-type ["application/json" "application/edn"]]
|
||||
(testing (str "default content type " content-type)
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/parameters"
|
||||
{:post {:description "parameters"
|
||||
:coercion @coercion
|
||||
:content-types [content-type] ;; TODO should this be under :openapi ?
|
||||
:parameters {:request {:content {"application/transit" (->schema :transit)}
|
||||
:body (->schema :default)}}
|
||||
:responses {200 {:description "success"
|
||||
:content {"application/transit" (->schema :transit)}
|
||||
:body (->schema :default)}}
|
||||
:handler (fn [req]
|
||||
{:status 200
|
||||
:body (-> req :parameters :request)})}}]
|
||||
["/openapi.json"
|
||||
{:get {:handler (openapi/create-openapi-handler)
|
||||
:openapi {:info {:title "" :version "0.0.1"}}
|
||||
:no-doc true}}]]
|
||||
{:validate reitit.ring.spec/validate
|
||||
:data {:middleware [openapi/openapi-feature
|
||||
rrc/coerce-request-middleware
|
||||
rrc/coerce-response-middleware]}}))
|
||||
spec (-> {:request-method :get
|
||||
:uri "/openapi.json"}
|
||||
app
|
||||
:body)]
|
||||
(testing "body parameter"
|
||||
(is (match? (matchers/in-any-order [content-type "application/transit"])
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :requestBody :content])
|
||||
keys))))
|
||||
(testing "body response"
|
||||
(is (match? (matchers/in-any-order [content-type "application/transit"])
|
||||
(-> spec
|
||||
(get-in [:paths "/parameters" :post :responses 200 :content])
|
||||
keys))))
|
||||
(testing "spec is valid"
|
||||
(is (nil? (validate spec))))))))))
|
||||
|
|
@ -11,8 +11,10 @@
|
|||
[reitit.coercion.spec :as spec]
|
||||
[reitit.core :as r]
|
||||
[reitit.ring :as ring]
|
||||
[reitit.ring.spec]
|
||||
[reitit.ring.coercion :as rrc]
|
||||
[schema.core :as s]
|
||||
[clojure.spec.alpha]
|
||||
[spec-tools.data-spec :as ds])
|
||||
#?(:clj
|
||||
(:import (clojure.lang ExceptionInfo)
|
||||
|
|
@ -582,6 +584,79 @@
|
|||
(is (= {:status 200, :body {:total "FOO: this, BAR: that"}} (call m/schema custom-meta-merge-checking-schema)))
|
||||
(is (= {:status 200, :body {:total "FOO: this, BAR: that"}} (call identity custom-meta-merge-checking-parameters)))))))
|
||||
|
||||
(deftest per-content-type-test
|
||||
(doseq [[coercion json-request edn-request default-request json-response edn-response default-response]
|
||||
[[#'malli/coercion
|
||||
[:map [:request [:enum :json]] [:response any?]]
|
||||
[:map [:request [:enum :edn]] [:response any?]]
|
||||
[:map [:request [:enum :default]] [:response any?]]
|
||||
[:map [:request any?] [:response [:enum :json]]]
|
||||
[:map [:request any?] [:response [:enum :edn]]]
|
||||
[:map [:request any?] [:response [:enum :default]]]]
|
||||
[#'schema/coercion
|
||||
{:request (s/eq :json) :response s/Any}
|
||||
{:request (s/eq :edn) :response s/Any}
|
||||
{:request (s/eq :default) :response s/Any}
|
||||
{:request s/Any :response (s/eq :json)}
|
||||
{:request s/Any :response (s/eq :edn)}
|
||||
{:request s/Any :response (s/eq :default)}]
|
||||
[#'spec/coercion
|
||||
{:request (clojure.spec.alpha/spec #{:json}) :response any?}
|
||||
{:request (clojure.spec.alpha/spec #{:edn}) :response any?}
|
||||
{:request (clojure.spec.alpha/spec #{:default}) :response any?}
|
||||
{:request any? :response (clojure.spec.alpha/spec #{:json})}
|
||||
{:request any? :response (clojure.spec.alpha/spec #{:end})}
|
||||
{:request any? :response (clojure.spec.alpha/spec #{:default})}]]]
|
||||
(testing coercion
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/foo" {:post {:parameters {:request {:content {"application/json" json-request
|
||||
"application/edn" edn-request}
|
||||
:body default-request}}
|
||||
:responses {200 {:content {"application/json" json-response
|
||||
"application/edn" edn-response}
|
||||
:body default-response}}
|
||||
:handler (fn [req]
|
||||
{:status 200
|
||||
:body (-> req :parameters :request)})}}]]
|
||||
{:validate reitit.ring.spec/validate
|
||||
:data {:middleware [rrc/coerce-request-middleware
|
||||
rrc/coerce-response-middleware]
|
||||
:coercion @coercion}}))
|
||||
call (fn [request]
|
||||
(try
|
||||
(app request)
|
||||
(catch ExceptionInfo e
|
||||
(select-keys (ex-data e) [:type :in]))))
|
||||
request (fn [request-format response-format body]
|
||||
{:request-method :post
|
||||
:uri "/foo"
|
||||
:muuntaja/request {:format request-format}
|
||||
:muuntaja/response {:format response-format}
|
||||
:body-params body})]
|
||||
(testing "succesful call"
|
||||
(is (= {:status 200 :body {:request :json, :response :json}}
|
||||
(call (request "application/json" "application/json" {:request :json :response :json}))))
|
||||
(is (= {:status 200 :body {:request :edn, :response :json}}
|
||||
(call (request "application/edn" "application/json" {:request :edn :response :json}))))
|
||||
(is (= {:status 200 :body {:request :default, :response :default}}
|
||||
(call (request "application/transit" "application/transit" {:request :default :response :default})))))
|
||||
(testing "request validation fails"
|
||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||
(call (request "application/edn" "application/json" {:request :json :response :json}))))
|
||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||
(call (request "application/json" "application/json" {:request :edn :response :json}))))
|
||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||
(call (request "application/transit" "application/json" {:request :edn :response :json})))))
|
||||
(testing "response validation fails"
|
||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||
(call (request "application/json" "application/json" {:request :json :response :edn}))))
|
||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||
(call (request "application/json" "application/edn" {:request :json :response :json}))))
|
||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||
(call (request "application/json" "application/transit" {:request :json :response :json})))))))))
|
||||
|
||||
|
||||
#?(:clj
|
||||
(deftest muuntaja-test
|
||||
(let [app (ring/ring-handler
|
||||
|
|
|
|||
|
|
@ -384,3 +384,29 @@
|
|||
spec (:body (app {:request-method :get, :uri "/swagger.json"}))]
|
||||
(is (= ["query" "body" "formData" "header" "path"]
|
||||
(map :in (get-in spec [:paths "/parameters" :post :parameters]))))))
|
||||
|
||||
(deftest multiple-content-types-test
|
||||
(testing ":request coercion"
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/parameters"
|
||||
{:post {:coercion spec/coercion
|
||||
:parameters {:request {:content {"application/json" {:x string?}}}}
|
||||
:handler identity}}]
|
||||
["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:handler (swagger/create-swagger-handler)}}]]))
|
||||
output (with-out-str (app {:request-method :get, :uri "/swagger.json"}))]
|
||||
(is (.contains output "WARN"))))
|
||||
(testing "multiple response content types"
|
||||
(let [app (ring/ring-handler
|
||||
(ring/router
|
||||
[["/parameters"
|
||||
{:post {:coercion spec/coercion
|
||||
:responses {200 {:content {"application/json" {:r string?}}}}
|
||||
:handler identity}}]
|
||||
["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
:handler (swagger/create-swagger-handler)}}]]))
|
||||
output (with-out-str (app {:request-method :get, :uri "/swagger.json"}))]
|
||||
(is (.contains output "WARN")))))
|
||||
|
|
|
|||
Loading…
Reference in a new issue