diff --git a/CHANGELOG.md b/CHANGELOG.md index 546b974d..50761e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### `reitit-core` +* `linear-router` now works with unnamed catch-all parameters, e.g. `"/files/*"` * `match-by-path` encodes parameters into strings using (internal) `reitit.impl/IntoString` protocol. Handles all of: strings, numbers, keywords, booleans, objects. Fixes [#75](https://github.com/metosin/reitit/issues/75). ```clj @@ -23,7 +24,7 @@ * `reitit.ring/default-handler` now works correctly with async ring * new helper `reitit.ring/router` to compose routes outside of a router. -* `reitit.ring/create-resource-handler` function to serve static routes. See (docs)[https://metosin.github.io/reitit/ring/static.html]. +* `reitit.ring/create-resource-handler` function to serve static routes. See [docs](https://metosin.github.io/reitit/ring/static.html). * new dependencies: @@ -33,47 +34,24 @@ ### `reitit-swagger` -* New module to produce swagger-docs from routing tree, including `Coercion` definitions. Works with both middleware & interceptors and Schema & Spec. See [docs](https://metosin.github.io/reitit/swagger.html). +* New module to produce swagger-docs from routing tree, including `Coercion` definitions. Works with both middleware & interceptors and Schema & Spec. See [docs](https://metosin.github.io/reitit/ring/swagger.html). + +### `reitit-swagger-ui` + +New module to server pre-integrated [Swagger-ui](https://github.com/swagger-api/swagger-ui). See [docs](https://metosin.github.io/reitit/ring/swagger.html#swagger-ui). + +* new dependencies: ```clj -(require '[reitit.ring :as ring]) -(require '[reitit.swagger :as swagger]) -(require '[reitit.ring.coercion :as rrc]) -(require '[reitit.coercion.spec :as spec]) -(require '[reitit.coercion.schema :as schema]) +[metosin/jsonista "0.2.0"] +[metosin/ring-swagger-ui "2.2.10"] +``` -(require '[schema.core :refer [Int]]) +### dependencies -(ring/ring-handler - (ring/router - ["/api" - {:swagger {:id ::math}} - - ["/swagger.json" - {:get {:no-doc true - :swagger {:info {:title "my-api"}} - :handler (swagger/create-swagger-handler)}}] - - ["/spec" {:coercion spec/coercion} - ["/plus" - {:get {:summary "plus" - :parameters {:query {:x int?, :y int?}} - :responses {200 {:body {:total int?}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (+ x y)}})}}]] - - ["/schema" {:coercion schema/coercion} - ["/plus" - {:get {:summary "plus" - :parameters {:query {:x Int, :y Int}} - :responses {200 {:body {:total Int}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (+ x y)}})}}]]] - - {:data {:middleware [rrc/coerce-exceptions-middleware - rrc/coerce-request-middleware - rrc/coerce-response-middleware - swagger/swagger-feature]}})) +```clj +[metosin/spec-tools "0.7.0"] is available but we use "0.6.1" +[metosin/schema-tools "0.10.2"] is available but we use "0.10.1" ``` ## 0.1.0 (2018-2-19) diff --git a/README.md b/README.md index b24a7559..2cad80b1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Optionally, the parts can be required separately: [metosin/reitit-ring "0.1.1-SNAPSHOT"] ; ring-router [metosin/reitit-spec "0.1.1-SNAPSHOT"] ; spec coercion [metosin/reitit-schema "0.1.1-SNAPSHOT"] ; schema coercion -[metosin/reitit-swagger "0.1.1-SNAPSHOT"] ; swagger docs +[metosin/reitit-swagger "0.1.1-SNAPSHOT"] ; swagger +[metosin/reitit-swagger-ui "0.1.1-SNAPSHOT"] ; swagger-ui ``` ## Quick start diff --git a/doc/README.md b/doc/README.md index 103d83ea..e5e05981 100644 --- a/doc/README.md +++ b/doc/README.md @@ -29,7 +29,8 @@ Optionally, the parts can be required separately: [metosin/reitit-ring "0.1.1-SNAPSHOT"] ; ring-router [metosin/reitit-spec "0.1.1-SNAPSHOT"] ; spec coercion [metosin/reitit-schema "0.1.1-SNAPSHOT"] ; schema coercion -[metosin/reitit-swagger "0.1.1-SNAPSHOT"] ; swagger docs +[metosin/reitit-swagger "0.1.1-SNAPSHOT"] ; swagger +[metosin/reitit-swagger-ui "0.1.1-SNAPSHOT"] ; swagger-ui ``` For discussions, there is a [#reitit](https://clojurians.slack.com/messages/reitit/) channel in [Clojurians slack](http://clojurians.net/). diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index a477bd95..41312dbb 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -28,7 +28,7 @@ * [Pluggable Coercion](ring/coercion.md) * [Route Data Validation](ring/route_data_validation.md) * [Compiling Middleware](ring/compiling_middleware.md) + * [Swagger Support](ring/swagger.md) * [Performance](performance.md) * [Interceptors (WIP)](interceptors.md) -* [Swagger-support](swagger.md) * [FAQ](faq.md) diff --git a/doc/images/swagger.png b/doc/images/swagger.png new file mode 100644 index 00000000..46d28517 Binary files /dev/null and b/doc/images/swagger.png differ diff --git a/doc/ring/README.md b/doc/ring/README.md index 623dac80..233547f4 100644 --- a/doc/ring/README.md +++ b/doc/ring/README.md @@ -8,3 +8,4 @@ * [Pluggable Coercion](coercion.md) * [Route Data Validation](route_data_validation.md) * [Compiling Middleware](compiling_middleware.md) +* [Swagger Support](swagger.md) diff --git a/doc/ring/swagger.md b/doc/ring/swagger.md new file mode 100644 index 00000000..5840708f --- /dev/null +++ b/doc/ring/swagger.md @@ -0,0 +1,251 @@ +# Swagger Support + +``` +[metosin/reitit-swagger "0.1.1-SNAPSHOT"] +``` + +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. + +To enable swagger-documentation for a ring-router: + +1. annotate you routes with swagger-data +2. mount a swagger-handler to serve the swagger-spec +3. optionally mount a swagger-ui to visualize the swagger-spec + +## Swagger data + +The following route data keys contribute to the generated swagger specification: + +| key | description | +| --------------|-------------| +| :swagger | map of any swagger-data. Must have `:id` (keyword or sequence of keywords) to identify the api +| :no-doc | optional boolean to exclude endpoint from api docs +| :tags | optional set of strings of keywords 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 responess, in a format defined by coercion + +There is a `reitit.swagger.swagger-feature`, which acts as both a `Middleware` and an `Interceptor` that is not participating in any request processing - it just defines the route data specs for the routes it's mounted to. It is only needed if the [route data validation](route_data_validation.html) is turned on. + +## Swagger spec + +To serve the actual [Swagger Specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md), there is `reitit.swagger/create-swagger-handler`. It takes no arguments and returns a ring-handler which collects at request-time data from all routes for the same swagger api and returns a formatted Swagger spesification as Clojure data, to be encoded by a response formatter. + +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 spesification. To make things easy, there is a pre-integrated version of the swagger-ui as a separate module. + +``` +[metosin/reitit-swagger-ui "0.1.1-SNAPSHOT"] +``` + +`reitit.swagger-ui/create-swagger-ui-hander` can be used to create a ring-handler to serve the swagger-ui. It accepts the following options: + +| key | description | +| -----------------|-------------| +| :parameter | optional name of the wildcard parameter, defaults to unnamed keyword `:` +| :root | optional resource root, defaults to `"swagger-ui"` +| :url | path to swagger endpoint, defaults to `/swagger.json` +| :path | optional path to mount the handler to. Works only if mounted outside of a router. +| :config | parameters passed to swaggger-ui, keys transformed into camelCase. See [the docs](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md) + +We use swagger-ui from [ring-swagger-ui](https://github.com/metosin/ring-swagger-ui), which can be easily configured from routing application. It stores files `swagger-ui` in the resource classpath. + +Webjars also hosts a [version](https://github.com/webjars/swagger-ui) of the swagger-ui. + +**NOTE**: Currently, swagger-ui module is just for Clojure. ClojureScript-support welcome as a PR! + +## Examples + +### Simple example + +* two routes in a single swagger-api `::api` +* swagger-spec served from `"/swagger.json"` +* swagger-ui mounted to `"/"` + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.swagger :as swagger]) +(require '[reitit.swagger-ui :as swagger-ui]) + +(def app + (ring/ring-handler + (ring/router + [["/api" + ["/ping" {:get (constantly "ping")}] + ["/pong" {:post (constantly "pong")}]] + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]] + {:data {:swagger {:id ::api}}}) ;; for all routes + (swagger-ui/create-swagger-ui-handler {:path "/"}))) +``` + +The generated swagger spec: + +```clj +(app {:request-method :get :uri "/swagger.json"}) +;{:status 200 +; :body {:swagger "2.0" +; :x-id #{:user/api} +; :paths {"/api/ping" {:get {}} +; "/api/pong" {:post {}}}}} +``` + +Swagger-ui: + +```clj +(app {:request-method :get :uri "/"}) +; ... the swagger-ui index-page, configured correctly +``` + +### More complete example + +* `clojure.spec` and `Schema` coercion +* swagger data (`:tags`, `:produces`, `:consumes`) +* swagger-spec served from `"/api/swagger.json"` +* swagger-ui mounted to `"/"` +* [Muuntaja](https://github.com/metosin/muuntaja) for request & response formatting +* `wrap-params` to capture query & path parameters +* missed routes are handled by `create-default-handler` +* served via [ring-jetty](https://github.com/ring-clojure/ring/tree/master/ring-jetty-adapter) + +Whole example project is in [`/examples/ring-swagger`](https://github.com/metosin/reitit/tree/master/examples/ring-swagger). + +```clj +(require '[reitit.ring :as ring] +(require '[reitit.swagger :as swagger] +(require '[reitit.swagger-ui :as swagger-ui] +;; coercion +(require '[reitit.ring.coercion :as rrc] +(require '[reitit.coercion.spec :as spec] +(require '[reitit.coercion.schema :as schema] +(require '[schema.core :refer [Int]] +;; web server +(require '[ring.adapter.jetty :as jetty] +(require '[ring.middleware.params] +(require '[muuntaja.middleware])) + +(def app + (ring/ring-handler + (ring/router + ["/api" + {:swagger {:id ::math}} + + ["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "my-api"}} + :handler (swagger/create-swagger-handler)}}] + + ["/spec" + {:coercion spec/coercion + :swagger {:tags ["spec"]}} + + ["/plus" + {:get {:summary "plus with spec" + :parameters {:query {:x int?, :y int?}} + :responses {200 {:body {:total int?}}} + :handler (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + + ["/schema" + {:coercion schema/coercion + :swagger {:tags ["schema"]}} + + ["/plus" + {:get {:summary "plus with schema" + :parameters {:query {:x Int, :y Int}} + :responses {200 {:body {:total Int}}} + :handler (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]]] + + {:data {:middleware [ring.middleware.params/wrap-params + muuntaja.middleware/wrap-format + swagger/swagger-feature + rrc/coerce-exceptions-middleware + rrc/coerce-request-middleware + rrc/coerce-response-middleware] + :swagger {:produces #{"application/json" + "application/edn" + "application/transit+json"} + :consumes #{"application/json" + "application/edn" + "application/transit+json"}}}}) + (ring/routes + (swagger-ui/create-swagger-ui-handler + {:path "", :url "/api/swagger.json"}) + (ring/create-default-handler)))) + +(defn start [] + (jetty/run-jetty #'app {:port 3000, :join? false}) + (println "server running in port 3000")) +``` + +http://localhost:3000 should render now the swagger-ui: + +![Swagger-ui](../images/swagger.png) + +## Advanced + +Route data in path `[:swagger :id]` can be either a keyword or a sequence of keywords. This enables one route to be part of multiple swagger apis. Normal route data [scoping rules](../basics/route_data.html#nested-route-data) rules apply. + +Example with: + +* 4 routes +* 2 swagger apis `::one` and `::two` +* 3 swagger specs + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.swagger :as swagger]) + +(def ping-route + ["/ping" {:get (constantly "ping")}]) + +(def spec-route + ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}]) + +(def app + (ring/ring-handler + (ring/router + [["/common" {:swagger {:id #{::one ::two}}} ping-route] + ["/one" {:swagger {:id ::one}} ping-route spec-route] + ["/two" {:swagger {:id ::two}} ping-route spec-route + ["/deep" {:swagger {:id ::one}} ping-route]] + ["/one-two" {:swagger {:id #{::one ::two}}} spec-route]]))) +``` + +```clj +(-> {:request-method :get, :uri "/one/swagger.json"} app :body :paths keys) +; ("/common/ping" "/one/ping" "/two/deep/ping") +``` + +```clj +(-> {:request-method :get, :uri "/two/swagger.json"} app :body :paths keys) +; ("/common/ping" "/two/ping") +``` + +```clj +(-> {:request-method :get, :uri "/one-two/swagger.json"} app :body :paths keys) +; ("/common/ping" "/one/ping" "/two/ping" "/two/deep/ping") +``` + +### TODO + +* create a data-driven version of [Muuntaja](https://github.com/metosin/muuntaja) that integrates into `:produces` and `:consumes` +* ClojureScript + * example for [Macchiato](https://github.com/macchiato-framework) + * body formatting + * resource handling diff --git a/doc/swagger.md b/doc/swagger.md deleted file mode 100644 index e410464c..00000000 --- a/doc/swagger.md +++ /dev/null @@ -1,58 +0,0 @@ -# Swagger - -Reitit supports [Swagger](https://swagger.io/) to generate route documentation. Documentation is extracted from existing coercion definitions `:parameters`, `:responses` and from a set of new doumentation keys. - -### Example - -Current `reitit-swagger` draft (with `reitit-ring` & data-specs): - - -```clj -(require '[reitit.ring :as ring]) -(require '[reitit.ring.swagger :as swagger]) -(require '[reitit.ring.coercion :as rrc]) -(require '[reitit.coercion.spec :as spec]) - -(def app - (ring/ring-handler - (ring/router - ["/api" - - ;; identify a swagger api - ;; there can be several in a routing tree - {:swagger {:id :math}} - - ;; the (undocumented) swagger spec endpoint - ["/swagger.json" - {:get {:no-doc true - :swagger {:info {:title "my-api"}} - :handler (swagger/create-swagger-handler)}}] - - ;; the (undocumented) swagger-ui - ;; [org.webjars/swagger-ui "3.13.4"] - ["/docs/*" - {:get {:no-doc true - :handler (ring/create-resource-handler - {:root "META-INF/resources/webjars/swagger-ui"})}}] - - ["/minus" - {:get {:summary "minus" - :parameters {:query {:x int?, :y int?}} - :responses {200 {:body {:total int?}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (- x y)}})}}] - - ["/plus" - {:get {:summary "plus" - :parameters {:query {:x int?, :y int?}} - :responses {200 {:body {:total int?}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (+ x y)}})}}]] - - {:data {:middleware [rrc/coerce-exceptions-middleware - rrc/coerce-request-middleware - rrc/coerce-response-middleware - ;; provides just route data specs - swagger/swagger-feature] - :coercion spec/coercion}}))) -``` diff --git a/examples/ring-swagger/project.clj b/examples/ring-swagger/project.clj index a47a801f..f3133c0c 100644 --- a/examples/ring-swagger/project.clj +++ b/examples/ring-swagger/project.clj @@ -3,6 +3,5 @@ :dependencies [[org.clojure/clojure "1.9.0"] [ring "1.6.3"] [metosin/muuntaja "0.5.0"] - [org.webjars/swagger-ui "3.13.6"] [metosin/reitit "0.1.1-SNAPSHOT"]] :repl-options {:init-ns example.server}) diff --git a/examples/ring-swagger/src/example/server.clj b/examples/ring-swagger/src/example/server.clj index 0dbef160..03033c6c 100644 --- a/examples/ring-swagger/src/example/server.clj +++ b/examples/ring-swagger/src/example/server.clj @@ -1,6 +1,7 @@ (ns example.server (:require [reitit.ring :as ring] [reitit.swagger :as swagger] + [reitit.swagger-ui :as swagger-ui] [reitit.ring.coercion :as rrc] [reitit.coercion.spec :as spec] [reitit.coercion.schema :as schema] @@ -13,43 +14,53 @@ (def app (ring/ring-handler (ring/router - [["/api" - {:swagger {:id ::math}} + ["/api" + {:swagger {:id ::math}} - ["/swagger.json" - {:get {:no-doc true - :swagger {:info {:title "my-api"}} - :handler (swagger/create-swagger-handler)}}] + ["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "my-api"}} + :handler (swagger/create-swagger-handler)}}] - ["/spec" {:coercion spec/coercion} - ["/plus" - {:get {:summary "plus" - :parameters {:query {:x int?, :y int?}} - :responses {200 {:body {:total int?}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (+ x y)}})}}]] + ["/spec" + {:coercion spec/coercion + :swagger {:tags ["spec"]}} - ["/schema" {:coercion schema/coercion} - ["/plus" - {:get {:summary "plus" - :parameters {:query {:x Int, :y Int}} - :responses {200 {:body {:total Int}}} - :handler (fn [{{{:keys [x y]} :query} :parameters}] - {:status 200, :body {:total (+ x y)}})}}]]] + ["/plus" + {:get {:summary "plus with spec" + :parameters {:query {:x int?, :y int?}} + :responses {200 {:body {:total int?}}} + :handler (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] - ["/api-docs/*" - {:no-doc true - :handler (ring/create-resource-handler - {:root "META-INF/resources/webjars/swagger-ui/3.13.6"})}]] + ["/schema" + {:coercion schema/coercion + :swagger {:tags ["schema"]}} + + ["/plus" + {:get {:summary "plus with schema" + :parameters {:query {:x Int, :y Int}} + :responses {200 {:body {:total Int}}} + :handler (fn [{{{:keys [x y]} :query} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]]] {:data {:middleware [ring.middleware.params/wrap-params muuntaja.middleware/wrap-format swagger/swagger-feature rrc/coerce-exceptions-middleware rrc/coerce-request-middleware - rrc/coerce-response-middleware]}}) + rrc/coerce-response-middleware] + :swagger {:produces #{"application/json" + "application/edn" + "application/transit+json"} + :consumes #{"application/json" + "application/edn" + "application/transit+json"}}}}) (ring/routes - (ring/create-resource-handler {:path "/"}) + (swagger-ui/create-swagger-ui-handler + {:path "", :url "/api/swagger.json"}) (ring/create-default-handler)))) (defn start [] diff --git a/examples/ring-swagger/swagger-ui.png b/examples/ring-swagger/swagger-ui.png index e3ae1b66..46d28517 100644 Binary files a/examples/ring-swagger/swagger-ui.png and b/examples/ring-swagger/swagger-ui.png differ diff --git a/examples/ring-swagger/swagger.png b/examples/ring-swagger/swagger.png new file mode 100644 index 00000000..46d28517 Binary files /dev/null and b/examples/ring-swagger/swagger.png differ diff --git a/modules/reitit-core/src/reitit/impl.cljc b/modules/reitit-core/src/reitit/impl.cljc index 9795cf11..1c654d36 100644 --- a/modules/reitit-core/src/reitit/impl.cljc +++ b/modules/reitit-core/src/reitit/impl.cljc @@ -56,7 +56,7 @@ (update-in [:path-parts] conj key) (update-in [:path-params] conj key) (assoc-in [:path-constraints key] "([^/]+)")))) - #"^\*(.+)$" :>> (fn [[_ token]] + #"^\*(.*)$" :>> (fn [[_ token]] (let [key (keyword token)] (-> out (update-in [:path-parts] conj key) diff --git a/modules/reitit-ring/src/reitit/ring.cljc b/modules/reitit-ring/src/reitit/ring.cljc index 2fd09a98..9d5f830e 100644 --- a/modules/reitit-ring/src/reitit/ring.cljc +++ b/modules/reitit-ring/src/reitit/ring.cljc @@ -69,6 +69,8 @@ #?(:clj ;; TODO: optimize for perf + ;; TODO: ring.middleware.not-modified/wrap-not-modified + ;; TODO: ring.middleware.head/wrap-head (defn create-resource-handler "A ring handler for serving classpath resources, configured via options: @@ -82,19 +84,20 @@ | :allow-symlinks? | allow symlinks that lead to paths outside the root classpath directories, defaults to `false`" ([] (create-resource-handler nil)) - ([{:keys [parameter root path loader allow-symlinks? index-files] + ([{:keys [parameter root path loader allow-symlinks? index-files paths] :or {parameter (keyword "") root "public" - index-files ["index.html"]}}] + index-files ["index.html"] + paths (constantly nil)}}] (let [options {:root root, :loader loader, :allow-symlinks? allow-symlinks?} - path-size (count path) + path-size (inc (count path)) create (fn [handler] (fn ([request] (handler request)) ([request respond _] (respond (handler request))))) resource-response (fn [path accept] (if-let [path (accept path)] - (if-let [response (response/resource-response path options)] + (if-let [response (or (paths path) (response/resource-response path options))] (response/content-type response (mime-type/ext-mime-type path))))) path-or-index-response (fn [path accept] (or (resource-response path accept) diff --git a/modules/reitit-spec/src/reitit/coercion/spec.cljc b/modules/reitit-spec/src/reitit/coercion/spec.cljc index 72e9dc84..d24f08c5 100644 --- a/modules/reitit-spec/src/reitit/coercion/spec.cljc +++ b/modules/reitit-spec/src/reitit/coercion/spec.cljc @@ -2,27 +2,33 @@ (:require [clojure.spec.alpha :as s] [spec-tools.core :as st #?@(:cljs [:refer [Spec]])] [spec-tools.data-spec :as ds] - [spec-tools.conform :as conform] + [spec-tools.transform :as stt] [spec-tools.swagger.core :as swagger] [reitit.coercion :as coercion] [clojure.set :as set]) #?(:clj (:import (spec_tools.core Spec)))) -(def string-conforming - (st/type-conforming - (merge - conform/string-type-conforming - conform/strip-extra-keys-type-conforming))) +(def string-transformer + (st/type-transformer + {:name :string + :decoders (merge + stt/string-type-decoders + stt/strip-extra-keys-type-decoders) + :encoders stt/string-type-encoders + :default-encoder stt/any->any})) -(def json-conforming - (st/type-conforming - (merge - conform/json-type-conforming - conform/strip-extra-keys-type-conforming))) +(def json-transformer + (st/type-transformer + {:name :json + :decoders (merge + stt/json-type-decoders + stt/strip-extra-keys-type-decoders) + :encoders stt/json-type-encoders + :default-encoder stt/any->any})) -(def default-conforming - ::default) +(def no-op-transformer + st/no-op-transformer) (defprotocol IntoSpec (into-spec [this name])) @@ -58,12 +64,12 @@ (def default-options {:coerce-response? coerce-response? - :conforming {:body {:default default-conforming - :formats {"application/json" json-conforming}} - :string {:default string-conforming} - :response {:default default-conforming}}}) + :transformers {:body {:default no-op-transformer + :formats {"application/json" json-transformer}} + :string {:default string-transformer} + :response {:default no-op-transformer}}}) -(defn create [{:keys [conforming coerce-response?] :as opts}] +(defn create [{:keys [transformers coerce-response?] :as opts}] ^{:type ::coercion/coercion} (reify coercion/Coercion (-get-name [_] :spec) @@ -98,16 +104,16 @@ (update :problems (partial mapv #(update % :pred stringify-pred))))) (-request-coercer [this type spec] (let [spec (coercion/-compile-model this spec nil) - {:keys [formats default]} (conforming type)] + {:keys [formats default]} (transformers type)] (fn [value format] - (if-let [conforming (or (get formats format) default)] - (let [conformed (st/conform spec value conforming)] - (if (s/invalid? conformed) - (let [problems (st/explain-data spec value conforming)] + (if-let [transformer (or (get formats format) default)] + (let [transformed (st/conform spec value transformer)] + (if (s/invalid? transformed) + (let [problems (st/explain-data spec value transformer)] (coercion/map->CoercionError {:spec spec :problems (::s/problems problems)})) - (s/unform spec conformed))) + (s/unform spec transformed))) value)))) (-response-coercer [this spec] (if (coerce-response? spec) diff --git a/modules/reitit-swagger-ui/project.clj b/modules/reitit-swagger-ui/project.clj new file mode 100644 index 00000000..fa43ea9e --- /dev/null +++ b/modules/reitit-swagger-ui/project.clj @@ -0,0 +1,11 @@ +(defproject metosin/reitit-swagger-ui "0.1.1-SNAPSHOT" + :description "Reitit: Swagger-ui support" + :url "https://github.com/metosin/reitit" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :plugins [[lein-parent "0.3.2"]] + :parent-project {:path "../../project.clj" + :inherit [:deploy-repositories :managed-dependencies]} + :dependencies [[metosin/reitit-ring] + [metosin/jsonista] + [metosin/ring-swagger-ui]]) diff --git a/modules/reitit-swagger-ui/src/reitit/swagger_ui.cljc b/modules/reitit-swagger-ui/src/reitit/swagger_ui.cljc new file mode 100644 index 00000000..202556a8 --- /dev/null +++ b/modules/reitit-swagger-ui/src/reitit/swagger_ui.cljc @@ -0,0 +1,52 @@ +(ns reitit.swagger-ui + (:require [clojure.string :as str] + [reitit.ring :as ring] + #?@(:clj [ + [jsonista.core :as j]]))) + +#?(:clj + (defn create-swagger-ui-handler + "Creates a ring handler which can be used to serve swagger-ui. + + | key | description | + | -----------------|-------------| + | :parameter | optional name of the wildcard parameter, defaults to unnamed keyword `:` + | :root | optional resource root, defaults to `\"swagger-ui\"` + | :url | path to swagger endpoint, defaults to `/swagger.json` + | :path | optional path to mount the handler to. Works only if mounted outside of a router. + | :config | parameters passed to swaggger-ui, keys transformed into camelCase. + + See https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md + for all available :config options + + Examples: + + ;; with defaults + (create-swagger-ui-handler) + + ;; with path and url set, swagger validator disabled + (swagger-ui/create-swagger-ui-handler + {:path \"\" + :url \"/api/swagger.json\" + :config {:validator-url nil})" + ([] + (create-swagger-ui-handler nil)) + ([options] + (let [mixed-case (fn [k] + (let [[f & rest] (str/split (name k) #"-")] + (apply str (str/lower-case f) (map str/capitalize rest)))) + mixed-case-key (fn [[k v]] [(mixed-case k) v]) + config-json (fn [{:keys [url config]}] (j/write-value-as-string (merge config {:url url}))) + conf-js (fn [opts] (str "window.API_CONF = " (config-json opts) ";")) + options (as-> options $ + (update $ :root (fnil identity "swagger-ui")) + (update $ :url (fnil identity "/swagger.json")) + (update $ :config #(->> % (map mixed-case-key) (into {}))) + (assoc $ :paths {"conf.js" {:headers {"Content-Type" "application/javascript"} + :status 200 + :body (conf-js $)} + "config.json" {:headers {"Content-Type" "application/json"} + :status 200 + :body (config-json $)}}))] + (ring/routes + (ring/create-resource-handler options)))))) diff --git a/modules/reitit-swagger/src/reitit/swagger.cljc b/modules/reitit-swagger/src/reitit/swagger.cljc index 0a296dac..1679a061 100644 --- a/modules/reitit-swagger/src/reitit/swagger.cljc +++ b/modules/reitit-swagger/src/reitit/swagger.cljc @@ -5,7 +5,7 @@ [clojure.set :as set] [reitit.coercion :as coercion])) -(s/def ::id keyword?) +(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 ::summary string?) @@ -21,16 +21,11 @@ documentation keys for the route data. Should be accompanied by a [[swagger-spec-handler]] to expose the swagger spec. - Swagger-specific keys: - - | key | description | - | --------------|-------------| - | :swagger | map of any swagger-data. Must have `:id` to identify the api - - The following common keys also contribute to swagger spec: + New route data keys contributing to swagger docs: | key | description | | --------------|-------------| + | :swagger | map of any swagger-data. Must have `:id` (keyword or sequence of keywords) to identify the api | :no-doc | optional boolean to exclude endpoint from api docs | :tags | optional set of strings of keywords tags for an endpoint api docs | :summary | optional short string summary of an endpoint @@ -38,6 +33,8 @@ Also the coercion keys contribute to swagger spec: + | key | description | + | --------------|-------------| | :parameters | optional input parameters for a route, in a format defined by the coercion | :responses | optional descriptions of responess, in a format defined by coercion @@ -66,11 +63,16 @@ :spec ::spec}) (defn create-swagger-handler [] - "Create a ring handler to emit swagger spec." + "Create a ring handler to emit swagger spec. Collects all routes from router which have + an intersecting `[:swagger :id]` and which are not marked with `:no-doc` route data." (fn [{:keys [::r/router ::r/match :request-method]}] (let [{:keys [id] :as swagger} (-> match :result request-method :data :swagger) - swagger (set/rename-keys swagger {:id :x-id}) - accept-route #(-> % second :swagger :id (= id)) + ->set (fn [x] (if (or (set? x) (sequential? x)) (set x) (conj #{} x))) + ids (->set id) + swagger (->> (dissoc swagger :id) + (merge {:swagger "2.0" + :x-id ids})) + accept-route #(-> % second :swagger :id ->set (set/intersection ids) seq) transform-endpoint (fn [[method {{:keys [coercion no-doc swagger] :as data} :data}]] (if (and data (not no-doc)) [method diff --git a/modules/reitit/project.clj b/modules/reitit/project.clj index 9263d98b..21b5b664 100644 --- a/modules/reitit/project.clj +++ b/modules/reitit/project.clj @@ -10,4 +10,5 @@ [metosin/reitit-ring] [metosin/reitit-spec] [metosin/reitit-schema] - [metosin/reitit-swagger]]) + [metosin/reitit-swagger] + [metosin/reitit-swagger-ui]]) diff --git a/project.clj b/project.clj index 257c8415..7a284a27 100644 --- a/project.clj +++ b/project.clj @@ -15,11 +15,14 @@ [metosin/reitit-spec "0.1.1-SNAPSHOT"] [metosin/reitit-schema "0.1.1-SNAPSHOT"] [metosin/reitit-swagger "0.1.1-SNAPSHOT"] + [metosin/reitit-swagger-ui "0.1.1-SNAPSHOT"] [meta-merge "1.0.0"] [ring/ring-core "1.6.3"] - [metosin/spec-tools "0.6.2-SNAPSHOT"] - [metosin/schema-tools "0.10.2-SNAPSHOT"]] + [metosin/spec-tools "0.7.0"] + [metosin/schema-tools "0.10.2"] + [metosin/ring-swagger-ui "2.2.10"] + [metosin/jsonista "0.2.0"]] :plugins [[jonase/eastwood "0.2.5"] [lein-doo "0.1.10"] @@ -36,10 +39,11 @@ "modules/reitit-ring/src" "modules/reitit-spec/src" "modules/reitit-schema/src" - "modules/reitit-swagger/src"] + "modules/reitit-swagger/src" + "modules/reitit-swagger-ui/src"] :dependencies [[org.clojure/clojure "1.9.0"] - [org.clojure/clojurescript "1.9.946"] + [org.clojure/clojurescript "1.10.238"] ;; modules dependencies [metosin/reitit] @@ -50,12 +54,13 @@ [ring "1.6.3"] [metosin/muuntaja "0.5.0"] - [metosin/jsonista "0.1.1"] + [metosin/jsonista "0.2.0"] + [metosin/ring-swagger-ui "2.2.10"] [criterium "0.4.4"] [org.clojure/test.check "0.9.0"] [org.clojure/tools.namespace "0.2.11"] - [com.gfredericks/test.chuck "0.2.8"]]} + [com.gfredericks/test.chuck "0.2.9"]]} :perf {:jvm-opts ^:replace ["-server" "-Xmx4096m" "-Dclojure.compiler.direct-linking=true"] diff --git a/scripts/lein-modules b/scripts/lein-modules index e93284b9..daf815fe 100755 --- a/scripts/lein-modules +++ b/scripts/lein-modules @@ -3,6 +3,6 @@ set -e # Modules -for ext in reitit-core reitit-ring reitit-spec reitit-schema reitit-swagger reitit; do +for ext in reitit-core reitit-ring reitit-spec reitit-schema reitit-swagger reitit-swagger-ui reitit; do cd modules/$ext; lein "$@"; cd ../..; done diff --git a/test/cljc/reitit/ring_coercion_test.cljc b/test/cljc/reitit/ring_coercion_test.cljc index a3e708ea..11eb5141 100644 --- a/test/cljc/reitit/ring_coercion_test.cljc +++ b/test/cljc/reitit/ring_coercion_test.cljc @@ -84,11 +84,11 @@ (app valid-request)))) (testing "invalid request" - (let [{:keys [status body]} (app invalid-request)] + (let [{:keys [status]} (app invalid-request)] (is (= 400 status)))) (testing "invalid response" - (let [{:keys [status body]} (app invalid-request2)] + (let [{:keys [status]} (app invalid-request2)] (is (= 500 status)))))))) (deftest schema-coercion-test diff --git a/test/cljc/reitit/ring_test.cljc b/test/cljc/reitit/ring_test.cljc index eb7d265c..c803a320 100644 --- a/test/cljc/reitit/ring_test.cljc +++ b/test/cljc/reitit/ring_test.cljc @@ -271,7 +271,9 @@ (ring/ring-handler (ring/router [["/ping" (constantly {:status 200, :body "pong"})] - ["/files/*" (ring/create-resource-handler)]]) + ["/files/*" (ring/create-resource-handler)] + ["/*" (ring/create-resource-handler)]] + {:conflicts (constantly nil)}) (ring/create-default-handler))] ["outside of a router" @@ -280,34 +282,37 @@ ["/ping" (constantly {:status 200, :body "pong"})]) (ring/routes (ring/create-resource-handler {:path "/files"}) - (ring/create-default-handler)))]]] + (ring/create-resource-handler {:path "/"}) + (ring/create-default-handler)))]] + prefix ["/" "/files"] + :let [request (fn [uri] {:uri (str prefix uri), :request-method :get})]] (testing test (testing "different file-types" - (let [response (app {:uri "/files/hello.json", :request-method :get})] + (let [response (app (request "/hello.json"))] (is (= "application/json" (get-in response [:headers "Content-Type"]))) (is (get-in response [:headers "Last-Modified"])) (is (= "{\"hello\": \"file\"}" (slurp (:body response))))) - (let [response (app {:uri "/files/hello.xml", :request-method :get})] + (let [response (app (request "/hello.xml"))] (is (= "text/xml" (get-in response [:headers "Content-Type"]))) (is (get-in response [:headers "Last-Modified"])) (is (= "file\n" (slurp (:body response)))))) (testing "index-files" - (let [response (app {:uri "/files/docs", :request-method :get})] + (let [response (app (request "/docs"))] (is (= "text/html" (get-in response [:headers "Content-Type"]))) (is (get-in response [:headers "Last-Modified"])) (is (= "

