diff --git a/CHANGELOG.md b/CHANGELOG.md index f63ae09e..e1f8084b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ We use [Break Versioning][breakver]. The version numbers follow a `. lein repl +(start) +``` + +- Swagger UI served at +- Openapi spec served at +- See [src/example/server.clj](src/example/server.clj) for details + + + +## License + +Copyright © 2023 Metosin Oy diff --git a/examples/openapi/openapi.png b/examples/openapi/openapi.png new file mode 100644 index 00000000..6f775221 Binary files /dev/null and b/examples/openapi/openapi.png differ diff --git a/examples/openapi/project.clj b/examples/openapi/project.clj new file mode 100644 index 00000000..b9750867 --- /dev/null +++ b/examples/openapi/project.clj @@ -0,0 +1,9 @@ +(defproject openapi "0.1.0-SNAPSHOT" + :description "Reitit OpenAPI example" + :dependencies [[org.clojure/clojure "1.10.0"] + [metosin/jsonista "0.2.6"] + [ring/ring-jetty-adapter "1.7.1"] + [metosin/reitit "0.7.0-alpha5"] + [metosin/ring-swagger-ui "5.0.0-alpha.0"]] + :repl-options {:init-ns example.server} + :profiles {:dev {:dependencies [[ring/ring-mock "0.3.2"]]}}) diff --git a/examples/openapi/src/example/server.clj b/examples/openapi/src/example/server.clj new file mode 100644 index 00000000..933e352f --- /dev/null +++ b/examples/openapi/src/example/server.clj @@ -0,0 +1,148 @@ +(ns example.server + (:require [reitit.ring :as ring] + [reitit.ring.spec] + [reitit.coercion.malli] + [reitit.openapi :as openapi] + [reitit.ring.malli] + [reitit.swagger-ui :as swagger-ui] + [reitit.ring.coercion :as coercion] + [reitit.dev.pretty :as pretty] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.exception :as exception] + [reitit.ring.middleware.multipart :as multipart] + [reitit.ring.middleware.parameters :as parameters] + [ring.adapter.jetty :as jetty] + [muuntaja.core :as m])) + +(def app + (ring/ring-handler + (ring/router + [["/openapi.json" + {:get {:no-doc true + :openapi {:info {:title "my-api" + :description "openapi3 docs with [malli](https://github.com/metosin/malli) and reitit-ring" + :version "0.0.1"} + ;; used in /secure APIs below + :components {:securitySchemes {"auth" {:type :apiKey + :in :header + :name "Example-Api-Key"}}}} + :handler (openapi/create-openapi-handler)}}] + + ["/pizza" + {:get {:summary "Fetch a pizza | Multiple content-types, multiple examples" + :responses {200 {:content {"application/json" {:description "Fetch a pizza as json" + :schema [:map + [:color :keyword] + [:pineapple :boolean]] + :examples {:white {:description "White pizza with pineapple" + :value {:color :white + :pineapple true}} + :red {:description "Red pizza" + :value {:color :red + :pineapple false}}}} + "application/edn" {:description "Fetch a pizza as edn" + :schema [:map + [:color :keyword] + [:pineapple :boolean]] + :examples {:red {:description "Red pizza with pineapple" + :value (pr-str {:color :red :pineapple true})}}}}}} + :handler (fn [_request] + {:status 200 + :body {:color :red + :pineapple true}})} + :post {:summary "Create a pizza | Multiple content-types, multiple examples" + :request {:content {"application/json" {:description "Create a pizza using json" + :schema [:map + [:color :keyword] + [:pineapple :boolean]] + :examples {:purple {:value {:color :purple + :pineapple false}}}} + "application/edn" {:description "Create a pizza using EDN" + :schema [:map + [:color :keyword] + [:pineapple :boolean]] + :examples {:purple {:value (pr-str {:color :purple + :pineapple false})}}}}} + ;; Need to list content types explicitly because we use :default in :responses + :openapi/response-content-types ["application/json" "application/edn"] + :responses {200 {:content {:default {:description "Success" + :schema [:map [:success :boolean]] + :example {:success true}}}}} + :handler (fn [_request] + {:status 200 + :body {:success true}})}}] + + + ["/contact" + {:get {:summary "Search for a contact | Customizing via malli properties" + :parameters {:query [:map + [:limit {:title "How many results to return? Optional." + :optional true + :json-schema/default 30 + :json-schema/example 10} + int?] + [:email {:title "Email address to search for" + :json-schema/format "email"} + string?]]} + :responses {200 {:content {:default {:schema [:vector + [:map + [:name {:json-schema/example "Heidi"} + string?] + [:email {:json-schema/example "heidi@alps.ch"} + string?]]]}}}} + :handler (fn [_request] + [{:name "Heidi" + :email "heidi@alps.ch"}])}}] + + ["/secure" + {:tags #{"secure"} + :openapi {:security [{"auth" []}]}} + ["/get" + {:get {:summary "endpoint authenticated with a header" + :responses {200 {:body [:map [:secret :string]]} + 401 {:body [:map [:error :string]]}} + :handler (fn [request] + ;; In a real app authentication would be handled by middleware + (if (= "secret" (get-in request [:headers "example-api-key"])) + {:status 200 + :body {:secret "I am a marmot"}} + {:status 401 + :body {:error "unauthorized"}}))}}]]] + + {;;:reitit.middleware/transform dev/print-request-diffs ;; pretty diffs + :validate reitit.ring.spec/validate + :exception pretty/exception + :data {:coercion reitit.coercion.malli/coercion + :muuntaja m/instance + :middleware [openapi/openapi-feature + ;; query-params & form-params + parameters/parameters-middleware + ;; content-negotiation + muuntaja/format-negotiate-middleware + ;; encoding response body + muuntaja/format-response-middleware + ;; exception handling + exception/exception-middleware + ;; decoding request body + muuntaja/format-request-middleware + ;; coercing response bodys + coercion/coerce-response-middleware + ;; coercing request parameters + coercion/coerce-request-middleware + ;; multipart + multipart/multipart-middleware]}}) + (ring/routes + (swagger-ui/create-swagger-ui-handler + {:path "/" + :config {:validatorUrl nil + :urls [{:name "openapi", :url "openapi.json"}] + :urls.primaryName "openapi" + :operationsSorter "alpha"}}) + (ring/create-default-handler)))) + +(defn start [] + (jetty/run-jetty #'app {:port 3000, :join? false}) + (println "server running in port 3000")) + +(comment + (start)) diff --git a/examples/ring-malli-swagger/src/example/server.clj b/examples/ring-malli-swagger/src/example/server.clj index c2dec67f..c5fafd19 100644 --- a/examples/ring-malli-swagger/src/example/server.clj +++ b/examples/ring-malli-swagger/src/example/server.clj @@ -12,7 +12,7 @@ [reitit.ring.middleware.multipart :as multipart] [reitit.ring.middleware.parameters :as parameters] ; [reitit.ring.middleware.dev :as dev] - ; [reitit.ring.spec :as spec] + [reitit.ring.spec :as spec] ; [spec-tools.spell :as spell] [ring.adapter.jetty :as jetty] [muuntaja.core :as m] @@ -46,7 +46,7 @@ :handler (openapi/create-openapi-handler)}}] ["/files" - {:tags ["files"]} + {:tags #{"files"}} ["/upload" {:post {:summary "upload a file" @@ -70,7 +70,7 @@ (io/input-stream))})}}]] ["/math" - {:tags ["math"]} + {:tags #{"math"}} ["/plus" {:get {:summary "plus with malli query parameters" @@ -93,29 +93,13 @@ :json-schema/default 42} int?] [:y int?]]} - ;; OpenAPI3 named examples for request & response - :openapi {:requestBody - {:content - {"application/json" - {:examples {"add-one-one" {:summary "1+1" - :value {:x 1 :y 1}} - "add-one-two" {:summary "1+2" - :value {:x 1 :y 2}}}}}} - :responses - {200 - {:content - {"application/json" - {:examples {"two" {:summary "2" - :value {:total 2}} - "three" {:summary "3" - :value {:total 3}}}}}}}} :responses {200 {:body [:map [:total int?]]}} :handler (fn [{{{:keys [x y]} :body} :parameters}] {:status 200 :body {:total (+ x y)}})}}]] ["/secure" - {:tags ["secure"] + {:tags #{"secure"} :openapi {:security [{"auth" []}]} :swagger {:security [{"auth" []}]}} ["/get" @@ -131,7 +115,7 @@ :body {:error "unauthorized"}}))}}]]] {;;:reitit.middleware/transform dev/print-request-diffs ;; pretty diffs - ;;:validate spec/validate ;; enable spec validation for route data + :validate spec/validate ;; enable spec validation for route data ;;:reitit.spec/wrap spell/closed ;; strict top-level validation :exception pretty/exception :data {:coercion (reitit.coercion.malli/create diff --git a/modules/reitit-core/src/reitit/coercion.cljc b/modules/reitit-core/src/reitit/coercion.cljc index 2bf11482..8dd08d83 100644 --- a/modules/reitit-core/src/reitit/coercion.cljc +++ b/modules/reitit-core/src/reitit/coercion.cljc @@ -103,10 +103,6 @@ (request-coercion-failed! result coercion value in request serialize-failed-result) result))))))))) -(defn get-default-schema [request-or-response] - (or (-> request-or-response :content :default :schema) - (:body request-or-response))) - (defn get-default [request-or-response] (or (-> request-or-response :content :default) (some->> request-or-response :body (assoc {} :schema)))) diff --git a/modules/reitit-openapi/src/reitit/openapi.cljc b/modules/reitit-openapi/src/reitit/openapi.cljc index 68dc2caa..b845e889 100644 --- a/modules/reitit-openapi/src/reitit/openapi.cljc +++ b/modules/reitit-openapi/src/reitit/openapi.cljc @@ -12,10 +12,11 @@ (s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?))) (s/def ::summary string?) (s/def ::description string?) -(s/def ::content-types (s/coll-of string?)) +(s/def :openapi/request-content-types (s/coll-of string?)) +(s/def :openapi/response-content-types (s/coll-of string?)) (s/def ::openapi (s/keys :opt-un [::id])) -(s/def ::spec (s/keys :opt-un [::openapi ::no-doc ::tags ::summary ::description ::content-types])) +(s/def ::spec (s/keys :opt-un [::openapi ::no-doc ::tags ::summary ::description] :opt [:openapi/request-content-types :openapi/response-content-types])) (def openapi-feature "Stability: alpha @@ -31,7 +32,8 @@ | key | description | | ---------------|-------------| | :openapi | map of any openapi-data. Can contain keys like `:deprecated`. - | :content-types | vector of supported content types. Defaults to `[\"application/json\"]` + | :openapi/request-content-types | vector of supported request content types. Defaults to `[\"application/json\"]` :response nnn :content :default. Only needed if you use the [:request :content :default] coercion. + | :openapi/response-content-types | vector of supported response content types. Defaults to `[\"application/json\"]`. Only needed if you use the [:response nnn :content :default] coercion. | :no-doc | optional boolean to exclude endpoint from api docs | :tags | optional set of string or keyword tags for an endpoint api docs | :summary | optional short string summary of an endpoint @@ -74,7 +76,9 @@ (-> path (trie/normalize opts) (str/replace #"\{\*" "{"))) (defn -get-apidocs-openapi - [coercion {:keys [request parameters responses content-types] :or {content-types ["application/json"]}}] + [coercion {:keys [request parameters responses openapi/request-content-types openapi/response-content-types] + :or {request-content-types ["application/json"] + response-content-types ["application/json"]}}] (let [{:keys [body multipart]} parameters parameters (dissoc parameters :request :body :multipart) ->content (fn [data schema] @@ -105,7 +109,7 @@ :type :schema :content-type content-type})] [content-type {:schema schema}]))) - content-types)}}) + request-content-types)}}) (when request ;; request allow to different :requestBody per content-type @@ -119,7 +123,7 @@ :type :schema :content-type content-type})] [content-type (->content data schema)]))) - content-types)) + request-content-types)) (into {} (map (fn [[content-type {:keys [schema] :as data}]] (let [schema (->schema-object schema {:in :requestBody @@ -139,16 +143,16 @@ {:responses (into {} (map (fn [[status {:keys [content], :as response}]] - (let [default (coercion/get-default-schema response) + (let [default (coercion/get-default response) content (-> (merge (when default (into {} (map (fn [content-type] - (let [schema (->schema-object default {:in :responses - :type :schema - :content-type content-type})] - [content-type (->content nil schema)]))) - content-types)) + (let [schema (->schema-object (:schema default) {:in :responses + :type :schema + :content-type content-type})] + [content-type (->content default schema)]))) + response-content-types)) (when content (into {} (map (fn [[content-type {:keys [schema] :as data}]] diff --git a/modules/reitit-swagger/src/reitit/swagger.cljc b/modules/reitit-swagger/src/reitit/swagger.cljc index 308bf50e..fffba6eb 100644 --- a/modules/reitit-swagger/src/reitit/swagger.cljc +++ b/modules/reitit-swagger/src/reitit/swagger.cljc @@ -9,7 +9,7 @@ (s/def ::id (s/or :keyword keyword? :set (s/coll-of keyword? :into #{}))) (s/def ::no-doc boolean?) -(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?) :kind #{})) +(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?))) (s/def ::summary string?) (s/def ::description string?) (s/def ::operationId string?) diff --git a/test/cljc/reitit/openapi_test.clj b/test/cljc/reitit/openapi_test.clj index 3724b7ac..c950e1c1 100644 --- a/test/cljc/reitit/openapi_test.clj +++ b/test/cljc/reitit/openapi_test.clj @@ -471,23 +471,21 @@ (ring/router [["/examples" {:post {:decription "examples" + :openapi/request-content-types ["application/json" "application/edn"] + :openapi/response-content-types ["application/json" "application/edn"] :coercion @coercion - :request {:body (->schema :b)} + :request {:content {"application/json" {:schema (->schema :b) + :examples {"named-example" {:description "a named example" + :value {:b "named"}}}} + :default {:schema (->schema :b2) + :examples {"default-example" {:description "default example" + :value {:b2 "named"}}}}}} :parameters {:query (->schema :q)} :responses {200 {:description "success" - :body (->schema :ok)}} - :openapi {:requestBody - {:content - {"application/json" - {:examples - {"named-example" {:description "a named example" - :value {:b "named"}}}}}} - :responses - {200 - {:content - {"application/json" - {:examples - {"response-example" {:value {:ok "response"}}}}}}}} + :content {"application/json" {:schema (->schema :ok) + :examples {"response-example" {:value {:ok "response"}}}} + :default {:schema (->schema :ok) + :examples {"default-response-example" {:value {:ok "default"}}}}}}} :handler identity}}] ["/openapi.json" {:get {:handler (openapi/create-openapi-handler) @@ -517,7 +515,18 @@ :value {:b "named"}}}} (-> spec (get-in [:paths "/examples" :post :requestBody :content "application/json"]) - normalize)))) + normalize))) + (testing "default" + (is (match? {:schema {:type "object" + :properties {:b2 {:type "string" + :example "EXAMPLE"}} + :required ["b2"] + :example {:b2 "EXAMPLE2"}} + :examples {:default-example {:description "default example" + :value {:b2 "named"}}}} + (-> spec + (get-in [:paths "/examples" :post :requestBody :content "application/edn"]) + normalize))))) (testing "body response" (is (match? {:schema {:type "object" :properties {:ok {:type "string" @@ -527,7 +536,17 @@ :examples {:response-example {:value {:ok "response"}}}} (-> spec (get-in [:paths "/examples" :post :responses 200 :content "application/json"]) - normalize)))) + normalize))) + (testing "default" + (is (match? {:schema {:type "object" + :properties {:ok {:type "string" + :example "EXAMPLE"}} + :required ["ok"] + :example {:ok "EXAMPLE2"}} + :examples {:default-response-example {:value {:ok "default"}}}} + (-> spec + (get-in [:paths "/examples" :post :responses 200 :content "application/edn"]) + normalize))))) (testing "spec is valid" (is (nil? (validate spec)))))))) @@ -678,7 +697,8 @@ [["/parameters" {:post {:description "parameters" :coercion coercion - :content-types [content-type] ;; TODO should this be under :openapi ? + :openapi/request-content-types [content-type] + :openapi/response-content-types [content-type "application/response"] :request {:content {"application/transit" {:schema (->schema :transit)}} :body (->schema :default)} :responses {200 {:description "success" @@ -705,7 +725,7 @@ (get-in [:paths "/parameters" :post :requestBody :content]) keys)))) (testing "body response" - (is (match? (matchers/in-any-order [content-type "application/transit"]) + (is (match? (matchers/in-any-order [content-type "application/transit" "application/response"]) (-> spec (get-in [:paths "/parameters" :post :responses 200 :content]) keys))))