API Endpoints at MetabaseWe use a custom macro called
As you can see, the arguments are:
How does defendpoint coersion work?The The exact coersion function [[mc/decode]], and uses the [[metabase.api.common.internal/defendpoint-transformer]], and gets called with the schema, value, and transformer. see: https://github.com/metosin/malli#value-transformation Here's an example repl session showing how it works:
To see how a schema will be transformed, call With the
The schemas can get quite complex, ( see: https://github.com/metosin/malli#advanced-transformations ) so it's best to test them out in the REPL to see how they'll be transformed. Example:
| |
Dynamic variables and utility functions/macros for writing API functions. | (ns metabase.api.common (:require [metabase.api.open-api :as open-api] [metabase.events :as events] [metabase.models.interface :as mi] [metabase.util :as u] [metabase.util.i18n :as i18n :refer [deferred-tru tru]] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [potemkin :as p] [toucan2.core :as t2])) |
(declare check-403 check-404) | |
#_{:clj-kondo/ignore [:aliased-namespace-symbol]} (p/import-vars [metabase.api.open-api root-open-api-object]) | |
----------------------------------------------- DYNAMIC VARIABLES ------------------------------------------------ These get bound by middleware for each HTTP request. | |
Int ID or TODO -- move this to [[metabase.request.current]] | (def ^:dynamic ^Integer *current-user-id* nil) |
Delay that returns the TODO -- move this to [[metabase.request.current]] | (def ^:dynamic *current-user* (atom nil)) ; default binding is just something that will return nil when dereferenced |
Is the current user a superuser? TODO -- move this to [[metabase.request.current]] | (def ^:dynamic ^Boolean *is-superuser?* false) |
Is the current user a group manager of at least one group? TODO -- move this to [[metabase.request.current]] | (def ^:dynamic ^Boolean *is-group-manager?* false) |
Delay to the set of permissions granted to the current user. See documentation in [[metabase.permissions.models.permissions]] for more information about the Metabase permissions system. TODO -- move this to [[metabase.request.current]] | (def ^:dynamic *current-user-permissions-set* (atom #{})) |
---------------------------------------- Precondition checking helper fns ---------------------------------------- | |
(defn- check-one [condition code message] (when-not condition (let [[message info] (if (and (map? message) (not (i18n/localized-string? message))) [(:message message) message] [message])] (throw (ex-info (str message) (assoc info :status-code code))))) condition) | |
Assertion mechanism for use inside API functions.
Checks that
This exception is automatically caught in the body of
or with the form
You can also include multiple tests in a single call: (check test1 code1 message1 test2 code2 message2) | (defn check {:style/indent [:form], :arglists '([condition [code message] & more] [condition code message & more])} [condition & args] (let [[code message & more] (if (sequential? (first args)) (concat (first args) (rest args)) args)] (check-one condition code message) (if (seq more) (recur (first more) (rest more)) condition))) |
Check that object with ID (or other key/values) exists in the DB, or throw a 404. | (defn check-exists? ([entity id] (check-exists? entity :id id)) ([entity k v & more] (check-404 (apply t2/exists? entity k v more)))) |
Check that | (defn check-superuser [] (check-403 *is-superuser?*)) |
checkp- functions: as in "check param". These functions expect that you pass a symbol so they can throw exceptions w/ relevant error messages. | |
Throw an | (defn throw-invalid-param-exception [field-name message] (throw (ex-info (tru "Invalid field: {0}" field-name) {:status-code 400 :errors {(keyword field-name) message}}))) |
Assertion mechanism for use inside API functions that validates individual input params.
Checks that This exception is automatically caught in the body of
| (defn checkp [tst field-name message] (when-not tst (throw-invalid-param-exception (str field-name) message))) |
---------------------------------------------- api-let, api->, etc. ---------------------------------------------- | |
The following all work exactly like the corresponding Clojure versions
but take an additional arg at the beginning called RESPONSE-PAIR.
RESPONSE-PAIR is of the form
| |
If (api-let [404 "Not found."] [user @current-user] (:id user)) | (defmacro do-api-let [response-pair bindings & body] ;; so `response-pair` doesn't get evaluated more than once (let [response-pair-symb (gensym "response-pair-")] `(let [~response-pair-symb ~response-pair ~@(vec (apply concat (for [[binding test] (partition-all 2 bindings)] [binding `(check ~test ~response-pair-symb)])))] ~@body))) |
GENERIC RESPONSE HELPERSThese are basically the same as the | |
Throw a | (defn check-400 ([arg] (check-400 arg (deferred-tru "Invalid Request."))) ([arg msg] (check arg [400 msg]))) |
GENERIC 404 RESPONSE HELPERS | (def ^:private generic-404 [404 (deferred-tru "Not found.")]) |
Throw a | (defn check-404 [arg] (check arg generic-404)) |
Bind a form as with | (defmacro let-404 {:style/indent 1} [bindings & body] `(do-api-let ~generic-404 ~bindings ~@body)) |
GENERIC 403 RESPONSE HELPERSIf you can't be bothered to write a custom error message | (defn- generic-403 [] [403 (tru "You don''t have permissions to do that.")]) |
Throw a | (defn check-403 [arg] (check arg (generic-403))) |
Throw a generic 403 (no permissions) error response. | (defn throw-403 ([] (throw-403 nil)) ([e] (throw (ex-info (tru "You don''t have permissions to do that.") {:status-code 403} e)))) |
GENERIC 500 RESPONSE HELPERSFor when you don't feel like writing something useful | (def ^:private generic-500 [500 (deferred-tru "Internal server error.")]) |
Throw a | (defn check-500 [arg] (check arg generic-500)) |
A 'No Content' response for TODO -- why does this live here but other 'generic' responses live in [[metabase.request.util]]. We should move this so it lives with its friends | (def generic-204-no-content {:status 204, :body nil}) |
Wrap a Ring handler to make sure the current user is a superuser before handling any requests. (api/+check-superuser routes) | (defn +check-superuser [handler] (open-api/handler-with-open-api-spec (fn [request respond raise] (if-let [e (try (check-superuser) nil (catch Throwable e e))] (raise e) (handler request respond raise))) (fn [prefix] (open-api/open-api-spec handler prefix)))) |
---------------------------------------- PERMISSIONS CHECKING HELPER FNS ----------------------------------------- | |
Check whether we can read an existing | (defn read-check ([obj] (check-404 obj) (try (check-403 (mi/can-read? obj)) (catch clojure.lang.ExceptionInfo e (events/publish-event! :event/read-permission-failure {:user-id *current-user-id* :object obj :has-access false}) (throw e))) obj) ([entity id] (read-check (t2/select-one entity :id id))) ([entity id & other-conditions] (read-check (apply t2/select-one entity :id id other-conditions)))) |
Check whether we can write an existing | (defn write-check ([obj] (check-404 obj) (try (check-403 (mi/can-write? obj)) (catch clojure.lang.ExceptionInfo e (events/publish-event! :event/write-permission-failure {:user-id *current-user-id* :object obj}) (throw e))) obj) ([entity id] (write-check (t2/select-one entity :id id))) ([entity id & other-conditions] (write-check (apply t2/select-one entity :id id other-conditions)))) |
NEW! Check whether the current user has permissions to CREATE a new instance of an object with properties in map This function was added years after | (defn create-check {:added "0.32.0"} [model entity] (try (check-403 (mi/can-create? model entity)) (catch clojure.lang.ExceptionInfo e (events/publish-event! :event/create-permission-failure {:model model :user-id *current-user-id*}) (throw e)))) |
NEW! Check whether the current user has permissions to UPDATE an object by applying a map of This function was added years after | (defn update-check {:added "0.36.0"} [instance changes] (try (check-403 (mi/can-update? instance changes)) (catch clojure.lang.ExceptionInfo e (events/publish-event! :event/update-permission-failure {:user-id *current-user-id* :object instance}) (throw e)))) |
------------------------------------------------ OTHER HELPER FNS ------------------------------------------------ | |
Check that the | (defn check-not-archived [object] (u/prog1 object (check-404 object) (check (not (:archived object)) [404 {:message (tru "The object has been archived."), :error_code "archived"}]))) |
Check on paginated stuff that, if the limit exists, the offset exists, and vice versa. | (defn check-valid-page-params [limit offset] (check (not (and limit (not offset))) [400 (tru "When including a limit, an offset must also be included.")]) (check (not (and offset (not limit))) [400 (tru "When including an offset, a limit must also be included.")])) |
(mu/defn column-will-change? :- :boolean "Helper for PATCH-style operations to see if a column is set to change when `object-updates` (i.e., the input to the endpoint) is applied. ;; assuming we have a Collection 10, that is not currently archived... (api/column-will-change? :archived (t2/select-one Collection :id 10) {:archived true}) ; -> true, because value will change (api/column-will-change? :archived (t2/select-one Collection :id 10) {:archived false}) ; -> false, because value did not change (api/column-will-change? :archived (t2/select-one Collection :id 10) {}) ; -> false; value not specified in updates (request body)" [k :- :keyword object-before-updates :- :map object-updates :- :map] (boolean (and (contains? object-updates k) (not= (get object-before-updates k) (get object-updates k))))) | |
------------------------------------------ COLLECTION POSITION HELPER FNS ---------------------------------------- | |
Compare | (mu/defn reconcile-position-for-collection! [collection-id :- [:maybe ms/PositiveInt] old-position :- [:maybe ms/PositiveInt] new-position :- [:maybe ms/PositiveInt]] (let [update-fn! (fn [plus-or-minus position-update-clause] (doseq [model '[Card Dashboard Pulse]] (t2/update! model {:collection_id collection-id :collection_position position-update-clause} {:collection_position [plus-or-minus :collection_position 1]})))] (when (not= new-position old-position) (cond (and (nil? new-position) old-position) (update-fn! :- [:> old-position]) (and new-position (nil? old-position)) (update-fn! :+ [:>= new-position]) (> new-position old-position) (update-fn! :- [:between old-position new-position]) (< new-position old-position) (update-fn! :+ [:between new-position old-position]))))) |
Intended to cover Cards/Dashboards/Pulses, it only asserts collection id and position, allowing extra keys | (def ^:private ModelWithPosition [:map [:collection_id [:maybe ms/PositiveInt]] [:collection_position [:maybe ms/PositiveInt]]]) |
Intended to cover Cards/Dashboards/Pulses updates. Collection id and position are optional, if they are not present, they didn't change. If they are present, they might have changed and we need to compare. | (def ^:private ModelWithOptionalPosition [:map [:collection_id {:optional true} [:maybe ms/PositiveInt]] [:collection_position {:optional true} [:maybe ms/PositiveInt]]]) |
Generic function for working on cards/dashboards/pulses. Checks the before and after changes to see if there is any impact to the collection position of that model instance. If so, executes updates to fix the collection position that goes with the change. The 2-arg version of this function is used for a new card/dashboard/pulse (i.e. not updating an existing instance, but creating a new one). | (mu/defn maybe-reconcile-collection-position! ([new-model-data :- ModelWithPosition] (maybe-reconcile-collection-position! nil new-model-data)) ([{old-collection-id :collection_id, old-position :collection_position, :as _before-update} :- [:maybe ModelWithPosition] {new-collection-id :collection_id, new-position :collection_position, :as model-updates} :- ModelWithOptionalPosition] (let [updated-collection? (and (contains? model-updates :collection_id) (not= old-collection-id new-collection-id)) updated-position? (and (contains? model-updates :collection_position) (not= old-position new-position))] (cond ;; If the collection hasn't changed, but we have a new collection position, we might need to reconcile (and (not updated-collection?) updated-position?) (reconcile-position-for-collection! old-collection-id old-position new-position) ;; If we have a new collection id, but no new position, reconcile the old collection, then update the new ;; collection with the existing position (and updated-collection? (not updated-position?)) (do (reconcile-position-for-collection! old-collection-id old-position nil) (reconcile-position-for-collection! new-collection-id nil old-position)) ;; We have a new collection id AND and new collection position ;; Update the old collection using the old position ;; Update the new collection using the new position (and updated-collection? updated-position?) (do (reconcile-position-for-collection! old-collection-id old-position nil) (reconcile-position-for-collection! new-collection-id nil new-position)))))) |
------------------------------------------ PARAM PARSING FNS ---------------------------------------- | |
Coerce a bit returned by some MySQL/MariaDB versions in some situations to Boolean. | (defn bit->boolean [v] (if (number? v) (not (zero? v)) v)) |
Parse a param that could have a single value or multiple values using Used for API that can parse single value or multiple values for a param: e.g: single value: api/card/series?exclude_ids=1 multi values: api/card/series?excludeids=1&excludeids=2 Example usage: (parse-multi-values-param "1" parse-long) => [1] (parse-multi-values-param ["1" "2"] parse-long) => [1, 2] | (defn parse-multi-values-param [xs parse-fn] (if (sequential? xs) (map parse-fn xs) [(parse-fn xs)])) |
---------------------------------------- SET | |
Sets | (defn updates-with-archived-directly [current-obj obj-updates] (cond-> obj-updates (column-will-change? :archived current-obj obj-updates) (assoc :archived_directly (boolean (:archived obj-updates))) ;; This is a hack around a frontend issue. Apparently, the undo functionality depends on calculating a diff ;; between the current state and the previous state. Sometimes this results in the frontend telling us to ;; *both* mark an item as archived *and* "move" it to the Trash. ;; ;; Let's just say that if you're marking something as archived, we throw away any `collection_id` you passed in ;; along with it. (and (column-will-change? :archived current-obj obj-updates) (:archived obj-updates)) (dissoc :collection_id))) |
If | (defn present-in-trash-if-archived-directly [item trash-collection-id] (cond-> item (:archived_directly item) (assoc :collection_id trash-collection-id))) |
A convenience function that takes a heterogeneous collection of items. Each item should have, at minimum, a | (mu/defn present-items [f items :- [:sequential [:map [:id ms/PositiveInt] [:model :keyword]]]] (let [id+model->order (into {} (map-indexed (fn [i row] [[(:id row) (:model row)] i]) items))] (->> items (group-by :model) (mapcat (fn [[model items]] (map #(assoc % ::model model) (f model items)))) (sort-by (comp id+model->order (juxt :id ::model))) (map #(dissoc % ::model))))) |
A mapping from the name of a model used in the API to information about it. This is reused in search, and entity_id translation. | (def model->db-model ;; NOTE search is decoupling itself from this mapping, favoring a self-contained spec in search.spec/define-spec ;; Once search.legacy is gone, this dependency should be gone as well. {"action" {:db-model :model/Action :alias :action} "card" {:db-model :model/Card :alias :card} "collection" {:db-model :model/Collection :alias :collection} "dashboard" {:db-model :model/Dashboard :alias :dashboard} "database" {:db-model :model/Database :alias :database} "dataset" {:db-model :model/Card :alias :card} "indexed-entity" {:db-model :model/ModelIndexValue :alias :model-index-value} "metric" {:db-model :model/Card :alias :card} "segment" {:db-model :model/Segment :alias :segment} "snippet" {:db-model :model/NativeQuerySnippet :alias :snippet} "table" {:db-model :model/Table :alias :table} "dashboard-card" {:db-model :model/DashboardCard :alias :dashboard-card} "dashboard-tab" {:db-model :model/DashboardTab :alias :dashboard-tab} "dimension" {:db-model :model/Dimension :alias :dimension} "permissions-group" {:db-model :model/PermissionsGroup :alias :permissions-group} "pulse" {:db-model :model/Pulse :alias :pulse} "pulse-card" {:db-model :model/PulseCard :alias :pulse-card} "pulse-channel" {:db-model :model/PulseChannel :alias :pulse-channel} "timeline" {:db-model :model/Timeline :alias :timeline} "user" {:db-model :model/User :alias :user}}) |