hello

\n" (slurp (:body response)))))) (testing "not found" - (let [response (app {:uri "/files/not-found", :request-method :get})] + (let [response (app (request "/not-found"))] (is (= 404 (:status response))))) (testing "3-arity" (let [result (atom nil) respond (partial reset! result) raise ::not-called] - (app {:uri "/files/hello.xml", :request-method :get} respond raise) + (app (request "/hello.xml") respond raise) (is (= "text/xml" (get-in @result [:headers "Content-Type"]))) (is (get-in @result [:headers "Last-Modified"])) (is (= "file\n" (slurp (:body @result)))))))))) diff --git a/test/cljc/reitit/swagger_test.clj b/test/cljc/reitit/swagger_test.clj index 5921fb7b..6745be01 100644 --- a/test/cljc/reitit/swagger_test.clj +++ b/test/cljc/reitit/swagger_test.clj @@ -57,7 +57,8 @@ (let [spec (:body (app {:request-method :get :uri "/api/swagger.json"}))] - (is (= {:x-id ::math + (is (= {:x-id #{::math} + :swagger "2.0" :info {:title "my-api"} :paths {"/api/schema/plus" {:get {:parameters [{:description "" :format "int32" @@ -97,3 +98,33 @@ :type "object"}}} :summary "plus"}}}} spec))))) + +(deftest multiple-swagger-apis-test + (let [ping-route ["/ping" {:get (constantly "ping")}] + spec-route ["/swagger.json" + {:get {:no-doc true + :handler (swagger/create-swagger-handler)}}] + app (ring/ring-handler + (ring/router + [["/common" {:swagger {:id #{::one ::two}}} + ping-route] + + ["/one" {:swagger {:id ::one}} + ping-route + spec-route] + + ["/two" {:swagger {:id ::two}} + ping-route + spec-route + ["/deep" {:swagger {:id ::one}} + ping-route]] + ["/one-two" {:swagger {:id #{::one ::two}}} + spec-route]])) + spec-paths (fn [uri] + (-> {:request-method :get, :uri uri} app :body :paths keys))] + (is (= ["/common/ping" "/one/ping" "/two/deep/ping"] + (spec-paths "/one/swagger.json"))) + (is (= ["/common/ping" "/two/ping"] + (spec-paths "/two/swagger.json"))) + (is (= ["/common/ping" "/one/ping" "/two/ping" "/two/deep/ping"] + (spec-paths "/one-two/swagger.json")))))