diff --git a/README.md b/README.md index 95a871c2..adace1eb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Posts: [![Clojars Project](http://clojars.org/metosin/spec-tools/latest-version.svg)](http://clojars.org/metosin/spec-tools) -Requires Java 1.8, tested with Clojure `1.10.0` and ClojureScript `1.10.520`+. +Requires Java 1.8, tested with Clojure `1.11.0` and ClojureScript `1.11.4`+. Status: **Alpha** (as spec is still alpha too). diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index 5587a448..49852134 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -1,5 +1,6 @@ (ns spec-tools.swagger.core - (:require [clojure.walk :as walk] + (:require [clojure.string :as string] + [clojure.walk :as walk] [spec-tools.json-schema :as json-schema] [spec-tools.visitor :as visitor] [spec-tools.impl :as impl] @@ -30,18 +31,22 @@ (defn- accept-merge [children] ;; Use x-anyOf and x-allOf instead of normal versions - {:type "object" - :properties (->> (concat children - (mapcat :x-anyOf children) - (mapcat :x-allOf children)) - (map :properties) - (reduce merge {})) - ;; Don't include top schema from s/or. - :required (->> (concat (remove :x-anyOf children) - (mapcat :x-allOf children)) - (map :required) - (reduce into (sorted-set)) - (into []))}) + (let [children' (map #(if (contains? % :$ref) + (first (vals (::definitions %))) + %) + children)] + {:type "object" + :properties (->> (concat children' + (mapcat :x-anyOf children') + (mapcat :x-allOf children')) + (map :properties) + (reduce merge {})) + ;; Don't include top schema from s/or. + :required (->> (concat (remove :x-anyOf children') + (mapcat :x-allOf children')) + (map :required) + (reduce into (sorted-set)) + (into []))})) (defmethod accept-spec 'clojure.spec.alpha/merge [_ _ children _] (accept-merge children)) @@ -89,43 +94,100 @@ (defmethod accept-spec ::default [dispatch spec children options] (json-schema/accept-spec dispatch spec children options)) +(defn- update-if [m k f & args] + (if (contains? m k) + (apply update m k f args) + m)) + +(defmulti create-or-raise-refs (fn [{:keys [type]} _] type)) + +(defmethod create-or-raise-refs "object" [swagger options] + (if (and (or (= :schema (:type options)) + (= :body (:in options))) + (contains? swagger :title)) + (let [title (string/replace (:title swagger) #"/" ".") + swagger' (create-or-raise-refs (dissoc swagger :title) options)] + {:$ref (str "#/definitions/" title) + ::definitions (merge {title (dissoc swagger' ::definitions)} (::definitions swagger'))}) + (let [definitions (apply merge + (::definitions (:additionalProperties swagger)) + (map ::definitions (vals (:properties swagger))))] + (if definitions + (-> swagger + (assoc ::definitions definitions) + (update-if :properties update-vals #(dissoc % ::definitions)) + (update-if :additionalProperties dissoc ::definitions)) + swagger)))) + +(defmethod create-or-raise-refs "array" [swagger _] + (let [definitions (get-in swagger [:items ::definitions])] + (if definitions + (-> swagger + (update ::definitions merge definitions) + (update :items dissoc ::definitions)) + swagger))) + +(defmethod create-or-raise-refs :default [swagger _] + swagger) + +(defn- accept-spec-with-refs [dispatch spec children options] + (create-or-raise-refs + (accept-spec dispatch spec children options) + options)) + (defn transform "Generate Swagger schema matching the given clojure.spec spec. Since clojure.spec is more expressive than Swagger schemas, everything that satisfies the spec should satisfy the resulting schema, but the converse is - not true." + not true. + + Available options: + + | Key | Description + |----------|----------------------------------------------------------- + | `:refs?` | Whether refs should be created for objects. Default: false + + " ([spec] (transform spec nil)) ([spec options] - (visitor/visit spec accept-spec options))) + (if (:refs? options) + (visitor/visit spec accept-spec-with-refs options) + (visitor/visit spec accept-spec options)))) ;; ;; extract swagger2 parameters ;; -(defmulti extract-parameter (fn [in _] in)) - -(defmethod extract-parameter :body [_ spec] - (let [schema (transform spec {:in :body, :type :parameter})] - [{:in "body" - :name (-> spec st/spec-name impl/qualified-name (or "body")) - :description (-> spec st/spec-description (or "")) - :required (not (impl/nilable-spec? spec)) - :schema schema}])) - -(defmethod extract-parameter :default [in spec] - (let [{:keys [properties required]} (transform spec {:in in, :type :parameter})] - (mapv - (fn [[k {:keys [type] :as schema}]] - (merge - {:in (name in) - :name k - :description (-> spec st/spec-description (or "")) - :type type - :required (contains? (set required) k)} - schema)) - properties))) +(defmulti extract-parameter (fn [in _ & _] in)) + +(defmethod extract-parameter :body + ([in spec] + (extract-parameter in spec nil)) + ([_ spec options] + (let [schema (transform spec (merge options {:in :body, :type :parameter}))] + [{:in "body" + :name (-> spec st/spec-name impl/qualified-name (or "body")) + :description (-> spec st/spec-description (or "")) + :required (not (impl/nilable-spec? spec)) + :schema schema}]))) + +(defmethod extract-parameter :default + ([in spec] + (extract-parameter in spec nil)) + ([in spec options] + (let [{:keys [properties required]} (transform spec (merge options {:in in, :type :parameter}))] + (mapv + (fn [[k {:keys [type] :as schema}]] + (merge + {:in (name in) + :name k + :description (-> spec st/spec-description (or "")) + :type type + :required (contains? (set required) k)} + schema)) + properties)))) ;; ;; expand the spec @@ -133,18 +195,21 @@ (defmulti expand (fn [k _ _ _] k)) -(defmethod expand ::responses [_ v acc _] - {:responses - (into - (or (:responses acc) {}) - (for [[status response] v] - [status (as-> response $ - (if (:schema $) (update $ :schema transform {:type :schema}) $) - (update $ :description (fnil identity "")))]))}) +(defmethod expand ::responses [_ v acc options] + (let [responses (into + (or (:responses acc) {}) + (for [[status response] v] + [status (as-> response $ + (if (:schema $) (update $ :schema transform (merge options {:type :schema})) $) + (update $ :description (fnil identity "")))]))] + (if (:refs? options) + {:responses (update-vals responses #(update-if % :schema dissoc ::definitions)) + :definitions (apply merge (map #(get-in % [:schema ::definitions]) (vals responses)))} + {:responses responses}))) -(defmethod expand ::parameters [_ v acc _] +(defmethod expand ::parameters [_ v acc options] (let [old (or (:parameters acc) []) - new (mapcat (fn [[in spec]] (extract-parameter in spec)) v) + new (mapcat (fn [[in spec]] (extract-parameter in spec options)) v) merged (->> (into old new) (reverse) (reduce @@ -157,23 +222,36 @@ (first) (reverse) (vec))] - {:parameters merged})) + (if (:refs? options) + {:parameters (mapv #(update-if % :schema dissoc ::definitions) merged) + :definitions (apply merge (map #(get-in % [:schema ::definitions]) merged))} + {:parameters merged}))) (defn expand-qualified-keywords [x options] - (let [accept? (set (keys (methods expand)))] + (let [accept? (set (keys (methods expand))) + merge-only-maps (fn [& colls] (if (every? map? colls) (apply merge colls) (last colls)))] (walk/postwalk (fn [x] (if (map? x) (reduce-kv (fn [acc k v] (if (accept? k) - (-> acc (dissoc k) (merge (expand k v acc options))) + (merge-with merge-only-maps (dissoc acc k) (expand k v acc options)) acc)) x x) x)) x))) +(defn- raise-refs-to-top [swagger-doc] + (let [swagger-doc' + (cond-> swagger-doc + (:paths swagger-doc) (-> + (assoc :definitions (apply merge (map :definitions (mapcat vals (vals (:paths swagger-doc)))))) + (update :paths update-vals (fn [path] (update-vals path #(dissoc % :definitions))))))] + (cond-> swagger-doc' + (nil? (:definitions swagger-doc')) (dissoc swagger-doc' :definitions)))) + ;; ;; generate the swagger spec ;; @@ -182,8 +260,16 @@ "Transforms data into a swagger2 spec. Input data must conform to the Swagger2 Spec (https://swagger.io/specification/v2/) with a exception that it can have any qualified keywords that are expanded - with the `spec-tools.swagger.core/expand` multimethod." + with the `spec-tools.swagger.core/expand` multimethod. + + Available options: + + | Key | Description + |----------|----------------------------------------------------------- + | `:refs?` | Whether refs should be created for objects. Default: false + " ([x] (swagger-spec x nil)) ([x options] - (expand-qualified-keywords x options))) + (cond-> (expand-qualified-keywords x options) + (:refs? options) (raise-refs-to-top)))) diff --git a/test/cljc/spec_tools/swagger/core_test.cljc b/test/cljc/spec_tools/swagger/core_test.cljc index 11b5973b..5f4bdcd6 100644 --- a/test/cljc/spec_tools/swagger/core_test.cljc +++ b/test/cljc/spec_tools/swagger/core_test.cljc @@ -174,6 +174,68 @@ (doseq [[spec swagger-spec] exceptations] (is (= swagger-spec (swagger/transform spec))))) +(s/def ::ref-spec (st/spec + {:spec ::keys2 + :description "description" + :swagger/title "RefSpec"})) + +(s/def ::coll-ref-spec (st/spec + {:spec (s/coll-of ::ref-spec)})) +(def ref-expectations + (merge + exceptations + {::keys + {:$ref "#/definitions/spec-tools.swagger.core-test.keys" + ::swagger/definitions {"spec-tools.swagger.core-test.keys" + {:type "object", + :properties {"integer" {:type "integer"}}, + :required ["integer"]}}} + + ::ref-spec + {:$ref "#/definitions/RefSpec" + ::swagger/definitions {"RefSpec" {:type "object" + :properties {"integer" {:type "integer"} + "spec" {:type "string" + :description "description" + :title "spec-tools.swagger.core-test/spec" + :default "123" + :example "swagger-example"}} + :required ["integer" "spec"] + :description "description"}}} + + (s/keys :req [::ref-spec]) + {:type "object" + :properties {"spec-tools.swagger.core-test/ref-spec" {:$ref "#/definitions/RefSpec"}} + :required ["spec-tools.swagger.core-test/ref-spec"] + ::swagger/definitions {"RefSpec" {:type "object" + :properties {"integer" {:type "integer"} + "spec" {:type "string" + :description "description" + :title "spec-tools.swagger.core-test/spec" + :default "123" + :example "swagger-example"}} + :required ["integer" "spec"] + :description "description"}}} + + ::coll-ref-spec + {:type "array" + :items {:$ref "#/definitions/RefSpec"} + ::swagger/definitions {"RefSpec" {:type "object" + :properties {"integer" {:type "integer"} + "spec" {:type "string" + :description "description" + :title "spec-tools.swagger.core-test/spec" + :default "123" + :example "swagger-example"}} + :required ["integer" "spec"] + :description "description"}} + :title "spec-tools.swagger.core-test/coll-ref-spec"}})) + +(deftest test-expectations-with-refs + (doseq [[spec swagger-spec] ref-expectations] + (is (= swagger-spec (swagger/transform spec {:refs? true :type :schema}))) + (is (= swagger-spec (swagger/transform spec {:refs? true :in :body}))) ) ) + (deftest parameter-test (testing "nilable body is not required" (is (= [{:in "body", @@ -190,7 +252,25 @@ :type "string"}}, :required ["integer" "spec"], :x-nullable true}}] - (swagger/extract-parameter :body (s/nilable ::keys2)))))) + (swagger/extract-parameter :body (s/nilable ::keys2))))) + + (testing "definitions are raised to the top of the parameter" + (is (= + [{:in "body" + :name "spec-tools.swagger.core-test/ref-spec" + :description "" + :required true + :schema {:$ref "#/definitions/RefSpec" + ::swagger/definitions {"RefSpec" {:type "object" + :properties {"integer" {:type "integer"} + "spec" {:type "string" + :description "description" + :title "spec-tools.swagger.core-test/spec" + :default "123" + :example "swagger-example"}} + :required ["integer" "spec"] + :description "description"}}}}] + (swagger/extract-parameter :body ::ref-spec {:refs? true}))))) #?(:clj (deftest test-parameter-validation @@ -207,7 +287,8 @@ (testing "all expectations pass the swagger spec validation" (doseq [[spec] exceptations] - (is (= nil (-> spec swagger/transform swagger-spec v/validate)))))))) + (is (= nil (-> spec swagger/transform swagger-spec v/validate))) + (is (nil? (-> spec (swagger/transform {:refs? true}) swagger-spec v/validate)))))))) (s/def ::id string?) (s/def ::name string?) @@ -324,6 +405,26 @@ :path (st/create-spec {:spec (s/keys :req [::id])}) :body (st/create-spec {:spec ::address})}})))) + (testing "::parameters with refs" + (is (= + {:parameters [{:in "body", + :name "spec-tools.swagger.core-test/ref-spec", + :description "", + :required true, + :schema {:$ref "#/definitions/RefSpec"}}], + :definitions {"RefSpec" {:type "object", + :properties {"integer" {:type "integer"}, + "spec" {:type "string", + :description "description", + :title "spec-tools.swagger.core-test/spec", + :default "123", + :example "swagger-example"}}, + :required ["integer" "spec"], + :description "description"}}} + (swagger/swagger-spec + {::swagger/parameters {:body ::ref-spec}} + {:refs? true})))) + (testing "::responses" (is (= {:responses {200 {:schema @@ -347,7 +448,49 @@ {:responses {404 {:description "fail"} 500 {:description "fail"}} ::swagger/responses {200 {:schema ::user} - 404 {:description "Ohnoes."}}}))))) + 404 {:description "Ohnoes."}}})))) + + (testing "::responses with refs" + (is (= + {:responses + {200 {:schema + {:$ref "#/definitions/User"}, + :description ""}}, + :definitions {"User" + {:type "object", + :properties {"id" {:type "string"}, + "name" {:type "string"}, + "address" {:$ref "#/definitions/spec-tools.swagger.core-test.address"}}, + :required ["id" "name" "address"]} + "spec-tools.swagger.core-test.address" + {:type "object", + :properties {"street" {:type "string"}, + "city" {:enum [:tre :hki], + :type "string", + :x-nullable true}}, + :required ["street" "city"]}}} + (swagger/swagger-spec + {::swagger/responses {200 {:schema (st/create-spec + {:spec ::user + :swagger/title "User"})}}} + {:refs? true})))) + + (testing "::responses with refs in additionalProperties" + (is (= + {:responses {200 {:schema {:$ref "#/definitions/Every Test"}, :description ""}}, + :definitions {"Every Test" {:type "object", + :additionalProperties {:$ref "#/definitions/spec-tools.swagger.core-test.address"}}, + "spec-tools.swagger.core-test.address" {:type "object", + :properties {"street" {:type "string"}, + "city" {:enum [:tre :hki], + :type "string", + :x-nullable true}}, + :required ["street" "city"]}}} + (swagger/swagger-spec + {::swagger/responses {200 {:schema (st/create-spec + {:spec (s/every-kv ::id ::address) + :swagger/title "Every Test"})}}} + {:refs? true}))))) #?(:clj (deftest test-schema-validation @@ -372,7 +515,8 @@ ::swagger/responses {200 {:schema ::user :description "Found it!"} 404 {:description "Ohnoes."}}}}}}] - (is (nil? (-> data swagger/swagger-spec v/validate)))))) + (is (nil? (-> data swagger/swagger-spec v/validate))) + (is (nil? (-> data (swagger/swagger-spec {:refs? true}) v/validate)))))) (deftest backport-swagger-meta-unnamespaced (is (= (swagger/transform