diff --git a/src/malli/json_schema.cljc b/src/malli/json_schema.cljc index 9011500c2..764e74c7a 100644 --- a/src/malli/json_schema.cljc +++ b/src/malli/json_schema.cljc @@ -7,12 +7,21 @@ (defprotocol JsonSchema (-accept [this children options] "transforms schema to JSON Schema")) -(defn -ref [x] {:$ref (str "#/definitions/" x)}) +(defn -ref [x] {:$ref (apply str "#/definitions/" + (cond + ;; / must be encoded as ~1 in JSON Schema + (qualified-keyword? x) [(namespace x) "~1" + (name x)] + (keyword? x) [(name x)] + :else [x]))}) (defn -schema [schema {::keys [transform definitions] :as options}] (let [result (transform (m/deref schema) options)] (if-let [ref (m/-ref schema)] - (do (swap! definitions assoc ref result) (-ref ref)) + (let [ref* (-ref ref)] + (when-not (= ref* result) ; don't create circular definitions + (swap! definitions assoc ref result)) + ref*) result))) (defn select [m] (select-keys m [:title :description :default])) diff --git a/src/malli/swagger.cljc b/src/malli/swagger.cljc index 87e9543ea..de03f6832 100644 --- a/src/malli/swagger.cljc +++ b/src/malli/swagger.cljc @@ -1,5 +1,6 @@ (ns malli.swagger (:require [clojure.set :as set] + [clojure.walk :as walk] [malli.core :as m] [malli.json-schema :as json-schema])) @@ -54,6 +55,10 @@ (defn -transform [?schema options] (m/walk ?schema -swagger-walker options)) +(defn -remove-empty-keys + [m] + (into (empty m) (filter (comp not nil? val) m))) + ;; ;; public api ;; @@ -67,3 +72,85 @@ ::json-schema/definitions definitions ::json-schema/transform -transform})] (cond-> (-transform ?schema options) (seq @definitions) (assoc :definitions @definitions))))) + +(defmulti extract-parameter (fn [in _] in)) + +(defmethod extract-parameter :body [_ schema] + (let [swagger-schema (transform schema {:in :body, :type :parameter})] + [{:in "body" + :name (:title swagger-schema "body") + :description (:description swagger-schema "") + :required (not= :maybe (m/type schema)) + :schema swagger-schema}])) + +(defmethod extract-parameter :default [in schema] + (let [{:keys [properties required definitions]} (transform schema {:in in, :type :parameter})] + (println "\nextract-parameter definitions:" + (with-out-str (clojure.pprint/pprint definitions))) + (mapv + (fn [[k {:keys [type] :as schema}]] + (merge + {:in (name in) + :name k + :description (:description schema "") + :type type + :required (contains? (set required) k)} + schema)) + properties))) + +(defmulti expand (fn [k _ _ _] k)) + +(defmethod expand ::responses [_ v acc _] + {:responses + (into + (or (:responses acc) {}) + (for [[status response] v] + [status (-> response + (update :schema transform {:type :schema}) + (update :description (fnil identity "")) + -remove-empty-keys)]))}) + +(defmethod expand ::parameters [_ v acc _] + (let [old (or (:parameters acc) []) + new (mapcat (fn [[in spec]] (extract-parameter in spec)) v) + merged (->> (into old new) + reverse + (reduce + (fn [[ps cache :as acc] p] + (let [c (select-keys p [:in :name])] + (if (cache c) + acc + [(conj ps p) (conj cache c)]))) + [[] #{}]) + first + reverse + vec)] + {:parameters merged})) + +(defn expand-qualified-keywords + [x options] + (let [accept? (-> expand methods keys set)] + (walk/postwalk + (fn [x] + (if (map? x) + (reduce-kv + (fn [acc k v] + (if (accept? k) + (let [expanded (expand k v acc options) + parameters (:parameters expanded) + responses (:responses expanded) + definitions (if parameters + (-> parameters first :schema :definitions) + (->> responses vals (map :schema) + (map :definitions) (apply merge)))] + (-> acc (dissoc k) (merge expanded) (update :definitions merge definitions))) + acc)) + x x) + x)) + x))) + +(defn swagger-spec + ([x] + (swagger-spec x nil)) + ([x options] + (expand-qualified-keywords x options))) diff --git a/test/malli/experimental/time/json_schema_test.cljc b/test/malli/experimental/time/json_schema_test.cljc index c13a2269b..7933b964f 100644 --- a/test/malli/experimental/time/json_schema_test.cljc +++ b/test/malli/experimental/time/json_schema_test.cljc @@ -8,10 +8,10 @@ (t/is (= {:type "object", :properties - {:date {:$ref "#/definitions/:time/local-date"}, - :time {:$ref "#/definitions/:time/offset-time"}, - :date-time {:$ref "#/definitions/:time/offset-date-time"}, - :duration {:$ref "#/definitions/:time/duration"}}, + {:date {:$ref "#/definitions/time~1local-date"}, + :time {:$ref "#/definitions/time~1offset-time"}, + :date-time {:$ref "#/definitions/time~1offset-date-time"}, + :duration {:$ref "#/definitions/time~1duration"}}, :required [:date :time :date-time :duration], :definitions #:time{:local-date {:type "string", :format "date"}, diff --git a/test/malli/json_schema_test.cljc b/test/malli/json_schema_test.cljc index b6b3d5a07..4896e2494 100644 --- a/test/malli/json_schema_test.cljc +++ b/test/malli/json_schema_test.cljc @@ -205,58 +205,64 @@ {:registry registry})))))) (deftest references-test - (is (= {:$ref "#/definitions/Order", - :definitions {"Country" {:type "object", - :properties {:name {:type "string" - :enum [:FI :PO]}, - :neighbors {:type "array" - :items {:$ref "#/definitions/Country"}}}, - :required [:name :neighbors]}, - "Burger" {:type "object", - :properties {:name {:type "string"}, - :description {:type "string"}, - :origin {:oneOf [{:$ref "#/definitions/Country"} {:type "null"}]}, - :price {:type "integer" - :minimum 1}}, - :required [:name :origin :price]}, - "OrderLine" {:type "object", - :properties {:burger {:$ref "#/definitions/Burger"}, - :amount {:type "integer"}}, - :required [:burger :amount]}, - "Order" {:type "object", - :properties {:lines {:type "array" - :items {:$ref "#/definitions/OrderLine"}}, - :delivery {:type "object", - :properties {:delivered {:type "boolean"}, - :address {:type "object", - :properties {:street {:type "string"}, - :zip {:type "integer"}, - :country {:$ref "#/definitions/Country"}}, - :required [:street :zip :country]}}, - :required [:delivered :address]}}, - :required [:lines :delivery]}}} - (json-schema/transform - [:schema - {:registry {"Country" [:map - [:name [:enum :FI :PO]] - [:neighbors [:vector [:ref "Country"]]]] - "Burger" [:map - [:name string?] - [:description {:optional true} string?] - [:origin [:maybe "Country"]] - [:price pos-int?]] - "OrderLine" [:map - [:burger "Burger"] - [:amount int?]] - "Order" [:map - [:lines [:vector "OrderLine"]] - [:delivery [:map - [:delivered boolean?] - [:address [:map - [:street string?] - [:zip int?] - [:country "Country"]]]]]]}} - "Order"])))) + (testing "absolute doc root definitions are created for ref schemas" + (is (= {:$ref "#/definitions/Order", + :definitions {"Country" {:type "object", + :properties {:name {:type "string" + :enum [:FI :PO]}, + :neighbors {:type "array" + :items {:$ref "#/definitions/Country"}}}, + :required [:name :neighbors]}, + "Burger" {:type "object", + :properties {:name {:type "string"}, + :description {:type "string"}, + :origin {:oneOf [{:$ref "#/definitions/Country"} {:type "null"}]}, + :price {:type "integer" + :minimum 1}}, + :required [:name :origin :price]}, + "OrderLine" {:type "object", + :properties {:burger {:$ref "#/definitions/Burger"}, + :amount {:type "integer"}}, + :required [:burger :amount]}, + "Order" {:type "object", + :properties {:lines {:type "array" + :items {:$ref "#/definitions/OrderLine"}}, + :delivery {:type "object", + :properties {:delivered {:type "boolean"}, + :address {:type "object", + :properties {:street {:type "string"}, + :zip {:type "integer"}, + :country {:$ref "#/definitions/Country"}}, + :required [:street :zip :country]}}, + :required [:delivered :address]}}, + :required [:lines :delivery]}}} + (json-schema/transform + [:schema + {:registry {"Country" [:map + [:name [:enum :FI :PO]] + [:neighbors [:vector [:ref "Country"]]]] + "Burger" [:map + [:name string?] + [:description {:optional true} string?] + [:origin [:maybe "Country"]] + [:price pos-int?]] + "OrderLine" [:map + [:burger "Burger"] + [:amount int?]] + "Order" [:map + [:lines [:vector "OrderLine"]] + [:delivery [:map + [:delivered boolean?] + [:address [:map + [:street string?] + [:zip int?] + [:country "Country"]]]]]]}} + "Order"])))) + + (testing "circular definitions are not created" + (is (= {:$ref "#/definitions/Foo", :definitions {"Foo" {:type "integer"}}} + (json-schema/transform + (mu/closed-schema [:schema {:registry {"Foo" :int}} "Foo"])))))) (deftest function-schema-test (is (= {} (json-schema/transform [:=> [:cat int? int?] int?])))) diff --git a/test/malli/swagger_test.cljc b/test/malli/swagger_test.cljc index 4d112f944..3ab8683a7 100644 --- a/test/malli/swagger_test.cljc +++ b/test/malli/swagger_test.cljc @@ -276,3 +276,157 @@ [:zip int?] [:country "Country"]]]]]]}} "Order"])))) + +(deftest swagger-spec-test + (testing "generates swagger for ::parameters and ::responses w/ basic schema" + (is (= {:definitions nil, + :parameters [{:description "", + :in "body", + :name "body", + :required true, + :schema {:properties {:foo {:type "string"}} + :required [:foo], :type "object"}}], + :responses {200 {:description "", + :schema {:properties {:bar {:type "string"}} + :required [:bar], :type "object"}}}} + (swagger/swagger-spec {::swagger/parameters + {:body [:map [:foo :string]]} + ::swagger/responses + {200 {:schema [:map [:bar :keyword]]}}})))) + (testing "generates swagger for ::parameters w/ basic schema + registry" + (let [registry (merge (m/type-schemas) + {::body [:string {:min 1}]})] + (is (= {:definitions {::body {:minLength 1, :type "string"}}, + :parameters [{:description "", + :in "body", + :name "body", + :required true, + :schema {:$ref "#/definitions/malli.swagger-test~1body", + :definitions {::body {:minLength 1, :type "string"}}}}]} + (swagger/swagger-spec {::swagger/parameters + {:body (m/schema ::body + {:registry registry})}}))))) + + (testing "generates swagger for ::responses w/ basic schema + registry" + (let [registry (merge (m/base-schemas) (m/type-schemas) + {::success [:map-of :keyword :string] + ::error [:string {:min 1}]})] + (is (= {:definitions {::error {:minLength 1, :type "string"}, + ::success {:additionalProperties {:type "string"}, + :type "object"}}, + :responses {200 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1success", + :definitions {::success {:additionalProperties {:type "string"}, + :type "object"}}}}, + 400 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1error", + :definitions {::error {:minLength 1, :type "string"}}}}}} + (swagger/swagger-spec {::swagger/responses + {200 {:schema (m/schema ::success + {:registry registry})} + 400 {:schema (m/schema ::error + {:registry registry})}}}))))) + + (testing "generates swagger for ::parameters and ::responses w/ basic schema + registry" + (let [registry (merge (m/base-schemas) (m/type-schemas) (m/comparator-schemas) + {::req-body [:map-of :keyword :any] + ::success-resp [:map [:it [:= "worked"]]] + ::error-resp [:string {:min 1}]})] + (is (= {:definitions {::error-resp {:minLength 1, :type "string"}, + ::req-body {:additionalProperties {}, :type "object"}, + ::success-resp {:properties {:it {:const "worked"}}, + :required [:it], + :type "object"}}, + :parameters [{:description "", + :in "body", + :name "body", + :required true, + :schema {:$ref "#/definitions/malli.swagger-test~1req-body", + :definitions {::req-body {:additionalProperties {}, + :type "object"}}}}], + :responses {200 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1success-resp", + :definitions {::success-resp {:properties {:it {:const "worked"}}, + :required [:it], + :type "object"}}}}, + 400 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1error-resp", + :definitions {::error-resp {:minLength 1, + :type "string"}}}}}} + (swagger/swagger-spec {::swagger/parameters + {:body (m/schema ::req-body + {:registry registry})} + ::swagger/responses + {200 {:schema (m/schema ::success-resp + {:registry registry})} + 400 {:schema (m/schema ::error-resp + {:registry registry})}}}))))) + + ;; This test currently fails due to https://github.com/metosin/malli/issues/464 + ;; TODO: Uncomment it when #464 is fixed + #_(testing "generates swagger for ::parameters and ::responses w/ recursive schema + registry" + (let [registry (merge (m/base-schemas) (m/type-schemas) + (m/comparator-schemas) (m/sequence-schemas) + {::a [:or + :string + [:ref ::b]] + ::b [:or + :keyword + [:ref ::c]] + ::c [:or + :symbol + [:ref ::a]] + ;; test would pass if the schema below were e.g. + ;; [:map [:a ::a] [:b ::b] [:c ::c]] (and the + ;; ::req-body expected adjusted accordingly) + ;; b/c then ::b & ::c would be directly used, not just refs + ::req-body [:map [:a ::a]] + ::success-resp [:map-of :keyword :string] + ::error-resp :string})] + (is (= {:definitions {::a {:type "string", + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1b"}]}, + ::b {:type "string" + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1c"}]} + ::c {:type "string" + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1a"}]} + ::error-resp {:type "string"}, + ::req-body {:properties {:a {:$ref "#/definitions/malli.swagger-test~1a"}}, + :required [:a], + :type "object"}, + ::success-resp {:additionalProperties {:type "string"}, + :type "object"}}, + :parameters [{:description "", + :in "body", + :name "body", + :required true, + :schema {:$ref "#/definitions/malli.swagger-test~1req-body", + :definitions {::a {:type "string", + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1b"}]}, + ::b {:type "string" + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1c"}]} + ::c {:type "string" + :x-anyOf [{:type "string"} + {:$ref "#/definitions/malli.swagger-test~1a"}]} + ::req-body {:properties {:a {:$ref "#/definitions/malli.swagger-test~1a"}}, + :required [:a], + :type "object"}}}}], + :responses {200 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1success-resp", + :definitions {:malli.swagger-test/success-resp {:additionalProperties {:type "string"}, + :type "object"}}}}, + 400 {:description "", + :schema {:$ref "#/definitions/malli.swagger-test~1error-resp", + :definitions {:malli.swagger-test/error-resp {:type "string"}}}}}} + (swagger/swagger-spec {::swagger/parameters + {:body (m/schema ::req-body + {:registry registry})} + ::swagger/responses + {200 {:schema (m/schema ::success-resp + {:registry registry})} + 400 {:schema (m/schema ::error-resp + {:registry registry})}}}))))))