Implementation for the api-documentation command, which generates doc pages for API endpoints.

(ns metabase.cmd.endpoint-dox
  (:require
   [clojure.java.classpath :as classpath]
   [clojure.java.io :as io]
   [clojure.string :as str]
   [clojure.tools.namespace.find :as ns.find]
   [metabase.config :as config]
   [metabase.plugins.classloader :as classloader]
   [metabase.util :as u]))

API docs intro

Exists just so we can write the intro in Markdown.

(defn- api-docs-intro
  []
  (str (slurp "src/metabase/cmd/resources/api-intro.md") "\n\n"))

API docs page title

Some paid endpoints have different formatting. This way we don't combine the api/table endpoint with sandbox.api.table, for example.

(defn- handle-enterprise-ns
  [endpoint]
  (if (str/includes? endpoint "metabase-enterprise")
    (str/split endpoint #"metabase-enterprise.")
    (str/split endpoint #"\.")))

Used to format initialisms/acronyms in generated docs.

(def initialisms
  '["SSO" "SAML" "GTAP" "LDAP" "SQL" "JSON" "API" "LLM" "SCIM"])

Converts initialisms to upper case.

(defn capitalize-initialisms
  [name initialisms]
  (let [re (re-pattern (str "(?i)(?:" (str/join "|" (map #(str % "\\b") initialisms)) ")"))]
    (str/replace name re u/upper-case-en)))

Creates a name for endpoints in a namespace, like all the endpoints for Alerts. Handles some edge cases for enterprise endpoints.

(defn- endpoint-ns-name
  [endpoint]
  (-> (:ns endpoint)
      ns-name
      name
      handle-enterprise-ns
      last
      u/capitalize-first-char
      (str/replace #"(.api.|-)" " ")
      (str/replace ".api" "") ; account for `serialization.api` namespace
      (capitalize-initialisms initialisms)
      (str/replace "SSO SSO" "SSO")))

Used for formatting YAML string punctuation for frontmatter descriptions.

(defn- handle-quotes
  [s]
  (-> s
      (str/replace #"\"" "'")
      str/split-lines
      (#(str/join "\n  " %))))

Formats description for YAML frontmatter.

(defn- format-frontmatter-description
  [desc]
  (str "|\n  " (handle-quotes desc)))

Used to grab namespace description, if it exists.

(defn- get-description
  [ep ep-data]
  (let [desc (-> ep-data
                 first
                 :ns
                 meta
                 :doc
                 u/add-period)]
    (if (str/blank? desc)
      (u/add-period (str "API endpoints for " ep))
      desc)))

Formats frontmatter, which includes title and summary, if any.

(defn- endpoint-page-frontmatter
  [ep ep-data]
  (let [desc (format-frontmatter-description (get-description ep ep-data))]
    (str "---\ntitle: \"" ep "\""
         "\nsummary: " desc "\n---\n\n")))

Creates a page title for a set of endpoints, e.g., # Card.

(defn- endpoint-page-title
  [ep-title]
  (str "# " ep-title "\n\n"))

API endpoint description

If there is a namespace docstring, include the docstring with a paragraph break.

(defn- endpoint-page-description
  [ep ep-data]
  (let [desc (get-description ep ep-data)]
    (if (str/blank? desc)
      desc
      (str desc "\n\n"))))

API endpoints

Creates a name for an endpoint: VERB /path/to/endpoint. Used to build anchor links in the table of contents.

(defn- endpoint-str
  [endpoint]
  (-> (:doc endpoint)
      (str/split #"\n")
      first
      str/trim))

Decorates endpoints with strings for building API endpoint pages.

(defn- process-endpoint
  [endpoint]
  (assoc endpoint
         :endpoint-str (endpoint-str endpoint)
         :ns-name (endpoint-ns-name endpoint)))

Regular expression to match endpoints. Needs to match namespaces like: - metabase.api.search - metabase-enterprise.serialization.api - metabase.api.api-key

(def api-ns
  (re-pattern "^metabase(?:-enterprise\\.[\\w-]+)?\\.api(?:\\.[\\w-]+)?$"))
(defn- api-namespaces []
  (for [ns-symb (ns.find/find-namespaces (classpath/system-classpath))
        :when   (and (re-find api-ns (name ns-symb))
                     (not (str/includes? (name ns-symb) "test")))]
    ns-symb))

Gets a list of all API endpoints.

(defn collect-endpoints
  []
  (for [ns-symb     (api-namespaces)
        [_sym varr] (do (classloader/require ns-symb)
                        (sort (ns-interns ns-symb)))
        :when       (:is-endpoint? (meta varr))]
    (meta varr)))

Builds a list of endpoints and their parameters. Relies on docstring generation in /api/common/internal.clj.

(defn- endpoint-docs
  [ep-data]
  (str/join "\n\n" (map #(str/trim (:doc %)) ep-data)))

Is the endpoint a paid feature?

(defn- paid?
  [ep-data]
  (or (str/includes? (:endpoint-str (first ep-data)) "/api/ee")
      ;; some ee endpoints are inconsistent in naming, see #22687
      (str/includes? (:endpoint-str (first ep-data)) "/api/mt")
      (= 'metabase-enterprise.sandbox.api.table (ns-name (:ns (first ep-data))))
      (str/includes? (:endpoint-str (first ep-data)) "/auth/sso")
      (str/includes? (:endpoint-str (first ep-data)) "/api/moderation-review")))

Adds a footer with a link back to the API index.

(defn endpoint-footer
  [ep-data]
  (let [level (if (paid? ep-data) "../../" "../")]
    (str "\n\n---\n\n[<< Back to API index](" level "api-documentation.md)")))

Build API pages

Builds a page with the name, description, table of contents for endpoints in a namespace, followed by the endpoint and their parameter descriptions.

(defn endpoint-page
  [ep ep-data]
  (apply str
         (endpoint-page-frontmatter ep ep-data)
         (endpoint-page-title ep)
         (endpoint-page-description ep ep-data)
         (endpoint-docs ep-data)
         (endpoint-footer ep-data)))

Creates a filepath from an endpoint.

(defn- build-filepath
  [dir endpoint-name ext]
  (let [file (-> endpoint-name
                 str/trim
                 (str/split #"\s+")
                 (#(str/join "-" %))
                 u/lower-case-en)]
    (str dir file ext)))

Creates a link to the page for each endpoint. Used to build links on the API index page at docs/api-documentation.md.

(defn build-endpoint-link
  [ep ep-data]
  (let [filepath (build-filepath (if (paid? ep-data) "api/ee/" "api/") ep ".md")]
    (str "- [" ep (when (paid? ep-data) "*") "](" filepath ")")))

Creates a string that lists links to all endpoint groups, e.g., - Activity.

(defn- build-index
  [endpoints]
  (str/join "\n" (map (fn [[ep ep-data]] (build-endpoint-link ep ep-data)) endpoints)))

Creates a sorted map of API endpoints. Currently includes some endpoints for paid features.

(defn- map-endpoints
  []
  (->> (collect-endpoints)
       (map process-endpoint)
       (group-by :ns-name)
       (into (sorted-map-by (fn [a b] (compare
                                       (u/lower-case-en a)
                                       (u/lower-case-en b)))))))

Page generators

Creates an index page that lists links to all endpoint pages.

(defn- generate-index-page!
  [endpoint-map]
  (let [endpoint-index (str
                        (api-docs-intro)
                        (build-index endpoint-map))]
    (spit (io/file "docs/api-documentation.md") endpoint-index)))

Takes a map of endpoint groups and generates markdown pages for all API endpoint groups.

(defn- generate-endpoint-pages!
  [endpoints]
  (doseq [[ep ep-data] endpoints]
    (let [file (build-filepath (str "docs/" (if (paid? ep-data) "api/ee/" "api/")) ep ".md")
          contents (endpoint-page ep ep-data)]
      (io/make-parents file)
      (spit file contents))))

Is it a markdown file?

(defn- md?
  [file]
  (= "md"
     (-> file
         str
         (str/split #"\.")
         last)))

Used to clear the API directory for rebuilding docs from scratch so we don't orphan files as the API changes.

(defn- reset-dir
  [file]
  (let [files (filter md? (file-seq file))]
    (doseq [f files]
      (try (io/delete-file f)
           (catch Exception e
             (println "File:" f "not deleted")
             (println e))))))

Builds an index page and sub-pages for groups of endpoints. Index page is docs/api-documentation.md. Endpoint pages are in /docs/api/{endpoint}.md

(defn generate-dox!
  []
  (when-not config/ee-available?
    (println (u/colorize
              :red (str "Warning: EE source code not available. EE endpoints will not be included. "
                        "If you want to include them, run the command with"
                        \newline
                        \newline
                        "clojure -M:ee:doc api-documentation"))))
  (let [endpoint-map (map-endpoints)]
    (reset-dir (io/file "docs/api"))
    (generate-index-page! endpoint-map)
    (println "API doc index generated at docs/api-documentation.md.")
    (generate-endpoint-pages! endpoint-map)
    (println "API endpoint docs generated in docs/api/{endpoint}.")))