Protocols and schemas for OpenAPI schema generation. Actual implementation for [[metabase.api.macros/defendpoint]] endpoints lives in [[metabase.api.macros.defendpoint.open-api]]. | (ns metabase.api.open-api (:require [metabase.config :as config] [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.malli.schema :as ms] [potemkin :as p] [pretty.core :as pretty])) |
(set! *warn-on-reflection* true) | |
(p/defprotocol+ OpenAPISpec
;; TODO -- context map instead of prefix?
(open-api-spec [this prefix]
"Get the OpenAPI spec base object (as a Clojure data structure) associated with a Ring handler. `prefix` is the
route prefix in the Compojure `context` sense, e.g. `/api/` for [[metabase.api.routes/routes]], or `/api/user/` by
the time we get to [[metabase.api.user]], etc.")) | |
(extend-protocol OpenAPISpec
nil
(open-api-spec [_nil _prefix] nil)
Object
(open-api-spec [this _prefix]
(throw (ex-info (format "Handler does not implement OpenAPISpec: did you forget to wrap it in %s?"
`handler-with-open-api-spec)
{:handler this})))
clojure.lang.Var
(open-api-spec [this prefix]
(open-api-spec (var-get this) prefix))) | |
(declare ->HandlerWithOpenAPISpec) | |
(p/deftype+ HandlerWithOpenAPISpec [^clojure.lang.IFn handler spec-fn metadata]
clojure.lang.IFn
(invoke [_this request respond raise]
(.invoke handler request respond raise))
OpenAPISpec
(open-api-spec [_this prefix]
(spec-fn prefix))
clojure.lang.IObj
(meta [_this]
metadata)
(withMeta [_this new-metadata]
(->HandlerWithOpenAPISpec handler spec-fn new-metadata))
Object
(equals [_this another]
(and (instance? HandlerWithOpenAPISpec another)
(= (.handler ^HandlerWithOpenAPISpec another) handler)))
pretty/PrettyPrintable
(pretty [_this]
(list `handler-with-open-api-spec handler spec-fn))) | |
(mr/def ::spec.info [:map [:title [:= "Metabase API"]] [:version [:= (:tag config/mb-version-info)]]]) | |
(mr/def ::path :string) | |
(mr/def ::method [:enum :get :post :put :delete :patch]) | |
(mr/def ::parameter.type [:enum :string :number :integer :boolean :null :object :array]) | |
(mr/def ::parameter.in [:enum :query :header :path :cookie]) | |
(mr/def ::parameter.schema.common
[:map
[:default {:optional true} :any]
[:description {:optional true} :string]
[:optional {:optional true} :boolean]]) | |
(mr/def ::parameter.schema.typed.common
[:merge
::parameter.schema.common
[:map
[:type ::parameter.type]
;; TODO -- I don't think `:null` can have `:enum`
[:enum {:optional true} [:sequential :any]]]]) | |
(mr/def ::parameter.schema.string
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :string]]
[:format {:optional true} [:= :binary]]
[:minLength {:optional true} integer?]
[:maxLength {:optional true} integer?]
[:pattern {:optional true} (ms/InstanceOfClass java.util.regex.Pattern)]]]) | |
(mr/def ::parameter.schema.number
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :number]]
[:minimum {:optional true} number?]
[:maximum {:optional true} number?]]]) | |
(mr/def ::parameter.schema.integer
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :integer]]
[:minimum {:optional true} integer?]
[:maximum {:optional true} integer?]]]) | |
(mr/def ::parameter.schema.boolean
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :boolean]]]]) | |
(mr/def ::parameter.schema.null
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :null]]]]) | |
(mr/def ::parameter.schema.object
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :object]]
[:properties {:optional true} [:map-of :string [:ref ::parameter.schema]]]
[:additionalProperties {:optional true} [:multi
{:dispatch boolean?}
[true [:= false]]
[false [:ref ::parameter.schema]]]]
[:required {:optional true} [:sequential :string]]
[:definitions {:optional true} [:map-of :string [:ref ::parameter.schema]]]]]) | |
(mr/def ::parameter.schema.array
[:merge
::parameter.schema.typed.common
[:map
[:type [:= :array]]
[:items [:multi
{:dispatch map?}
[true [:ref ::parameter.schema]]
;; for a tuple. I don't think this is correct, I think you're supposed to use `prefixItems` -- see
;; https://stackoverflow.com/questions/57464633/how-to-define-a-json-array-with-concrete-item-definition-for-every-index-i-e-a
[false [:sequential [:ref ::parameter.schema]]]]]
[:uniqueItems {:optional true} :boolean]
[:additionalItems {:optional true} :boolean] ; for tuples
[:minItems {:optional true} integer?]
[:maxItems {:optional true} integer?]]]) | |
(mr/def ::parameter.schema.typed
[:and
[:map
[:type ::parameter.type]]
[:multi
{:dispatch :type}
[:string ::parameter.schema.string]
[:number ::parameter.schema.number]
[:integer ::parameter.schema.integer]
[:boolean ::parameter.schema.boolean]
[:null ::parameter.schema.null]
[:object ::parameter.schema.object]
[:array ::parameter.schema.array]]]) | |
(mr/def ::parameter.schema.ref
[:merge
::parameter.schema.common
[:map
[:$ref [:re
{:description "string starting with '#/components/schemas/'"}
#"^#/components/schemas/[^/]+$"]]
[:definitions {:optional true} [:map-of :string [:ref ::parameter.schema]]]]]) | |
(mr/def ::parameter.schema.or
"Not sure what the difference is between `:oneOf` and `:anyOf` but they seem to both mean 'or'."
[:multi
{:dispatch (fn [x]
(if (contains? x :anyOf)
:anyOf
:oneOf))}
[:anyOf
[:merge
::parameter.schema.common
[:map
[:anyOf [:sequential [:ref ::parameter.schema]]]]]]
[:oneOf
[:merge
::parameter.schema.common
[:map
[:oneOf [:sequential [:ref ::parameter.schema]]]]]]]) | |
(mr/def ::parameter.schema.and
[:merge
::parameter.schema.common
[:map
[:allOf [:sequential [:ref ::parameter.schema]]]]]) | |
(mr/def ::parameter.schema.const
[:merge
::parameter.schema.common
[:map
[:const :any]]]) | |
(mr/def ::parameter.schema.untyped-enum
[:merge
::parameter.schema.common
[:map
[:enum [:sequential :any]]]]) | |
(mr/def ::parameter.schema.empty "These are mostly the result of `:fn` schemas which get translated to empty maps." ::parameter.schema.common) | |
(mr/def ::parameter.schema
[:and
:map
[:multi
{:dispatch (fn [x]
(cond
(not (map? x)) :invalid
(:type x) :typed
(contains? x :$ref) :ref
(contains? x :oneOf) :or
(contains? x :anyOf) :or
(contains? x :allOf) :and
(contains? x :const) :const
(:enum x) :untyped-enum
:else :empty))}
[:invalid :map]
[:typed ::parameter.schema.typed]
[:ref ::parameter.schema.ref]
[:or ::parameter.schema.or]
[:and ::parameter.schema.and]
[:const ::parameter.schema.const]
[:untyped-enum ::parameter.schema.untyped-enum]
[:empty ::parameter.schema.empty]]]) | |
(mr/def ::parameter
"https://swagger.io/specification/#parameter-object"
[:map
[:name string?]
[:in ::parameter.in]
[:description {:optional true} :string]
[:required :boolean]
[:schema ::parameter.schema]]) | |
(mr/def ::path-item.request-body
[:map
[:content [:map-of
[:enum "application/json" "multipart/form-data"]
[:map
[:schema ::parameter.schema]]]]]) | |
(mr/def ::path-item
[:map
[:summary :string]
[:description :string]
[:parameters [:sequential ::parameter]]
[:requestBody {:optional true} ::path-item.request-body]
[:tags {:optional true} [:sequential :string]]]) | |
(mr/def ::components [:map [:schemas [:map-of :string ::parameter.schema]]]) | |
(mr/def ::spec
"Based on https://swagger.io/specification/."
[:map
[:openapi {:optional true} :string]
[:info {:optional true} ::spec.info]
[:paths [:map-of ::path [:map-of ::method ::path-item]]]
[:components ::components]]) | |
Attach (spec-fn prefix) => open-api-spec to a Ring | (defn handler-with-open-api-spec [handler spec-fn] (->HandlerWithOpenAPISpec handler spec-fn (meta handler))) |
(mu/defn root-open-api-object :- ::spec
"Generate base object for OpenAPI (/paths and /components/schemas)
https://spec.openapis.org/oas/latest.html#openapi-object"
[handler :- [:=> [:cat :map fn? fn?] any?]]
{:closed true}
(merge
{:openapi "3.1.0"
:info {:title "Metabase API"
:version (:tag config/mb-version-info)}}
(open-api-spec handler "/api"))) | |
#_:clj-kondo/ignore (comment (open-api-spec (metabase.api.macros/ns-handler 'metabase.api.geojson) "/api/geojson") (root-open-api-object (requiring-resolve 'metabase.api.routes/routes))) | |