From 7082254be295360f95d17c0dcc2c6e0775ac7959 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Thu, 6 Apr 2023 07:30:29 +0800 Subject: [PATCH 1/6] Add :refs? option to swagger/transform - Update Clojure to 1.11.1 so we can access `update-vals` - Add an option to swagger/transform to allow users to specify that they want to create references - When references are to be created create Swagger $refs and return the definitions under a ::swagger/definitions key in the top level map --- README.md | 2 +- src/spec_tools/swagger/core.cljc | 50 +++++++++++++++- test/cljc/spec_tools/swagger/core_test.cljc | 65 ++++++++++++++++++++- 3 files changed, 112 insertions(+), 5 deletions(-) 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..cfa052aa 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] @@ -89,16 +90,59 @@ (defmethod accept-spec ::default [dispatch spec children options] (json-schema/accept-spec dispatch spec children options)) +(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 (map ::definitions (vals (:properties swagger))))] + (if definitions + (-> swagger + (assoc ::definitions definitions) + (update :properties update-vals #(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 diff --git a/test/cljc/spec_tools/swagger/core_test.cljc b/test/cljc/spec_tools/swagger/core_test.cljc index 11b5973b..4e156c47 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", @@ -207,7 +269,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?) From ad9c9ce48b7bfb14349ffcfcc0b89c98b61f8d50 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Thu, 6 Apr 2023 15:27:22 +0800 Subject: [PATCH 2/6] Raise references through parameters --- src/spec_tools/swagger/core.cljc | 50 ++++++++++++--------- test/cljc/spec_tools/swagger/core_test.cljc | 20 ++++++++- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index cfa052aa..ab6a370e 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -148,28 +148,34 @@ ;; 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 diff --git a/test/cljc/spec_tools/swagger/core_test.cljc b/test/cljc/spec_tools/swagger/core_test.cljc index 4e156c47..bf643381 100644 --- a/test/cljc/spec_tools/swagger/core_test.cljc +++ b/test/cljc/spec_tools/swagger/core_test.cljc @@ -252,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 From 2da89ac1421a146cb0ae4f8e77cb0400889dde47 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Thu, 6 Apr 2023 15:29:28 +0800 Subject: [PATCH 3/6] Raise references to the top of the Swagger doc --- src/spec_tools/swagger/core.cljc | 57 +++++++++++++++------ test/cljc/spec_tools/swagger/core_test.cljc | 50 +++++++++++++++++- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index ab6a370e..e03fd902 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -181,20 +181,27 @@ ;; expand the spec ;; +(defn- update-if [m k f & args] + (if (contains? m k) + (apply update m k f args) + m)) (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 ::parameters [_ v acc _] +(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 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 @@ -207,23 +214,33 @@ (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 [x] + (cond-> x + (:paths x) (-> + (assoc :definitions (apply merge (map :definitions (mapcat vals (vals (:paths x)))))) + (update :paths update-vals (fn [path] (update-vals path #(dissoc % :definitions))))))) + ;; ;; generate the swagger spec ;; @@ -232,8 +249,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 bf643381..b0adb111 100644 --- a/test/cljc/spec_tools/swagger/core_test.cljc +++ b/test/cljc/spec_tools/swagger/core_test.cljc @@ -405,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 @@ -428,7 +448,32 @@ {: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}))))) #?(:clj (deftest test-schema-validation @@ -453,7 +498,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 From 9e32739afaa76bf2ada1342022c093a924107f84 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Thu, 13 Apr 2023 18:43:11 +1000 Subject: [PATCH 4/6] Handle additionalProperties properly --- src/spec_tools/swagger/core.cljc | 16 ++++++++++------ test/cljc/spec_tools/swagger/core_test.cljc | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index e03fd902..f27ec2ba 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -90,6 +90,11 @@ (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] @@ -100,11 +105,14 @@ 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 (map ::definitions (vals (:properties swagger))))] + (let [definitions (apply merge + (::definitions (:additionalProperties swagger)) + (map ::definitions (vals (:properties swagger))))] (if definitions (-> swagger (assoc ::definitions definitions) - (update :properties update-vals #(dissoc % ::definitions))) + (update-if :properties update-vals #(dissoc % ::definitions)) + (update-if :additionalProperties dissoc ::definitions)) swagger)))) (defmethod create-or-raise-refs "array" [swagger _] @@ -181,10 +189,6 @@ ;; expand the spec ;; -(defn- update-if [m k f & args] - (if (contains? m k) - (apply update m k f args) - m)) (defmulti expand (fn [k _ _ _] k)) (defmethod expand ::responses [_ v acc options] diff --git a/test/cljc/spec_tools/swagger/core_test.cljc b/test/cljc/spec_tools/swagger/core_test.cljc index b0adb111..5f4bdcd6 100644 --- a/test/cljc/spec_tools/swagger/core_test.cljc +++ b/test/cljc/spec_tools/swagger/core_test.cljc @@ -473,6 +473,23 @@ {::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 From 65e8235bdd12182a62eba73e2f08ce91f0913d70 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Tue, 18 Apr 2023 19:27:29 +1000 Subject: [PATCH 5/6] Remove a nil :definitions key --- src/spec_tools/swagger/core.cljc | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index f27ec2ba..0a7ee9e0 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -239,11 +239,14 @@ x)) x))) -(defn- raise-refs-to-top [x] - (cond-> x - (:paths x) (-> - (assoc :definitions (apply merge (map :definitions (mapcat vals (vals (:paths x)))))) - (update :paths update-vals (fn [path] (update-vals path #(dissoc % :definitions))))))) +(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 From 98ffa587497435b5e388c9a6097d4948788e4780 Mon Sep 17 00:00:00 2001 From: Hugh Powell Date: Fri, 4 Aug 2023 11:54:30 +1000 Subject: [PATCH 6/6] Extract child definition when necessary --- src/spec_tools/swagger/core.cljc | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/spec_tools/swagger/core.cljc b/src/spec_tools/swagger/core.cljc index 0a7ee9e0..49852134 100644 --- a/src/spec_tools/swagger/core.cljc +++ b/src/spec_tools/swagger/core.cljc @@ -31,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))