From 37873844dcdc73142e3b4ef2efffd5c9c177e543 Mon Sep 17 00:00:00 2001
From: Tom Robinson <tlrobinson@gmail.com>
Date: Wed, 2 Oct 2019 13:36:48 -0700
Subject: [PATCH] Fix public questions with required parameters (#10794)

---
 .../components/LoadingAndErrorWrapper.jsx     |  5 +-
 frontend/src/metabase/meta/types/Dataset.js   |  1 +
 .../public/containers/PublicQuestion.jsx      | 12 ++-
 src/metabase/api/dataset.clj                  |  9 +-
 src/metabase/api/embed.clj                    | 13 ++-
 src/metabase/api/public.clj                   | 30 +++++--
 src/metabase/api/routes.clj                   |  8 +-
 src/metabase/query_processor/error_type.clj   | 87 +++++++++++++++++++
 .../middleware/catch_exceptions.clj           |  6 +-
 .../middleware/parameters/native/values.clj   | 14 +--
 .../middleware/process_userland_query.clj     |  2 +-
 test/metabase/api/embed_test.clj              |  3 +-
 test/metabase/api/public_test.clj             | 35 ++++++++
 13 files changed, 190 insertions(+), 35 deletions(-)
 create mode 100644 src/metabase/query_processor/error_type.clj

diff --git a/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx b/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx
index af08de46963..a7c9f5a7771 100644
--- a/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx
+++ b/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx
@@ -42,9 +42,10 @@ export default class LoadingAndErrorWrapper extends Component {
       // NOTE Atte Keinänen 5/10/17 Dashboard API endpoint returns the error as JSON with `message` field
       (error.data && (error.data.message ? error.data.message : error.data)) ||
       error.statusText ||
-      error.message;
+      error.message ||
+      error;
 
-    if (!errorMessage || typeof errorMessage === "object") {
+    if (!errorMessage || typeof errorMessage !== "string") {
       errorMessage = t`An error occurred`;
     }
     return errorMessage;
diff --git a/frontend/src/metabase/meta/types/Dataset.js b/frontend/src/metabase/meta/types/Dataset.js
index faea3fa5644..02ce487463d 100644
--- a/frontend/src/metabase/meta/types/Dataset.js
+++ b/frontend/src/metabase/meta/types/Dataset.js
@@ -41,4 +41,5 @@ export type DatasetData = {
 export type Dataset = {
   data: DatasetData,
   json_query: DatasetQuery,
+  error?: string,
 };
diff --git a/frontend/src/metabase/public/containers/PublicQuestion.jsx b/frontend/src/metabase/public/containers/PublicQuestion.jsx
index a248584747b..02907e450e5 100644
--- a/frontend/src/metabase/public/containers/PublicQuestion.jsx
+++ b/frontend/src/metabase/public/containers/PublicQuestion.jsx
@@ -184,18 +184,26 @@ export default class PublicQuestion extends Component {
       />
     );
 
+    const parameters = card && getParametersWithExtras(card);
+
     return (
       <EmbedFrame
         name={card && card.name}
         description={card && card.description}
-        parameters={card && getParametersWithExtras(card)}
+        parameters={parameters}
         actionButtons={actionButtons}
         parameterValues={parameterValues}
         setParameterValue={this.setParameterValue}
       >
-        <LoadingAndErrorWrapper loading={!result} noWrapper>
+        <LoadingAndErrorWrapper
+          className="flex-full"
+          loading={!result}
+          error={typeof result === "string" ? result : null}
+          noWrapper
+        >
           {() => (
             <Visualization
+              error={result && result.error}
               rawSeries={[{ card: card, data: result && result.data }]}
               className="full flex-full z1"
               onUpdateVisualizationSettings={settings =>
diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj
index 34a3f15b321..a89457ba743 100644
--- a/src/metabase/api/dataset.clj
+++ b/src/metabase/api/dataset.clj
@@ -15,6 +15,7 @@
             [metabase.query-processor :as qp]
             [metabase.query-processor
              [async :as qp.async]
+             [error-type :as qp.error-type]
              [util :as qputil]]
             [metabase.query-processor.middleware.constraints :as constraints]
             [metabase.util
@@ -102,7 +103,7 @@
 (defn- as-format-response
   "Return a response containing the `results` of a query in the specified format."
   {:style/indent 1, :arglists '([export-format results])}
-  [export-format {{:keys [rows cols]} :data, :keys [status], :as response}]
+  [export-format {{:keys [rows cols]} :data, :keys [status error], error-type :error_type, :as response}]
   (api/let-404 [export-conf (ex/export-formats export-format)]
     (if (= status :completed)
       ;; successful query, send file
@@ -114,8 +115,10 @@
                  "Content-Disposition" (format "attachment; filename=\"query_result_%s.%s\""
                                                (du/date->iso-8601) (:ext export-conf))}}
       ;; failed query, send error message
-      {:status 500
-       :body   (:error response)})))
+      {:status (if (qp.error-type/server-error? error-type)
+                 500
+                 400)
+       :body   error})))
 
 (s/defn as-format-async
   "Write the results of an async query to API `respond` or `raise` functions in `export-format`. `in-chan` should be a
diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj
index a789d98a8ae..622973dd926 100644
--- a/src/metabase/api/embed.clj
+++ b/src/metabase/api/embed.clj
@@ -67,8 +67,8 @@
 
 (defn- validate-param-sets
   "Validate that sets of params passed as part of the JWT token and by the user (as query params, i.e. as part of the
-  URL) are valid for the OBJECT-EMBEDDING-PARAMS. TOKEN-PARAMS and USER-PARAMS should be sets of all valid param keys
-  specified in the JWT or by the user, respectively."
+  URL) are valid for the `object-embedding-params`. `token-params` and `user-params` should be sets of all valid param
+  keys specified in the JWT or by the user, respectively."
   [object-embedding-params token-params user-params]
   ;; TODO - maybe make this log/debug once embedding is wrapped up
   (log/debug "Validating params for embedded object:\n"
@@ -108,7 +108,7 @@
     param))
 
 (s/defn ^:private remove-locked-and-disabled-params
-  "Remove the `:parameters` for DASHBOARD-OR-CARD that listed as `disabled` or `locked` in the `embedding-params`
+  "Remove the `:parameters` for `dashboard-or-card` that listed as `disabled` or `locked` in the `embedding-params`
   whitelist, or not present in the whitelist. This is done so the frontend doesn't display widgets for params the user
   can't set."
   [dashboard-or-card, embedding-params :- su/EmbeddingParams]
@@ -144,7 +144,7 @@
      :default (:default tag)}))
 
 (defn- add-implicit-card-parameters
-  "Add template tag parameter information to CARD's `:parameters`."
+  "Add template tag parameter information to `card`'s `:parameters`."
   [card]
   (update card :parameters concat (template-tag-parameters card)))
 
@@ -197,8 +197,8 @@
 ;;; ---------------------------- Card Fns used by both /api/embed and /api/preview_embed -----------------------------
 
 (defn card-for-unsigned-token
-  "Return the info needed for embedding about Card specified in TOKEN.
-   Additional CONSTRAINTS can be passed to the `public-card` function that fetches the Card."
+  "Return the info needed for embedding about Card specified in `token`. Additional `constraints` can be passed to the
+  `public-card` function that fetches the Card."
   {:style/indent 1}
   [unsigned-token & {:keys [embedding-params constraints]}]
   (let [card-id        (eu/get-in-unsigned-token-or-throw unsigned-token [:resource :question])
@@ -332,7 +332,6 @@
     (dashboard-for-unsigned-token unsigned, :constraints {:enable_embedding true})))
 
 
-
 (defn- card-for-signed-token-async
   "Fetch the results of running a Card belonging to a Dashboard using a JSON Web Token signed with the
    `embedding-secret-key`.
diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj
index 4b70d2b8d42..d350220a1f3 100644
--- a/src/metabase/api/public.clj
+++ b/src/metabase/api/public.clj
@@ -25,6 +25,7 @@
              [dimension :refer [Dimension]]
              [field :refer [Field]]
              [params :as params]]
+            [metabase.query-processor.error-type :as qp.error-type]
             [metabase.query-processor.middleware.constraints :as constraints]
             [metabase.util
              [embed :as embed]
@@ -42,14 +43,14 @@
 ;;; -------------------------------------------------- Public Cards --------------------------------------------------
 
 (defn- remove-card-non-public-columns
-  "Remove everyting from public CARD that shouldn't be visible to the general public."
+  "Remove everyting from public `card` that shouldn't be visible to the general public."
   [card]
   (card/map->CardInstance
    (u/select-nested-keys card [:id :name :description :display :visualization_settings
                                [:dataset_query :type [:native :template-tags]]])))
 
 (defn public-card
-  "Return a public Card matching key-value CONDITIONS, removing all columns that should not be visible to the general
+  "Return a public Card matching key-value `conditions`, removing all columns that should not be visible to the general
    public. Throws a 404 if the Card doesn't exist."
   [& conditions]
   (-> (api/check-404 (apply db/select-one [Card :id :dataset_query :description :display :name :visualization_settings]
@@ -66,13 +67,24 @@
   (api/check-public-sharing-enabled)
   (card-with-uuid uuid))
 
-(defn- transform-results [results]
-  (if (= (:status results) :failed)
-    ;; if the query failed instead of returning anything about the query just return a generic error message
-    (ex-info "An error occurred while running the query." {:status-code 400})
-    (u/select-nested-keys
-     results
-     [[:data :cols :rows :rows_truncated :insights] [:json_query :parameters] :error :status])))
+(defmulti ^:private transform-results
+  {:arglists '([results])}
+  :status)
+
+(defmethod transform-results :default
+  [results]
+  (u/select-nested-keys
+   results
+   [[:data :cols :rows :rows_truncated :insights] [:json_query :parameters] :status]))
+
+(defmethod transform-results :failed
+  [{:keys [error], error-type :error_type, :as results}]
+  ;; if the query failed instead, unless the error type is specified and is EXPLICITLY allowed to be shown for embeds,
+  ;; instead of returning anything about the query just return a generic error message
+  (merge
+   (select-keys results [:status :error :error_type])
+   (when-not (qp.error-type/show-in-embeds? error-type)
+     {:error (tru "An error occurred while running the query.")})))
 
 (defn run-query-for-card-with-id-async
   "Run the query belonging to Card with `card-id` with `parameters` and other query options (e.g. `:constraints`).
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index 2a2be750a4d..b28754f522b 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -41,21 +41,21 @@
             [metabase.util.i18n :refer [deferred-tru]]))
 
 (def ^:private +generic-exceptions
-  "Wrap ROUTES so any Exception thrown is just returned as a generic 400, to prevent details from leaking in public
+  "Wrap `routes` so any Exception thrown is just returned as a generic 400, to prevent details from leaking in public
   endpoints."
   middleware.exceptions/genericize-exceptions)
 
 (def ^:private +message-only-exceptions
-  "Wrap ROUTES so any Exception thrown is just returned as a 400 with only the message from the original
+  "Wrap `routes` so any Exception thrown is just returned as a 400 with only the message from the original
   Exception (i.e., remove the original stacktrace), to prevent details from leaking in public endpoints."
   middleware.exceptions/message-only-exceptions)
 
 (def ^:private +apikey
-  "Wrap ROUTES so they may only be accessed with proper apikey credentials."
+  "Wrap `routes` so they may only be accessed with a correct API key header."
   middleware.auth/enforce-api-key)
 
 (def ^:private +auth
-  "Wrap ROUTES so they may only be accessed with proper authentiaction credentials."
+  "Wrap `routes` so they may only be accessed with proper authentication credentials."
   middleware.auth/enforce-authentication)
 
 (defroutes ^{:doc "Ring routes for API endpoints."} routes
diff --git a/src/metabase/query_processor/error_type.clj b/src/metabase/query_processor/error_type.clj
new file mode 100644
index 00000000000..532721325b6
--- /dev/null
+++ b/src/metabase/query_processor/error_type.clj
@@ -0,0 +1,87 @@
+(ns metabase.query-processor.error-type
+  "A hierarchy of all QP error types. Ideally all QP exceptions should be `ex-data` maps with an `:type` key whose value
+  is one of the types here. If you see an Exception in QP code that doesn't return an `:type`, add it!")
+
+(def ^:private hierarchy
+  (-> (make-hierarchy)
+      ;; errors deriving from `:client-error` are the equivalent of HTTP 4xx client status codes
+      (derive :client-error :error)
+      (derive :invalid-query :client-error)
+      ;; errors deriving from `:unexpected-server-error` are the equivalent of HTTP 5xx status codes
+      (derive :unexpected-server-error :error)
+      (derive :unexpected-qp-error :unexpected-server-error)
+      (derive :unexpected-db-error :unexpected-server-error)))
+
+(defn known-error-types
+  "Set of all known QP error types."
+  []
+  (descendants hierarchy :error))
+
+(defn known-error-type?
+  "Is `error-type` a known QP error type (i.e., one defined with `deferror` above)?"
+  [error-type]
+  (isa? hierarchy error-type :error))
+
+(defn show-in-embeds?
+  "Should errors of this type be shown to users of Metabase in embedded Cards or Dashboards? Normally, we return a
+  generic 'Query Failed' error message for embedded queries, so as not to leak information. Some errors (like missing
+  parameter errors), however, should be shown even in these situations."
+  [error-type]
+  (isa? hierarchy error-type :show-in-embeds?))
+
+(defmacro ^:private deferror
+  {:style/indent 1}
+  [error-name docstring & {:keys [parent show-in-embeds?]}]
+  {:pre [(some? parent)]}
+  `(do
+     (def ~error-name ~docstring ~(keyword error-name))
+     (alter-var-root #'hierarchy derive ~(keyword error-name) ~(keyword parent))
+     ~(when show-in-embeds?
+        `(alter-var-root #'hierarchy derive ~(keyword error-name) :show-in-embeds?))))
+
+;;;; ### Client Errors
+
+(deferror client
+  "Generic ancestor type for all errors with the query map itself. Equivalent of a HTTP 4xx status code."
+  :parent :error)
+
+(defn client-error?
+  "Is `error-type` a client error type, the equivalent of an HTTP 4xx status code?"
+  [error-type]
+  (isa? hierarchy error-type :client))
+
+(deferror missing-required-permissions
+  "The current user does not have required permissions to run the current query."
+  :parent client)
+
+(deferror invalid-query
+  "Generic ancestor type for errors with the query map itself."
+  :parent client)
+
+(deferror missing-required-parameter
+  "The query is parameterized, and a required parameter was not supplied."
+  :parent invalid-query
+  :show-in-embeds? true)
+
+;;;; ### Server-Side Errors
+
+(deferror server
+  "Generic ancestor type for all unexpected server-side errors. Equivalent of a HTTP 5xx status code."
+  :parent :error)
+
+(defn server-error?
+  "Is `error-type` a server error type, the equivalent of an HTTP 5xx status code?"
+  [error-type]
+  (isa? hierarchy error-type :server))
+
+;;;; #### QP Errors
+
+(deferror qp
+  "Generic ancestor type for all unexpected errors (e.g., uncaught Exceptions) in QP code."
+  :parent server)
+
+;;;; #### Data Warehouse (DB) Errors
+
+(deferror db
+  "Generic ancestor type for all unexpected errors returned or thrown by a data warehouse when running a query."
+  :parent server)
diff --git a/src/metabase/query_processor/middleware/catch_exceptions.clj b/src/metabase/query_processor/middleware/catch_exceptions.clj
index fbd1ee48156..35a6cd3c64e 100644
--- a/src/metabase/query_processor/middleware/catch_exceptions.clj
+++ b/src/metabase/query_processor/middleware/catch_exceptions.clj
@@ -1,6 +1,8 @@
 (ns metabase.query-processor.middleware.catch-exceptions
   "Middleware for catching exceptions thrown by the query processor and returning them in a friendlier format."
-  (:require [metabase.query-processor.interface :as qp.i]
+  (:require [metabase.query-processor
+             [error-type :as qp.error-type]
+             [interface :as qp.i]]
             [metabase.util :as u]
             schema.utils)
   (:import clojure.lang.ExceptionInfo
@@ -85,6 +87,8 @@
      (when-let [error-msg (and (= error-type :schema.core/error)
                                (explain-schema-validation-error error))]
        {:error error-msg})
+     (when (qp.error-type/known-error-type? error-type)
+       {:error_type error-type})
      {:ex-data (dissoc data :schema)})))
 
 
diff --git a/src/metabase/query_processor/middleware/parameters/native/values.clj b/src/metabase/query_processor/middleware/parameters/native/values.clj
index 1c12f18b448..4ab025e5c38 100644
--- a/src/metabase/query_processor/middleware/parameters/native/values.clj
+++ b/src/metabase/query_processor/middleware/parameters/native/values.clj
@@ -10,6 +10,7 @@
                                      :value  \"2015-01-01~2016-09-01\"}}}"
   (:require [clojure.string :as str]
             [metabase.models.field :refer [Field]]
+            [metabase.query-processor.error-type :as qp.error-type]
             [metabase.query-processor.middleware.parameters.native.interface :as i]
             [metabase.util
              [i18n :as ui18n :refer [tru]]
@@ -18,8 +19,7 @@
             [toucan.db :as db])
   (:import java.text.NumberFormat
            java.util.UUID
-           [metabase.query_processor.middleware.parameters.native.interface CommaSeparatedNumbers FieldFilter
-            MultipleValues]))
+           [metabase.query_processor.middleware.parameters.native.interface CommaSeparatedNumbers FieldFilter MultipleValues]))
 
 (def ^:private ParamType
   (s/enum :number
@@ -68,13 +68,17 @@
 
 ;;; FieldFilter Params (Field Filters) (e.g. WHERE {{x}})
 
+(defn- missing-required-param-exception [param-display-name]
+  (ex-info (tru "You''ll need to pick a value for ''{0}'' before this query can run." param-display-name)
+    {:type qp.error-type/missing-required-parameter}))
+
 (s/defn ^:private default-value-for-field-filter
   "Return the default value for a FieldFilter (`:type` = `:dimension`) param defined by the map `tag`, if one is set."
   [tag :- TagParam]
   (when (and (:required tag) (not (:default tag)))
-    (throw (Exception. (tru "''{0}'' is a required param." (:display-name tag)))))
+    (throw (missing-required-param-exception (:display-name tag))))
   (when-let [default (:default tag)]
-    {:type   (:widget-type tag :dimension)             ; widget-type is the actual type of the default value if set
+    {:type   (:widget-type tag :dimension) ; widget-type is the actual type of the default value if set
      :target [:dimension [:template-tag (:name tag)]]
      :value  default}))
 
@@ -123,7 +127,7 @@
   [{:keys [default display-name required]} :- TagParam]
   (or default
       (when required
-        (throw (Exception. (tru "''{0}'' is a required param." display-name))))))
+        (throw (missing-required-param-exception display-name)))))
 
 
 ;;; Parsing Values
diff --git a/src/metabase/query_processor/middleware/process_userland_query.clj b/src/metabase/query_processor/middleware/process_userland_query.clj
index 89047b931ee..2906d760c89 100644
--- a/src/metabase/query_processor/middleware/process_userland_query.clj
+++ b/src/metabase/query_processor/middleware/process_userland_query.clj
@@ -68,7 +68,7 @@
                 :cols []}}
    ;; include stacktrace and preprocessed/native stages of the query if available in the response which should
    ;; make debugging queries a bit easier
-   (-> (select-keys result [:stacktrace :preprocessed :native])
+   (-> (select-keys result [:stacktrace :preprocessed :native :error_type])
        (m/dissoc-in [:preprocessed :info]))))
 
 
diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj
index 1b70fd33782..dfab4c3e6fe 100644
--- a/test/metabase/api/embed_test.clj
+++ b/test/metabase/api/embed_test.clj
@@ -428,7 +428,8 @@
 ;; but if the card has an invalid query we should just get a generic "query failed" exception (rather than leaking
 ;; query info)
 (expect
-  "An error occurred while running the query."
+  {:status "failed"
+   :error  "An error occurred while running the query."}
   (tu.log/suppress-output
     (with-embedding-enabled-and-new-secret-key
       (with-temp-dashcard [dashcard {:dash {:enable_embedding true}
diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj
index b6b65a73d3e..365aa64061b 100644
--- a/test/metabase/api/public_test.clj
+++ b/test/metabase/api/public_test.clj
@@ -191,6 +191,41 @@
                            :parameters (json/encode [{:name "Venue ID", :slug "venue_id", :type "id", :value 2}]))
               [:json_query :parameters]))))
 
+;; Cards with required params
+(defn- do-with-required-param-card [f]
+  (tu/with-temporary-setting-values [enable-public-sharing true]
+    (with-temp-public-card [{uuid :public_uuid}
+                            {:dataset_query
+                             {:database (data/id)
+                              :type     :native
+                              :native   {:query         "SELECT count(*) FROM venues v WHERE price = {{price}}"
+                                         :template-tags {"price" {:name         "price"
+                                                                  :display-name "Price"
+                                                                  :type         :number
+                                                                  :required     true}}}}}]
+      (f uuid))))
+
+;; should be able to run a Card with a required param
+(expect
+  [[22]]
+  (do-with-required-param-card
+   (fn [uuid]
+     (qp.test/rows
+       (http/client :get 200 (str "public/card/" uuid "/query")
+                    :parameters (json/encode [{:type   "category"
+                                               :target [:variable [:template-tag "price"]]
+                                               :value  1}]))))))
+
+;; if you're missing a required param, the error message should get passed thru, rather than the normal generic 'Query
+;; Failed' message that we show for most embedding errors
+(expect
+  {:status     "failed"
+   :error      "You'll need to pick a value for 'Price' before this query can run."
+   :error_type "missing-required-parameter"}
+  (do-with-required-param-card
+   (fn [uuid]
+     (http/client :get 200 (str "public/card/" uuid "/query")))))
+
 ;; make sure CSV (etc.) downloads take editable params into account (#6407)
 
 (defn- card-with-date-field-filter []
-- 
GitLab