Implementation of OpenAPI spec generation for [[metabase.api.macros/defendpoint]]. TODO -- I'm not convinced this should be a separate namespace versus merging it into [[metabase.api.macros/defendpoint]]. | (ns metabase.api.macros.defendpoint.open-api (:require [clojure.string :as str] [malli.core :as mc] [malli.json-schema :as mjs] [medley.core :as m] [metabase.api.open-api] [metabase.util :as u] [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.malli.schema :as ms])) |
(def ^:private ^:dynamic *definitions* nil) | |
(mu/defn- merge-required :- :metabase.api.open-api/parameter.schema.object
[schema]
(let [optional? (set (keep (fn [[k v]] (when (:optional v) k))
(:properties schema)))]
(-> schema
(m/update-existing :required #(into []
(comp (map u/qualified-name)
(remove optional?))
%))
(m/update-existing :properties #(update-vals % (fn [v] (dissoc v :optional))))))) | |
(def ^:private file-schema (mjs/transform ms/File {::mjs/definitions-path "#/components/schemas/"})) | |
(mu/defn- fix-json-schema :- :metabase.api.open-api/parameter.schema
"Clean-up JSON schema to make it more understandable for OpenAPI tools.
Returns a new schema WITH an indicator if it's *NOT* required.
NOTE: maybe instead of fixing it up later we should re-work Malli's json-schema transformation into a way we want it
to be?"
[schema :- :map]
(try
(let [schema (-> schema
(m/update-existing :description str)
(m/update-existing :type keyword)
(m/update-existing :definitions #(update-vals % fix-json-schema))
(m/update-existing :oneOf #(mapv fix-json-schema %))
(m/update-existing :anyOf #(mapv fix-json-schema %))
(m/update-existing :allOf #(mapv fix-json-schema %))
(m/update-existing :additionalProperties (fn [additional-properties]
(cond-> additional-properties
(map? additional-properties) fix-json-schema))))]
(cond
;; we're using `[:maybe ...]` a lot, and it generates `{:oneOf [... {:type "null"}]}`
;; it needs to be cleaned up to be presented in OpenAPI viewers
(and (:oneOf schema)
(= (second (:oneOf schema)) {:type :null}))
(fix-json-schema (merge (first (:oneOf schema))
(select-keys schema [:description :default])
{:optional true}))
;; this happens when we use `[:and ... [:fn ...]]`, the `:fn` schema gets converted into an empty object
(:allOf schema)
(let [schema (update schema :allOf (partial remove (partial = {})))]
(if (= (count (:allOf schema)) 1)
(fix-json-schema (merge (dissoc schema :allOf) (first (:allOf schema))))
schema))
(= (select-keys schema (keys file-schema)) file-schema)
;; I got this from StackOverflow and docs are not in agreement, but RapiDoc
;; shows file input, so... :)
(merge {:type :string, :format :binary}
(select-keys schema [:description :default]))
(:properties schema)
(merge-required
(update schema :properties (fn [properties]
(into (sorted-map)
(map (fn [[k v]]
[(u/qualified-name k) (fix-json-schema v)]))
properties))))
(= (:type schema) :array)
(update schema :items (fn [items]
;; apparently `:tuple` creates multiple `:items` entries... I don't think this is
;; correct. I think we're supposed to use `:prefixItems` instead. See
;; https://stackoverflow.com/questions/57464633/how-to-define-a-json-array-with-concrete-item-definition-for-every-index-i-e-a
(if (sequential? items)
(mapv fix-json-schema items)
(fix-json-schema items))))
:else
schema))
(catch Throwable e
(throw (ex-info (format "Error fixing schema: %s" (ex-message e))
{:schema schema}
e))))) | |
We transform json-schema in a few different places, but we need to collect all defitions in a single one. | (defn- mjs-collect-definitions
[malli-schema]
(let [jss (mjs/transform malli-schema {::mjs/definitions-path "#/components/schemas/"})]
(when *definitions*
(swap! *definitions* merge (:definitions (fix-json-schema jss))))
(dissoc jss :definitions))) |
(mu/defn- schema->params* :- [:sequential :metabase.api.open-api/parameter]
[schema in-fn renames]
(let [{:keys [properties required]} (mjs-collect-definitions schema)
required (set required)]
(for [[k param-schema] properties
:let [k (get renames k k)]
:when (in-fn k)
:let [schema (fix-json-schema param-schema)
;; if schema does not indicate it's optional, it's not :)
optional? (:optional schema)]]
(cond-> {:in (in-fn k)
:name (u/qualified-name k)
:required (and (contains? required k) (not optional?))
:schema (dissoc schema :optional :description)}
(:description schema) (assoc :description (str (:description schema))))))) | |
(mu/defn- multipart-schema [form :- :metabase.api.macros/parsed-args]
(when-let [request-schema (get-in form [:params :request :schema])]
(let [schema (-> request-schema mr/resolve-schema mc/schema)]
(when (= (mc/type schema) :map)
(some (fn [[k _opts schema]]
(when (= k :multipart-params)
schema))
(mc/children schema)))))) | |
(mu/defn- path-item :- :metabase.api.open-api/path-item
"Generate OpenAPI desc for `defendpoint` 2.0 ([[metabase.api.macros/defendpoint]]) handler.
https://spec.openapis.org/oas/latest.html#path-item-object"
[full-path :- string?
form :- :metabase.api.macros/parsed-args]
(try
(let [method (:method form)
route-params (when-let [schema (get-in form [:params :route :schema])]
(schema->params* schema (constantly :path) nil))
query-params (when-let [schema (get-in form [:params :query :schema])]
(schema->params* schema (constantly :query) nil))
params (concat
(for [param route-params]
(assoc param :in :path))
query-params)
ctype (if (get-in form [:metadata :multipart])
"multipart/form-data"
"application/json")
body-schema (some-> (if (= ctype "multipart/form-data")
(multipart-schema form)
(get-in form [:params :body :schema]))
mjs-collect-definitions
fix-json-schema)]
;; summary is the string in the sidebar of Scalar
(cond-> {:summary (str (u/upper-case-en (name method)) " " full-path)
:description (some-> (:docstr form) str)
:parameters params}
body-schema (assoc :requestBody {:content {ctype {:schema body-schema}}})))
(catch Throwable e
(throw (ex-info (str (format "Error creating OpenAPI spec for endpoint %s %s: %s"
(:method form)
(pr-str (get-in form [:route :path]))
(ex-message e))
"\n\nDebug this with\n\n"
(pr-str (list
'metabase.api.macros.defendpoint.open-api/path-item
full-path
(list :form
(list 'metabase.api.macros/find-route
(symbol "'my.namespace") ; so it prints as 'my.namespace
(:method form)
(get-in form [:route :path]))))))
{:full-path full-path, :form form, :definitions @*definitions*}
e))))) | |
(mu/defn open-api-spec :- :metabase.api.open-api/spec
"Create an OpenAPI spec for then `endpoints` in a namespace. Note this returns an incomplete OpenAPI object;
use [[metabase.api.open-api/root-open-api-object]] to get something complete."
[endpoints :- :metabase.api.macros/ns-endpoints
prefix :- :string]
(binding [*definitions* (atom (sorted-map))]
{:paths (transduce
(map (fn [endpoint]
(let [local-path (-> (get-in endpoint [:form :route :path])
(str/replace #"/:([^/]+)" "/{$1}"))
full-path (str prefix local-path)
method (get-in endpoint [:form :method])]
{full-path {method (assoc (path-item full-path (:form endpoint))
:tags [prefix])}})))
m/deep-merge
(sorted-map)
(vals endpoints))
:components {:schemas @*definitions*}})) | |
#_:clj-kondo/ignore
(comment
(open-api-spec (metabase.api.macros/ns-routes 'metabase.api.geojson) "/api/geojson")
(metabase.api.macros.defendpoint.open-api/path-item
"/api/card/:id/series"
(:form (metabase.api.macros/find-route 'metabase.api.card :get "/:id/series"))
(-> (mjs/transform :metabase.util.cron/CronScheduleString {::mjs/definitions-path "#/components/schemas/"})
fix-json-schema))) | |