From c3d1a35b03f093bf61fc9e510e9f57e8079adacb Mon Sep 17 00:00:00 2001 From: Bryan Maass <bryan.maass@gmail.com> Date: Wed, 11 Jan 2023 16:00:38 -0700 Subject: [PATCH] Ensure api errors remain strings + provide structural hints (#27563) * - ensure errors are strings + provide strucrual hints Instead of just overwriting the (string) erorr messages, swap in the malli string description at [:errors :<field-name>] key. Adds humanized error (which highlights which parts of req were not sufficient) under the :specific-errors key. * fix tests + note about auto-cohersion --- src/metabase/api/common/internal.clj | 13 +++-- test/metabase/api/action_test.clj | 24 ++++++--- test/metabase/api/common/internal_test.clj | 33 +++++++++---- test/metabase/util/malli/describe_test.clj | 57 +++++++++++----------- 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj index 7ca4c5e1936..2ec34ea13b0 100644 --- a/src/metabase/api/common/internal.clj +++ b/src/metabase/api/common/internal.clj @@ -281,11 +281,14 @@ "Validate a parameter against its respective malli schema, or throw an Exception." [field-name value schema] (when-not (mc/validate schema value) - (let [explained (mc/explain schema value)] - (throw (ex-info (tru "Invalid m field: {0}" field-name) - {:status-code 400 - :errors {(keyword field-name) - (me/humanize (me/with-spell-checking explained))}}))))) + (throw (ex-info (tru "Invalid m field: {0}" field-name) + {:status-code 400 + :errors {(keyword field-name) (umd/describe schema)} + :specific-errors {(keyword field-name) + (-> schema + (mc/explain value) + me/with-spell-checking + me/humanize)}})))) (defn validate-param "Validate a parameter against its respective schema, or throw an Exception." diff --git a/test/metabase/api/action_test.clj b/test/metabase/api/action_test.clj index ed2f7bf9d24..4e167c84770 100644 --- a/test/metabase/api/action_test.clj +++ b/test/metabase/api/action_test.clj @@ -178,21 +178,31 @@ action-path (str "action/" (:id created-action))] (testing "Validate POST" (testing "Required fields" - (is (partial= {:errors {:name ["should be a string"]}} + (is (partial= {:errors {:name "string"}, + :specific-errors {:name ["should be a string"]}} (mt/user-http-request :crowberto :post 400 "action" {:type "http"}))) - (is (partial= {:errors {:model_id ["should be a positive int"]}} + (is (partial= {:errors {:model_id "integer greater than 0"}, + :specific-errors {:model_id ["should be a positive int"]}} (mt/user-http-request :crowberto :post 400 "action" {:type "http" :name "test"})))) (testing "Handles need to be valid jq" - (is (partial= {:errors {:response_handle ["must be a valid json-query, something like '.item.title'"]}} + (is (partial= {:errors {:response_handle "nullable string, and must be a valid json-query, something like '.item.title'"}, + :specific-errors {:response_handle ["must be a valid json-query, something like '.item.title'"]}} (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :response_handle "body")))) - (is (partial= {:errors {:error_handle ["must be a valid json-query, something like '.item.title'"]}} + (is (partial= {:errors {:error_handle "nullable string, and must be a valid json-query, something like '.item.title'"}, + :specific-errors {:error_handle ["must be a valid json-query, something like '.item.title'"]}} (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :error_handle "x")))))) (testing "Validate PUT" (testing "Template needs method and url" - (is (partial= {:errors {:template {:method ["missing required key",] :url ["missing required key"]}}} + (is (partial= {:errors + {:template + "nullable map where {:method -> <enum of GET, POST, PUT, DELETE, PATCH>, :url -> <string with length <= 1>, :body (optional) -> <nullable string>, :headers (optional) -> <nullable string>, :parameters (optional) -> <nullable sequence of map>, :parameter_mappings (optional) -> <nullable map>} with no other keys"}, + :specific-errors {:template {:method ["missing required key"], + :url ["missing required key"]}}} (mt/user-http-request :crowberto :put 400 action-path {:type "http" :template {}})))) (testing "Handles need to be valid jq" - (is (partial= {:errors {:response_handle ["must be a valid json-query, something like '.item.title'"]}} + (is (partial= {:errors {:response_handle "nullable string, and must be a valid json-query, something like '.item.title'"}, + :specific-errors {:response_handle ["must be a valid json-query, something like '.item.title'"]}} (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :response_handle "body")))) - (is (partial= {:errors {:error_handle ["must be a valid json-query, something like '.item.title'"]}} + (is (partial= {:errors {:error_handle "nullable string, and must be a valid json-query, something like '.item.title'"}, + :specific-errors {:error_handle ["must be a valid json-query, something like '.item.title'"]}} (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :error_handle "x"))))))))))) diff --git a/test/metabase/api/common/internal_test.clj b/test/metabase/api/common/internal_test.clj index 254a9c4d46a..5c24c6bf14a 100644 --- a/test/metabase/api/common/internal_test.clj +++ b/test/metabase/api/common/internal_test.clj @@ -17,8 +17,12 @@ [:map {:title "Address"} [:id string?] - ;; TODO it is possible to coerce things like this automatically: - ;; [:tags [:set keyword?]] + ;; TODO: coerce stuff automatically via: + ;; (mc/decode + ;; [:map [:tags [:set keyword?]]] + ;; (json/decode "{\"tags\": [\"a\", \"b\"]}" true) + ;; (mtx/json-transformer)) + ;; ;; => {:tags #{:b :a}} [:tags [:vector string?]] [:address [:map @@ -75,7 +79,9 @@ (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 (= {:errors {:id "integer"}, + :specific-errors {:id ["should be an int"]}} + (:body (post! "/post/id-int" {:id "1"})))) (is (= {:id "myid" :tags ["abc"] @@ -88,12 +94,17 @@ :zip 2999 :lonlat [0.0 0.0]}})))) - (is (= {:errors {:address {:id ["missing required key"] - :tags ["missing required key"] - :address ["missing required key"]}}} + (is (= {:errors + {:address "map where {:id -> <string>, :tags -> <vector of string>, :address -> <map where {:street -> <string>, :city -> <string>, :zip -> <integer>, :lonlat -> <vector with exactly 2 items of type: double, double>}>}"}, + :specific-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"] + (is (= {:errors + {:address "map where {:id -> <string>, :tags -> <vector of string>, :address -> <map where {:street -> <string>, :city -> <string>, :zip -> <integer>, :lonlat -> <vector with exactly 2 items of type: double, double>}>}"}, + :specific-errors {:address + {:id ["should be a string"] :tags ["invalid type"] :address {:street ["missing required key"] :zip ["should be an int"]}}}} @@ -105,9 +116,11 @@ :lonlat [0.0 0.0]}})))) (is (= {:errors - {:address {:address ["missing required key"] - :a ["disallowed key"] - :b ["disallowed key"]}}} + {:address "map where {:id -> <string>, :tags -> <vector of string>, :address -> <map where {:street -> <string>, :city -> <string>, :zip -> <integer>, :lonlat -> <vector with exactly 2 items of type: double, double>} with no other keys>} with no other keys"}, + :specific-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 diff --git a/test/metabase/util/malli/describe_test.clj b/test/metabase/util/malli/describe_test.clj index 7c8ef1485b2..29468e51eeb 100644 --- a/test/metabase/util/malli/describe_test.clj +++ b/test/metabase/util/malli/describe_test.clj @@ -53,34 +53,35 @@ (is (= "Order which is: <Country is map where {:name -> <enum of :FI, :PO>, :neighbors (optional) -> <vector of \"Country\">} with no other keys, Burger is map where {:name -> <string>, :description (optional) -> <string>, :origin -> <nullable Country>, :price -> <integer greater than 0>}, OrderLine is map where {:burger -> <Burger>, :amount -> <integer>} with no other keys, Order is map where {:lines -> <vector of OrderLine>, :delivery -> <map where {:delivered -> <boolean>, :address -> <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"]))) + {: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 <nullable vector with exactly 2 items of type: integer, \"ConsCell\">" (umd/describe [:schema -- GitLab