From 4192501b16a468a764e1b5a0a9a81d654c658108 Mon Sep 17 00:00:00 2001 From: Bryan Maass <bryan.maass@gmail.com> Date: Thu, 29 Dec 2022 15:10:02 -0700 Subject: [PATCH] adds malli defendpoint + linting + tests (#27314) The new macro is called defendpoint. The previous defendpoint is now defendpoint-schema, as per @camsaul's idea. It'll make it easier to realize that we should use the Malli version of defendpoint going forward. * adds malli defendpoint + linting + tests - I decided to add a new defendpoint macro -- it is nearly exactly the same as `defendpoint`. If the duplication is an issue I can handle that too. * malli-defendpoint -> defendpoint-malli * defendpoint -> defendpoint-schema * defendpoint-malli -> defendpoint * fine tuning error messages + comments - fix kondo config * put back dox-for-plumatic * fix name collision * adds local malli description ns + test * update alias in test * linting fix * linting fix pt. 2 * hook up umd/describe --- .clj-kondo/config.edn | 1 + .clj-kondo/hooks/metabase/api/common.clj | 5 +- .dir-locals.el | 2 + .lsp/config.edn | 3 +- .../advanced_permissions/api/application.clj | 4 +- .../audit_app/api/user.clj | 2 +- .../content_management/api/review.clj | 2 +- .../metabase_enterprise/sandbox/api/gtap.clj | 10 +- .../metabase_enterprise/sandbox/api/table.clj | 2 +- .../metabase_enterprise/sandbox/api/user.clj | 4 +- .../serialization/api/serialize.clj | 2 +- .../src/metabase_enterprise/sso/api/sso.clj | 4 +- src/metabase/api/action.clj | 10 +- src/metabase/api/activity.clj | 8 +- src/metabase/api/alert.clj | 12 +- src/metabase/api/app.clj | 12 +- src/metabase/api/automagic_dashboards.clj | 18 +- src/metabase/api/bookmark.clj | 8 +- src/metabase/api/card.clj | 40 +-- src/metabase/api/collection.clj | 26 +- src/metabase/api/common.clj | 51 +++- src/metabase/api/common/internal.clj | 62 +++++ src/metabase/api/dashboard.clj | 52 ++-- src/metabase/api/database.clj | 56 ++--- src/metabase/api/dataset.clj | 12 +- src/metabase/api/email.clj | 6 +- src/metabase/api/embed.clj | 32 +-- src/metabase/api/field.clj | 26 +- src/metabase/api/google.clj | 2 +- src/metabase/api/ldap.clj | 2 +- src/metabase/api/login_history.clj | 2 +- src/metabase/api/metric.clj | 18 +- src/metabase/api/model_action.clj | 8 +- src/metabase/api/native_query_snippet.clj | 8 +- src/metabase/api/notify.clj | 2 +- src/metabase/api/permissions.clj | 24 +- src/metabase/api/persist.clj | 12 +- src/metabase/api/premium_features.clj | 2 +- src/metabase/api/preview_embed.clj | 12 +- src/metabase/api/public.clj | 32 +-- src/metabase/api/pulse.clj | 20 +- src/metabase/api/revision.clj | 4 +- src/metabase/api/search.clj | 4 +- src/metabase/api/segment.clj | 16 +- src/metabase/api/session.clj | 14 +- src/metabase/api/setting.clj | 8 +- src/metabase/api/setup.clj | 8 +- src/metabase/api/slack.clj | 4 +- src/metabase/api/table.clj | 24 +- src/metabase/api/task.clj | 6 +- src/metabase/api/testing.clj | 6 +- src/metabase/api/tiles.clj | 2 +- src/metabase/api/timeline.clj | 10 +- src/metabase/api/timeline_event.clj | 8 +- src/metabase/api/transform.clj | 2 +- src/metabase/api/user.clj | 20 +- src/metabase/api/util.clj | 12 +- src/metabase/query_processor/streaming.clj | 2 +- src/metabase/util/malli/describe.clj | 227 ++++++++++++++++++ src/metabase/util/schema.clj | 2 +- test/metabase/api/common/internal_test.clj | 108 ++++++++- test/metabase/api/common_test.clj | 2 +- test/metabase/util/malli/describe_test.clj | 78 ++++++ test/metabase/util/schema_test.clj | 2 +- 64 files changed, 854 insertions(+), 331 deletions(-) create mode 100644 src/metabase/util/malli/describe.clj create mode 100644 test/metabase/util/malli/describe_test.clj diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index b93eedf16d9..ef929278c81 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -427,6 +427,7 @@ metabase.api.card-test/with-temp-native-card hooks.common/with-two-bindings metabase.api.card-test/with-temp-native-card-with-params hooks.common/with-two-bindings metabase.api.collection-test/with-french-user-and-personal-collection hooks.common/with-two-top-level-bindings + metabase.api.common/defendpoint-schema hooks.metabase.api.common/defendpoint metabase.api.common/defendpoint hooks.metabase.api.common/defendpoint metabase.api.common/defendpoint-async hooks.metabase.api.common/defendpoint metabase.api.dashboard-test/with-chain-filter-fixtures hooks.common/let-one-with-optional-value diff --git a/.clj-kondo/hooks/metabase/api/common.clj b/.clj-kondo/hooks/metabase/api/common.clj index a7272ae3463..0d19c0b52e8 100644 --- a/.clj-kondo/hooks/metabase/api/common.clj +++ b/.clj-kondo/hooks/metabase/api/common.clj @@ -5,13 +5,16 @@ [hooks.common :as common])) (defn route-fn-name + "route fn hook" [method route] (let [route (if (vector? route) (first route) route)] (-> (str (name method) route) (str/replace #"/" "_") symbol))) -(defn defendpoint [{:keys [node]}] +(defn defendpoint + "defendpoint hook" + [{:keys [node]}] (let [[method route & body] (rest (:children node))] {:node (hooks/vector-node [;; register usage of compojure core var diff --git a/.dir-locals.el b/.dir-locals.el index e96d9b64f6d..ec6fab4945c 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -19,9 +19,11 @@ (clojure-mode ;; Specify which arg is the docstring for certain macros ;; (Add more as needed) + (eval . (put 'defendpoint-schema 'clojure-doc-string-elt 3)) (eval . (put 'defendpoint 'clojure-doc-string-elt 3)) (eval . (put 'defendpoint-async 'clojure-doc-string-elt 3)) (eval . (put 'define-premium-feature 'clojure-doc-string-elt 2)) + (eval . (put 'api/defendpoint-schema 'clojure-doc-string-elt 3)) (eval . (put 'api/defendpoint 'clojure-doc-string-elt 3)) (eval . (put 'api/defendpoint-async 'clojure-doc-string-elt 3)) (eval . (put 'defsetting 'clojure-doc-string-elt 2)) diff --git a/.lsp/config.edn b/.lsp/config.edn index 52ba8320eec..e708575f05a 100644 --- a/.lsp/config.edn +++ b/.lsp/config.edn @@ -5,4 +5,5 @@ :clean {:ns-inner-blocks-indentation :keep} :linters {:clojure-lsp/unused-public-var {:level :warning :exclude-when-defined-by - #{metabase.api.common/defendpoint}}}} + #{metabase.api.common/defendpoint + metabase.api.common/malli-defendpoint}}}} diff --git a/enterprise/backend/src/metabase_enterprise/advanced_permissions/api/application.clj b/enterprise/backend/src/metabase_enterprise/advanced_permissions/api/application.clj index 07ccc077f03..66e4f879dfa 100644 --- a/enterprise/backend/src/metabase_enterprise/advanced_permissions/api/application.clj +++ b/enterprise/backend/src/metabase_enterprise/advanced_permissions/api/application.clj @@ -7,7 +7,7 @@ [metabase-enterprise.advanced-permissions.models.permissions.application-permissions :as a-perms] [metabase.api.common :as api])) -(api/defendpoint GET "/graph" +(api/defendpoint-schema GET "/graph" "Fetch a graph of Application Permissions." [] (api/check-superuser) @@ -30,7 +30,7 @@ [graph] (update graph :groups dejsonify-groups)) -(api/defendpoint PUT "/graph" +(api/defendpoint-schema PUT "/graph" "Do a batch update of Application Permissions by passing a modified graph." [:as {:keys [body]}] (api/check-superuser) diff --git a/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj b/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj index 08742c82b3c..abd151be499 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj @@ -8,7 +8,7 @@ [metabase.models.pulse-channel-recipient :refer [PulseChannelRecipient]] [toucan.db :as db])) -(api/defendpoint DELETE "/:id/subscriptions" +(api/defendpoint-schema DELETE "/:id/subscriptions" "Delete all Alert and DashboardSubscription subscriptions for a User (i.e., so they will no longer receive them). Archive all Alerts and DashboardSubscriptions created by the User. Only allowed for admins or for the current user." [id] diff --git a/enterprise/backend/src/metabase_enterprise/content_management/api/review.clj b/enterprise/backend/src/metabase_enterprise/content_management/api/review.clj index e417bcbd246..5e27c7bb9a5 100644 --- a/enterprise/backend/src/metabase_enterprise/content_management/api/review.clj +++ b/enterprise/backend/src/metabase_enterprise/content_management/api/review.clj @@ -7,7 +7,7 @@ [metabase.util.schema :as su] [schema.core :as s])) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `ModerationReview`." [:as {{:keys [text moderated_item_id moderated_item_type status]} :body}] {text (s/maybe s/Str) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/gtap.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/gtap.clj index 47d11bfd621..13849fbc53c 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/gtap.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/gtap.clj @@ -11,13 +11,13 @@ [schema.core :as s] [toucan.db :as db])) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of all the GTAPs currently in use." [] ;; TODO - do we need to hydrate anything here? (db/select GroupTableAccessPolicy)) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch GTAP by `id`" [id] (api/check-404 (GroupTableAccessPolicy id))) @@ -28,7 +28,7 @@ (su/with-api-error-message (s/maybe {su/NonBlankString su/NonBlankString}) "value must be a valid attribute remappings map (attribute name -> remapped name)")) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new GTAP." [:as {{:keys [table_id card_id group_id attribute_remappings]} :body}] {table_id su/IntGreaterThanZero @@ -41,7 +41,7 @@ :group_id group_id :attribute_remappings attribute_remappings})) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a GTAP entry. The only things you're allowed to update for a GTAP are the Card being used (`card_id`) or the paramter mappings; changing `table_id` or `group_id` would effectively be deleting this entry and creating a new one. If that's what you want to do, do so explicity with appropriate calls to the `DELETE` and `POST` endpoints." @@ -57,7 +57,7 @@ :present #{:card_id :attribute_remappings}))) (GroupTableAccessPolicy id)) -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a GTAP entry." [id] (api/check-404 (GroupTableAccessPolicy id)) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj index 6ad70d42e63..b875d234c66 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj @@ -53,7 +53,7 @@ (update query-metadata-response :fields #(filter (comp (set gtap-field-ids) u/the-id) %)) query-metadata-response)) -(api/defendpoint GET "/:id/query_metadata" +(api/defendpoint-schema GET "/:id/query_metadata" "This endpoint essentially acts as a wrapper for the OSS version of this route. When a user has segmented permissions that only gives them access to a subset of columns for a given table, those inaccessable columns should also be excluded from what is show in the query builder. When the user has full permissions (or no permissions) this route diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/user.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/user.clj index 091ea9d12c7..c5c69f14c60 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/user.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/user.clj @@ -15,14 +15,14 @@ ;; TODO - not sure we need this endpoint now that we're just letting you edit from the regular `PUT /api/user/:id ;; endpoint -(api/defendpoint PUT "/:id/attributes" +(api/defendpoint-schema PUT "/:id/attributes" "Update the `login_attributes` for a User." [id :as {{:keys [login_attributes]} :body}] {login_attributes UserAttributes} (api/check-404 (db/select-one User :id id)) (db/update! User id :login_attributes login_attributes)) -(api/defendpoint GET "/attributes" +(api/defendpoint-schema GET "/attributes" "Fetch a list of possible keys for User `login_attributes`. This just looks at keys that have already been set for existing Users and returns those. " [] diff --git a/enterprise/backend/src/metabase_enterprise/serialization/api/serialize.clj b/enterprise/backend/src/metabase_enterprise/serialization/api/serialize.clj index be7ac06a091..a6e21948473 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/api/serialize.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/api/serialize.clj @@ -10,7 +10,7 @@ [metabase.util.schema :as su] [toucan.db :as db])) -(api/defendpoint POST "/data-model" +(api/defendpoint-schema POST "/data-model" "This endpoint should serialize: the data model, settings.yaml, and all the selected Collections The data model should only change if the user triggers a manual sync or scan (since the scheduler is turned off) diff --git a/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj b/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj index f09a390c800..c4c91310775 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/api/sso.clj @@ -24,7 +24,7 @@ (throw (ex-info (str (tru "SSO requires a valid token")) {:status-code 403})))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "SSO entry-point for an SSO user that has not logged in yet" [:as req] (throw-if-no-premium-features-token) @@ -44,7 +44,7 @@ :exceptionClass (.getName Exception) :additionalData data}))}) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Route the SSO backends call with successful login details" [:as req] (throw-if-no-premium-features-token) diff --git a/src/metabase/api/action.clj b/src/metabase/api/action.clj index 756db45a193..d76181f7276 100644 --- a/src/metabase/api/action.clj +++ b/src/metabase/api/action.clj @@ -54,22 +54,22 @@ (throw (ex-info (i18n/tru "Actions are not enabled for Database {0}." database-id) {:status-code 400})))))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Returns cards that can be used for QueryActions" [model-id] {model-id su/IntGreaterThanZero} (action/merged-model-action nil :card_id model-id)) -(api/defendpoint GET "/:action-id" +(api/defendpoint-schema GET "/:action-id" [action-id] (api/check-404 (first (action/select-actions :id action-id)))) -(api/defendpoint DELETE "/:action-id" +(api/defendpoint-schema DELETE "/:action-id" [action-id] (db/delete! HTTPAction :action_id action-id) api/generic-204-no-content) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new HTTP action." [:as {{:keys [type name template response_handle error_handle] :as action} :body}] {type SupportedActionType @@ -84,7 +84,7 @@ ;; so we return the most recently updated http action. (last (action/select-actions :type "http"))))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" [id :as {{:keys [type name template response_handle error_handle] :as action} :body}] {id su/IntGreaterThanZero type SupportedActionType diff --git a/src/metabase/api/activity.clj b/src/metabase/api/activity.clj index 8a807a90f80..57cf64e2732 100644 --- a/src/metabase/api/activity.clj +++ b/src/metabase/api/activity.clj @@ -5,7 +5,7 @@ [compojure.core :refer [GET]] [medley.core :as m] [metabase.api.common - :refer [*current-user-id* defendpoint define-routes]] + :refer [*current-user-id* defendpoint-schema define-routes]] [metabase.models.activity :refer [Activity]] [metabase.models.app :refer [App]] [metabase.models.bookmark :refer [CardBookmark DashboardBookmark]] @@ -98,7 +98,7 @@ (or (existing-dataset? (:card_id dashcard)) (existing-card? (:card_id dashcard)))))))))))) -(defendpoint GET "/" +(defendpoint-schema GET "/" "Get recent activity." [] (filter mi/can-read? (-> (db/select Activity, {:order-by [[:timestamp :desc]], :limit 40}) @@ -197,7 +197,7 @@ (def ^:private views-limit 8) (def ^:private card-runs-limit 8) -(defendpoint GET "/recent_views" +(defendpoint-schema GET "/recent_views" "Get the list of 5 things the current user has been viewing most recently." [] (let [views (views-and-runs views-limit card-runs-limit false) @@ -260,7 +260,7 @@ (let [groups (group-by :model items)] (mapcat #(get groups %) model-precedence)))) -(defendpoint GET "/popular_items" +(defendpoint-schema GET "/popular_items" "Get the list of 5 popular things for the current user. Query takes 8 and limits to 5 so that if it finds anything archived, deleted, etc it can hopefully still get 5." [] diff --git a/src/metabase/api/alert.clj b/src/metabase/api/alert.clj index 431df5dfe47..88d1b3f38ce 100644 --- a/src/metabase/api/alert.clj +++ b/src/metabase/api/alert.clj @@ -26,7 +26,7 @@ (u/ignore-exceptions (classloader/require 'metabase-enterprise.advanced-permissions.common)) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch all alerts" [archived user_id] {archived (s/maybe su/BooleanString) @@ -36,13 +36,13 @@ (filter mi/can-read? <>) (hydrate <> :can_write))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch an alert by ID" [id] (-> (api/read-check (pulse/retrieve-alert id)) (hydrate :can_write))) -(api/defendpoint GET "/question/:id" +(api/defendpoint-schema GET "/question/:id" "Fetch all questions for the given question (`Card`) id" [id archived] {id (s/maybe su/IntGreaterThanZero) @@ -128,7 +128,7 @@ (assoc card :include_csv true) card)) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new Alert." [:as {{:keys [alert_condition card channels alert_first_only alert_above_goal] :as new-alert-request-body} :body}] @@ -162,7 +162,7 @@ (doseq [recipient (collect-alert-recipients alert)] (messages/send-admin-unsubscribed-alert-email! alert recipient @api/*current-user*)))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a `Alert` with ID." [id :as {{:keys [alert_condition alert_first_only alert_above_goal card channels archived] :as alert-updates} :body}] @@ -241,7 +241,7 @@ ;; Finally, return the updated Alert updated-alert))) -(api/defendpoint DELETE "/:id/subscription" +(api/defendpoint-schema DELETE "/:id/subscription" "For users to unsubscribe themselves from the given alert." [id] (validation/check-has-application-permission :subscription false) diff --git a/src/metabase/api/app.clj b/src/metabase/api/app.clj index 1a99796912f..e71111d4e70 100644 --- a/src/metabase/api/app.clj +++ b/src/metabase/api/app.clj @@ -33,7 +33,7 @@ app (db/insert! App app-params)] (hydrate-details app)))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Endpoint to create an app" [:as {{:keys [dashboard_id options nav_items] {:keys [name color description namespace authority_level]} :collection @@ -48,7 +48,7 @@ authority_level collection/AuthorityLevel} (create-app! app)) -(api/defendpoint PUT "/:app-id" +(api/defendpoint-schema PUT "/:app-id" "Endpoint to change an app" [app-id :as {{:keys [dashboard_id options nav_items] :as body} :body}] {app-id su/IntGreaterThanOrEqualToZero @@ -59,7 +59,7 @@ (db/update! App app-id (select-keys body [:dashboard_id :options :nav_items])) (hydrate-details (db/select-one App :id app-id))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of all Apps that the current user has read permissions for. By default, this returns Apps with non-archived Collections, but instead you can show archived ones by passing @@ -76,7 +76,7 @@ (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*))] :order-by [[:%lower.collection.name :asc]]})))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch a specific App" [id] (hydrate-details (api/read-check App id) :models)) @@ -268,7 +268,7 @@ :hidden true :sectionId "id"}])))})) -(api/defendpoint POST "/scaffold" +(api/defendpoint-schema POST "/scaffold" "Endpoint to scaffold a fully working data-app" [:as {{:keys [table-ids app-name]} :body}] (db/transaction @@ -282,7 +282,7 @@ (create-scaffold-dashcards! scaffold-target->id pages) (hydrate-details (db/select-one App :id app-id))))) -(api/defendpoint POST "/:app-id/scaffold" +(api/defendpoint-schema POST "/:app-id/scaffold" "Endpoint to scaffold a new table onto an existing data-app" [app-id :as {{:keys [table-ids]} :body}] (db/transaction diff --git a/src/metabase/api/automagic_dashboards.clj b/src/metabase/api/automagic_dashboards.clj index ee46b0b30ea..95b817e5571 100644 --- a/src/metabase/api/automagic_dashboards.clj +++ b/src/metabase/api/automagic_dashboards.clj @@ -56,7 +56,7 @@ (s/pred decode-base64-json) (deferred-tru "value couldn''t be parsed as base64 encoded JSON"))) -(api/defendpoint GET "/database/:id/candidates" +(api/defendpoint-schema GET "/database/:id/candidates" "Return a list of candidates for automagic dashboards orderd by interestingness." [id] (-> (db/select-one Database :id id) @@ -129,7 +129,7 @@ (s/enum "segment" "adhoc" "table") (deferred-tru "Invalid comparison entity type. Can only be one of \"table\", \"segment\", or \"adhoc\""))) -(api/defendpoint GET "/:entity/:entity-id-or-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query" "Return an automagic dashboard for entity `entity` with id `id`." [entity entity-id-or-query show] {show Show @@ -139,7 +139,7 @@ (-> (->entity entity entity-id-or-query) (automagic-analysis {:show (keyword show)})))) -(api/defendpoint GET "/:entity/:entity-id-or-query/rule/:prefix/:rule" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/rule/:prefix/:rule" "Return an automagic dashboard for entity `entity` with id `id` using rule `rule`." [entity entity-id-or-query prefix rule show] {entity Entity @@ -150,7 +150,7 @@ (automagic-analysis {:show (keyword show) :rule ["table" prefix rule]}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/cell/:cell-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/cell/:cell-query" "Return an automagic dashboard analyzing cell in automagic dashboard for entity `entity` defined by query `cell-querry`." @@ -162,7 +162,7 @@ (automagic-analysis {:show (keyword show) :cell-query (decode-base64-json cell-query)}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:rule" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:rule" "Return an automagic dashboard analyzing cell in question with id `id` defined by query `cell-querry` using rule `rule`." [entity entity-id-or-query cell-query prefix rule show] @@ -176,7 +176,7 @@ :rule ["table" prefix rule] :cell-query (decode-base64-json cell-query)}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/compare/:comparison-entity/:comparison-entity-id-or-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for entity `entity` with id `id` compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`" [entity entity-id-or-query show comparison-entity comparison-entity-id-or-query] @@ -190,7 +190,7 @@ :comparison? true})] (comparison-dashboard dashboard left right {}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/rule/:prefix/:rule/compare/:comparison-entity/:comparison-entity-id-or-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/rule/:prefix/:rule/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for entity `entity` with id `id` using rule `rule`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." [entity entity-id-or-query prefix rule show comparison-entity comparison-entity-id-or-query] @@ -207,7 +207,7 @@ :comparison? true})] (comparison-dashboard dashboard left right {}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/cell/:cell-query/compare/:comparison-entity/:comparison-entity-id-or-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/cell/:cell-query/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for cell in automagic dashboard for entity `entity` with id `id` defined by query `cell-querry`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." @@ -223,7 +223,7 @@ :comparison? true})] (comparison-dashboard dashboard left right {:left {:cell-query (decode-base64-json cell-query)}}))) -(api/defendpoint GET "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:rule/compare/:comparison-entity/:comparison-entity-id-or-query" +(api/defendpoint-schema GET "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:rule/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for cell in automagic dashboard for entity `entity` with id `id` defined by query `cell-querry` using rule `rule`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." diff --git a/src/metabase/api/bookmark.clj b/src/metabase/api/bookmark.clj index b2eff14c4dd..a1ee9b65238 100644 --- a/src/metabase/api/bookmark.clj +++ b/src/metabase/api/bookmark.clj @@ -32,14 +32,14 @@ "dashboard" [Dashboard DashboardBookmark :dashboard_id] "collection" [Collection CollectionBookmark :collection_id]}) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch all bookmarks for the user" [] ;; already sorted by created_at in query. Can optionally use user sort preferences here and not in the function ;; below (bookmark/bookmarks-for-user api/*current-user-id*)) -(api/defendpoint POST "/:model/:id" +(api/defendpoint-schema POST "/:model/:id" "Create a new bookmark for user." [model id] {model Models @@ -51,7 +51,7 @@ [400 "Bookmark already exists"]) (db/insert! bookmark-model {item-key id :user_id api/*current-user-id*}))) -(api/defendpoint DELETE "/:model/:id" +(api/defendpoint-schema DELETE "/:model/:id" "Delete a bookmark. Will delete a bookmark assigned to the user making the request by model and id." [model id] {model Models @@ -63,7 +63,7 @@ item-key id) api/generic-204-no-content)) -(api/defendpoint PUT "/ordering" +(api/defendpoint-schema PUT "/ordering" "Sets the order of bookmarks for user." [:as {{:keys [orderings]} :body}] {orderings BookmarkOrderings} diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 875af384603..2afe02eb7bd 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -135,7 +135,7 @@ "Schema for a valid card filter option." (apply s/enum (map name (keys (methods cards-for-filter-option*))))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Get all the Cards. Option filter param `f` can be used to change the set of Cards that are returned; default is `all`, but other options include `mine`, `bookmarked`, `database`, `table`, `recent`, `popular`, and `archived`. See corresponding implementation functions above for the specific behavior of each filter option. :card_index:" @@ -158,7 +158,7 @@ card))) cards)))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get `Card` with ID." [id ignore_view] (let [raw-card (db/select-one Card :id id) @@ -179,7 +179,7 @@ (when-not (Boolean/parseBoolean ignore_view) (events/publish-event! :card-read (assoc <> :actor_id api/*current-user-id*)))))) -(api/defendpoint GET "/:id/timelines" +(api/defendpoint-schema GET "/:id/timelines" "Get the timelines for card with ID. Looks up the collection the card is in and uses that." [id include start end] {include (s/maybe api.timeline/Include) @@ -396,7 +396,7 @@ saved later when it is ready." (when timed-out? (schedule-metadata-saving result-metadata-chan <>)))))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `Card`." [:as {{:keys [collection_id collection_position dataset_query description display name parameters parameter_mappings result_metadata visualization_settings cache_ttl is_write], :as body} :body}] @@ -420,7 +420,7 @@ saved later when it is ready." (check-allowed-to-set-is-write body) (create-card! body)) -(api/defendpoint POST "/:id/copy" +(api/defendpoint-schema POST "/:id/copy" "Copy a `Card`, with the new name 'Copy of _name_'" [id] {id (s/maybe su/IntGreaterThanZero)} @@ -630,7 +630,7 @@ saved later when it is ready." (:is_write card) (hydrate :card/action-id)) (assoc :last-edit-info (last-edit/edit-information-for-user @api/*current-user*))))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a `Card`." [id :as {{:keys [dataset_query description display name visualization_settings archived collection_id collection_position enable_embedding embedding_params result_metadata parameters @@ -686,7 +686,7 @@ saved later when it is ready." ;; TODO - Pretty sure this endpoint is not actually used any more, since Cards are supposed to get archived (via PUT ;; /api/card/:id) instead of deleted. Should we remove this? -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a Card. (DEPRECATED -- don't delete a Card anymore -- archive it instead.)" [id] (log/warn (tru "DELETE /api/card/:id is deprecated. Instead, change its `archived` value via PUT /api/card/:id.")) @@ -763,7 +763,7 @@ saved later when it is ready." (db/update-where! Card {:id [:in (set cards-without-position)]} :collection_id new-collection-id-or-nil)))))) -(api/defendpoint POST "/collections" +(api/defendpoint-schema POST "/collections" "Bulk update endpoint for Card Collections. Move a set of `Cards` with CARD_IDS into a `Collection` with COLLECTION_ID, or remove them from any Collections by passing a `null` COLLECTION_ID." [:as {{:keys [card_ids collection_id]} :body}] @@ -775,7 +775,7 @@ saved later when it is ready." ;;; ------------------------------------------------ Running a Query ------------------------------------------------- -(api/defendpoint ^:streaming POST "/:card-id/query" +(api/defendpoint-schema ^:streaming POST "/:card-id/query" "Run the query associated with a Card." [card-id :as {{:keys [parameters ignore_cache dashboard_id collection_preview], :or {ignore_cache false dashboard_id nil}} :body}] {ignore_cache (s/maybe s/Bool) @@ -794,7 +794,7 @@ saved later when it is ready." :context (if collection_preview :collection :question) :middleware {:process-viz-settings? false})) -(api/defendpoint ^:streaming POST "/:card-id/query/:export-format" +(api/defendpoint-schema ^:streaming POST "/:card-id/query/:export-format" "Run the query associated with a Card, and return its results as a file in the specified format. `parameters` should be passed as query parameter encoded as a serialized JSON string (this is because this endpoint @@ -815,7 +815,7 @@ saved later when it is ready." ;;; ----------------------------------------------- Sharing is Caring ------------------------------------------------ -(api/defendpoint POST "/:card-id/public_link" +(api/defendpoint-schema POST "/:card-id/public_link" "Generate publicly-accessible links for this Card. Returns UUID to be used in public links. (If this Card has already been shared, it will return the existing public link rather than creating a new one.) Public sharing must be enabled." @@ -835,7 +835,7 @@ saved later when it is ready." :public_uuid <> :made_public_by_id api/*current-user-id*)))})) -(api/defendpoint DELETE "/:card-id/public_link" +(api/defendpoint-schema DELETE "/:card-id/public_link" "Delete the publicly-accessible link to this Card." [card-id] (validation/check-has-application-permission :setting) @@ -846,14 +846,14 @@ saved later when it is ready." :made_public_by_id nil) {:status 204, :body nil}) -(api/defendpoint GET "/public" +(api/defendpoint-schema GET "/public" "Fetch a list of Cards with public UUIDs. These cards are publicly-accessible *if* public sharing is enabled." [] (validation/check-has-application-permission :setting) (validation/check-public-sharing-enabled) (db/select [Card :name :id :public_uuid], :public_uuid [:not= nil], :archived false)) -(api/defendpoint GET "/embeddable" +(api/defendpoint-schema GET "/embeddable" "Fetch a list of Cards where `enable_embedding` is `true`. The cards can be embedded using the embedding endpoints and a signed JWT." [] @@ -861,17 +861,17 @@ saved later when it is ready." (validation/check-embedding-enabled) (db/select [Card :name :id], :enable_embedding true, :archived false)) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Card :id id) api/read-check related/related)) -(api/defendpoint POST "/related" +(api/defendpoint-schema POST "/related" "Return related entities for an ad-hoc query." [:as {query :body}] (related/related (query/adhoc-query query))) -(api/defendpoint ^:streaming POST "/pivot/:card-id/query" +(api/defendpoint-schema ^:streaming POST "/pivot/:card-id/query" "Run the query associated with a Card." [card-id :as {{:keys [parameters ignore_cache] :or {ignore_cache false}} :body}] @@ -881,7 +881,7 @@ saved later when it is ready." :qp-runner qp.pivot/run-pivot-query :ignore_cache ignore_cache)) -(api/defendpoint POST "/:card-id/persist" +(api/defendpoint-schema POST "/:card-id/persist" "Mark the model (card) as persisted. Runs the query and saves it to the database backing the card and hot swaps this query in place of the model's query." [card-id] @@ -905,7 +905,7 @@ saved later when it is ready." (task.persist-refresh/schedule-refresh-for-individual! persisted-info)) api/generic-204-no-content))) -(api/defendpoint POST "/:card-id/refresh" +(api/defendpoint-schema POST "/:card-id/refresh" "Refresh the persisted model caching `card-id`." [card-id] {card-id su/IntGreaterThanZero} @@ -919,7 +919,7 @@ saved later when it is ready." (task.persist-refresh/schedule-refresh-for-individual! persisted-info) api/generic-204-no-content)) -(api/defendpoint POST "/:card-id/unpersist" +(api/defendpoint-schema POST "/:card-id/unpersist" "Unpersist this model. Deletes the persisted table backing the model and all queries after this will use the card's query rather than the saved version of the query." [card-id] diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index 92414741437..8e52656849f 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -41,7 +41,7 @@ (declare root-collection) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of all Collections that the current user has read permissions for (`:can_write` is returned as an additional property of each Collection so you can tell which of these you have write permissions for.) @@ -74,7 +74,7 @@ (dissoc ::collection.root/is-root?) collection/personal-collection-with-ui-details))))) -(api/defendpoint GET "/tree" +(api/defendpoint-schema GET "/tree" "Similar to `GET /`, but returns Collections in a tree structure, e.g. ``` @@ -128,7 +128,7 @@ (def ^:private ModelString (apply s/enum valid-model-param-values)) -; This is basically a union type. defendpoint splits the string if it only gets one +; This is basically a union type. defendpoint-schema splits the string if it only gets one (def ^:private models-schema (s/conditional vector? [ModelString] :else ModelString)) (def ^:private valid-pinned-state-values @@ -643,12 +643,12 @@ collection/personal-collection-with-ui-details (hydrate :parent_id :effective_location [:effective_ancestors :can_write] :can_write :app_id))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch a specific Collection with standard details added" [id] (collection-detail (api/read-check Collection id))) -(api/defendpoint GET "/root/timelines" +(api/defendpoint-schema GET "/root/timelines" "Fetch the root Collection's timelines." [include archived] {include (s/maybe api.timeline/Include) @@ -657,7 +657,7 @@ (timeline/timelines-for-collection nil {:timeline/events? (= include "events") :timeline/archived? archived?}))) -(api/defendpoint GET "/:id/timelines" +(api/defendpoint-schema GET "/:id/timelines" "Fetch a specific Collection's timelines." [id include archived] {include (s/maybe api.timeline/Include) @@ -666,7 +666,7 @@ (timeline/timelines-for-collection id {:timeline/events? (= include "events") :timeline/archived? archived?}))) -(api/defendpoint GET "/:id/items" +(api/defendpoint-schema GET "/:id/items" "Fetch a specific Collection's items with the following options: * `models` - only include objects of a specific set of `models`. If unspecified, returns objects of all models @@ -694,7 +694,7 @@ (defn- root-collection [collection-namespace] (collection-detail (collection/root-collection-with-ui-details collection-namespace))) -(api/defendpoint GET "/root" +(api/defendpoint-schema GET "/root" "Return the 'Root' Collection object with standard details added" [namespace] {namespace (s/maybe su/NonBlankString)} @@ -714,7 +714,7 @@ #{:collection} #{:no_models}))) -(api/defendpoint GET "/root/items" +(api/defendpoint-schema GET "/root/items" "Fetch objects that the current user should see at their root level. As mentioned elsewhere, the 'Root' Collection doesn't actually exist as a row in the application DB: it's simply a virtual Collection where things with no `collection_id` exist. It does, however, have its own set of Permissions. @@ -777,7 +777,7 @@ (when parent_id {:location (collection/children-location (db/select-one [Collection :location :id] :id parent_id))})))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new Collection." [:as {{:keys [name color description parent_id namespace authority_level] :as body} :body}] {name su/NonBlankString @@ -839,7 +839,7 @@ {:card-ids (db/select-ids Card :collection_id (u/the-id collection-before-update))}))] (api.card/delete-alert-and-notify-archived! alerts)))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Modify an existing Collection, including archiving or unarchiving it, or moving it." [id, :as {{:keys [name color description archived parent_id authority_level], :as collection-updates} :body}] {name (s/maybe su/NonBlankString) @@ -874,7 +874,7 @@ ;;; ------------------------------------------------ GRAPH ENDPOINTS ------------------------------------------------- -(api/defendpoint GET "/graph" +(api/defendpoint-schema GET "/graph" "Fetch a graph of all Collection Permissions." [namespace] {namespace (s/maybe su/NonBlankString)} @@ -900,7 +900,7 @@ [graph] (update graph :groups dejsonify-groups)) -(api/defendpoint PUT "/graph" +(api/defendpoint-schema PUT "/graph" "Do a batch update of Collections Permissions by passing in a modified graph." [:as {{:keys [namespace], :as body} :body}] {body su/Map diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index 9b543ec5805..5e51da1af1e 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -10,6 +10,8 @@ [metabase.api.common.internal :refer [add-route-param-regexes auto-parse + malli-route-dox + malli-validate-params route-dox route-fn-name validate-params @@ -222,13 +224,13 @@ :route (some-fn string? sequential?) :docstr (s/? string?) :args vector? - :arg->schema (s/? (s/map-of symbol? any?)) + :arg->schema (s/? (s/map-of symbol? any?)) ;; any? is either a plumatic or malli schema :body (s/* any?))) (defn- parse-defendpoint-args [args] (let [parsed (s/conform ::defendpoint-args args)] (when (= parsed ::s/invalid) - (throw (ex-info (str "Invalid defendpoint args: " (s/explain-str ::defendpoint-args args)) + (throw (ex-info (str "Invalid defendpoint-schema args: " (s/explain-str ::defendpoint-args args)) (s/explain-data ::defendpoint-args args)))) (let [{:keys [method route docstr args arg->schema body]} parsed fn-name (route-fn-name method route) @@ -242,6 +244,23 @@ (ns-name *ns*) fn-name))) (assoc parsed :fn-name fn-name, :route route, :docstr docstr)))) +(defn- malli-parse-defendpoint-args [args] + (let [parsed (s/conform ::defendpoint-args args)] + (when (= parsed ::s/invalid) + (throw (ex-info (str "Invalid defendpoint args: " (s/explain-str ::defendpoint-args args)) + (s/explain-data ::defendpoint-args args)))) + (let [{:keys [method route docstr args arg->schema body]} parsed + fn-name (route-fn-name method route) + route (add-route-param-regexes route) + ;; eval the vals in arg->schema to make sure the actual schemas are resolved so we can document + ;; their API error messages + docstr (malli-route-dox method route docstr args (m/map-vals eval arg->schema) body)] + ;; Don't i18n this, it's dev-facing only + (when-not docstr + (log/warn (u/format-color 'red "Warning: endpoint %s/%s does not have a docstring. Go add one." + (ns-name *ns*) fn-name))) + (assoc parsed :fn-name fn-name, :route route, :docstr docstr)))) + (defmacro defendpoint* "Impl macro for [[defendpoint]]; don't use this directly." [{:keys [method route fn-name docstr args body]}] @@ -255,7 +274,7 @@ ;; TODO - several of the things `defendpoint` does could and should just be done by custom Ring middleware instead ;; e.g. `auto-parse` -(defmacro defendpoint +(defmacro defendpoint-schema "Define an API function. This automatically does several things: @@ -281,6 +300,32 @@ (wrap-response-if-needed (do ~@body)))))))) +(defmacro defendpoint + "Define an API function. + This automatically does several things: + + - calls `auto-parse` to automatically parse certain args. e.g. `id` is converted from `String` to `Integer` via + `Integer/parseInt` + + - converts `route` from a simple form like `\"/:id\"` to a typed one like `[\"/:id\" :id #\"[0-9]+\"]` + + - sequentially applies specified annotation functions on args to validate them. + + - automatically calls `wrap-response-if-needed` on the result of `body` + + - tags function's metadata in a way that subsequent calls to `define-routes` (see below) will automatically include + the function in the generated `defroutes` form. + + - Generates a super-sophisticated Markdown-formatted docstring" + {:arglists '([method route docstr? args schemas-map? & body])} + [& defendpoint-args] + (let [{:keys [args body arg->schema], :as defendpoint-args} (malli-parse-defendpoint-args defendpoint-args)] + `(defendpoint* ~(assoc defendpoint-args + :body `((auto-parse ~args + ~@(malli-validate-params arg->schema) + (wrap-response-if-needed + (do ~@body)))))))) + (defmacro defendpoint-async "Like `defendpoint`, but generates an endpoint that accepts the usual `[request respond raise]` params." {:arglists '([method route docstr? args schemas-map? & body])} diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj index 4318a61db71..b1706843843 100644 --- a/src/metabase/api/common/internal.clj +++ b/src/metabase/api/common/internal.clj @@ -4,10 +4,13 @@ (:require [clojure.string :as str] [clojure.tools.logging :as log] + [malli.core :as mc] + [malli.error :as me] [metabase.async.streaming-response :as streaming-response] [metabase.config :as config] [metabase.util :as u] [metabase.util.i18n :refer [tru]] + [metabase.util.malli.describe :as umd] [metabase.util.schema :as su] [potemkin.types :as p.types] [schema.core :as s]) @@ -81,6 +84,20 @@ "Consider wrapping it in `su/with-api-error-message`.") (u/pprint-to-str schema) (u/add-period route-str))))))) +(defn- malli-dox-for-schema + "Look up the docstring for `schema` for use in auto-generated API documentation. In most cases this is defined by + wrapping the schema with `with-api-error-message`." + [schema route-str] + (if-not schema + "" + (u/prog1 (umd/describe schema) + ;; Don't try to i18n this stuff! It's developer-facing only. + (when config/is-dev? + (log/warn + (u/format-color 'red (str "We don't have a nice error message for MALLI SCHEMA: %s defined at %s") + (u/pprint-to-str schema) + (u/add-period route-str))))))) + (defn- param-name "Return the appropriate name for this `param-symb` based on its `schema`. Usually this is just the name of the `param-symb`, but if the schema used a call to `su/api-param` we;ll use that name instead." @@ -89,6 +106,19 @@ (:api-param-name schema)) (name param-symb))) +(defn- malli-format-route-schema-dox + "Generate the `params` section of the documentation for a `defendpoint`-defined function by using the + `param-symb->schema` map passed in after the argslist." + [param-symb->schema route-str] + ;; these are here + (when (seq param-symb->schema) + (str "\n\n### PARAMS:\n\n" + (str/join "\n\n" + (for [[param-symb schema] param-symb->schema] + (format "* **`%s`** %s" + (param-name param-symb schema) + (malli-dox-for-schema schema route-str))))))) + (defn- format-route-schema-dox "Generate the `params` section of the documentation for a `defendpoint`-defined function by using the `param-symb->schema` map passed in after the argslist." @@ -106,6 +136,14 @@ (str "\n\n" (u/add-period docstr))) (format-route-schema-dox param->schema route-str))) +(defn- malli-format-route-dox + "Return a markdown-formatted string to be used as documentation for a `defendpoint` function." + [route-str docstr param->schema] + (str (format "## `%s`" route-str) + (when (seq docstr) + (str "\n\n" (u/add-period docstr))) + (malli-format-route-schema-dox param->schema route-str))) + (defn- contains-superuser-check? "Does the BODY of this `defendpoint` form contain a call to `check-superuser`?" [body] @@ -113,6 +151,15 @@ (or (contains? body '(check-superuser)) (contains? body '(api/check-superuser))))) +(defn malli-route-dox + "Prints a markdown route doc for defendpoint" + [method route docstr args param->schema body] + (malli-format-route-dox (endpoint-name method route) + (str (u/add-period docstr) (when (contains-superuser-check? body) + "\n\nYou must be a superuser to do this.")) + (merge (args-form-symbols args) + param->schema))) + (defn route-dox "Generate a documentation string for a `defendpoint` route." [method route docstr args param->schema body] @@ -231,6 +278,15 @@ ;;; | PARAM VALIDATION | ;;; +----------------------------------------------------------------------------------------------------------------+ +(defn malli-validate-param + "Validate a parameter against its respective malli schema, or throw an Exception." + [field-name value schema] + (when-not (mc/validate schema value) + (throw (ex-info (tru "Invalid m field: {0}" field-name) + {:status-code 400 + :errors {(keyword field-name) + (me/humanize (mc/explain schema value))}})))) + (defn validate-param "Validate a parameter against its respective schema, or throw an Exception." [field-name value schema] @@ -242,6 +298,12 @@ (:message (ex-data e)) (.getMessage e))}}))))) +(defn malli-validate-params + "Generate a series of `malli-validate-param` calls for each param and malli schema pair in PARAM->SCHEMA." + [param->schema] + (for [[param schema] param->schema] + `(malli-validate-param '~param ~param ~schema))) + (defn validate-params "Generate a series of `validate-param` calls for each param and schema pair in PARAM->SCHEMA." [param->schema] diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 0baaa0d245d..96ad2fc730f 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -58,7 +58,7 @@ (hydrate <> :creator) (filter mi/can-read? <>))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Get `Dashboards`. With filter option `f` (default `all`), restrict results as follows: * `all` - Return all Dashboards. @@ -75,7 +75,7 @@ dashboard))) dashboards))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new Dashboard." [:as {{:keys [name description parameters cache_ttl collection_id collection_position is_app_page], :as _dashboard} :body}] {name su/NonBlankString @@ -315,7 +315,7 @@ series))))))) ordered-cards))) -(api/defendpoint POST "/:from-dashboard-id/copy" +(api/defendpoint-schema POST "/:from-dashboard-id/copy" "Copy a Dashboard." [from-dashboard-id :as {{:keys [name description collection_id collection_position is_deep_copy], :as _dashboard} :body}] @@ -363,7 +363,7 @@ ;;; --------------------------------------------- Fetching/Updating/Etc. --------------------------------------------- -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get Dashboard with ID." [id] (let [dashboard (get-dashboard id)] @@ -379,7 +379,7 @@ (validation/check-embedding-enabled) (api/check-superuser))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a Dashboard. Usually, you just need write permissions for this Dashboard to do this (which means you have appropriate @@ -428,7 +428,7 @@ ;; TODO - We can probably remove this in the near future since it should no longer be needed now that we're going to ;; be setting `:archived` to `true` via the `PUT` endpoint instead -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a Dashboard." [id] (log/warn (str "DELETE /api/dashboard/:id is deprecated. Instead of deleting a Dashboard, you should change its " @@ -483,7 +483,7 @@ :actual-permissions @api/*current-user-permissions-set*}))))))))) ;; TODO - param should be `card_id`, not `cardId` (fix here + on frontend at the same time) -(api/defendpoint POST "/:id/cards" +(api/defendpoint-schema POST "/:id/cards" "Add a `Card` to a Dashboard." [id :as {{:keys [cardId parameter_mappings], :as dashboard-card} :body}] {cardId (s/maybe su/IntGreaterThanZero) @@ -550,7 +550,7 @@ "value must be a valid DashboardCard map.")) ;; TODO - we should use schema to validate the format of the Cards :D -(api/defendpoint PUT "/:id/cards" +(api/defendpoint-schema PUT "/:id/cards" "Update `Cards` on a Dashboard. Request body should have the form: {:cards [{:id ... ; DashboardCard ID @@ -570,7 +570,7 @@ (events/publish-event! :dashboard-reposition-cards {:id id, :actor_id api/*current-user-id*, :dashcards cards}) {:status :ok}) -(api/defendpoint DELETE "/:id/cards" +(api/defendpoint-schema DELETE "/:id/cards" "Remove a `DashboardCard` from a Dashboard." [id dashcardId] {dashcardId su/IntStringGreaterThanZero} @@ -579,13 +579,13 @@ (api/check-500 (dashboard-card/delete-dashboard-card! dashboard-card api/*current-user-id*)) api/generic-204-no-content)) -(api/defendpoint GET "/:id/revisions" +(api/defendpoint-schema GET "/:id/revisions" "Fetch `Revisions` for Dashboard with ID." [id] (api/read-check Dashboard id) (revision/revisions+details Dashboard id)) -(api/defendpoint POST "/:id/revert" +(api/defendpoint-schema POST "/:id/revert" "Revert a Dashboard to a prior `Revision`." [id :as {{:keys [revision_id]} :body}] {revision_id su/IntGreaterThanZero} @@ -598,7 +598,7 @@ ;;; ----------------------------------------------- Sharing is Caring ------------------------------------------------ -(api/defendpoint POST "/:dashboard-id/public_link" +(api/defendpoint-schema POST "/:dashboard-id/public_link" "Generate publicly-accessible links for this Dashboard. Returns UUID to be used in public links. (If this Dashboard has already been shared, it will return the existing public link rather than creating a new one.) Public sharing must be enabled." @@ -612,7 +612,7 @@ :public_uuid <> :made_public_by_id api/*current-user-id*)))}) -(api/defendpoint DELETE "/:dashboard-id/public_link" +(api/defendpoint-schema DELETE "/:dashboard-id/public_link" "Delete the publicly-accessible link to this Dashboard." [dashboard-id] (validation/check-has-application-permission :setting) @@ -623,7 +623,7 @@ :made_public_by_id nil) {:status 204, :body nil}) -(api/defendpoint GET "/public" +(api/defendpoint-schema GET "/public" "Fetch a list of Dashboards with public UUIDs. These dashboards are publicly-accessible *if* public sharing is enabled." [] @@ -631,7 +631,7 @@ (validation/check-public-sharing-enabled) (db/select [Dashboard :name :id :public_uuid], :public_uuid [:not= nil], :archived false)) -(api/defendpoint GET "/embeddable" +(api/defendpoint-schema GET "/embeddable" "Fetch a list of Dashboards where `enable_embedding` is `true`. The dashboards can be embedded using the embedding endpoints and a signed JWT." [] @@ -639,21 +639,21 @@ (validation/check-embedding-enabled) (db/select [Dashboard :name :id], :enable_embedding true, :archived false)) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Dashboard :id id) api/read-check related/related)) ;;; ---------------------------------------------- Transient dashboards ---------------------------------------------- -(api/defendpoint POST "/save/collection/:parent-collection-id" +(api/defendpoint-schema POST "/save/collection/:parent-collection-id" "Save a denormalized description of dashboard into collection with ID `:parent-collection-id`." [parent-collection-id :as {dashboard :body}] (collection/check-write-perms-for-collection parent-collection-id) (->> (dashboard/save-transient-dashboard! dashboard parent-collection-id) (events/publish-event! :dashboard-create))) -(api/defendpoint POST "/save" +(api/defendpoint-schema POST "/save" "Save a denormalized description of dashboard." [:as {dashboard :body}] (let [parent-collection-id (if api/*is-superuser?* @@ -790,7 +790,7 @@ "card" (card-parameter-values param query) nil (chain-filter dashboard param-key constraint-param-key->value query))))) -(api/defendpoint GET "/:id/params/:param-key/values" +(api/defendpoint-schema GET "/:id/params/:param-key/values" "Fetch possible values of the parameter whose ID is `:param-key`. If the values come directly from a query, optionally restrict these values by passing query parameters like `other-parameter=value` e.g. @@ -800,7 +800,7 @@ (let [dashboard (api/read-check Dashboard id)] (param-values dashboard param-key query-params))) -(api/defendpoint GET "/:id/params/:param-key/search/:query" +(api/defendpoint-schema GET "/:id/params/:param-key/search/:query" "Fetch possible values of the parameter whose ID is `:param-key` that contain `:query`. Optionally restrict these values by passing query parameters like `other-parameter=value` e.g. @@ -813,7 +813,7 @@ (let [dashboard (api/read-check Dashboard id)] (param-values dashboard param-key query-params query))) -(api/defendpoint GET "/params/valid-filter-fields" +(api/defendpoint-schema GET "/params/valid-filter-fields" "Utility endpoint for powering Dashboard UI. Given some set of `filtered` Field IDs (presumably Fields used in parameters) and a set of `filtering` Field IDs that will be used to restrict values of `filtered` Fields, for each `filtered` Field ID return the subset of `filtering` Field IDs that would actually be used in a chain filter query @@ -860,7 +860,7 @@ ;;; ---------------------------------- Executing the action associated with a Dashcard ------------------------------- -(api/defendpoint GET "/:dashboard-id/dashcard/:dashcard-id/execute/:slug" +(api/defendpoint-schema GET "/:dashboard-id/dashcard/:dashcard-id/execute/:slug" "Fetches the values for filling in execution parameters." [dashboard-id dashcard-id slug] {dashboard-id su/IntGreaterThanZero @@ -869,7 +869,7 @@ (action/check-data-apps-enabled) (throw (UnsupportedOperationException. "Not implemented"))) -(api/defendpoint POST "/:dashboard-id/dashcard/:dashcard-id/execute/:slug" +(api/defendpoint-schema POST "/:dashboard-id/dashcard/:dashcard-id/execute/:slug" "Execute the associated Action in the context of a `Dashboard` and `DashboardCard` that includes it. `parameters` should be the mapped dashboard parameters with values. @@ -885,7 +885,7 @@ ;;; ---------------------------------- Running the query associated with a Dashcard ---------------------------------- -(api/defendpoint POST "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query" +(api/defendpoint-schema POST "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query" "Run the query associated with a Saved Question (`Card`) in the context of a `Dashboard` that includes it." [dashboard-id dashcard-id card-id :as {{:keys [parameters], :as body} :body}] {parameters (s/maybe [ParameterWithID])} @@ -896,7 +896,7 @@ :card-id card-id :dashcard-id dashcard-id}))) -(api/defendpoint POST "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query/:export-format" +(api/defendpoint-schema POST "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query/:export-format" "Run the query associated with a Saved Question (`Card`) in the context of a `Dashboard` that includes it, and return its results as a file in the specified format. @@ -923,7 +923,7 @@ :format-rows? false :js-int-to-string? false}}))) -(api/defendpoint POST "/pivot/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query" +(api/defendpoint-schema POST "/pivot/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query" "Run a pivot table query for a specific DashCard." [dashboard-id dashcard-id card-id :as {{:keys [parameters], :as body} :body}] {parameters (s/maybe [ParameterWithID])} diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 41f768dfc8b..eed6079cf56 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -241,7 +241,7 @@ (s/maybe (s/eq "tables")) (deferred-tru "include must be either empty or the value 'tables'"))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch all `Databases`. * `include=tables` means we should hydrate the Tables belonging to each DB. Default: `false`. @@ -324,7 +324,7 @@ ; filter hidden fields (= include "tables.fields") (map #(update % :fields filter-sensitive-fields)))))))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get a single Database with `id`. Optionally pass `?include=tables` or `?include=tables.fields` to include the Tables belonging to this database, or the Tables and Fields, respectively. If the requestor has write permissions for the DB (i.e. is an admin or has data model permissions), then certain inferred secret values will also be included in the @@ -357,7 +357,7 @@ ;; we'll create another endpoint to specifically match the ID of the 'virtual' database. The `defendpoint` macro ;; requires either strings or vectors for the route so we'll have to use a vector and create a regex to only ;; match the virtual ID (and nothing else). -(api/defendpoint GET ["/:virtual-db/metadata" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] +(api/defendpoint-schema GET ["/:virtual-db/metadata" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] "Endpoint that provides metadata for the Saved Questions 'virtual' database. Used for fooling the frontend and allowing it to treat the Saved Questions virtual DB just like any other database." [] @@ -392,7 +392,7 @@ (update :segments (partial filter mi/can-read?)) (update :metrics (partial filter mi/can-read?))))))))) -(api/defendpoint GET "/:id/metadata" +(api/defendpoint-schema GET "/:id/metadata" "Get metadata about a `Database`, including all of its `Tables` and `Fields`. Returns DB, fields, and field values. By default only non-hidden tables and fields are returned. Passing include_hidden=true includes them. @@ -511,7 +511,7 @@ {:option v :valid-options autocomplete-matching-options})))))) -(api/defendpoint GET "/:id/autocomplete_suggestions" +(api/defendpoint-schema GET "/:id/autocomplete_suggestions" "Return a list of autocomplete suggestions for a given `prefix`, or `substring`. Should only specify one, but `substring` will have priority if both are present. @@ -537,7 +537,7 @@ (catch Throwable t (log/warn "Error with autocomplete: " (.getMessage t))))) -(api/defendpoint GET "/:id/card_autocomplete_suggestions" +(api/defendpoint-schema GET "/:id/card_autocomplete_suggestions" "Return a list of `Card` autocomplete suggestions for a given `query` in a given `Database`. This is intended for use with the ACE Editor when the User is typing in a template tag for a `Card`, e.g. {{#...}}." @@ -555,7 +555,7 @@ ;;; ------------------------------------------ GET /api/database/:id/fields ------------------------------------------ -(api/defendpoint GET "/:id/fields" +(api/defendpoint-schema GET "/:id/fields" "Get a list of all `Fields` in `Database`." [id] (api/read-check Database id) @@ -574,7 +574,7 @@ ;;; ----------------------------------------- GET /api/database/:id/idfields ----------------------------------------- -(api/defendpoint GET "/:id/idfields" +(api/defendpoint-schema GET "/:id/idfields" "Get a list of all primary key `Fields` for `Database`." [id include_editable_data_model] (let [[db-perm-check field-perm-check] (if (Boolean/parseBoolean include_editable_data_model) @@ -655,7 +655,7 @@ (assoc :valid false)) details))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Add a new `Database`." [:as {{:keys [name engine details is_full_sync is_on_demand schedules auto_run_queries cache_ttl]} :body}] {name su/NonBlankString @@ -701,7 +701,7 @@ {:status 400 :body (dissoc details-or-error :valid)})))) -(api/defendpoint POST "/validate" +(api/defendpoint-schema POST "/validate" "Validate that we can connect to a database given a set of details." ;; TODO - why do we pass the DB in under the key `details`? [:as {{{:keys [engine details]} :details} :body}] @@ -714,7 +714,7 @@ ;;; --------------------------------------- POST /api/database/sample_database ---------------------------------------- -(api/defendpoint POST "/sample_database" +(api/defendpoint-schema POST "/sample_database" "Add the sample database as a new `Database`." [] (api/check-superuser) @@ -737,7 +737,7 @@ details (database/sensitive-fields-for-db database))))) -(api/defendpoint POST "/:id/persist" +(api/defendpoint-schema POST "/:id/persist" "Attempt to enable model persistence for a database. If already enabled returns a generic 204." [id] {:id su/IntGreaterThanZero} @@ -763,7 +763,7 @@ {:error error :database (:name database)}))))))) -(api/defendpoint POST "/:id/unpersist" +(api/defendpoint-schema POST "/:id/unpersist" "Attempt to disable model persistence for a database. If already not enabled, just returns a generic 204." [id] {:id su/IntGreaterThanZero} @@ -778,7 +778,7 @@ ;; todo: a response saying this was a no-op? an error? same on the post to persist api/generic-204-no-content))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a `Database`." [id :as {{:keys [name engine details is_full_sync is_on_demand description caveats points_of_interest schedules auto_run_queries refingerprint cache_ttl settings]} :body}] @@ -857,7 +857,7 @@ ;;; -------------------------------------------- DELETE /api/database/:id -------------------------------------------- -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a `Database`." [id] (api/check-superuser) @@ -871,7 +871,7 @@ ;; TODO - Shouldn't we just check for superuser status instead of write checking? ;; NOTE Atte: This becomes maybe obsolete -(api/defendpoint POST "/:id/sync" +(api/defendpoint-schema POST "/:id/sync" "Update the metadata for this `Database`. This happens asynchronously." [id] ;; just publish a message and let someone else deal with the logistics @@ -884,7 +884,7 @@ ;; Currently these match the titles of the admin UI buttons that call these endpoints ;; Should somehow trigger sync-database/sync-database! -(api/defendpoint POST "/:id/sync_schema" +(api/defendpoint-schema POST "/:id/sync_schema" "Trigger a manual update of the schema metadata for this `Database`." [id] ;; just wrap this in a future so it happens async @@ -894,7 +894,7 @@ (analyze/analyze-db! db))) {:status :ok}) -(api/defendpoint POST "/:id/dismiss_spinner" +(api/defendpoint-schema POST "/:id/dismiss_spinner" "Manually set the initial sync status of the `Database` and corresponding tables to be `complete` (see #20863)" [id] @@ -914,7 +914,7 @@ true) ;; Should somehow trigger cached-values/cache-field-values-for-database! -(api/defendpoint POST "/:id/rescan_values" +(api/defendpoint-schema POST "/:id/rescan_values" "Trigger a manual scan of the field values for this `Database`." [id] ;; just wrap this is a future so it happens async @@ -943,7 +943,7 @@ ;; TODO - should this be something like DELETE /api/database/:id/field_values instead? -(api/defendpoint POST "/:id/discard_values" +(api/defendpoint-schema POST "/:id/discard_values" "Discards all saved field values for this `Database`." [id] (delete-all-field-values-for-database! (api/write-check (db/select-one Database :id id))) @@ -962,7 +962,7 @@ (perms/set-has-full-permissions? @api/*current-user-permissions-set* (perms/data-model-write-perms-path database-id schema-name)))) -(api/defendpoint GET "/:id/schemas" +(api/defendpoint-schema GET "/:id/schemas" "Returns a list of all the schemas found for the database `id`" [id] (api/read-check Database id) @@ -977,7 +977,7 @@ distinct sort)) -(api/defendpoint GET ["/:virtual-db/schemas" +(api/defendpoint-schema GET ["/:virtual-db/schemas" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] "Returns a list of all the schemas found for the saved questions virtual database." [] @@ -987,7 +987,7 @@ distinct (sort-by str/lower-case)))) -(api/defendpoint GET ["/:virtual-db/datasets" +(api/defendpoint-schema GET ["/:virtual-db/datasets" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] "Returns a list of all the datasets found for the saved questions virtual database." [] @@ -1011,18 +1011,18 @@ :visibility_type nil {:order-by [[:display_name :asc]]}))) -(api/defendpoint GET "/:id/schema/:schema" +(api/defendpoint-schema GET "/:id/schema/:schema" "Returns a list of Tables for the given Database `id` and `schema`" [id schema] (api/check-404 (seq (schema-tables-list id schema)))) -(api/defendpoint GET "/:id/schema/" +(api/defendpoint-schema GET "/:id/schema/" "Return a list of Tables for a Database whose `schema` is `nil` or an empty string." [id] (api/check-404 (seq (concat (schema-tables-list id nil) (schema-tables-list id ""))))) -(api/defendpoint GET ["/:virtual-db/schema/:schema" +(api/defendpoint-schema GET ["/:virtual-db/schema/:schema" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] "Returns a list of Tables for the saved questions virtual database." [schema] @@ -1034,7 +1034,7 @@ [:in :collection_id (api/check-404 (seq (db/select-ids Collection :name schema)))])]) (map api.table/card->virtual-table)))) -(api/defendpoint GET ["/:virtual-db/datasets/:schema" +(api/defendpoint-schema GET ["/:virtual-db/datasets/:schema" :virtual-db (re-pattern (str mbql.s/saved-questions-virtual-database-id))] "Returns a list of Tables for the datasets virtual database." [schema] @@ -1046,7 +1046,7 @@ [:in :collection_id (api/check-404 (seq (db/select-ids Collection :name schema)))])]) (map api.table/card->virtual-table)))) -(api/defendpoint GET "/db-ids-with-deprecated-drivers" +(api/defendpoint-schema GET "/db-ids-with-deprecated-drivers" "Return a list of database IDs using currently deprecated drivers." [] (map diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index b72e89bac1d..84d064f91e8 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -69,7 +69,7 @@ (qp.streaming/streaming-response [context export-format] (qp-runner query info context))))) -(api/defendpoint ^:streaming POST "/" +(api/defendpoint-schema ^:streaming POST "/" "Execute a query and retrieve the results in the usual format. The query will not use the cache." [:as {{:keys [database] :as query} :body}] {database (s/maybe s/Int)} @@ -94,7 +94,7 @@ "Regex for matching valid export formats (e.g., `json`) for queries. Inteneded for use in an endpoint definition: - (api/defendpoint POST [\"/:export-format\", :export-format export-format-regex]" + (api/defendpoint-schema POST [\"/:export-format\", :export-format export-format-regex]" (re-pattern (str "(" (str/join "|" (map u/qualified-name (qp.streaming/export-formats))) ")"))) (def ^:private column-ref-regex #"^\[.+\]$") @@ -107,7 +107,7 @@ json-key (keyword json-key))) -(api/defendpoint ^:streaming POST ["/:export-format", :export-format export-format-regex] +(api/defendpoint-schema ^:streaming POST ["/:export-format", :export-format export-format-regex] "Execute a query and download the result data as a file in the specified format." [export-format :as {{:keys [query visualization_settings] :or {visualization_settings "{}"}} :params}] {query su/JSONString @@ -136,7 +136,7 @@ ;;; ------------------------------------------------ Other Endpoints ------------------------------------------------- ;; TODO - this is no longer used. Should we remove it? -(api/defendpoint POST "/duration" +(api/defendpoint-schema POST "/duration" "Get historical query execution duration." [:as {{:keys [database], :as query} :body}] (api/read-check Database database) @@ -148,14 +148,14 @@ (assoc query :constraints (qp.constraints/default-query-constraints))]) 0)}) -(api/defendpoint POST "/native" +(api/defendpoint-schema POST "/native" "Fetch a native version of an MBQL query." [:as {query :body}] (binding [persisted-info/*allow-persisted-substitution* false] (qp.perms/check-current-user-has-adhoc-native-query-perms query) (qp/compile-and-splice-parameters query))) -(api/defendpoint ^:streaming POST "/pivot" +(api/defendpoint-schema ^:streaming POST "/pivot" "Generate a pivoted dataset for an ad-hoc query" [:as {{:keys [database] :as query} :body}] {database (s/maybe s/Int)} diff --git a/src/metabase/api/email.clj b/src/metabase/api/email.clj index 3a5fa064ffa..74923917e17 100644 --- a/src/metabase/api/email.clj +++ b/src/metabase/api/email.clj @@ -88,7 +88,7 @@ :when (some? value)] [setting-name value]))) -(api/defendpoint PUT "/" +(api/defendpoint-schema PUT "/" "Update multiple email Settings. You must be a superuser or have `setting` permission to do this." [:as {settings :body}] {settings su/Map} @@ -123,14 +123,14 @@ {:status 400 :body (humanize-error-messages response)}))) -(api/defendpoint DELETE "/" +(api/defendpoint-schema DELETE "/" "Clear all email related settings. You must be a superuser or have `setting` permission to do this." [] (validation/check-has-application-permission :setting) (setting/set-many! (zipmap (keys mb-to-smtp-settings) (repeat nil))) api/generic-204-no-content) -(api/defendpoint POST "/test" +(api/defendpoint-schema POST "/test" "Send a test email using the SMTP Settings. You must be a superuser or have `setting` permission to do this. Returns `{:ok true}` if we were able to send the message successfully, otherwise a standard 400 error response." [] diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index c0491d8b310..93697d1e740 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -327,7 +327,7 @@ ;;; ------------------------------------------- /api/embed/card endpoints -------------------------------------------- -(api/defendpoint GET "/card/:token" +(api/defendpoint-schema GET "/card/:token" "Fetch a Card via a JSON Web Token signed with the `embedding-secret-key`. Token should have the following format: @@ -357,7 +357,7 @@ :constraints constraints :options options))) -(api/defendpoint ^:streaming GET "/card/:token/query" +(api/defendpoint-schema ^:streaming GET "/card/:token/query" "Fetch the results of running a Card using a JSON Web Token signed with the `embedding-secret-key`. Token should have the following format: @@ -367,7 +367,7 @@ [token & query-params] (run-query-for-unsigned-token-async (embed/unsign token) :api query-params)) -(api/defendpoint ^:streaming GET ["/card/:token/query/:export-format", :export-format api.dataset/export-format-regex] +(api/defendpoint-schema ^:streaming GET ["/card/:token/query/:export-format", :export-format api.dataset/export-format-regex] "Like `GET /api/embed/card/query`, but returns the results as a file in the specified format." [token export-format :as {:keys [query-params]}] {export-format api.dataset/ExportFormat} @@ -383,7 +383,7 @@ ;;; ----------------------------------------- /api/embed/dashboard endpoints ----------------------------------------- -(api/defendpoint GET "/dashboard/:token" +(api/defendpoint-schema GET "/dashboard/:token" "Fetch a Dashboard via a JSON Web Token signed with the `embedding-secret-key`. Token should have the following format: @@ -425,7 +425,7 @@ :constraints constraints :qp-runner qp-runner))) -(api/defendpoint ^:streaming GET "/dashboard/:token/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/dashboard/:token/dashcard/:dashcard-id/card/:card-id" "Fetch the results of running a Card belonging to a Dashboard using a JSON Web Token signed with the `embedding-secret-key`" [token dashcard-id card-id & query-params] @@ -438,7 +438,7 @@ ;;; -------------------------------------------------- Field Values -------------------------------------------------- -(api/defendpoint GET "/card/:token/field/:field-id/values" +(api/defendpoint-schema GET "/card/:token/field/:field-id/values" "Fetch FieldValues for a Field that is referenced by an embedded Card." [token field-id] (let [unsigned-token (embed/unsign token) @@ -446,7 +446,7 @@ (check-embedding-enabled-for-card card-id) (api.public/card-and-field-id->values card-id field-id))) -(api/defendpoint GET "/dashboard/:token/field/:field-id/values" +(api/defendpoint-schema GET "/dashboard/:token/field/:field-id/values" "Fetch FieldValues for a Field that is used as a param in an embedded Dashboard." [token field-id] (let [unsigned-token (embed/unsign token) @@ -457,7 +457,7 @@ ;;; --------------------------------------------------- Searching ---------------------------------------------------- -(api/defendpoint GET "/card/:token/field/:field-id/search/:search-field-id" +(api/defendpoint-schema GET "/card/:token/field/:field-id/search/:search-field-id" "Search for values of a Field that is referenced by an embedded Card." [token field-id search-field-id value limit] {value su/NonBlankString @@ -467,7 +467,7 @@ (check-embedding-enabled-for-card card-id) (api.public/search-card-fields card-id field-id search-field-id value (when limit (Integer/parseInt limit))))) -(api/defendpoint GET "/dashboard/:token/field/:field-id/search/:search-field-id" +(api/defendpoint-schema GET "/dashboard/:token/field/:field-id/search/:search-field-id" "Search for values of a Field that is referenced by a Card in an embedded Dashboard." [token field-id search-field-id value limit] {value su/NonBlankString @@ -481,7 +481,7 @@ ;;; --------------------------------------------------- Remappings --------------------------------------------------- -(api/defendpoint GET "/card/:token/field/:field-id/remapping/:remapped-id" +(api/defendpoint-schema GET "/card/:token/field/:field-id/remapping/:remapped-id" "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with embedded Cards." [token field-id remapped-id value] @@ -491,7 +491,7 @@ (check-embedding-enabled-for-card card-id) (api.public/card-field-remapped-values card-id field-id remapped-id value))) -(api/defendpoint GET "/dashboard/:token/field/:field-id/remapping/:remapped-id" +(api/defendpoint-schema GET "/dashboard/:token/field/:field-id/remapping/:remapped-id" "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with embedded Dashboards." [token field-id remapped-id value] @@ -501,7 +501,7 @@ (check-embedding-enabled-for-dashboard dashboard-id) (api.public/dashboard-field-remapped-values dashboard-id field-id remapped-id value))) -(api/defendpoint ^:streaming GET ["/dashboard/:token/dashcard/:dashcard-id/card/:card-id/:export-format" +(api/defendpoint-schema ^:streaming GET ["/dashboard/:token/dashcard/:dashcard-id/card/:card-id/:export-format" :export-format api.dataset/export-format-regex] "Fetch the results of running a Card belonging to a Dashboard using a JSON Web Token signed with the `embedding-secret-key` return the data in one of the export formats" @@ -583,17 +583,17 @@ (log/errorf e "Chain filter error\n%s" (u/pprint-to-str (u/all-ex-data e))) (throw e)))))) -(api/defendpoint GET "/dashboard/:token/params/:param-key/values" +(api/defendpoint-schema GET "/dashboard/:token/params/:param-key/values" "Embedded version of chain filter values endpoint." [token param-key :as {:keys [query-params]}] (param-values token param-key nil query-params)) -(api/defendpoint GET "/dashboard/:token/params/:param-key/search/:prefix" +(api/defendpoint-schema GET "/dashboard/:token/params/:param-key/search/:prefix" "Embedded version of chain filter search endpoint." [token param-key prefix :as {:keys [query-params]}] (param-values token param-key prefix query-params)) -(api/defendpoint ^:streaming GET "/pivot/card/:token/query" +(api/defendpoint-schema ^:streaming GET "/pivot/card/:token/query" "Fetch the results of running a Card using a JSON Web Token signed with the `embedding-secret-key`. Token should have the following format: @@ -603,7 +603,7 @@ [token & query-params] (run-query-for-unsigned-token-async (embed/unsign token) :api query-params :qp-runner qp.pivot/run-pivot-query)) -(api/defendpoint ^:streaming GET "/pivot/dashboard/:token/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/pivot/dashboard/:token/dashcard/:dashcard-id/card/:card-id" "Fetch the results of running a Card belonging to a Dashboard using a JSON Web Token signed with the `embedding-secret-key`" [token dashcard-id card-id & query-params] diff --git a/src/metabase/api/field.clj b/src/metabase/api/field.clj index 19d8a80b2ce..88c55a49daa 100644 --- a/src/metabase/api/field.clj +++ b/src/metabase/api/field.clj @@ -49,7 +49,7 @@ (has-segmented-query-permissions? (field/table field))) (api/throw-403))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get `Field` with ID." [id] (let [field (-> (api/check-404 (db/select-one Field :id id)) @@ -94,7 +94,7 @@ (db/delete! Dimension :id old-dim-id)) true) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update `Field` with ID." [id :as {{:keys [caveats description display_name fk_target_field_id points_of_interest semantic_type coercion_strategy visibility_type has_field_values settings nfc_path] @@ -151,7 +151,7 @@ ;;; ------------------------------------------------- Field Metadata ------------------------------------------------- -(api/defendpoint GET "/:id/summary" +(api/defendpoint-schema GET "/:id/summary" "Get the count and distinct count of `Field` with ID." [id] (let [field (api/read-check Field id)] @@ -161,7 +161,7 @@ ;;; --------------------------------------------------- Dimensions --------------------------------------------------- -(api/defendpoint POST "/:id/dimension" +(api/defendpoint-schema POST "/:id/dimension" "Sets the dimension for the given field at ID" [id :as {{dimension-type :type, dimension-name :name, human_readable_field_id :human_readable_field_id} :body}] {dimension-type (su/api-param "type" (s/enum "internal" "external")) @@ -184,7 +184,7 @@ :human_readable_field_id human_readable_field_id})) (db/select-one Dimension :field_id id)) -(api/defendpoint DELETE "/:id/dimension" +(api/defendpoint-schema DELETE "/:id/dimension" "Remove the dimension associated to field at ID" [id] (api/write-check Field id) @@ -223,7 +223,7 @@ (field->values field))) ;; TODO -- not sure `has_field_values` actually has to be `:list` -- see code above. -(api/defendpoint GET "/:id/values" +(api/defendpoint-schema GET "/:id/values" "If a Field's value of `has_field_values` is `:list`, return a list of all the distinct values of the Field, and (if defined by a User) a map of human-readable remapped values." [id] @@ -231,7 +231,7 @@ ;; match things like GET /field%2Ccreated_at%2options ;; (this is how things like [field,created_at,{:base-type,:type/Datetime}] look when URL-encoded) -(api/defendpoint GET "/field%2C:field-name%2C:options/values" +(api/defendpoint-schema GET "/field%2C:field-name%2C:options/values" "Implementation of the field values endpoint for fields in the Saved Questions 'virtual' DB. This endpoint is just a convenience to simplify the frontend code. It just returns the standard 'empty' field values response." ;; we don't actually care what field-name or field-type are, so they're ignored @@ -266,7 +266,7 @@ :human_readable_values (when human-readable-values? (map second value-pairs))))) -(api/defendpoint POST "/:id/values" +(api/defendpoint-schema POST "/:id/values" "Update the fields values and human-readable values for a `Field` whose semantic type is `category`/`city`/`state`/`country` or whose base type is `type/Boolean`. The human-readable values are optional." [id :as {{value-pairs :values} :body}] @@ -280,7 +280,7 @@ (create-field-values! field value-pairs))) {:status :success}) -(api/defendpoint POST "/:id/rescan_values" +(api/defendpoint-schema POST "/:id/rescan_values" "Manually trigger an update for the FieldValues for this Field. Only applies to Fields that are eligible for FieldValues." [id] @@ -292,7 +292,7 @@ (field-values/create-or-update-full-field-values! field))) {:status :success}) -(api/defendpoint POST "/:id/discard_values" +(api/defendpoint-schema POST "/:id/discard_values" "Discard the FieldValues belonging to this Field. Only applies to fields that have FieldValues. If this Field's Database is set up to automatically sync FieldValues, they will be recreated during the next cycle." [id] @@ -376,7 +376,7 @@ nil)))) -(api/defendpoint GET "/:id/search/:search-id" +(api/defendpoint-schema GET "/:id/search/:search-id" "Search for values of a Field with `search-id` that start with `value`. See docstring for `metabase.api.field/search-values` for a more detailed explanation." [id search-id value] @@ -424,7 +424,7 @@ (.parse (NumberFormat/getInstance) value) value)) -(api/defendpoint GET "/:id/remapping/:remapped-id" +(api/defendpoint-schema GET "/:id/remapping/:remapped-id" "Fetch remapped Field values." [id remapped-id, ^String value] (let [field (api/read-check Field id) @@ -432,7 +432,7 @@ value (parse-query-param-value-for-field field value)] (remapped-value field remapped-field value))) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Field :id id) api/read-check related/related)) diff --git a/src/metabase/api/google.clj b/src/metabase/api/google.clj index 9b8df6b3704..2cf626ff9d3 100644 --- a/src/metabase/api/google.clj +++ b/src/metabase/api/google.clj @@ -8,7 +8,7 @@ [schema.core :as s] [toucan.db :as db])) -(api/defendpoint PUT "/settings" +(api/defendpoint-schema PUT "/settings" "Update Google Sign-In related settings. You must be a superuser or have `setting` permission to do this." [:as {{:keys [google-auth-client-id google-auth-enabled google-auth-auto-create-accounts-domain]} :body}] {google-auth-client-id (s/maybe s/Str) diff --git a/src/metabase/api/ldap.clj b/src/metabase/api/ldap.clj index faefbfbcd62..db5245941ec 100644 --- a/src/metabase/api/ldap.clj +++ b/src/metabase/api/ldap.clj @@ -96,7 +96,7 @@ current-password new-password))) -(api/defendpoint PUT "/settings" +(api/defendpoint-schema PUT "/settings" "Update LDAP related settings. You must be a superuser or have `setting` permission to do this." [:as {settings :body}] {settings su/Map} diff --git a/src/metabase/api/login_history.clj b/src/metabase/api/login_history.clj index e3bfc734f27..497442ca8dd 100644 --- a/src/metabase/api/login_history.clj +++ b/src/metabase/api/login_history.clj @@ -17,7 +17,7 @@ :user_id (u/the-id user-or-id) {:order-by [[:timestamp :desc]]}))) -(api/defendpoint GET "/current" +(api/defendpoint-schema GET "/current" "Fetch recent logins for the current user." [] (login-history api/*current-user-id*)) diff --git a/src/metabase/api/metric.clj b/src/metabase/api/metric.clj index 08db5b9e6ef..53f9602d205 100644 --- a/src/metabase/api/metric.clj +++ b/src/metabase/api/metric.clj @@ -20,7 +20,7 @@ [toucan.db :as db] [toucan.hydrate :refer [hydrate]])) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `Metric`." [:as {{:keys [name description table_id definition], :as body} :body}] {name su/NonBlankString @@ -52,7 +52,7 @@ :query_description (api.qd/generate-query-description table (:definition metric))))))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch `Metric` with ID." [id] (first (add-query-descriptions [(hydrated-metric id)]))) @@ -65,7 +65,7 @@ (for [metric metrics] (assoc metric :database_id (table-id->db-id (:table_id metric))))))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch *all* `Metrics`." [] (as-> (db/select Metric, :archived false, {:order-by [:%lower.name]}) metrics @@ -95,7 +95,7 @@ (events/publish-event! (if archive? :metric-delete :metric-update) (assoc <> :actor_id api/*current-user-id*, :revision_message revision_message))))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a `Metric` with ID." [id :as {{:keys [name definition revision_message archived caveats description how_is_this_calculated points_of_interest show_in_getting_started] @@ -111,7 +111,7 @@ show_in_getting_started (s/maybe s/Bool)} (write-check-and-update-metric! id body)) -(api/defendpoint PUT "/:id/important_fields" +(api/defendpoint-schema PUT "/:id/important_fields" "Update the important `Fields` for a `Metric` with ID. (This is used for the Getting Started guide)." [id :as {{:keys [important_field_ids]} :body}] @@ -132,7 +132,7 @@ {:success true})) -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Archive a Metric. (DEPRECATED -- Just pass updated value of `:archived` to the `PUT` endpoint instead.)" [id revision_message] {revision_message su/NonBlankString} @@ -142,14 +142,14 @@ api/generic-204-no-content) -(api/defendpoint GET "/:id/revisions" +(api/defendpoint-schema GET "/:id/revisions" "Fetch `Revisions` for `Metric` with ID." [id] (api/read-check Metric id) (revision/revisions+details Metric id)) -(api/defendpoint POST "/:id/revert" +(api/defendpoint-schema POST "/:id/revert" "Revert a `Metric` to a prior `Revision`." [id :as {{:keys [revision_id]} :body}] {revision_id su/IntGreaterThanZero} @@ -160,7 +160,7 @@ :user-id api/*current-user-id* :revision-id revision_id)) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Metric :id id) api/read-check related/related)) diff --git a/src/metabase/api/model_action.clj b/src/metabase/api/model_action.clj index e08f127f5a1..c8d8d7330aa 100644 --- a/src/metabase/api/model_action.clj +++ b/src/metabase/api/model_action.clj @@ -9,7 +9,7 @@ [schema.core :as s] [toucan.db :as db])) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Endpoint to fetch actions for a model, must filter with card-id=" [card-id] {card-id su/IntGreaterThanZero} @@ -22,7 +22,7 @@ :where [:= :model_action.card_id card-id] :order-by [:model_action.id]})) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Endpoint to associate an action with a model" [:as {{:keys [card_id action_id slug requires_pk parameter_mappings visualization_settings] :as body} :body}] {card_id su/IntGreaterThanZero @@ -33,7 +33,7 @@ visualization_settings (s/maybe su/Map)} (db/insert! ModelAction body)) -(api/defendpoint PUT "/:model-action-id" +(api/defendpoint-schema PUT "/:model-action-id" "Endpoint to modify an action of a model" [model-action-id :as {{:keys [action_id slug requires_pk parameter_mappings visualization_settings] :as body} :body}] {action_id (s/maybe su/IntGreaterThanZero) @@ -44,7 +44,7 @@ (db/update! ModelAction model-action-id (dissoc body :card_id)) api/generic-204-no-content) -(api/defendpoint DELETE "/:model-action-id" +(api/defendpoint-schema DELETE "/:model-action-id" "Endpoint to delete an action" [model-action-id] (let [action_id (db/select-field :action_id ModelAction :id model-action-id)] diff --git a/src/metabase/api/native_query_snippet.clj b/src/metabase/api/native_query_snippet.clj index f340ea6b543..9e4af3f27c6 100644 --- a/src/metabase/api/native_query_snippet.clj +++ b/src/metabase/api/native_query_snippet.clj @@ -20,7 +20,7 @@ (-> (api/read-check (db/select-one NativeQuerySnippet :id id)) (hydrate :creator))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch all snippets" [archived] {archived (s/maybe su/BooleanString)} @@ -29,7 +29,7 @@ {:order-by [[:%lower.name :asc]]})] (hydrate (filter mi/can-read? snippets) :creator))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch native query snippet with ID." [id] (hydrated-native-query-snippet id)) @@ -39,7 +39,7 @@ (throw (ex-info (tru "A snippet with that name already exists. Please pick a different name.") {:status-code 400})))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `NativeQuerySnippet`." [:as {{:keys [content description name collection_id]} :body}] {content s/Str @@ -71,7 +71,7 @@ (db/update! NativeQuerySnippet id changes)) (hydrated-native-query-snippet id))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update an existing `NativeQuerySnippet`." [id :as {{:keys [archived content description name collection_id] :as body} :body}] {archived (s/maybe s/Bool) diff --git a/src/metabase/api/notify.clj b/src/metabase/api/notify.clj index 484c83f0412..b65eae3d23f 100644 --- a/src/metabase/api/notify.clj +++ b/src/metabase/api/notify.clj @@ -11,7 +11,7 @@ [schema.core :as s] [toucan.db :as db])) -(api/defendpoint POST "/db/:id" +(api/defendpoint-schema POST "/db/:id" "Notification about a potential schema change to one of our `Databases`. Caller can optionally specify a `:table_id` or `:table_name` in the body to limit updates to a single `Table`. Optional Parameter `:scan` can be `\"full\"` or `\"schema\"` for a full sync or a schema sync, available diff --git a/src/metabase/api/permissions.clj b/src/metabase/api/permissions.clj index 03c0b421460..48e41d8e144 100644 --- a/src/metabase/api/permissions.clj +++ b/src/metabase/api/permissions.clj @@ -27,13 +27,13 @@ ;;; --------------------------------------------------- Endpoints ---------------------------------------------------- -(api/defendpoint GET "/graph" +(api/defendpoint-schema GET "/graph" "Fetch a graph of all Permissions." [] (api/check-superuser) (perms/data-perms-graph)) -(api/defendpoint PUT "/graph" +(api/defendpoint-schema PUT "/graph" "Do a batch update of Permissions by passing in a modified graph. This should return the same graph, in the same format, that you got from `GET /api/permissions/graph`, with any changes made in the wherever necessary. This modified graph must correspond to the `PermissionsGraph` schema. If successful, this endpoint returns the updated @@ -90,7 +90,7 @@ (for [group groups] (assoc group :member_count (get group-id->num-members (u/the-id group) 0))))) -(api/defendpoint GET "/group" +(api/defendpoint-schema GET "/group" "Fetch all `PermissionsGroups`, including a count of the number of `:members` in that group. This API requires superuser or group manager of more than one group. Group manager is only available if `advanced-permissions` is enabled and returns only groups that user @@ -111,14 +111,14 @@ (-> (ordered-groups mw.offset-paging/*limit* mw.offset-paging/*offset* query) (hydrate :member_count)))) -(api/defendpoint GET "/group/:id" +(api/defendpoint-schema GET "/group/:id" "Fetch the details for a certain permissions group." [id] (validation/check-group-manager id) (-> (db/select-one PermissionsGroup :id id) (hydrate :members))) -(api/defendpoint POST "/group" +(api/defendpoint-schema POST "/group" "Create a new `PermissionsGroup`." [:as {{:keys [name]} :body}] {name su/NonBlankString} @@ -126,7 +126,7 @@ (db/insert! PermissionsGroup :name name)) -(api/defendpoint PUT "/group/:group-id" +(api/defendpoint-schema PUT "/group/:group-id" "Update the name of a `PermissionsGroup`." [group-id :as {{:keys [name]} :body}] {name su/NonBlankString} @@ -137,7 +137,7 @@ ;; return the updated group (db/select-one PermissionsGroup :id group-id)) -(api/defendpoint DELETE "/group/:group-id" +(api/defendpoint-schema DELETE "/group/:group-id" "Delete a specific `PermissionsGroup`." [group-id] (validation/check-manager-of-group group-id) @@ -147,7 +147,7 @@ ;;; ------------------------------------------- Group Membership Endpoints ------------------------------------------- -(api/defendpoint GET "/membership" +(api/defendpoint-schema GET "/membership" "Fetch a map describing the group memberships of various users. This map's format is: @@ -168,7 +168,7 @@ [:= :user_id api/*current-user-id*] [:= :is_group_manager true]]}]))))) -(api/defendpoint POST "/membership" +(api/defendpoint-schema POST "/membership" "Add a `User` to a `PermissionsGroup`. Returns updated list of members belonging to the group." [:as {{:keys [group_id user_id is_group_manager]} :body}] {group_id su/IntGreaterThanZero @@ -190,7 +190,7 @@ ;; let the frontend add it as appropriate (perms-group/members {:id group_id}))) -(api/defendpoint PUT "/membership/:id" +(api/defendpoint-schema PUT "/membership/:id" "Update a Permission Group membership. Returns the updated record." [id :as {{:keys [is_group_manager]} :body}] {is_group_manager schema.core/Bool} @@ -208,7 +208,7 @@ :is_group_manager is_group_manager) (db/select-one PermissionsGroupMembership :id (:id old)))) -(api/defendpoint PUT "/membership/:group-id/clear" +(api/defendpoint-schema PUT "/membership/:group-id/clear" "Remove all members from a `PermissionsGroup`." [group-id] (validation/check-manager-of-group group-id) @@ -216,7 +216,7 @@ (db/delete! PermissionsGroupMembership :group_id group-id) api/generic-204-no-content) -(api/defendpoint DELETE "/membership/:id" +(api/defendpoint-schema DELETE "/membership/:id" "Remove a User from a PermissionsGroup (delete their membership)." [id] (let [membership (db/select-one PermissionsGroupMembership :id id)] diff --git a/src/metabase/api/persist.clj b/src/metabase/api/persist.clj index e9e70cd6fef..bab73d970a8 100644 --- a/src/metabase/api/persist.clj +++ b/src/metabase/api/persist.clj @@ -58,7 +58,7 @@ :schema_name (ddl.i/schema-name {:id database_id} site-uuid-str) :next-fire-time (get-in db-id->fire-time [database_id :next-fire-time])))))))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "List the entries of [[PersistedInfo]] in order to show a status page." [] (validation/check-has-application-permission :monitoring) @@ -76,7 +76,7 @@ :limit mw.offset-paging/*limit* :offset mw.offset-paging/*offset*})) -(api/defendpoint GET "/:persisted-info-id" +(api/defendpoint-schema GET "/:persisted-info-id" "Fetch a particular [[PersistedInfo]] by id." [persisted-info-id] {persisted-info-id (s/maybe su/IntGreaterThanZero)} @@ -84,7 +84,7 @@ (api/write-check (db/select-one Database :id (:database_id persisted-info))) persisted-info)) -(api/defendpoint GET "/card/:card-id" +(api/defendpoint-schema GET "/card/:card-id" "Fetch a particular [[PersistedInfo]] by card-id." [card-id] {card-id (s/maybe su/IntGreaterThanZero)} @@ -101,7 +101,7 @@ (deferred-tru "String representing a cron schedule")) (deferred-tru "Value must be a string representing a cron schedule of format <seconds> <minutes> <hours> <day of month> <month> <day of week> <year>"))) -(api/defendpoint POST "/set-refresh-schedule" +(api/defendpoint-schema POST "/set-refresh-schedule" "Set the cron schedule to refresh persisted models. Shape should be JSON like {cron: \"0 30 1/8 * * ? *\"}." [:as {{:keys [cron], :as _body} :body}] @@ -117,7 +117,7 @@ (task.persist-refresh/reschedule-refresh!) api/generic-204-no-content) -(api/defendpoint POST "/enable" +(api/defendpoint-schema POST "/enable" "Enable global setting to allow databases to persist models." [] (validation/check-has-application-permission :setting) @@ -140,7 +140,7 @@ :options (not-empty (dissoc (:options db) :persist-models-enabled)))) (task.persist-refresh/disable-persisting!))) -(api/defendpoint POST "/disable" +(api/defendpoint-schema POST "/disable" "Disable global setting to allow databases to persist models. This will remove all tasks to refresh tables, remove that option from databases which might have it enabled, and delete all cached tables." [] diff --git a/src/metabase/api/premium_features.clj b/src/metabase/api/premium_features.clj index 7d5a990f665..7ada5f14f85 100644 --- a/src/metabase/api/premium_features.clj +++ b/src/metabase/api/premium_features.clj @@ -4,7 +4,7 @@ [metabase.api.common :as api] [metabase.public-settings.premium-features :as premium-features])) -(api/defendpoint GET "/token/status" +(api/defendpoint-schema GET "/token/status" "Fetch info about the current Premium-Features premium features token including whether it is `valid`, a `trial` token, its `features`, when it is `valid_thru`, and the `status` of the account." [] diff --git a/src/metabase/api/preview_embed.clj b/src/metabase/api/preview_embed.clj index 41b02045adf..069929edb2a 100644 --- a/src/metabase/api/preview_embed.clj +++ b/src/metabase/api/preview_embed.clj @@ -21,7 +21,7 @@ (validation/check-embedding-enabled) (embed/unsign token)) -(api/defendpoint GET "/card/:token" +(api/defendpoint-schema GET "/card/:token" "Fetch a Card you're considering embedding by passing a JWT `token`." [token] (let [unsigned-token (check-and-unsign token)] @@ -32,7 +32,7 @@ "Embedding previews need to be limited in size to avoid performance issues (#20938)." 2000) -(api/defendpoint ^:streaming GET "/card/:token/query" +(api/defendpoint-schema ^:streaming GET "/card/:token/query" "Fetch the query results for a Card you're considering embedding by passing a JWT `token`." [token & query-params] (let [unsigned-token (check-and-unsign token) @@ -45,14 +45,14 @@ :constraints {:max-results max-results} :query-params query-params))) -(api/defendpoint GET "/dashboard/:token" +(api/defendpoint-schema GET "/dashboard/:token" "Fetch a Dashboard you're considering embedding by passing a JWT `token`. " [token] (let [unsigned-token (check-and-unsign token)] (api.embed/dashboard-for-unsigned-token unsigned-token :embedding-params (embed/get-in-unsigned-token-or-throw unsigned-token [:_embedding_params])))) -(api/defendpoint ^:streaming GET "/dashboard/:token/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/dashboard/:token/dashcard/:dashcard-id/card/:card-id" "Fetch the results of running a Card belonging to a Dashboard you're considering embedding with JWT `token`." [token dashcard-id card-id & query-params] (let [unsigned-token (check-and-unsign token) @@ -68,7 +68,7 @@ :token-params token-params :query-params query-params))) -(api/defendpoint ^:streaming GET "/pivot/card/:token/query" +(api/defendpoint-schema ^:streaming GET "/pivot/card/:token/query" "Fetch the query results for a Card you're considering embedding by passing a JWT `token`." [token & query-params] (let [unsigned-token (check-and-unsign token) @@ -81,7 +81,7 @@ :query-params query-params :qp-runner qp.pivot/run-pivot-query))) -(api/defendpoint ^:streaming GET "/pivot/dashboard/:token/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/pivot/dashboard/:token/dashcard/:dashcard-id/card/:card-id" "Fetch the results of running a Card belonging to a Dashboard you're considering embedding with JWT `token`." [token dashcard-id card-id & query-params] (let [unsigned-token (check-and-unsign token) diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj index 5e8c0345643..de0b3766fea 100644 --- a/src/metabase/api/public.clj +++ b/src/metabase/api/public.clj @@ -59,7 +59,7 @@ (defn- card-with-uuid [uuid] (public-card :public_uuid uuid)) -(api/defendpoint GET "/card/:uuid" +(api/defendpoint-schema GET "/card/:uuid" "Fetch a publicly-accessible Card an return query results as well as `:card` information. Does not require auth credentials. Public sharing must be enabled." [uuid] @@ -138,14 +138,14 @@ (let [card-id (api/check-404 (db/select-one-id Card :public_uuid uuid, :archived false))] (apply run-query-for-card-with-id-async card-id export-format parameters options))) -(api/defendpoint ^:streaming GET "/card/:uuid/query" +(api/defendpoint-schema ^:streaming GET "/card/:uuid/query" "Fetch a publicly-accessible Card an return query results as well as `:card` information. Does not require auth credentials. Public sharing must be enabled." [uuid parameters] {parameters (s/maybe su/JSONString)} (run-query-for-card-with-public-uuid-async uuid :api (json/parse-string parameters keyword))) -(api/defendpoint ^:streaming GET "/card/:uuid/query/:export-format" +(api/defendpoint-schema ^:streaming GET "/card/:uuid/query/:export-format" "Fetch a publicly-accessible Card and return query results in the specified format. Does not require auth credentials. Public sharing must be enabled." [uuid export-format :as {{:keys [parameters]} :params}] @@ -181,7 +181,7 @@ (defn- dashboard-with-uuid [uuid] (public-dashboard :public_uuid uuid)) -(api/defendpoint GET "/dashboard/:uuid" +(api/defendpoint-schema GET "/dashboard/:uuid" "Fetch a publicly-accessible Dashboard. Does not require auth credentials. Public sharing must be enabled." [uuid] (validation/check-public-sharing-enabled) @@ -220,7 +220,7 @@ (binding [api/*current-user-permissions-set* (atom #{"/"})] (m/mapply qp.dashboard/run-query-for-dashcard-async options)))) -(api/defendpoint ^:streaming GET "/dashboard/:uuid/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/dashboard/:uuid/dashcard/:dashcard-id/card/:card-id" "Fetch the results for a Card in a publicly-accessible Dashboard. Does not require auth credentials. Public sharing must be enabled." [uuid card-id dashcard-id parameters] @@ -234,7 +234,7 @@ :export-format :api :parameters parameters))) -(api/defendpoint GET "/oembed" +(api/defendpoint-schema GET "/oembed" "oEmbed endpoint used to retreive embed code and metadata for a (public) Metabase URL." [url format maxheight maxwidth] ;; the format param is not used by the API, but is required as part of the oEmbed spec: http://oembed.com/#section2 @@ -312,7 +312,7 @@ (check-field-is-referenced-by-card field-id card-id) (api.field/field->values (db/select-one Field :id field-id))) -(api/defendpoint GET "/card/:uuid/field/:field-id/values" +(api/defendpoint-schema GET "/card/:uuid/field/:field-id/values" "Fetch FieldValues for a Field that is referenced by a public Card." [uuid field-id] (validation/check-public-sharing-enabled) @@ -326,7 +326,7 @@ (check-field-is-referenced-by-dashboard field-id dashboard-id) (api.field/field->values (db/select-one Field :id field-id))) -(api/defendpoint GET "/dashboard/:uuid/field/:field-id/values" +(api/defendpoint-schema GET "/dashboard/:uuid/field/:field-id/values" "Fetch FieldValues for a Field that is referenced by a Card in a public Dashboard." [uuid field-id] (validation/check-public-sharing-enabled) @@ -352,7 +352,7 @@ (check-search-field-is-allowed field-id search-id) (api.field/search-values (db/select-one Field :id field-id) (db/select-one Field :id search-id) value limit)) -(api/defendpoint GET "/card/:uuid/field/:field-id/search/:search-field-id" +(api/defendpoint-schema GET "/card/:uuid/field/:field-id/search/:search-field-id" "Search for values of a Field that is referenced by a public Card." [uuid field-id search-field-id value limit] {value su/NonBlankString @@ -361,7 +361,7 @@ (let [card-id (db/select-one-id Card :public_uuid uuid, :archived false)] (search-card-fields card-id field-id search-field-id value (when limit (Integer/parseInt limit))))) -(api/defendpoint GET "/dashboard/:uuid/field/:field-id/search/:search-field-id" +(api/defendpoint-schema GET "/dashboard/:uuid/field/:field-id/search/:search-field-id" "Search for values of a Field that is referenced by a Card in a public Dashboard." [uuid field-id search-field-id value limit] {value su/NonBlankString @@ -393,7 +393,7 @@ (check-field-is-referenced-by-dashboard field-id dashboard-id) (field-remapped-values field-id remapped-field-id value-str)) -(api/defendpoint GET "/card/:uuid/field/:field-id/remapping/:remapped-id" +(api/defendpoint-schema GET "/card/:uuid/field/:field-id/remapping/:remapped-id" "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with public Cards." [uuid field-id remapped-id value] @@ -402,7 +402,7 @@ (let [card-id (api/check-404 (db/select-one-id Card :public_uuid uuid, :archived false))] (card-field-remapped-values card-id field-id remapped-id value))) -(api/defendpoint GET "/dashboard/:uuid/field/:field-id/remapping/:remapped-id" +(api/defendpoint-schema GET "/dashboard/:uuid/field/:field-id/remapping/:remapped-id" "Fetch remapped Field values. This is the same as `GET /api/field/:id/remapping/:remapped-id`, but for use with public Dashboards." [uuid field-id remapped-id value] @@ -413,14 +413,14 @@ ;;; ------------------------------------------------ Param Values ------------------------------------------------- -(api/defendpoint GET "/dashboard/:uuid/params/:param-key/values" +(api/defendpoint-schema GET "/dashboard/:uuid/params/:param-key/values" "Fetch filter values for dashboard parameter `param-key`." [uuid param-key :as {:keys [query-params]}] (let [dashboard (dashboard-with-uuid uuid)] (binding [api/*current-user-permissions-set* (atom #{"/"})] (api.dashboard/param-values dashboard param-key query-params)))) -(api/defendpoint GET "/dashboard/:uuid/params/:param-key/search/:query" +(api/defendpoint-schema GET "/dashboard/:uuid/params/:param-key/search/:query" "Fetch filter values for dashboard parameter `param-key`, containing specified `query`." [uuid param-key query :as {:keys [query-params]}] (let [dashboard (dashboard-with-uuid uuid)] @@ -430,14 +430,14 @@ ;;; ----------------------------------------------------- Pivot Tables ----------------------------------------------- ;; TODO -- why do these endpoints START with `/pivot/` whereas the version in Dash -(api/defendpoint ^:streaming GET "/pivot/card/:uuid/query" +(api/defendpoint-schema ^:streaming GET "/pivot/card/:uuid/query" "Fetch a publicly-accessible Card an return query results as well as `:card` information. Does not require auth credentials. Public sharing must be enabled." [uuid parameters] {parameters (s/maybe su/JSONString)} (run-query-for-card-with-public-uuid-async uuid :api (json/parse-string parameters keyword) :qp-runner qp.pivot/run-pivot-query)) -(api/defendpoint ^:streaming GET "/pivot/dashboard/:uuid/dashcard/:dashcard-id/card/:card-id" +(api/defendpoint-schema ^:streaming GET "/pivot/dashboard/:uuid/dashcard/:dashcard-id/card/:card-id" "Fetch the results for a Card in a publicly-accessible Dashboard. Does not require auth credentials. Public sharing must be enabled." [uuid card-id dashcard-id parameters] diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index b993b9173c8..e5a32e2b768 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -37,7 +37,7 @@ (u/ignore-exceptions (classloader/require 'metabase-enterprise.sandbox.api.util 'metabase-enterprise.advanced-permissions.common)) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch all Pulses. If `dashboard_id` is specified, restricts results to dashboard subscriptions associated with that dashboard. If `user_id` is specified, restricts results to pulses or subscriptions created by the user, or for which the user is a known recipient." @@ -59,7 +59,7 @@ (assert (integer? card-id)) (api/read-check Card card-id))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `Pulse`." [:as {{:keys [name cards channels skip_if_empty collection_id collection_position dashboard_id parameters]} :body}] {name su/NonBlankString @@ -95,13 +95,13 @@ (api/check-500 (pulse/create-pulse! (map pulse/card->ref cards) channels pulse-data))))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch `Pulse` with ID." [id] (-> (api/read-check (pulse/retrieve-pulse id)) (hydrate :can_write))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a Pulse with `id`." [id :as {{:keys [name cards channels skip_if_empty collection_id archived parameters], :as pulse-updates} :body}] {name (s/maybe su/NonBlankString) @@ -149,7 +149,7 @@ ;; return updated Pulse (pulse/retrieve-pulse id)) -(api/defendpoint GET "/form_input" +(api/defendpoint-schema GET "/form_input" "Provides relevant configuration information and user choices for creating/updating Pulses." [] (validation/check-has-application-permission :subscription false) @@ -190,7 +190,7 @@ :context :pulse :card-id card-id}))) -(api/defendpoint GET "/preview_card/:id" +(api/defendpoint-schema GET "/preview_card/:id" "Get HTML rendering of a Card with `id`." [id] (let [card (api/read-check Card id) @@ -203,7 +203,7 @@ render/*include-buttons* true] (render/render-pulse-card-for-display (metabase.pulse/defaulted-timezone card) card result))]])})) -(api/defendpoint GET "/preview_card_info/:id" +(api/defendpoint-schema GET "/preview_card_info/:id" "Get JSON object containing HTML rendering of a Card with `id` and other information." [id] (let [card (api/read-check Card id) @@ -222,7 +222,7 @@ (def ^:private preview-card-width 400) -(api/defendpoint GET "/preview_card_png/:id" +(api/defendpoint-schema GET "/preview_card_png/:id" "Get PNG rendering of a Card with `id`." [id] (let [card (api/read-check Card id) @@ -231,7 +231,7 @@ (render/render-pulse-card-to-png (metabase.pulse/defaulted-timezone card) card result preview-card-width))] {:status 200, :headers {"Content-Type" "image/png"}, :body (ByteArrayInputStream. ba)})) -(api/defendpoint POST "/test" +(api/defendpoint-schema POST "/test" "Test send an unsaved pulse." [:as {{:keys [name cards channels skip_if_empty collection_id collection_position dashboard_id] :as body} :body}] {name su/NonBlankString @@ -248,7 +248,7 @@ (metabase.pulse/send-pulse! (assoc body :creator_id api/*current-user-id*)) {:ok true}) -(api/defendpoint DELETE "/:id/subscription" +(api/defendpoint-schema DELETE "/:id/subscription" "For users to unsubscribe themselves from a pulse subscription." [id] (api/let-404 [pulse-id (db/select-one-id Pulse :id id) diff --git a/src/metabase/api/revision.clj b/src/metabase/api/revision.clj index fa3e24851df..c62e9cbba36 100644 --- a/src/metabase/api/revision.clj +++ b/src/metabase/api/revision.clj @@ -21,7 +21,7 @@ "card" [Card (db/select-one Card :id id)] "dashboard" [Dashboard (db/select-one Dashboard :id id)])) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Get revisions of an object." [entity id] {entity Entity, id s/Int} @@ -29,7 +29,7 @@ (when (api/read-check instance) (revision/revisions+details model id)))) -(api/defendpoint POST "/revert" +(api/defendpoint-schema POST "/revert" "Revert an object to a prior revision." [:as {{:keys [entity id revision_id]} :body}] {entity Entity, id s/Int, revision_id s/Int} diff --git a/src/metabase/api/search.clj b/src/metabase/api/search.clj index 43a69f48d3a..d98a8c62d96 100644 --- a/src/metabase/api/search.clj +++ b/src/metabase/api/search.clj @@ -493,11 +493,11 @@ (some? limit) (assoc :limit-int limit) (some? offset) (assoc :offset-int offset))) -(api/defendpoint GET "/models" +(api/defendpoint-schema GET "/models" "Get the set of models that a search query will return" [q archived-string table-db-id] (query-model-set (search-context q archived-string table-db-id nil nil nil))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Search within a bunch of models for the substring `q`. For the list of models, check `metabase.search.config/all-models. diff --git a/src/metabase/api/segment.clj b/src/metabase/api/segment.clj index 1ef5531b677..212716ccefd 100644 --- a/src/metabase/api/segment.clj +++ b/src/metabase/api/segment.clj @@ -19,7 +19,7 @@ [toucan.db :as db] [toucan.hydrate :refer [hydrate]])) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `Segment`." [:as {{:keys [name description table_id definition], :as body} :body}] {name su/NonBlankString @@ -51,12 +51,12 @@ :query_description (api.qd/generate-query-description table (:definition segment))))))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch `Segment` with ID." [id] (first (add-query-descriptions [(hydrated-segment id)]))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch *all* `Segments`." [] (as-> (db/select Segment, :archived false, {:order-by [[:%lower.name :asc]]}) segments @@ -85,7 +85,7 @@ (events/publish-event! (if archive? :segment-delete :segment-update) (assoc <> :actor_id api/*current-user-id*, :revision_message revision_message))))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a `Segment` with ID." [id :as {{:keys [name definition revision_message archived caveats description points_of_interest show_in_getting_started] @@ -100,7 +100,7 @@ show_in_getting_started (s/maybe s/Bool)} (write-check-and-update-segment! id body)) -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Archive a Segment. (DEPRECATED -- Just pass updated value of `:archived` to the `PUT` endpoint instead.)" [id revision_message] {revision_message su/NonBlankString} @@ -110,14 +110,14 @@ api/generic-204-no-content) -(api/defendpoint GET "/:id/revisions" +(api/defendpoint-schema GET "/:id/revisions" "Fetch `Revisions` for `Segment` with ID." [id] (api/read-check Segment id) (revision/revisions+details Segment id)) -(api/defendpoint POST "/:id/revert" +(api/defendpoint-schema POST "/:id/revert" "Revert a `Segement` to a prior `Revision`." [id :as {{:keys [revision_id]} :body}] {revision_id su/IntGreaterThanZero} @@ -128,7 +128,7 @@ :user-id api/*current-user-id* :revision-id revision_id)) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Segment :id id) api/read-check related/related)) diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index f8bbcff0c4f..3d6970a1fcd 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -163,7 +163,7 @@ [& body] `(do-http-401-on-error (fn [] ~@body))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Login." [:as {{:keys [username password]} :body, :as request}] {username su/NonBlankString @@ -181,7 +181,7 @@ (login-throttlers :username) username] (do-login)))))) -(api/defendpoint DELETE "/" +(api/defendpoint-schema DELETE "/" "Logout." [:as {:keys [metabase-session-id]}] (api/check-exists? Session metabase-session-id) @@ -218,7 +218,7 @@ (log/info password-reset-url) (messages/send-password-reset-email! email false false password-reset-url is-active?)))))) -(api/defendpoint POST "/forgot_password" +(api/defendpoint-schema POST "/forgot_password" "Send a reset email when user has forgotten their password." [:as {{:keys [email]} :body, :as request}] {email su/Email} @@ -249,7 +249,7 @@ (when (< token-age reset-token-ttl-ms) user))))))) -(api/defendpoint POST "/reset_password" +(api/defendpoint-schema POST "/reset_password" "Reset password with a reset token." [:as {{:keys [token password]} :body, :as request}] {token su/NonBlankString @@ -267,13 +267,13 @@ (mw.session/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT"))))) (api/throw-invalid-param-exception :password (tru "Invalid reset token")))) -(api/defendpoint GET "/password_reset_token_valid" +(api/defendpoint-schema GET "/password_reset_token_valid" "Check is a password reset token is valid and isn't expired." [token] {token s/Str} {:valid (boolean (valid-reset-token->user token))}) -(api/defendpoint GET "/properties" +(api/defendpoint-schema GET "/properties" "Get all global properties and their values. These are the specific `Settings` which are meant to be public." [] (merge @@ -283,7 +283,7 @@ (when api/*is-superuser?* (setting/user-readable-values-map :admin)))) -(api/defendpoint POST "/google_auth" +(api/defendpoint-schema POST "/google_auth" "Login with Google Auth." [:as {{:keys [token]} :body, :as request}] {token su/NonBlankString} diff --git a/src/metabase/api/setting.clj b/src/metabase/api/setting.clj index 401589224df..450f4e32a32 100644 --- a/src/metabase/api/setting.clj +++ b/src/metabase/api/setting.clj @@ -24,28 +24,28 @@ `(do-with-setting-access-control (fn [] ~@body))) ;; TODO: deprecate /api/session/properties and have a single endpoint for listing settings -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Get all `Settings` and their values. You must be a superuser or have `setting` permission to do this. For non-superusers, a list of visible settings and values can be retrieved using the /api/session/properties endpoint." [] (validation/check-has-application-permission :setting) (setting/admin-writable-settings)) -(api/defendpoint PUT "/" +(api/defendpoint-schema PUT "/" "Update multiple `Settings` values. If called by a non-superuser, only user-local settings can be updated." [:as {settings :body}] (with-setting-access-control (setting/set-many! settings)) api/generic-204-no-content) -(api/defendpoint GET "/:key" +(api/defendpoint-schema GET "/:key" "Fetch a single `Setting`." [key] {key su/NonBlankString} (with-setting-access-control (setting/user-facing-value key))) -(api/defendpoint PUT "/:key" +(api/defendpoint-schema PUT "/:key" "Create/update a `Setting`. If called by a non-admin, only user-local settings can be updated. This endpoint can also be used to delete Settings by passing `nil` for `:value`." [key :as {{:keys [value]} :body}] diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index b276c40d675..41ec8d9f5b3 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -108,7 +108,7 @@ (public-settings/anon-tracking-enabled! (or (nil? allow-tracking?) allow-tracking?))) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Special endpoint for creating the first user during setup. This endpoint both creates the user AND logs them in and returns a session ID. This endpoint can also be used to add a database, create and invite a second admin, and/or set specific settings from the setup flow." @@ -170,7 +170,7 @@ ;; return response with session ID and set the cookie as well (mw.session/set-session-cookies request {:id session-id} session (t/zoned-date-time (t/zone-id "GMT")))))) -(api/defendpoint POST "/validate" +(api/defendpoint-schema POST "/validate" "Validate that we can connect to a database given a set of details." [:as {{{:keys [engine details]} :details, token :token} :body}] {token SetupToken @@ -299,7 +299,7 @@ (defn- admin-checklist [] (partition-steps-into-groups (add-next-step-info (admin-checklist-values)))) -(api/defendpoint GET "/admin_checklist" +(api/defendpoint-schema GET "/admin_checklist" "Return various \"admin checklist\" steps and whether they've been completed. You must be a superuser to see this!" [] (validation/check-has-application-permission :setting) @@ -307,7 +307,7 @@ ;; User defaults endpoint -(api/defendpoint GET "/user_defaults" +(api/defendpoint-schema GET "/user_defaults" "Returns object containing default user details for initial setup, if configured, and if the provided token value matches the token in the configuration value." [token] diff --git a/src/metabase/api/slack.clj b/src/metabase/api/slack.clj index b806b823dc3..11281cce4c4 100644 --- a/src/metabase/api/slack.clj +++ b/src/metabase/api/slack.clj @@ -11,7 +11,7 @@ [metabase.util.schema :as su] [schema.core :as s])) -(api/defendpoint PUT "/settings" +(api/defendpoint-schema PUT "/settings" "Update Slack related settings. You must be a superuser to do this. Also updates the slack-cache. There are 3 cases where we alter the slack channel/user cache: 1. falsy token -> clear @@ -49,7 +49,7 @@ (def ^:private slack-manifest (delay (slurp (io/resource "slack-manifest.yaml")))) -(api/defendpoint GET "/manifest" +(api/defendpoint-schema GET "/manifest" "Returns the YAML manifest file that should be used to bootstrap new Slack apps" [] (validation/check-has-application-permission :setting) diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj index dd117b0ed21..f4ad4720b60 100644 --- a/src/metabase/api/table.clj +++ b/src/metabase/api/table.clj @@ -33,14 +33,14 @@ "Schema for a valid table field ordering." (apply s/enum (map name table/field-orderings))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Get all `Tables`." [] (as-> (db/select Table, :active true, {:order-by [[:name :asc]]}) tables (hydrate tables :db) (filterv mi/can-read? tables))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get `Table` with ID." [id include_editable_data_model] (let [api-perm-check-fn (if (Boolean/parseBoolean include_editable_data_model) @@ -91,7 +91,7 @@ (sync-unhidden-tables newly-unhidden) updated-tables))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update `Table` with ID." [id :as {{:keys [display_name entity_type visibility_type description caveats points_of_interest show_in_getting_started field_order], :as body} :body}] @@ -105,7 +105,7 @@ field_order (s/maybe FieldOrder)} (first (update-tables! [id] body))) -(api/defendpoint PUT "/" +(api/defendpoint-schema PUT "/" "Update all `Table` in `ids`." [:as {{:keys [ids display_name entity_type visibility_type description caveats points_of_interest show_in_getting_started], :as body} :body}] @@ -290,7 +290,7 @@ :sensitive include-sensitive-fields? true))))))) -(api/defendpoint GET "/:id/query_metadata" +(api/defendpoint-schema GET "/:id/query_metadata" "Get metadata about a `Table` useful for running queries. Returns DB, fields, field FKs, and field values. @@ -370,7 +370,7 @@ (assoc field :semantic_type nil) field)))) -(api/defendpoint GET "/card__:id/query_metadata" +(api/defendpoint-schema GET "/card__:id/query_metadata" "Return metadata for the 'virtual' table for a Card." [id] (let [{:keys [database_id] :as card} (db/select-one [Card :id :dataset_query :result_metadata :name :description @@ -392,13 +392,13 @@ (assoc-dimension-options (driver.u/database->driver database_id)) remove-nested-pk-fk-semantic-types))) -(api/defendpoint GET "/card__:id/fks" +(api/defendpoint-schema GET "/card__:id/fks" "Return FK info for the 'virtual' table for a Card. This is always empty, so this endpoint serves mainly as a placeholder to avoid having to change anything on the frontend." [] []) ; return empty array -(api/defendpoint GET "/:id/fks" +(api/defendpoint-schema GET "/:id/fks" "Get all foreign keys whose destination is a `Field` that belongs to this `Table`." [id] (api/read-check Table id) @@ -412,7 +412,7 @@ :destination (hydrate (db/select-one Field :id (:fk_target_field_id origin-field)) :table)}))) -(api/defendpoint POST "/:id/rescan_values" +(api/defendpoint-schema POST "/:id/rescan_values" "Manually trigger an update for the FieldValues for the Fields belonging to this Table. Only applies to Fields that are eligible for FieldValues." [id] @@ -427,7 +427,7 @@ (sync.field-values/update-field-values-for-table! table)))) {:status :success})) -(api/defendpoint POST "/:id/discard_values" +(api/defendpoint-schema POST "/:id/discard_values" "Discard the FieldValues belonging to the Fields in this Table. Only applies to fields that have FieldValues. If this Table's Database is set up to automatically sync FieldValues, they will be recreated during the next cycle." [id] @@ -436,12 +436,12 @@ (db/simple-delete! FieldValues :field_id [:in field-ids])) {:status :success}) -(api/defendpoint GET "/:id/related" +(api/defendpoint-schema GET "/:id/related" "Return related entities." [id] (-> (db/select-one Table :id id) api/read-check related/related)) -(api/defendpoint PUT "/:id/fields/order" +(api/defendpoint-schema PUT "/:id/fields/order" "Reorder fields" [id :as {field_order :body}] {field_order [su/IntGreaterThanZero]} diff --git a/src/metabase/api/task.clj b/src/metabase/api/task.clj index 3a9609db3e6..a6064aa938d 100644 --- a/src/metabase/api/task.clj +++ b/src/metabase/api/task.clj @@ -10,7 +10,7 @@ [toucan.db :as db])) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of recent tasks stored as Task History" [] (validation/check-has-application-permission :monitoring) @@ -19,12 +19,12 @@ :offset mw.offset-paging/*offset* :data (task-history/all mw.offset-paging/*limit* mw.offset-paging/*offset*)}) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Get `TaskHistory` entry with ID." [id] (api/check-404 (api/read-check TaskHistory id))) -(api/defendpoint GET "/info" +(api/defendpoint-schema GET "/info" "Return raw data about all scheduled tasks (i.e., Quartz Jobs and Triggers)." [] (validation/check-has-application-permission :monitoring) diff --git a/src/metabase/api/testing.clj b/src/metabase/api/testing.clj index 5b5ce369ed0..2250cdad1cb 100644 --- a/src/metabase/api/testing.clj +++ b/src/metabase/api/testing.clj @@ -33,7 +33,7 @@ (jdbc/query {:datasource mdb.connection/*application-db*} ["SCRIPT TO ?" path])) :ok) -(api/defendpoint POST "/snapshot/:name" +(api/defendpoint-schema POST "/snapshot/:name" "Snapshot the database for testing purposes." [name] (save-snapshot! name) @@ -82,13 +82,13 @@ (.. lock writeLock unlock)))) :ok) -(api/defendpoint POST "/restore/:name" +(api/defendpoint-schema POST "/restore/:name" "Restore a database snapshot for testing purposes." [name] (restore-snapshot! name) nil) -(api/defendpoint POST "/echo" +(api/defendpoint-schema POST "/echo" [fail :as {:keys [body]}] (if fail {:status 400 diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj index 1013a6040bb..7e0ba907972 100644 --- a/src/metabase/api/tiles.clj +++ b/src/metabase/api/tiles.clj @@ -167,7 +167,7 @@ ;; ;; TODO - this should reduce results from the QP in a streaming fashion instead of requiring them all to be in memory ;; at the same time -(api/defendpoint GET "/:zoom/:x/:y/:lat-field/:lon-field" +(api/defendpoint-schema GET "/:zoom/:x/:y/:lat-field/:lon-field" "This endpoints provides an image with the appropriate pins rendered given a MBQL `query` (passed as a GET query string param). We evaluate the query and find the set of lat/lon pairs which are relevant and then render the appropriate ones. It's expected that to render a full map view several calls will be made to this endpoint in diff --git a/src/metabase/api/timeline.clj b/src/metabase/api/timeline.clj index c3b44bb0fda..290b7781953 100644 --- a/src/metabase/api/timeline.clj +++ b/src/metabase/api/timeline.clj @@ -19,7 +19,7 @@ "Events Query Parameters Schema" (s/enum "events")) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new [[Timeline]]." [:as {{:keys [name default description icon collection_id archived], :as body} :body}] {name su/NonBlankString @@ -36,7 +36,7 @@ {:icon timeline/DefaultIcon}))] (db/insert! Timeline tl))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of [[Timelines]]. Can include `archived=true` to return archived timelines." [include archived] {include (s/maybe Include) @@ -53,7 +53,7 @@ (= include "events") (map #(timeline-event/include-events-singular % {:events/all? archived?}))))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch the [[Timeline]] with `id`. Include `include=events` to unarchived events included on the timeline. Add `archived=true` to return all events on the timeline, both archived and unarchived." [id include archived start end] @@ -74,7 +74,7 @@ :events/start (when start (u.date/parse start)) :events/end (when end (u.date/parse end))})))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update the [[Timeline]] with `id`. Returns the timeline without events. Archiving a timeline will archive all of the events in that timeline." [id :as {{:keys [name default description icon collection_id archived] :as timeline-updates} :body}] @@ -95,7 +95,7 @@ (db/update-where! TimelineEvent {:timeline_id id} :archived archived)) (hydrate (db/select-one Timeline :id id) :creator [:collection :can_write]))) -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a [[Timeline]]. Will cascade delete its events as well." [id] (api/write-check Timeline id) diff --git a/src/metabase/api/timeline_event.clj b/src/metabase/api/timeline_event.clj index 64e888d21b4..6abbed9568b 100644 --- a/src/metabase/api/timeline_event.clj +++ b/src/metabase/api/timeline_event.clj @@ -16,7 +16,7 @@ [schema.core :as s] [toucan.db :as db])) -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new [[TimelineEvent]]." [:as {{:keys [name description timestamp time_matters timezone icon timeline_id source question_id archived] :as body} :body}] {name su/NonBlankString @@ -52,12 +52,12 @@ (boolean question_id) (assoc :question_id question_id))) (db/insert! TimelineEvent tl-event)))) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch the [[TimelineEvent]] with `id`." [id] (api/read-check TimelineEvent id)) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update a [[TimelineEvent]]." [id :as {{:keys [name description timestamp time_matters timezone icon timeline_id archived] :as timeline-event-updates} :body}] @@ -80,7 +80,7 @@ :non-nil #{:name})) (db/select-one TimelineEvent :id id))) -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Delete a [[TimelineEvent]]." [id] (api/write-check TimelineEvent id) diff --git a/src/metabase/api/transform.clj b/src/metabase/api/transform.clj index 7eca484a686..e32eb1f0d88 100644 --- a/src/metabase/api/transform.clj +++ b/src/metabase/api/transform.clj @@ -7,7 +7,7 @@ [metabase.transforms.core :as tf] [metabase.transforms.specs :as tf.specs])) -(api/defendpoint GET "/:db-id/:schema/:transform-name" +(api/defendpoint-schema GET "/:db-id/:schema/:transform-name" "Look up a database schema transform" [db-id schema transform-name] (api/check-403 (perms/set-has-full-permissions? @api/*current-user-permissions-set* diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index 2728804884d..a200c8080f2 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -135,7 +135,7 @@ [:= :core_user.id :permissions_group_membership.user_id]) (some? group_id) (hh/merge-where [:= :permissions_group_membership.group_id group_id]))) -(api/defendpoint GET "/" +(api/defendpoint-schema GET "/" "Fetch a list of `Users`. By default returns every active user but only active users. - If `status` is `deactivated`, include deactivated users only. @@ -213,7 +213,7 @@ (t/offset-date-time))] (assoc user :first_login ts))) -(api/defendpoint GET "/current" +(api/defendpoint-schema GET "/current" "Fetch the current `User`." [] (-> (api/check-404 @api/*current-user*) @@ -223,7 +223,7 @@ maybe-add-advanced-permissions maybe-add-sso-source)) -(api/defendpoint GET "/:id" +(api/defendpoint-schema GET "/:id" "Fetch a `User`. You must be fetching yourself *or* be a superuser *or* a Group Manager." [id] (try @@ -238,7 +238,7 @@ ;;; | Creating a new User -- POST /api/user | ;;; +----------------------------------------------------------------------------------------------------------------+ -(api/defendpoint POST "/" +(api/defendpoint-schema POST "/" "Create a new `User`, return a 400 if the email address is already taken" [:as {{:keys [first_name last_name email user_group_memberships login_attributes] :as body} :body}] {first_name (s/maybe su/NonBlankString) @@ -290,7 +290,7 @@ (not google_auth) (not ldap_auth)))) -(api/defendpoint PUT "/:id" +(api/defendpoint-schema PUT "/:id" "Update an existing, active `User`. Self or superusers can update user info and groups. Group Managers can only add/remove users from groups they are manager of." @@ -356,7 +356,7 @@ ;; now return the existing user whether they were originally active or not (fetch-user :id (u/the-id existing-user))) -(api/defendpoint PUT "/:id/reactivate" +(api/defendpoint-schema PUT "/:id/reactivate" "Reactivate user at `:id`" [id] (api/check-superuser) @@ -372,7 +372,7 @@ ;;; | Updating a Password -- PUT /api/user/:id/password | ;;; +----------------------------------------------------------------------------------------------------------------+ -(api/defendpoint PUT "/:id/password" +(api/defendpoint-schema PUT "/:id/password" "Update a user's password." [id :as {{:keys [password old_password]} :body}] {password su/ValidPassword} @@ -393,7 +393,7 @@ ;;; | Deleting (Deactivating) a User -- DELETE /api/user/:id | ;;; +----------------------------------------------------------------------------------------------------------------+ -(api/defendpoint DELETE "/:id" +(api/defendpoint-schema DELETE "/:id" "Disable a `User`. This does not remove the `User` from the DB, but instead disables their account." [id] (api/check-superuser) @@ -405,7 +405,7 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ ;; TODO - This could be handled by PUT /api/user/:id, we don't need a separate endpoint -(api/defendpoint PUT "/:id/modal/:modal" +(api/defendpoint-schema PUT "/:id/modal/:modal" "Indicate that a user has been informed about the vast intricacies of 'the' Query Builder." [id modal] (check-self-or-superuser id) @@ -418,7 +418,7 @@ (api/check-500 (db/update! User id, k false))) {:success true}) -(api/defendpoint POST "/:id/send_invite" +(api/defendpoint-schema POST "/:id/send_invite" "Resend the user invite email for a given user." [id] (api/check-superuser) diff --git a/src/metabase/api/util.clj b/src/metabase/api/util.clj index ef57a8a8d00..091a7566cd2 100644 --- a/src/metabase/api/util.clj +++ b/src/metabase/api/util.clj @@ -12,39 +12,39 @@ [metabase.util.schema :as su] [ring.util.response :as response])) -(api/defendpoint POST "/password_check" +(api/defendpoint-schema POST "/password_check" "Endpoint that checks if the supplied password meets the currently configured password complexity rules." [:as {{:keys [password]} :body}] {password su/ValidPassword} ;; if we pass the su/ValidPassword test we're g2g {:valid true}) -(api/defendpoint GET "/logs" +(api/defendpoint-schema GET "/logs" "Logs." [] (validation/check-has-application-permission :monitoring) (logger/messages)) -(api/defendpoint GET "/stats" +(api/defendpoint-schema GET "/stats" "Anonymous usage stats. Endpoint for testing, and eventually exposing this to instance admins to let them see what is being phoned home." [] (validation/check-has-application-permission :monitoring) (stats/anonymous-usage-stats)) -(api/defendpoint GET "/random_token" +(api/defendpoint-schema GET "/random_token" "Return a cryptographically secure random 32-byte token, encoded as a hexadecimal string. Intended for use when creating a value for `embedding-secret-key`." [] {:token (crypto-random/hex 32)}) -(api/defendpoint GET "/bug_report_details" +(api/defendpoint-schema GET "/bug_report_details" "Returns version and system information relevant to filing a bug report against Metabase." [] (validation/check-has-application-permission :monitoring) {:system-info (troubleshooting/system-info) :metabase-info (troubleshooting/metabase-info)}) -(api/defendpoint GET "/diagnostic_info/connection_pool_info" +(api/defendpoint-schema GET "/diagnostic_info/connection_pool_info" "Returns database connection pool info for the current Metabase instance." [] (validation/check-has-application-permission :monitoring) diff --git a/src/metabase/query_processor/streaming.clj b/src/metabase/query_processor/streaming.clj index ea9ba02e8a2..2b1e09caf88 100644 --- a/src/metabase/query_processor/streaming.clj +++ b/src/metabase/query_processor/streaming.clj @@ -178,7 +178,7 @@ Typical example: - (api/defendpoint GET \"/whatever\" [] + (api/defendpoint-schema GET \"/whatever\" [] (qp.streaming/streaming-response [context :json] (qp/process-query-and-save-with-max-results-constraints! (assoc query :async true) context))) diff --git a/src/metabase/util/malli/describe.clj b/src/metabase/util/malli/describe.clj new file mode 100644 index 00000000000..9cabb881ed4 --- /dev/null +++ b/src/metabase/util/malli/describe.clj @@ -0,0 +1,227 @@ +(ns metabase.util.malli.describe + "This is a temporary fix while this PR is getting mulled over: + https://github.com/metosin/malli/pull/805 + + If malli is > 0.9.2 we can remove this, and use link^ instead." + (:require [clojure.string :as str] + [malli.core :as mc])) + +(declare -describe describe) + +(defprotocol Descriptor (-accept [this children options] "transforms schema to a text descriptor")) + +(defn- diamond [s] (str "<" s ">")) +(defn- titled [schema] (if-let [t (-> schema mc/properties :title)] (str "(titled: ‘" t "’) ") "")) +(defn- min-max-suffix [schema] + (let [{:keys [min max]} (-> schema mc/properties)] + (cond + (and min max) (str " with length between " min " and " max) + min (str " with length longer than " min) + max (str " with length shorter than " max) + :else ""))) + +(defmulti accept + "Can this be accepted?" + (fn [name _schema _children _options] name) :default ::default) + +(defmethod accept ::default [name schema children {:keys [missing-fn]}] (if missing-fn (missing-fn name schema children) "")) + +(defn- -schema [schema children _options] + (let [just-one (= 1 (count (:registry (mc/properties schema))))] + (str (last children) + (when (:registry (mc/properties schema)) + (str " " + (when-not just-one "which is: ") + (diamond + (str/join ", " + (for [[name schema] (:registry (mc/properties schema))] + (str (when-not just-one (str name " is ")) + (describe schema)))))))))) + +(defmethod accept :schema [_ schema children options] (-schema schema children options)) +(defmethod accept ::mc/schema [_ schema children options] (-schema schema children options)) +(defmethod accept :ref [_ _schema children _] (pr-str (first children))) + +(defmethod accept 'ident? [_ _ _ _] "ident") +(defmethod accept 'simple-ident? [_ _ _ _] "simple-ident") + +(defmethod accept 'uuid? [_ _ _ _] "uuid") +(defmethod accept 'uri? [_ _ _ _] "uri") +(defmethod accept 'decimal? [_ _ _ _] "decimal") +(defmethod accept 'inst? [_ _ _ _] "inst (aka date time)") +(defmethod accept 'seqable? [_ _ _ _] "seqable") +(defmethod accept 'indexed? [_ _ _ _] "indexed") +(defmethod accept 'vector? [_ _ _ _] "vector") +(defmethod accept 'list? [_ _ _ _] "list") +(defmethod accept 'seq? [_ _ _ _] "seq") +(defmethod accept 'char? [_ _ _ _] "char") +(defmethod accept 'set? [_ _ _ _] "set") + +(defmethod accept 'false? [_ _ _ _] "false") +(defmethod accept 'true? [_ _ _ _] "true") +(defmethod accept 'zero? [_ _ _ _] "zero") +(defmethod accept 'rational? [_ _ _ _] "rational") +(defmethod accept 'coll? [_ _ _ _] "collection") +(defmethod accept 'empty? [_ _ _ _] "empty") +(defmethod accept 'associative? [_ _ _ _] "is associative") +(defmethod accept 'ratio? [_ _ _ _] "ratio") +(defmethod accept 'bytes? [_ _ _ _] "bytes") +(defmethod accept 'ifn? [_ _ _ _] "implmenets IFn") +(defmethod accept 'fn? [_ _ _ _] "function") + +(defmethod accept :> [_ _ [value] _] (str "> " value)) +(defmethod accept :>= [_ _ [value] _] (str ">= " value)) +(defmethod accept :< [_ _ [value] _] (str "< " value)) +(defmethod accept :<= [_ _ [value] _] (str "<= " value)) +(defmethod accept := [_ _ [value] _] (str "must equal " value)) +(defmethod accept :not= [_ _ [value] _] (str "not equal " value)) +(defmethod accept :not [_ _ children _] {:not (last children)}) + +(defmethod accept :multi [_ s children _] + (let [dispatcher (or (-> s mc/properties :dispatch-description) + (-> s mc/properties :dispatch))] + (str "one of " + (diamond + (str/join " | " (map (fn [[title _ shape]] (str title " = " shape)) children))) + " dispatched by " dispatcher))) + +(defmethod accept :map-of [_ schema children _] + (str "a map " (titled schema) "from " (diamond (first children)) " to " (diamond (second children)) (min-max-suffix schema))) + +(defmethod accept 'vector? [_ schema children _] (str "vector" (titled schema) (min-max-suffix schema) " of " (first children))) +(defmethod accept :vector [_ schema children _] (str "vector" (titled schema) (min-max-suffix schema) " of " (first children))) + +(defmethod accept 'sequential? [_ schema children _] (str "sequence" (titled schema) (min-max-suffix schema) " of " (first children))) +(defmethod accept :sequential [_ schema children _] (str "sequence" (titled schema) (min-max-suffix schema) " of " (first children))) + +(defmethod accept 'set? [_ schema children _] (str "set" (titled schema) (min-max-suffix schema) " of " (first children))) +(defmethod accept :set [_ schema children _] (str "set" (titled schema) (min-max-suffix schema) " of " (first children))) + +(defmethod accept 'string? [_ schema _ _] (str "string" (min-max-suffix schema))) +(defmethod accept :string [_ schema _ _] (str "string" (min-max-suffix schema))) + +(defmethod accept 'number? [_ _ _ _] "number") +(defmethod accept :number [_ _ _ _] "number") + +(defmethod accept 'pos-int? [_ _ _ _] "integer greater than 0") +(defmethod accept :pos-int [_ _ _ _] "integer greater than 0") + +(defmethod accept 'neg-int? [_ _ _ _] "integer less than 0") +(defmethod accept :neg-int [_ _ _ _] "integer less than 0") + +(defmethod accept 'nat-int? [_ _ _ _] "natural integer") +(defmethod accept :nat-int [_ _ _ _] "natural integer") + +(defmethod accept 'float? [_ _ _ _] "float") +(defmethod accept :float [_ _ _ _] "float") + +(defmethod accept 'pos? [_ _ _ _] "number greater than 0") +(defmethod accept :pos [_ _ _ _] "number greater than 0") + +(defmethod accept 'neg? [_ _ _ _] "number less than 0") +(defmethod accept :neg [_ _ _ _] "number less than 0") + +(defmethod accept 'integer? [_ schema _ _] (str "integer" (min-max-suffix schema))) +(defmethod accept 'int? [_ schema _ _] (str "integer" (min-max-suffix schema))) +(defmethod accept :int [_ schema _ _] (str "integer" (min-max-suffix schema))) + +(defmethod accept 'double? [_ schema _ _] (str "double" (min-max-suffix schema))) +(defmethod accept :double [_ schema _ _] (str "double" (min-max-suffix schema))) + +(defmethod accept :merge [_ schema _ options] ((::describe options) (mc/deref schema) options)) +(defmethod accept :union [_ schema _ options] ((::describe options) (mc/deref schema) options)) +(defmethod accept :select-keys [_ schema _ options] ((::describe options) (mc/deref schema) options)) + +(defmethod accept :and [_ s children _] (str (str/join ", and " children) (titled s))) +(defmethod accept :enum [_ s children _options] (str "an enum" (titled s) " of " (str/join ", " children))) +(defmethod accept :maybe [_ s children _] (str "a nullable " (titled s) (first children))) +(defmethod accept :tuple [_ s children _] (str "a vector " (titled s) "with exactly " (count children) " items of type: " (str/join ", " children))) +(defmethod accept :re [_ s _ options] (str "a regex pattern " (titled s) "matching " (pr-str (first (mc/children s options))))) + +(defmethod accept 'any? [_ s _ _] (str "anything" (titled s))) +(defmethod accept :any [_ s _ _] (str "anything" (titled s))) + +(defmethod accept 'some? [_ _ _ _] "anything but null") +(defmethod accept :some [_ _ _ _] "anything but null") + +(defmethod accept 'nil? [_ _ _ _] "null") +(defmethod accept :nil [_ _ _ _] "null") + +(defmethod accept 'qualified-ident? [_ _ _ _] "qualified-ident") +(defmethod accept :qualified-ident [_ _ _ _] "qualified-ident") + +(defmethod accept 'simple-keyword? [_ _ _ _] "simple-keyword") +(defmethod accept :simple-keyword [_ _ _ _] "simple-keyword") + +(defmethod accept 'simple-symbol? [_ _ _ _] "simple-symbol") +(defmethod accept :simple-symbol [_ _ _ _] "simple-symbol") + +(defmethod accept 'qualified-keyword? [_ _ _ _] "qualified-keyword") +(defmethod accept :qualified-keyword [_ _ _ _] "qualified keyword") + +(defmethod accept 'symbol? [_ _ _ _] "symbol") +(defmethod accept :symbol [_ _ _ _] "symbol") + +(defmethod accept 'qualified-symbol? [_ _ _ _] "qualified-symbol") +(defmethod accept :qualified-symbol [_ _ _ _] "qualified symbol") +(defmethod accept :uuid [_ _ _ _] "uuid") + +(defmethod accept :=> [_ s _ _] + (let [{:keys [input output]} (mc/-function-info s)] + (str "a function that takes input: [" (describe input) "] and returns " (describe output)))) + +(defmethod accept :function [_ _ _children _] "a function") +(defmethod accept :fn [_ _ _ _] "a function") + +(defmethod accept :or [_ _ children _] (str/join ", or " children)) +(defmethod accept :orn [_ _ children _] (str/join ", or " (map (fn [[tag _ c]] (str c " (tag: " tag ")" )) children))) + +(defmethod accept :cat [_ _ children _] (str/join ", " children)) +(defmethod accept :catn [_ _ children _] (str/join ", or " (map (fn [[tag _ c]] (str c " (tag: " tag ")" )) children))) + +(defmethod accept 'boolean? [_ _ _ _] "boolean") +(defmethod accept :boolean [_ _ _ _] "boolean") + +(defmethod accept 'keyword? [_ _ _ _] "keyword") +(defmethod accept :keyword [_ _ _ _] "keyword") + +(defmethod accept 'integer? [_ _ _ _] "integer") +(defmethod accept 'int? [_ _ _ _] "integer") +(defmethod accept :int [_ _ _ _] "integer") + +(defn- -map [_n schema children _o] + (let [optional (set (->> children (filter (mc/-comp :optional second)) (mapv first))) + additional-properties (:closed (mc/properties schema)) + kv-description (str/join ", " (map (fn [[k _ s]] (str k (when (contains? optional k) " (optional)") " -> " (diamond s))) children))] + (cond-> (str "a map where {" kv-description "}") + additional-properties (str " with no other keys")))) + +(defmethod accept ::mc/val [_ _ children _] (first children)) +(defmethod accept 'map? [n schema children o] (-map n schema children o)) +(defmethod accept :map [n schema children o] (-map n schema children o)) + +(defn- -descriptor-walker [schema _ children options] + (let [p (merge (mc/type-properties schema) (mc/properties schema))] + (or (get p :description) + (if (satisfies? Descriptor schema) + (-accept schema children options) + (accept (mc/type schema) schema children options))))) + +(defn- -describe [?schema options] + (mc/walk ?schema -descriptor-walker options)) + +;; +;; public api +;; + +(defn describe + "Given a schema, returns a string explaiaing the required shape in English" + ([?schema] + (describe ?schema nil)) + ([?schema options] + (let [definitions (atom {}) + options (merge options + {::mc/walk-entry-vals true, + ::definitions definitions, + ::describe -describe})] + (-describe ?schema options)))) diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj index 71290de53cc..a35f5217d23 100644 --- a/src/metabase/util/schema.clj +++ b/src/metabase/util/schema.clj @@ -38,7 +38,7 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | API Schema Validation & Error Messages | +;;; | Plumatic API Schema Validation & Error Messages | ;;; +----------------------------------------------------------------------------------------------------------------+ (defn with-api-error-message diff --git a/test/metabase/api/common/internal_test.clj b/test/metabase/api/common/internal_test.clj index 3d321fc3e97..d8c1f861224 100644 --- a/test/metabase/api/common/internal_test.clj +++ b/test/metabase/api/common/internal_test.clj @@ -1,10 +1,114 @@ (ns metabase.api.common.internal-test (:require + [cheshire.core :as json] + [clj-http.client :as http] [clojure.test :refer :all] + [compojure.core :refer [POST]] + [malli.util :as mu] [medley.core :as m] + [metabase.api.common :as api] [metabase.api.common.internal :as internal] [metabase.config :as config] - [metabase.util :as u])) + [metabase.server.middleware.exceptions :as mw.exceptions] + [metabase.util :as u] + [ring.adapter.jetty :as jetty])) + +(def TestAddress + [:map + {:title "Address"} + [:id string?] + ;; TODO it is possible to coerce things like this automatically: + ;; [:tags [:set keyword?]] + [:tags [:vector string?]] + [:address + [:map + [:street string?] + [:city string?] + [:zip int?] + [:lonlat [:tuple double? double?]]]]]) + +(def ClosedTestAddress + (mu/closed-schema TestAddress)) + +(api/defendpoint POST "/post/any" [:as {body :body :as _request}] + {:status 200 :body body}) + +(api/defendpoint POST "/post/id-int" + [:as {{:keys [id] :as body} :body :as _request}] + {id int?} + {:status 200 :body body}) + +(api/defendpoint POST "/post/test-address" + [:as {address :body :as _request}] + {address TestAddress} + {:status 200 :body address}) + +(api/defendpoint POST "/post/closed-test-address" + [:as {address :body :as _request}] + {address ClosedTestAddress} + {:status 200 :body address}) + +(api/define-routes) + +(defn- json-mw [handler] + (fn [req] + (update + (handler + (update req :body #(-> % slurp (json/parse-string true)))) + :body json/generate-string))) + +(defn exception-mw [handler] + (fn [req] (try + (handler req) + (catch Exception e (mw.exceptions/api-exception-response e))))) + +(deftest defendpoint-test + (let [server (jetty/run-jetty (json-mw (exception-mw #'routes)) {:port 0 :join? false}) + port (.. server getURI getPort) + post! (fn [route body] + (http/post (str "http://localhost:" port route) + {:throw-exceptions false + :accept :json + :as :json + :coerce :always + :body (json/generate-string body)}))] + (is (= {:a 1 :b 2} (:body (post! "/post/any" {:a 1 :b 2})))) + + (is (= {:id 1} (:body (post! "/post/id-int" {:id 1})))) + (is (= {:errors {:id ["should be an int"]}} (:body (post! "/post/id-int" {:id "1"})))) + + (is (= {:id "myid" + :tags ["abc"] + :address {:street "abc" :city "sdasd" :zip 2999 :lonlat [0.0 0.0]}} + (:body (post! "/post/test-address" + {:id "myid" + :tags ["abc"] + :address {:street "abc" + :city "sdasd" + :zip 2999 + :lonlat [0.0 0.0]}})))) + + (is (= {:errors {:address {:id ["missing required key"] + :tags ["missing required key"] + :address ["missing required key"]}}} + (:body (post! "/post/test-address" {:x "1"})))) + + (is (= {:errors {:address {:id ["should be a string"] + :tags ["invalid type"] + :address {:street ["missing required key"] + :zip ["should be an int"]}}}} + (:body (post! "/post/test-address" {:id 1288 + :tags "a,b,c" + :address {:streeqt "abc" + :city "sdasd" + :zip "12342" + :lonlat [0.0 0.0]}})))) + + (is (= {:errors + {:address {:address ["missing required key"] + :a ["disallowed key"] + :b ["disallowed key"]}}} + (:body (post! "/post/closed-test-address" {:id "1" :tags [] :a 1 :b 2})))))) (deftest route-fn-name-test (are [method route expected] (= expected @@ -29,7 +133,7 @@ {:style/indent 0} [& body] `(binding [internal/*auto-parse-types* (m/map-vals #(update % :route-param-regex (partial str "#")) - internal/*auto-parse-types*)] + internal/*auto-parse-types*)] ~@body)) (deftest route-param-regex-test diff --git a/test/metabase/api/common_test.clj b/test/metabase/api/common_test.clj index ad3de746698..965606a427f 100644 --- a/test/metabase/api/common_test.clj +++ b/test/metabase/api/common_test.clj @@ -104,6 +104,6 @@ (metabase.api.common.internal/wrap-response-if-needed (do (select-one Card :id id)))))) - (macroexpand '(metabase.api.common/defendpoint compojure.core/GET "/:id" [id] + (macroexpand '(metabase.api.common/defendpoint-schema compojure.core/GET "/:id" [id] {id metabase.util.schema/IntGreaterThanZero} (select-one Card :id id))))))) diff --git a/test/metabase/util/malli/describe_test.clj b/test/metabase/util/malli/describe_test.clj new file mode 100644 index 00000000000..722688fc563 --- /dev/null +++ b/test/metabase/util/malli/describe_test.clj @@ -0,0 +1,78 @@ +(ns metabase.util.malli.describe-test + (:require [clojure.test :refer [deftest is]] + [metabase.util.malli.describe :as umd])) + +(deftest descriptor-test + + (is (= "a map where {:x -> <integer>}" + (umd/describe [:map [:x int?]]))) + + (is (= "a map where {:x (optional) -> <integer>, :y -> <boolean>}" + (umd/describe [:map [:x {:optional true} int?] [:y :boolean]]))) + + (is (= "a map where {:x -> <integer>} with no other keys" + (umd/describe [:map {:closed true} [:x int?]]))) + + (is (= "a map where {:x (optional) -> <integer>, :y -> <boolean>} with no other keys" + (umd/describe [:map {:closed true} [:x {:optional true} int?] [:y :boolean]]))) + + (is (= "a function that takes input: [integer] and returns integer" + (umd/describe [:=> [:cat int?] int?]))) + + (is (= "a map where {:j-code -> <keyword, and has length 4>}" + (umd/describe [:map [:j-code [:and + :keyword + [:fn {:description "has length 4"} #(= 4 (count (name %)))]]]]))) + + (is (= (umd/describe [:map-of {:title "dict"} :int :string]) + "a map (titled: ‘dict’) from <integer> to <string>")) + + (is (= (umd/describe [:vector [:sequential [:set :int]]]) + "vector of sequence of set of integer")) + + (is (= "one of <:dog = a map where {:x -> <integer>} | :cat = anything> dispatched by the type of animal" + (umd/describe [:multi {:dispatch :type + :dispatch-description "the type of animal"} + [:dog [:map [:x :int]]] + [:cat :any]]))) + + (is (= "one of <:dog = a map where {:x -> <integer>} | :cat = anything> dispatched by :type" + (umd/describe [:multi {:dispatch :type} + [:dog [:map [:x :int]]] + [:cat :any]]))) + + (is (= "Order which is: <Country is a map where {:name -> <an enum of :FI, :PO>, :neighbors (optional) -> <vector of \"Country\">} with no other keys, Burger is a map where {:name -> <string>, :description (optional) -> <string>, :origin -> <a nullable Country>, :price -> <integer greater than 0>}, OrderLine is a map where {:burger -> <Burger>, :amount -> <integer>} with no other keys, Order is a map where {:lines -> <vector of OrderLine>, :delivery -> <a map where {:delivered -> <boolean>, :address -> <a map where {:street -> <string>, :zip -> <integer>, :country -> <Country>}>} with no other keys>} with no other keys>" + (umd/describe [:schema + {:registry {"Country" [:map + {:closed true} + [:name [:enum :FI :PO]] + [:neighbors + {:optional true} + [:vector [:ref "Country"]]]], + "Burger" [:map + [:name string?] + [:description {:optional true} string?] + [:origin [:maybe "Country"]] + [:price pos-int?]], + "OrderLine" [:map + {:closed true} + [:burger "Burger"] + [:amount int?]], + "Order" [:map + {:closed true} + [:lines [:vector "OrderLine"]] + [:delivery + [:map + {:closed true} + [:delivered boolean?] + [:address + [:map + [:street string?] + [:zip int?] + [:country "Country"]]]]]]}} + "Order"]))) + + (is (= "ConsCell <a nullable a vector with exactly 2 items of type: integer, \"ConsCell\">" + (umd/describe [:schema + {:registry {"ConsCell" [:maybe [:tuple :int [:ref "ConsCell"]]]}} + "ConsCell"])))) diff --git a/test/metabase/util/schema_test.clj b/test/metabase/util/schema_test.clj index 5c2430e5f79..f5221541ce6 100644 --- a/test/metabase/util/schema_test.clj +++ b/test/metabase/util/schema_test.clj @@ -32,7 +32,7 @@ {:a {:b {:c {:d {:key (s/maybe s/Bool) (s/optional-key :optional-key) s/Int}}}}})))))) -(api/defendpoint POST "/:id/dimension" +(api/defendpoint-schema POST "/:id/dimension" "Sets the dimension for the given object with ID." #_{:clj-kondo/ignore [:unused-binding]} [id :as {{dimension-type :type, dimension-name :name} :body}] -- GitLab