diff --git a/CHANGES.md b/CHANGES.md index 8e7668f..b2dbb97 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,12 @@ We use [Break Versioning][breakver]. The version numbers follow a `. x su/class-schema :schema)] (let [name #?(:clj (.getSimpleName ^Class x), :cljs (some-> su/class-schema :klass pr-str (str/split "/") last))] - (s/named schema (str name "Record"))))) + (s/schema-with-name schema (str name "Record"))))) (defn- collection-schema [e options] @@ -47,10 +48,9 @@ (into (empty m) (filter (comp not nil? val) m))) (defn schema-name - [schema opts] + [schema _opts] (when-let [name (some-> - (or (:name opts) - (s/schema-name schema) + (or (s/schema-name schema) (when (instance? #?(:clj schema.core.NamedSchema :cljs s/NamedSchema) schema) @@ -76,10 +76,10 @@ (into (empty schema)))) (defn additional-properties - [schema] + [schema opts] (if-let [extra-key (s/find-extra-keys-schema schema)] (let [v (get schema extra-key)] - (transform v nil)) + (transform v opts)) false)) (defn object-schema @@ -89,16 +89,15 @@ {:type "object" :title (schema-name this opts) :properties (properties this opts) - :additionalProperties (additional-properties this) + :additionalProperties (additional-properties this opts) :required (some->> (filterv s/required-key? (keys this)) (seq) (mapv key-name))}))) (defn not-supported! [schema] - (ex-info - (str "don't know how to convert " schema " into a OpenAPI schema. ") - {:schema schema})) + (throw (ex-info (str "don't know how to convert " schema " into a OpenAPI schema. ") + {:schema schema}))) ;; ;; transformations @@ -217,7 +216,13 @@ (defprotocol OpenapiSchema (-transform [this opts])) -(defn transform +(def ref-root "#/components/schemas/") + +(defn- ref-name + [name] + (str/replace name "/" ".")) + +(defn- transform-one [schema opts] (if (satisfies? OpenapiSchema schema) (-transform schema opts) @@ -225,6 +230,33 @@ (transform rschema opts) (transform-type schema opts)))) +(defn transform + [schema opts] + (let [inline? (::inline? opts) + toplevel? (nil? (::definitions opts)) + opts (-> opts + (update ::definitions #(or % (atom {}))) + (dissoc ::inline?)) + definitions (::definitions opts) + name (some-> (schema-name schema opts) ref-name) + transformed (cond + (or inline? (not name)) + (transform-one schema opts) + + (get @definitions name) + {:$ref (str ref-root name)} + + :else + (do + (swap! definitions assoc name ::recursion-stopper) + (swap! definitions assoc name (transform-one schema opts)) + {:$ref (str ref-root name)}))] + (cond-> transformed (and toplevel? (seq @definitions)) (assoc :definitions @definitions)))) + +(defn transform-inline + [schema opts] + (transform schema (assoc opts ::inline? true))) + (extend-protocol OpenapiSchema nil @@ -234,7 +266,10 @@ (-transform [{:keys [schema data]} opts] (or (:openapi data) (merge - (transform schema (merge opts (select-keys data [:name]))) + (transform (if-let [name (:name data)] + (s/schema-with-name schema name) + schema) + opts) (select-keys data [:description]) (impl/unlift-keys data "openapi")))) @@ -264,9 +299,9 @@ (-transform [this opts] {:oneOf (mapv #(transform % opts) (:schemas this))}) - #_#_schema.core.Recursive + schema.core.Recursive (-transform [this opts] - (transform (:derefable this) opts)) + (transform @(:derefable this) opts)) schema.core.EqSchema (-transform [this opts] @@ -299,7 +334,7 @@ schema.core.NamedSchema (-transform [{:keys [schema name]} opts] - (transform schema (assoc opts :name name))) + (transform-inline (s/schema-with-name schema name) opts)) #?(:clj clojure.lang.Sequential :cljs cljs.core/List) @@ -325,146 +360,3 @@ :cljs cljs.core.PersistentHashMap) (-transform [this opts] (object-schema this opts))) - -;; -;; Extract OpenAPI parameters -;; - -(defn- is-nilable? - [spec] - (and (contains? spec :oneOf) - (= 2 (count (:oneOf spec))) - (-> :type - (group-by (:oneOf spec)) - (contains? "null")))) - -(defn- extract-nilable - [spec] - (->> (:oneOf spec) - (remove #(= (:type %) "null")) - (first))) - -(defn- extract-single-param - [in spec] - (let [nilable? (is-nilable? spec) - new-spec (if nilable? - (extract-nilable spec) - spec)] - {:name (or (schema-name new-spec nil) - (:title new-spec) - (:type new-spec)) - :in in - :description (or (:description spec) - "") - :required (case in - :path true - (not nilable?)) - :schema new-spec})) - -(defn- extract-object-param - [in {:keys [properties required]}] - (mapv - (fn [[k schema]] - {:name (or (schema-name schema nil) - (key-name k)) - :in (name in) - :description (or (:description schema) - "") - :required (case in - :path true - (contains? (set required) k)) - :schema schema}) - properties)) - -(defn extract-parameter - [in spec] - (let [parameter-spec (transform spec nil) - object? (and (contains? parameter-spec :properties) - (= "object" (:type parameter-spec)))] - (if object? - (extract-object-param in parameter-spec) - (-> (extract-single-param in parameter-spec) vector)))) - -;; -;; expand the spec -;; - -(defmulti expand (fn [k _ _ _] k)) - -(defmethod expand ::schemas - [_ v acc _] - {:schemas - (into - (or (:schemas acc) {}) - (for [[name schema] v] - {name (transform schema nil)}))}) - -(defmethod expand ::content - [_ v acc _] - {:content - (into - (or (:content acc) {}) - (for [[content-type schema] v] - {content-type {:schema (transform schema nil)}}))}) - -(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-not (cache c) - [(conj ps p) (conj cache c)] - acc))) - [[] #{}]) - (first) - (reverse) - (vec))] - {:parameters merged})) - -(defmethod expand ::headers - [_ v acc _] - {:headers - (into - (or (:headers acc) {}) - (for [[name spec] v] - {name (-> (extract-single-param :header (transform spec nil)) - (dissoc :in :name))}))}) - -(defn expand-qualified-keywords - [x options] - (let [accept? (set (keys (methods expand)))] - (walk/postwalk - (fn [x] - (if (plain-map? x) - (reduce-kv - (fn [acc k v] - (if (accept? k) - (-> acc (dissoc k) (merge (expand k v acc options))) - acc)) - x - x) - x)) - x))) - -;; -;; Generate the OpenAPI spec -;; - -;; Top-level openapi spec generation was moved to reitit in -;; https://github.com/metosin/reitit/pull/638 -;; -;; Once reitit-0.7.0-alpha6 has been out for some time, this can be -;; deleted since it should have no other users. -(defn ^:deprecated openapi-spec - "Transform data into an OpenAPI spec. Input data must conform to the Swagger3 - Spec (https://swagger.io/specification/) with a exception that it can have - any qualified keywords which are expanded with the - `schema-tools.openapi.core/expand` multimethod." - ([x] - (openapi-spec x nil)) - ([x options] - (expand-qualified-keywords x options))) diff --git a/test/cljc/schema_tools/openapi/core_test.cljc b/test/cljc/schema_tools/openapi/core_test.cljc index 83f0be4..67051d2 100644 --- a/test/cljc/schema_tools/openapi/core_test.cljc +++ b/test/cljc/schema_tools/openapi/core_test.cljc @@ -74,11 +74,21 @@ :items {:type "string"}}] [Item - {:type "object" - :title "schema-tools.openapi.core-test/Item" - :properties {"field" {:type "string"}} - :additionalProperties false - :required ["field"]}] + {:$ref "#/components/schemas/schema-tools.openapi.core-test.Item" + :definitions {"schema-tools.openapi.core-test.Item" {:type "object" + :title "schema-tools.openapi.core-test/Item" + :properties {"field" {:type "string"}} + :additionalProperties false + :required ["field"]}}}] + + [{s/Keyword Item} + {:type "object" + :additionalProperties {:$ref "#/components/schemas/schema-tools.openapi.core-test.Item"} + :definitions {"schema-tools.openapi.core-test.Item" {:type "object" + :title "schema-tools.openapi.core-test/Item" + :properties {"field" {:type "string"}} + :additionalProperties false + :required ["field"]}}}] [(st/schema {:field s/Str}) {:type "object" @@ -87,11 +97,12 @@ :required ["field"]}] [(st/schema {:field s/Str} {:name "OpenAPI"}) - {:type "object" - :title "OpenAPI" - :properties {"field" {:type "string"}} - :additionalProperties false - :required ["field"]}] + {:$ref "#/components/schemas/OpenAPI" + :definitions {"OpenAPI" {:type "object" + :title "OpenAPI" + :properties {"field" {:type "string"}} + :additionalProperties false + :required ["field"]}}}] [(st/schema {:field s/Str} {:openapi {:type "string" :format "bytes"}}) @@ -114,9 +125,10 @@ {:type "number" :multipleOf 2}]}] [(s/named {} "Named") - {:type "object" - :title "Named" - :additionalProperties false}] + {:$ref "#/components/schemas/Named" + :definitions {"Named" {:type "object" + :title "Named" + :additionalProperties false}}}] [(s/pred neg? 'neg?) {:type "number" @@ -144,11 +156,12 @@ ;; clj only #?(:clj [Param - {:type "object" - :title "ParamRecord" - :properties {"a" {:type "string"}} - :additionalProperties false - :required ["a"]}]) + {:$ref "#/components/schemas/ParamRecord" + :definitions {"ParamRecord" {:type "object" + :title "ParamRecord" + :properties {"a" {:type "string"}} + :additionalProperties false + :required ["a"]}}}]) #?(:clj [java.util.regex.Pattern @@ -194,470 +207,35 @@ (is (= "string" (:type spec))) (is (= (set ["s" "l" "m"]) (set (:enum spec))))))) -(def Id s/Int) -(def Name s/Str) -(def Street s/Str) -(s/defschema City (st/schema (s/maybe (s/enum :tre :hki)) - {:openapi/description "a city"})) -(s/defschema Filters [s/Str]) -(s/defschema Address - {:street Street - :city City}) -(s/defschema User - {:id Id - :name Name - :address Address}) -(def Token s/Str) - -(deftest expand-test - (testing "::parameters" - (is (= {:parameters - [{:name "username" - :in "path" - :description "username to fetch" - :required true - :schema {:type "string"} - :style "simple"} - {:name "id" - :in "path" - :description "" - :required true - :schema {:type "integer" - :format "int32"}} - {:name "name" - :in "query" - :description "" - :required true - :schema {:type "string"}} - {:name "city" - :in "query" - :description "a city" - :required false - :schema {:description "a city" - :oneOf [{:enum [:tre :hki], :type "string"} ;from set - {:type "null"}]}} - {:name "street" - :in "query" - :description "" - :required false - :schema {:type "string"}} - {:name "filters" - :in "query" - :description "" - :required false - :schema {:type "array" - :items {:type "string"}}} - {:name "id" - :in "header" - :description "" - :required true - :schema {:type "integer" - :format "int32"}} - {:name "name" - :in "header" - :description "" - :required true - :schema {:type "string"}} - {:name "address" - :in "header" - :description "" - :required true - :schema - {:type "object" - :properties - {"street" {:type "string"} - "city" {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}}]} - (openapi/openapi-spec - {:parameters - [{:name "username" - :in "path" - :description "username to fetch" - :required true - :schema {:type "string"} - :style "simple"}] - ::openapi/parameters - {:path {:id Id} - :query {:name Name - (s/optional-key :city) City - (s/optional-key :street) Street - (s/optional-key :filters) Filters} - :header User}}))) - - (is (= {:parameters - [{:name "name2" - :in "query" - :description "Will be the same" - :required true - :schema {:type "string"}} - {:name "id" - :in "path" - :description "" - :required true - :schema {:type "integer" :format "int32"}} - {:name "city" - :in "query" - :description "a city" - :required true - :schema {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} {:type "null"}]}} - {:name "name" - :in "query" - :description "" - :required false - :schema {:type "string"}} - {:name "street" - :in "query" - :description "" - :required false - :schema {:type "string"}} - {:name "filters" - :in "query" - :description "" - :required false - :schema {:type "array" :items {:type "string"}}} - {:name "street" - :in "cookie" - :description "" - :required true - :schema {:type "string"}} - {:name "city" - :in "cookie" - :description "a city" - :required true - :schema {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} {:type "null"}]}}]} - (openapi/openapi-spec - {:parameters - [{:name "name" - :in "query" - :description "Will be overridden" - :required false - :schema {:type "string"}} - {:name "name2" - :in "query" - :description "Will be the same" - :required true - :schema {:type "string"}}] - ::openapi/parameters - {:path {:id Id} - :query {:city City - (s/optional-key :name) Name - (s/optional-key :street) Street - (s/optional-key :filters) Filters} - :cookie Address}})))) - - (testing "::schemas" - (is (= {:components - {:schemas - {:some-object - {:type "object" - :properties - {"name" {:type "string"} - "desc" {:type "string"}}} - :id {:type "integer" :format "int32"} - :user - {:type "object" - :properties - {"id" {:type "integer" :format "int32"}, - "name" {:type "string"} - "address" {:type "object" - :properties - {"street" {:type "string"}, - "city" {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}} - :required ["id" "name" "address"] - :additionalProperties false - :title "schema-tools.openapi.core-test/User"} - :address - {:type "object" - :properties - {"street" {:type "string"} - "city" {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"} - :some-request - {:type "object" - :properties - {"id" {:type "integer" :format "int32"} - "name" {:type "string"} - "street" {:type "string"} - "filters" {:type "array" :items {:type "string"}}} - :required ["id" "name"] - :additionalProperties false}}}} - (openapi/openapi-spec - {:components - {:schemas - {:some-object - {:type "object" - :properties - {"name" {:type "string"} - "desc" {:type "string"}}} - :user - {:type "string" - :title "Will be overridden"}} - ::openapi/schemas - {:id Id - :user User - :address Address - :some-request {:id Id - :name Name - (s/optional-key :street) Street - (s/optional-key :filters) Filters}}}})))) - - (testing "::content" - (is (= {:content - {"text/html" - {:schema - {:type "string"}} - "application/json" - {:schema - {:type "object" - :properties - {"id" {:type "integer" :format "int32"} - "name" {:type "string"} - "address" - {:type "object" - :properties - {"street" {:type "string"} - "city" - {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}} - :required ["id" "name" "address"] - :additionalProperties false - :title "schema-tools.openapi.core-test/User"}} - "application/xml" - {:schema - {:type "object" - :properties - {"street" {:type "string"} - "city" - {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}} - "*/*" - {:schema - {:type "object" - :properties - {"id" {:type "integer" :format "int32"} - "name" {:type "string"} - "street" {:type "string"} - "filters" {:type "array" :items {:type "string"}}} - :required ["id" "name"] - :additionalProperties false}}}} - (openapi/openapi-spec - {:content - {"text/html" - {:schema - {:type "string"}}} - ::openapi/content - {"application/json" User - "application/xml" Address - "*/*" {:id Id - :name Name - (s/optional-key :street) Street - (s/optional-key :filters) Filters}}}))) - - (is (= {:content - {"application/json" - {:schema - {:type "object" - :properties - {"id" {:type "integer" :format "int32"} - "name" {:type "string"} - "address" - {:type "object" - :properties - {"street" {:type "string"} - "city" - {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}} - :required ["id" "name" "address"] - :additionalProperties false - :title "schema-tools.openapi.core-test/User" - :example "Some examples here" - :examples - {:admin - {:summary "Admin user" - :description "Super user" - :value {:anything :here} - :externalValue "External value"}} - :encoding {:contentType "application/json"}}}}} - (openapi/openapi-spec - {::openapi/content - {"application/json" - (st/schema - User - {:openapi/example "Some examples here" - :openapi/examples {:admin - {:summary "Admin user" - :description "Super user" - :value {:anything :here} - :externalValue "External value"}} - :openapi/encoding {:contentType "application/json"}})}})))) - - (testing "::headers" - (is (= {:headers - {:X-Rate-Limit-Limit - {:description "The number of allowed requests in the current period", - :schema {:type "integer"}}, - :City - {:description "a city", - :required false, - :schema - {:enum [:tre :hki] :type "string"}} - :Authorization - {:description "" - :required true - :schema {:type "string"}} - :User - {:description "" - :required true - :schema - {:type "object" - :properties - {"id" {:type "integer" :format "int32"} - "name" {:type "string"} - "address" - {:type "object" - :properties - {"street" {:type "string"} - "city" - {:description "a city" - :oneOf [{:enum [:tre :hki] :type "string"} - {:type "null"}]}} - :required ["street" "city"] - :additionalProperties false - :title "schema-tools.openapi.core-test/Address"}} - :required ["id" "name" "address"] - :additionalProperties false - :title "schema-tools.openapi.core-test/User"}}}} - (openapi/openapi-spec - {:headers - {:X-Rate-Limit-Limit - {:description "The number of allowed requests in the current period" - :schema {:type "integer"}}} - ::openapi/headers - {:City City - :Authorization Token - :User User}}))))) - -;; TODO: This test does not really validate schema -#?(:clj - (deftest test-schema-validation - (is (not - (nil? - (openapi/openapi-spec - {:openapi "3.0.3" - :info - {:title "Sample Pet Store App" - :description "This is a sample server for a pet store." - :termsOfService "http://example.com/terms/" - :contact - {:name "API Support", - :url "http://www.example.com/support" - :email "support@example.com"} - :license - {:name "Apache 2.0", - :url "https://www.apache.org/licenses/LICENSE-2.0.html"} - :version "1.0.1"} - :servers - [{:url "https://development.gigantic-server.com/v1" - :description "Development server"} - {:url "https://staging.gigantic-server.com/v1" - :description "Staging server"} - {:url "https://api.gigantic-server.com/v1" - :description "Production server"}] - :components - {::openapi/schemas {:user User - :address Address} - ::openapi/headers {:token Token}} - :paths - {"/api/ping" - {:get - {:description "Returns all pets from the system that the user has access to" - :responses {200 {::openapi/content - {"application/xml" User - "application/json" - (st/schema - Address - {:openapi/example "Some examples here" - :openapi/examples {:admin - {:summary "Admin user" - :description "Super user" - :value {:anything :here} - :externalValue "External value"}} - :openapi/encoding {:contentType "application/json"}})}}}}} - "/user/:id" - {:post - {:tags ["user"] - :description "Returns pets based on ID" - :summary "Find pets by ID" - :operationId "getPetsById" - :requestBody {::openapi/content {"application/json" User}} - :responses {200 {:description "pet response" - ::openapi/content - {"application/json" User}} - :default {:description "error payload", - ::openapi/content - {"text/html" User}}} - ::openapi/parameters {:path {:id Id} - :header {:token Token}}}}}})))))) - -(deftest backport-openapi-meta-unnamespaced - (is (= {:type "string" :format "password" :random-value "42"} - (openapi/transform - (st/schema - s/Str - {:openapi/type "string" - :openapi/format "password" - :openapi/random-value "42"}) - nil)))) - -(deftest description-test - (is (= [{:name "string" - :in :query - :description "xyz" - :required true - :schema {:type "string" :description "xyz"}}] - (openapi/extract-parameter :query (st/schema s/Str {:openapi/description "xyz"})))) - (is (= [{:name "string" - :in :query - :description "xyz" - :required true - :schema {:type "string" :description "xyz"}}] - (openapi/extract-parameter :query (st/schema s/Str {:description "xyz"})))) - (is (= [{:name "a" - :in "query" - :description "xyz" - :required true - :schema {:type "string" :description "xyz"}} - {:name "b" - :in "query" - :description "abc" - :required true - :schema {:type "string" :description "abc"}}] - (openapi/extract-parameter :query {:a (st/schema s/Str {:openapi/description "xyz"}) - :b (st/schema s/Str {:description "abc"})})))) +(s/defschema Tree + {:value s/Str + :left (s/recursive #'Tree) + :right (s/recursive #'Tree)}) + +(deftest recursive-test + (is (= {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree" + :definitions {"schema-tools.openapi.core-test.Tree" + {:type "object" + :title "schema-tools.openapi.core-test/Tree" + :properties {"value" {:type "string"} + "left" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"} + "right" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"}} + :additionalProperties false + :required ["value" "left" "right"]}}} + (openapi/transform Tree nil))) + (is (= {:type "object" + :title "schema-tools.openapi.core-test/Tree" + :properties {"value" {:type "string"} + "left" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"} + "right" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"}} + :additionalProperties false + :required ["value" "left" "right"] + :definitions {"schema-tools.openapi.core-test.Tree" + {:type "object" + :title "schema-tools.openapi.core-test/Tree" + :properties {"value" {:type "string"} + "left" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"} + "right" {:$ref "#/components/schemas/schema-tools.openapi.core-test.Tree"}} + :additionalProperties false + :required ["value" "left" "right"]}}} + (openapi/transform-inline Tree nil))))